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/i18n/app_locale_utils.dart'; import 'package:plezy/i18n/strings.g.dart'; import 'package:plezy/models/seerr/seerr_page.dart'; import 'package:plezy/models/seerr/seerr_request.dart'; import 'package:plezy/models/seerr/seerr_session.dart'; import 'package:plezy/services/seerr/seerr_auth_service.dart'; import 'package:plezy/services/seerr/seerr_client.dart'; import 'package:plezy/services/seerr/seerr_constants.dart'; import 'package:plezy/services/seerr/seerr_exceptions.dart'; import 'package:plezy/services/seerr/seerr_http_client.dart'; SeerrSession _session({SeerrAuthMethod method = SeerrAuthMethod.jellyfin, String secret = 'hunter2'}) => SeerrSession( baseUrl: 'https://seerr.example.com', method: method, identifier: 'alice', secret: secret, cookie: 'old-cookie', userId: 7, permissions: 2, displayName: 'Alice', instanceLabel: 'Seerr', createdAt: 0, ); http.Response _json(Object body, {int status = 200, Map? headers}) => http.Response(jsonEncode(body), status, headers: {'content-type': 'application/json', ...?headers}); Map _user() => {'id': 7, 'displayName': 'Alice', 'permissions': 2, 'avatar': '/a.png'}; void main() { group('SeerrHttpClient', () { test('normalizes trailing slashes off the base URL', () { expect(SeerrHttpClient.normalizeBaseUrl(' https://seerr.example.com// '), 'https://seerr.example.com'); }); test('encodes query spaces as %20, not +', () async { late Uri seen; final client = SeerrHttpClient( baseUrl: 'https://seerr.example.com', httpClient: MockClient((request) async { seen = request.url; return _json({'results': []}); }), ); await client.send('GET', '/search', query: {'query': 'blade runner', 'page': 1}); expect(seen.toString(), 'https://seerr.example.com/api/v1/search?query=blade%20runner&page=1'); }); test('captures connect.sid out of a multi-cookie Set-Cookie header', () { final client = SeerrHttpClient(baseUrl: 'https://seerr.example.com'); final response = http.Response( '', 200, headers: { 'set-cookie': 'other=1; Path=/, ${SeerrConstants.sessionCookieName}=s%3Aabc.def; Path=/; HttpOnly; SameSite=Lax', }, ); expect(client.captureSessionCookie(response), isTrue); expect(client.cookie, 's%3Aabc.def'); }); test('replays the cookie on authenticated requests only', () async { final cookies = []; final client = SeerrHttpClient( baseUrl: 'https://seerr.example.com', cookie: 'abc', httpClient: MockClient((request) async { cookies.add(request.headers['Cookie']); return _json({}); }), ); await client.send('GET', '/auth/me'); await client.send('GET', '/settings/public', authenticated: false); expect(cookies, ['${SeerrConstants.sessionCookieName}=abc', null]); }); }); group('SeerrAuthService', () { test('probe rejects an uninitialized instance', () async { final auth = SeerrAuthService( httpClientFactory: () => MockClient((request) async => _json({'initialized': false})), ); expect(() => auth.probe('https://seerr.example.com'), throwsA(isA())); }); test('jellyfin sign-in posts serverType and packs the session', () async { late Map body; final auth = SeerrAuthService( httpClientFactory: () => MockClient((request) async { expect(request.url.path, '/api/v1/auth/jellyfin'); body = jsonDecode(request.body) as Map; return _json(_user(), headers: {'set-cookie': '${SeerrConstants.sessionCookieName}=fresh; Path=/'}); }), ); final session = await auth.signInWithJellyfin( baseUrl: 'https://seerr.example.com/', username: 'alice', password: 'hunter2', ); expect(body, {'username': 'alice', 'password': 'hunter2', 'serverType': SeerrMediaServerType.jellyfin}); expect(session.method, SeerrAuthMethod.jellyfin); expect(session.baseUrl, 'https://seerr.example.com'); expect(session.cookie, 'fresh'); expect(session.userId, 7); expect(session.secret, 'hunter2'); expect(session.displayName, 'Alice'); }); test('plex sign-in posts the token and stores no secret', () async { late Map body; final auth = SeerrAuthService( httpClientFactory: () => MockClient((request) async { expect(request.url.path, '/api/v1/auth/plex'); body = jsonDecode(request.body) as Map; return _json(_user(), headers: {'set-cookie': '${SeerrConstants.sessionCookieName}=fresh'}); }), ); final session = await auth.signInWithPlex(baseUrl: 'https://seerr.example.com', plexToken: 'plex-token'); expect(body, {'authToken': 'plex-token'}); expect(session.method, SeerrAuthMethod.plex); expect(session.secret, isEmpty); expect(session.identifier, isEmpty); }); test('rejected credentials surface as SeerrAuthException', () async { final auth = SeerrAuthService( httpClientFactory: () => MockClient((request) async => _json({'message': 'nope'}, status: 401)), ); expect( () => auth.signInWithLocal(baseUrl: 'https://seerr.example.com', email: 'a@b.c', password: 'x'), throwsA(isA()), ); }); }); group('SeerrClient silent re-auth', () { test('401 triggers one re-login, retries, and persists the new session', () async { var meCalls = 0; var loginCalls = 0; SeerrSession? updated; final mock = MockClient((request) async { if (request.url.path == '/api/v1/auth/jellyfin') { loginCalls++; expect(jsonDecode(request.body), containsPair('password', 'hunter2')); return _json(_user(), headers: {'set-cookie': '${SeerrConstants.sessionCookieName}=fresh'}); } expect(request.url.path, '/api/v1/auth/me'); meCalls++; final cookie = request.headers['Cookie']; if (cookie != '${SeerrConstants.sessionCookieName}=fresh') return _json({}, status: 401); return _json(_user()); }); final client = SeerrClient( _session(), onSessionInvalidated: () => fail('must not invalidate'), onSessionUpdated: (s) => updated = s, authService: SeerrAuthService(httpClientFactory: () => mock), httpClient: mock, ); addTearDown(client.dispose); final user = await client.getMe(); expect(user.id, 7); expect(loginCalls, 1); expect(meCalls, 2); expect(updated?.cookie, 'fresh'); // The re-packed session keeps its re-auth credentials. expect(updated?.secret, 'hunter2'); expect(updated?.method, SeerrAuthMethod.jellyfin); }); test('plex re-auth pulls the live token from the supplier', () async { var suppliedToken = false; final mock = MockClient((request) async { if (request.url.path == '/api/v1/auth/plex') { expect(jsonDecode(request.body), {'authToken': 'live-token'}); return _json(_user(), headers: {'set-cookie': '${SeerrConstants.sessionCookieName}=fresh'}); } final cookie = request.headers['Cookie']; if (cookie != '${SeerrConstants.sessionCookieName}=fresh') return _json({}, status: 401); return _json(_user()); }); final client = SeerrClient( _session(method: SeerrAuthMethod.plex, secret: ''), onSessionInvalidated: () => fail('must not invalidate'), plexTokenSupplier: () async { suppliedToken = true; return 'live-token'; }, authService: SeerrAuthService(httpClientFactory: () => mock), httpClient: mock, ); addTearDown(client.dispose); await client.getMe(); expect(suppliedToken, isTrue); }); test('re-auth without stored credentials invalidates the session', () async { var invalidated = false; final mock = MockClient((request) async => _json({}, status: 401)); final client = SeerrClient( _session(secret: ''), onSessionInvalidated: () => invalidated = true, authService: SeerrAuthService(httpClientFactory: () => mock), httpClient: mock, ); addTearDown(client.dispose); await expectLater(client.getMe(), throwsA(isA())); expect(invalidated, isTrue); }); test('a transiently-unresolvable plex token errors WITHOUT unlinking the session', () async { var invalidated = false; var loginAttempts = 0; final mock = MockClient((request) async { if (request.url.path == '/api/v1/auth/plex') loginAttempts++; return _json({}, status: 401); }); final client = SeerrClient( _session(method: SeerrAuthMethod.plex, secret: ''), onSessionInvalidated: () => invalidated = true, // Degraded launch: identity not resolvable right now. plexTokenSupplier: () async => null, authService: SeerrAuthService(httpClientFactory: () => mock), httpClient: mock, ); addTearDown(client.dispose); await expectLater(client.getMe(), throwsA(isA())); expect(invalidated, isFalse, reason: 'a retryable failure must not clear the stored session'); expect(loginAttempts, 0); // Once the supplier recovers, the next 401 re-auths normally. final recovering = SeerrClient( _session(method: SeerrAuthMethod.plex, secret: ''), onSessionInvalidated: () => invalidated = true, plexTokenSupplier: () async => 'live-token', authService: SeerrAuthService( httpClientFactory: () => MockClient((request) async { if (request.url.path == '/api/v1/auth/plex') { return _json(_user(), headers: {'set-cookie': '${SeerrConstants.sessionCookieName}=fresh'}); } return _json(_user()); }), ), httpClient: MockClient((request) async { final cookie = request.headers['Cookie']; if (cookie != '${SeerrConstants.sessionCookieName}=fresh') return _json({}, status: 401); return _json(_user()); }), ); addTearDown(recovering.dispose); final user = await recovering.getMe(); expect(user.id, 7); expect(invalidated, isFalse); }); }); group('SeerrClient parsing', () { SeerrClient clientWith(MockClient mock) { final client = SeerrClient( _session(), onSessionInvalidated: () {}, authService: SeerrAuthService(httpClientFactory: () => mock), httpClient: mock, ); addTearDown(client.dispose); return client; } test('popular movies coerces missing mediaType to movie', () async { final client = clientWith( MockClient((request) async { expect(request.url.path, '/api/v1/discover/movies'); return _json({ 'page': 1, 'totalPages': 1, 'totalResults': 37, 'results': [ {'id': 4, 'title': 'Dune', 'releaseDate': '2021-09-15'}, ], }); }), ); final page = await client.getPopularMovies(); expect(page.items.single.isMovie, isTrue); expect(page.hasMore, isFalse); expect(page.totalResults, 37); }); test('adds the current Plezy locale to the catalog GETs Seerr localizes', () async { final urls = []; final client = clientWith( MockClient((request) async { urls.add(request.url); if (request.url.path == '/api/v1/movie/4' || request.url.path == '/api/v1/tv/4') { return _json({}); } return _json({'page': 1, 'totalPages': 1, 'results': []}); }), ); await client.getUpcomingMovies(); await client.getUpcomingTv(); await client.getTrending(); await client.search('dune'); await client.getMovieRecommendations(4); await client.getTvRecommendations(4); await client.getMovie(4); await client.getTv(4); expect(urls.map((url) => url.path).toSet(), { '/api/v1/discover/movies/upcoming', '/api/v1/discover/tv/upcoming', '/api/v1/discover/trending', '/api/v1/search', '/api/v1/movie/4/recommendations', '/api/v1/tv/4/recommendations', '/api/v1/movie/4', '/api/v1/tv/4', }); final expectedLanguage = LocaleSettings.currentLocale.plexLanguageCode; for (final url in urls) { expect(url.queryParameters['language'], expectedLanguage, reason: url.path); } }); test('popular rows omit language so Seerr cannot filter them by original language', () async { // Overseerr and Jellyseerr pass `/discover/movies` and `/discover/tv`'s // `language` straight into `originalLanguage`, i.e. TMDB's // `with_original_language`. Sending the app locale collapsed both shelves // to titles originally made in that language (#1763). final urls = []; final client = clientWith( MockClient((request) async { urls.add(request.url); return _json({'page': 1, 'totalPages': 1, 'results': []}); }), ); await client.getPopularMovies(page: 2); await client.getPopularTv(); expect(urls.map((url) => url.path).toList(), ['/api/v1/discover/movies', '/api/v1/discover/tv']); for (final url in urls) { expect(url.queryParameters.containsKey('language'), isFalse, reason: url.path); } expect(urls.first.queryParameters['page'], '2', reason: 'paging must survive the locale opt-out'); }); test('createRequest posts the movie payload without seasons', () async { late Map body; final client = clientWith( MockClient((request) async { expect(request.method, 'POST'); expect(request.url.path, '/api/v1/request'); body = jsonDecode(request.body) as Map; return _json({'id': 10, 'status': 1}, status: 201); }), ); final created = await client.createRequest(const SeerrRequestPayload(mediaType: 'movie', mediaId: 603)); expect(body, {'mediaType': 'movie', 'mediaId': 603, 'is4k': false}); expect(created.status, SeerrRequestStatus.pending); }); test('createRequest posts tv seasons, defaulting to all', () async { final bodies = >[]; final client = clientWith( MockClient((request) async { bodies.add(jsonDecode(request.body) as Map); return _json({'id': 11, 'status': 2}); }), ); await client.createRequest(const SeerrRequestPayload(mediaType: 'tv', mediaId: 1396, seasons: [1, 2])); await client.createRequest( const SeerrRequestPayload(mediaType: 'tv', mediaId: 1396, is4k: true, serverId: 1, profileId: 6), ); expect(bodies[0]['seasons'], [1, 2]); expect(bodies[1]['seasons'], 'all'); expect(bodies[1]['is4k'], true); expect(bodies[1]['serverId'], 1); expect(bodies[1]['profileId'], 6); }); test('API errors carry the server message', () async { final client = clientWith( MockClient((request) async => _json({'message': 'Request quota exceeded'}, status: 429)), ); await expectLater( client.createRequest(const SeerrRequestPayload(mediaType: 'movie', mediaId: 603)), throwsA(isA().having((e) => e.message, 'message', 'Request quota exceeded')), ); }); }); group('SeerrPage', () { test('parses the pageInfo pagination shape', () { final page = SeerrPage.fromJson({ 'pageInfo': {'page': 2, 'pages': 2, 'totalResults': 55}, 'results': [ {'id': 1}, ], }, (item) => item['id'] as int); expect(page.hasMore, isFalse); expect(page.items, [1]); expect(page.totalResults, 55); }); }); group('seerrHasPermission', () { test('admin implies everything, otherwise any-of applies', () { expect(seerrHasPermission(SeerrPermission.admin, [SeerrPermission.request4k]), isTrue); expect(seerrHasPermission(SeerrPermission.request, [SeerrPermission.request4k]), isFalse); expect( seerrHasPermission(SeerrPermission.requestMovie, [SeerrPermission.request, SeerrPermission.requestMovie]), isTrue, ); }); }); group('SeerrSession', () { test('round-trips through encode/decode', () { final decoded = SeerrSession.decode(_session().encode()); expect(decoded.baseUrl, 'https://seerr.example.com'); expect(decoded.method, SeerrAuthMethod.jellyfin); expect(decoded.identifier, 'alice'); expect(decoded.secret, 'hunter2'); expect(decoded.cookie, 'old-cookie'); expect(decoded.userId, 7); expect(decoded.permissions, 2); expect(decoded.displayName, 'Alice'); expect(decoded.instanceLabel, 'Seerr'); }); }); }