import 'dart:convert'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:http/http.dart' as http; import 'package:http/testing.dart'; import 'package:plezy/focus/focusable_button.dart'; import 'package:plezy/i18n/strings.g.dart'; import 'package:plezy/media/media_kind.dart'; import 'package:plezy/models/seerr/seerr_session.dart'; import 'package:plezy/services/catalog/seerr_catalog_source.dart'; import 'package:plezy/services/seerr/seerr_client.dart'; import 'package:plezy/services/seerr/seerr_constants.dart'; import 'package:plezy/widgets/overlay_sheet.dart'; import 'package:plezy/widgets/seerr_request_sheet.dart'; http.Response _json(Object body, {int status = 200}) => http.Response(jsonEncode(body), status, headers: {'content-type': 'application/json'}); SeerrCatalogSource _source(MockClient mock, {int permissions = SeerrPermission.request}) { final client = SeerrClient( SeerrSession( baseUrl: 'https://seerr.example.com', method: SeerrAuthMethod.local, identifier: 'a@b.c', secret: 'pw', cookie: 'cookie', userId: 1, permissions: permissions, displayName: 'Alice', instanceLabel: 'Seerr', createdAt: 0, ), onSessionInvalidated: () {}, httpClient: mock, ); final source = SeerrCatalogSource(client); addTearDown(() { source.dispose(); client.dispose(); }); return source; } Map _publicSettings() => { 'initialized': true, 'localLogin': true, 'mediaServerLogin': true, 'movie4kEnabled': false, 'series4kEnabled': false, 'partialRequestsEnabled': true, }; /// Mirrors production: the sheet is opened via [showSeerrRequestSheet] on a /// pushed route that hosts its own [OverlaySheetHost] (like /// CatalogItemDetailScreen), so the sheet renders in the host's stack rather /// than as a route of its own. Future _pumpSheet( WidgetTester tester, { required SeerrCatalogSource source, required MediaKind kind, required int tmdbId, required String title, }) async { await tester.pumpWidget( MaterialApp( home: Builder( builder: (context) => Scaffold( body: Center( child: TextButton( onPressed: () => Navigator.of(context).push( MaterialPageRoute( builder: (_) => OverlaySheetHost( canPop: true, child: Scaffold( body: Builder( builder: (context) => Center( child: TextButton( onPressed: () => showSeerrRequestSheet( context, source: source, kind: kind, tmdbId: tmdbId, title: title, ), child: const Text('request'), ), ), ), ), ), ), ), child: const Text('open'), ), ), ), ), ), ); await tester.tap(find.text('open')); await tester.pumpAndSettle(); await tester.tap(find.text('request')); await tester.pumpAndSettle(); } void main() { TestWidgetsFlutterBinding.ensureInitialized(); setUp(() { LocaleSettings.setLocaleSync(AppLocale.en); }); testWidgets('TV: disables unavailable seasons, drops specials, posts selected seasons', (tester) async { Map? postedBody; final mock = MockClient((request) async { switch (request.url.path) { case '/api/v1/settings/public': return _json(_publicSettings()); case '/api/v1/tv/1396': return _json({ 'id': 1396, 'name': 'Breaking Bad', 'seasons': [ {'seasonNumber': 0, 'episodeCount': 5, 'name': 'Specials'}, {'seasonNumber': 1, 'episodeCount': 7, 'name': 'Season 1'}, {'seasonNumber': 2, 'episodeCount': 13, 'name': 'Season 2'}, ], 'mediaInfo': { 'status': 4, 'status4k': 1, 'seasons': [ {'seasonNumber': 1, 'status': 5, 'status4k': 1}, ], 'requests': [], }, }); case '/api/v1/request': postedBody = jsonDecode(request.body) as Map; return _json({'id': 10, 'status': 1}, status: 201); } fail('unexpected request ${request.url.path}'); }); final source = _source(mock); await _pumpSheet(tester, source: source, kind: MediaKind.show, tmdbId: 1396, title: 'Breaking Bad'); expect(find.text('Specials'), findsNothing); expect(find.text('Season 1'), findsOneWidget); expect(find.text('Season 2'), findsOneWidget); // Season 1 is available on the server: checked, disabled, labeled. expect(find.text('Available'), findsOneWidget); final season1 = tester.widget( find.ancestor(of: find.text('Season 1'), matching: find.byType(CheckboxListTile)), ); expect(season1.onChanged, isNull); expect(season1.value, isTrue); // Nothing selected yet: submit disabled. final submitFinder = find.widgetWithText(FilledButton, 'Request'); expect(tester.widget(submitFinder).onPressed, isNull); final focusableSubmit = tester.widget( find.ancestor(of: submitFinder, matching: find.byType(FocusableButton)), ); expect(focusableSubmit.useBackgroundFocus, isTrue); await tester.tap(find.text('Season 2')); await tester.pump(); expect(tester.widget(submitFinder).onPressed, isNotNull); final season2 = tester.widget( find.ancestor(of: find.text('Season 2'), matching: find.byType(CheckboxListTile)), ); season2.focusNode!.requestFocus(); await tester.pump(); await tester.sendKeyEvent(LogicalKeyboardKey.arrowDown); await tester.pumpAndSettle(); expect(FocusManager.instance.primaryFocus?.debugLabel, 'seerr_request_submit'); await tester.tap(submitFinder); await tester.pumpAndSettle(); expect(postedBody, { 'mediaType': 'tv', 'mediaId': 1396, 'seasons': [2], 'is4k': false, }); // The sheet closed but the hosting screen must survive the submit — // a bare Navigator.pop here would pop the whole detail route. expect(find.text('Season 2'), findsNothing); expect(find.text('request'), findsOneWidget); expect(find.text('Request submitted'), findsOneWidget); }); testWidgets('movie that is already available offers nothing to request', (tester) async { final mock = MockClient((request) async { switch (request.url.path) { case '/api/v1/settings/public': return _json(_publicSettings()); case '/api/v1/movie/603': return _json({ 'id': 603, 'title': 'The Matrix', 'mediaInfo': {'status': 5, 'status4k': 1}, }); } fail('unexpected request ${request.url.path}'); }); final source = _source(mock); await _pumpSheet(tester, source: source, kind: MediaKind.movie, tmdbId: 603, title: 'The Matrix'); expect(find.text('Available'), findsOneWidget); expect(find.byType(FilledButton), findsNothing); }); testWidgets('advanced permission loads servers and sends destination overrides', (tester) async { Map? postedBody; final mock = MockClient((request) async { switch (request.url.path) { case '/api/v1/settings/public': return _json(_publicSettings()); case '/api/v1/movie/550': return _json({'id': 550, 'title': 'Fight Club'}); case '/api/v1/service/radarr': return _json([ { 'id': 0, 'name': 'Radarr Main', 'is4k': false, 'isDefault': true, 'activeProfileId': 6, 'activeDirectory': '/movies', }, ]); case '/api/v1/service/radarr/0': return _json({ 'server': { 'id': 0, 'name': 'Radarr Main', 'is4k': false, 'isDefault': true, 'activeProfileId': 6, 'activeDirectory': '/movies', }, 'profiles': [ {'id': 6, 'name': '1080p'}, {'id': 7, 'name': '4K Remux'}, ], 'rootFolders': [ {'id': 1, 'path': '/movies'}, ], }); case '/api/v1/request': postedBody = jsonDecode(request.body) as Map; return _json({'id': 11, 'status': 2}, status: 201); } fail('unexpected request ${request.url.path}'); }); final source = _source(mock, permissions: SeerrPermission.admin); await _pumpSheet(tester, source: source, kind: MediaKind.movie, tmdbId: 550, title: 'Fight Club'); // Single server: no server picker, but profile/folder pickers show // the instance defaults. expect(find.text('Destination server'), findsNothing); expect(find.text('Quality profile'), findsOneWidget); expect(find.text('1080p'), findsOneWidget); await tester.tap(find.widgetWithText(FilledButton, 'Request')); await tester.pumpAndSettle(); expect(postedBody, { 'mediaType': 'movie', 'mediaId': 550, 'is4k': false, 'serverId': 0, 'profileId': 6, 'rootFolder': '/movies', }); }); }