A Seerr permission change reached the app only as a sign-in-time snapshot: a catalog detail decided once, when opened, whether it could show Request, and the request sheet only re-read the mask when something else rebuilt it. A grant left the action hidden until the screen was reopened, a revocation left it visible, and submitting then surfaced Seerr's raw English 403 body while the sheet stayed open with gates it no longer had. A disconnect while a detail was open left it holding a source wrapping a disposed client. Permission changes now propagate through the existing provider graph without replacing clients: the client adopts the fresh `/auth/me` body it already fetches while telling a permission miss from a dead session, and a live-session denial is a typed `SeerrPermissionException`. The detail screen derives its Request eligibility from the account's current mask and source on every build; the sheet reconciles grants (loads destinations, shows 4K) and revocations (trims them, or closes with a localized reason the host surfaces) and re-checks authority locally before submitting.
1020 lines
38 KiB
Dart
1020 lines
38 KiB
Dart
import 'dart:async';
|
|
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/providers/seerr_account_provider.dart';
|
|
import 'package:plezy/services/catalog/seerr_catalog_source.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/widgets/loading_indicator_box.dart';
|
|
import 'package:plezy/widgets/overlay_sheet.dart';
|
|
import 'package:plezy/widgets/seerr_request_sheet.dart';
|
|
import 'package:provider/provider.dart';
|
|
|
|
import '../test_helpers/prefs.dart';
|
|
import '../test_helpers/theme.dart';
|
|
|
|
http.Response _json(Object body, {int status = 200}) =>
|
|
http.Response(jsonEncode(body), status, headers: {'content-type': 'application/json'});
|
|
|
|
/// Parks a mock response behind a completer the test releases later, so a
|
|
/// detail load can be completed out of order with the sheet's selections.
|
|
Future<http.Response> _gate(List<Completer<http.Response>> pending) {
|
|
final completer = Completer<http.Response>();
|
|
pending.add(completer);
|
|
return completer.future;
|
|
}
|
|
|
|
/// The advanced-section spinner keeps the frame scheduler busy, so tests with
|
|
/// a detail load in flight step frames instead of settling.
|
|
Future<void> _pumpFrames(WidgetTester tester) async {
|
|
await tester.pump();
|
|
await tester.pump(const Duration(milliseconds: 300));
|
|
}
|
|
|
|
Map<String, dynamic> _radarrServer({
|
|
required int id,
|
|
required String name,
|
|
int profileId = 6,
|
|
String folder = '/movies',
|
|
}) => {
|
|
'id': id,
|
|
'name': name,
|
|
'is4k': false,
|
|
'isDefault': id == 0,
|
|
'activeProfileId': profileId,
|
|
'activeDirectory': folder,
|
|
};
|
|
|
|
Map<String, dynamic> _radarrDetail(Map<String, dynamic> server, {required String profileName}) => {
|
|
'server': server,
|
|
'profiles': [
|
|
{'id': server['activeProfileId'], 'name': profileName},
|
|
],
|
|
'rootFolders': [
|
|
{'id': 1, 'path': server['activeDirectory']},
|
|
],
|
|
};
|
|
|
|
Future<void> _pickServer(WidgetTester tester, String name) async {
|
|
await tester.tap(find.text('Destination server'));
|
|
await _pumpFrames(tester);
|
|
await tester.tap(find.text(name));
|
|
await _pumpFrames(tester);
|
|
}
|
|
|
|
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<String, dynamic> _publicSettings({int? mediaServerType}) => {
|
|
'initialized': true,
|
|
'localLogin': true,
|
|
'mediaServerLogin': true,
|
|
'mediaServerType': ?mediaServerType,
|
|
'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<void> _pumpSheet(
|
|
WidgetTester tester, {
|
|
required SeerrCatalogSource source,
|
|
required MediaKind kind,
|
|
required int tmdbId,
|
|
required String title,
|
|
SeerrAccountProvider? account,
|
|
}) async {
|
|
final app = MaterialApp(
|
|
theme: ThemeData(extensions: const [testMonoTokens]),
|
|
home: Builder(
|
|
builder: (context) => Scaffold(
|
|
body: Center(
|
|
child: TextButton(
|
|
onPressed: () => Navigator.of(context).push(
|
|
MaterialPageRoute<void>(
|
|
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.pumpWidget(
|
|
account == null ? app : ChangeNotifierProvider<SeerrAccountProvider>.value(value: account, child: app),
|
|
);
|
|
await tester.tap(find.text('open'));
|
|
await tester.pumpAndSettle();
|
|
await tester.tap(find.text('request'));
|
|
await tester.pumpAndSettle();
|
|
}
|
|
|
|
Map<String, dynamic> _sonarrServer() => {
|
|
'id': 0,
|
|
'name': 'Sonarr Main',
|
|
'is4k': false,
|
|
'isDefault': true,
|
|
'activeProfileId': 1,
|
|
'activeDirectory': '/tv',
|
|
'activeLanguageProfileId': 1,
|
|
'activeAnimeProfileId': 2,
|
|
'activeAnimeDirectory': '/anime',
|
|
'activeAnimeLanguageProfileId': 2,
|
|
'activeTags': [7],
|
|
'activeAnimeTags': [5],
|
|
};
|
|
|
|
Map<String, dynamic> _sonarrDetail() => {
|
|
'server': _sonarrServer(),
|
|
'profiles': [
|
|
{'id': 1, 'name': 'TV'},
|
|
{'id': 2, 'name': 'Anime'},
|
|
],
|
|
'rootFolders': [
|
|
{'id': 1, 'path': '/tv'},
|
|
{'id': 2, 'path': '/anime'},
|
|
],
|
|
'languageProfiles': [
|
|
{'id': 1, 'name': 'English'},
|
|
{'id': 2, 'name': 'Japanese'},
|
|
],
|
|
'tags': [
|
|
{'id': 5, 'label': 'anime'},
|
|
{'id': 7, 'label': 'tv'},
|
|
{'id': 9, 'label': 'uhd'},
|
|
],
|
|
};
|
|
|
|
Map<String, dynamic> _tvDetails({required bool anime}) => {
|
|
'id': 46260,
|
|
'name': 'Naruto',
|
|
'keywords': [
|
|
{'id': 9715, 'name': 'superhero'},
|
|
if (anime) {'id': 210024, 'name': 'anime'},
|
|
],
|
|
'seasons': [
|
|
{'seasonNumber': 1, 'episodeCount': 57, 'name': 'Season 1'},
|
|
],
|
|
'mediaInfo': {'status': 1, 'status4k': 1, 'seasons': [], 'requests': []},
|
|
};
|
|
|
|
void main() {
|
|
TestWidgetsFlutterBinding.ensureInitialized();
|
|
|
|
setUp(() {
|
|
LocaleSettings.setLocaleSync(AppLocale.en);
|
|
});
|
|
|
|
testWidgets('TV: disables unavailable seasons, drops specials, posts selected seasons', (tester) async {
|
|
Map<String, dynamic>? 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<String, dynamic>;
|
|
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<CheckboxListTile>(
|
|
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<FilledButton>(submitFinder).onPressed, isNull);
|
|
final focusableSubmit = tester.widget<FocusableButton>(
|
|
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<FilledButton>(submitFinder).onPressed, isNotNull);
|
|
|
|
final season2 = tester.widget<CheckboxListTile>(
|
|
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('failed and completed requests do not block re-requesting a movie', (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': 1,
|
|
'requests': [
|
|
{'id': 1, 'status': 4, 'is4k': false},
|
|
{'id': 2, 'status': 5, 'is4k': false},
|
|
],
|
|
},
|
|
});
|
|
}
|
|
fail('unexpected request ${request.url.path}');
|
|
});
|
|
final source = _source(mock);
|
|
|
|
await _pumpSheet(tester, source: source, kind: MediaKind.movie, tmdbId: 603, title: 'The Matrix');
|
|
|
|
// Only pending/approved requests hold a claim; a failed arr push or a
|
|
// settled request must leave the title re-requestable.
|
|
final submit = find.widgetWithText(FilledButton, 'Request');
|
|
expect(tester.widget<FilledButton>(submit).onPressed, isNotNull);
|
|
expect(find.text('Requested'), findsNothing);
|
|
});
|
|
|
|
testWidgets('a request the middleware refuses over a live session closes the sheet with a localized reason', (
|
|
tester,
|
|
) async {
|
|
// Seerr's isAuthenticated(REQUEST) middleware answers exactly like a
|
|
// session rejection; the client's /auth/me probe proves the session
|
|
// live and carries the current (now empty) mask. The sheet must not
|
|
// echo the server's English body nor stay open on a gate that is gone.
|
|
final paths = <String>[];
|
|
final mock = MockClient((request) async {
|
|
paths.add(request.url.path);
|
|
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': 1},
|
|
});
|
|
case '/api/v1/request':
|
|
return _json({'status': 403, 'error': 'You do not have permission to access this endpoint'}, status: 403);
|
|
case '/api/v1/auth/me':
|
|
return _json({'id': 1, 'displayName': 'Alice', 'permissions': 0});
|
|
}
|
|
fail('unexpected request ${request.url.path}');
|
|
});
|
|
final source = _source(mock);
|
|
|
|
await _pumpSheet(tester, source: source, kind: MediaKind.movie, tmdbId: 603, title: 'The Matrix');
|
|
await tester.tap(find.widgetWithText(FilledButton, 'Request'));
|
|
await tester.pumpAndSettle();
|
|
|
|
expect(paths.sublist(paths.length - 2), ['/api/v1/request', '/api/v1/auth/me']);
|
|
expect(find.byType(SeerrRequestSheet), findsNothing);
|
|
expect(find.text('You do not have permission to access this endpoint'), findsNothing);
|
|
expect(find.text(t.seerr.permissionRevoked), findsOneWidget);
|
|
expect(source.client.session.permissions, 0);
|
|
});
|
|
|
|
testWidgets('a failed request unblocks a stale Processing status when nothing live backs it', (tester) async {
|
|
// Seerr marks the request Failed on arr-push failure but can leave the
|
|
// media status Processing; that stale status must not keep blocking.
|
|
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': 3,
|
|
'requests': [
|
|
{'id': 1, 'status': 4, 'is4k': false},
|
|
],
|
|
},
|
|
});
|
|
}
|
|
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('Processing'), findsNothing);
|
|
final submit = find.widgetWithText(FilledButton, 'Request');
|
|
expect(tester.widget<FilledButton>(submit).onPressed, isNotNull);
|
|
});
|
|
|
|
testWidgets('a live approved retry keeps a failed title blocked as Processing', (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': 3,
|
|
'requests': [
|
|
{'id': 1, 'status': 4, 'is4k': false},
|
|
{'id': 2, 'status': 2, 'is4k': false},
|
|
],
|
|
},
|
|
});
|
|
}
|
|
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('Processing'), findsOneWidget);
|
|
expect(find.byType(FilledButton), findsNothing);
|
|
});
|
|
|
|
testWidgets('a Jellyseerr blocklisted movie offers nothing to request', (tester) async {
|
|
final mock = MockClient((request) async {
|
|
switch (request.url.path) {
|
|
case '/api/v1/settings/public':
|
|
// mediaServerType present -> Jellyseerr -> status 6 = BLOCKLISTED.
|
|
return _json(_publicSettings(mediaServerType: SeerrMediaServerType.jellyfin));
|
|
case '/api/v1/movie/603':
|
|
return _json({
|
|
'id': 603,
|
|
'title': 'The Matrix',
|
|
'mediaInfo': {'status': 6},
|
|
});
|
|
}
|
|
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('Blocklisted'), findsOneWidget);
|
|
expect(find.byType(FilledButton), findsNothing);
|
|
});
|
|
|
|
testWidgets('the settings fetch upgrades a legacy session so Overseerr code 7 stays requestable', (tester) async {
|
|
// The session starts without a product (legacy persist); code 7 would be
|
|
// conservatively blocked. The sheet's settings fetch resolves the
|
|
// instance as Overseerr (no mediaServerType), where 7 is meaningless,
|
|
// so the title must offer a request.
|
|
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': 7},
|
|
});
|
|
}
|
|
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('Blocklisted'), findsNothing);
|
|
final submit = find.widgetWithText(FilledButton, 'Request');
|
|
expect(tester.widget<FilledButton>(submit).onPressed, isNotNull);
|
|
});
|
|
|
|
testWidgets('advanced permission loads servers and sends destination overrides', (tester) async {
|
|
Map<String, dynamic>? 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<String, dynamic>;
|
|
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, marked the way Seerr's web requester does.
|
|
expect(find.text('Destination server'), findsNothing);
|
|
expect(find.text('Quality profile'), findsOneWidget);
|
|
expect(find.text('1080p (Default)'), findsOneWidget);
|
|
// No `tags` in the detail: nothing to pick, nothing to override.
|
|
expect(find.text('Tags'), findsNothing);
|
|
|
|
await tester.tap(find.widgetWithText(FilledButton, 'Request'));
|
|
await tester.pumpAndSettle();
|
|
|
|
expect(postedBody, {
|
|
'mediaType': 'movie',
|
|
'mediaId': 550,
|
|
'is4k': false,
|
|
'serverId': 0,
|
|
'profileId': 6,
|
|
'rootFolder': '/movies',
|
|
});
|
|
});
|
|
|
|
testWidgets('permission changes the account adopts while open reshape and then close the sheet', (tester) async {
|
|
// The provider adopts a refreshed mask in place (no client rebuild):
|
|
// an advanced grant must fetch the servers the initial load skipped,
|
|
// and losing the request permission must close the sheet with the
|
|
// reason surfaced on the host. The provider persists every adoption,
|
|
// and the prefs store only completes under real async.
|
|
resetSharedPreferencesForTest();
|
|
var permissions = SeerrPermission.request;
|
|
final paths = <String>[];
|
|
final mock = MockClient((request) async {
|
|
paths.add(request.url.path);
|
|
switch (request.url.path) {
|
|
case '/api/v1/auth/me':
|
|
return _json({'id': 1, 'displayName': 'Alice', 'permissions': permissions});
|
|
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([_radarrServer(id: 0, name: 'Radarr Main'), _radarrServer(id: 1, name: 'Radarr Alt')]);
|
|
case '/api/v1/service/radarr/0':
|
|
return _json(_radarrDetail(_radarrServer(id: 0, name: 'Radarr Main'), profileName: '1080p'));
|
|
}
|
|
fail('unexpected request ${request.url.path}');
|
|
});
|
|
final account = (await tester.runAsync(() async {
|
|
final account = SeerrAccountProvider(authService: SeerrAuthService(httpClientFactory: () => mock));
|
|
addTearDown(account.dispose);
|
|
await account.adoptSession(
|
|
SeerrSession(
|
|
baseUrl: 'https://seerr.example.com',
|
|
method: SeerrAuthMethod.local,
|
|
identifier: 'a@b.c',
|
|
secret: '',
|
|
cookie: 'cookie',
|
|
userId: 1,
|
|
permissions: permissions,
|
|
displayName: 'Alice',
|
|
instanceLabel: 'Seerr',
|
|
createdAt: 0,
|
|
),
|
|
);
|
|
return account;
|
|
}))!;
|
|
final source = SeerrCatalogSource(account.catalogClient!);
|
|
addTearDown(source.dispose);
|
|
|
|
await _pumpSheet(tester, source: source, kind: MediaKind.movie, tmdbId: 550, title: 'Fight Club', account: account);
|
|
expect(paths, isNot(contains('/api/v1/service/radarr')));
|
|
expect(find.text('Destination server'), findsNothing);
|
|
|
|
permissions = SeerrPermission.request | SeerrPermission.requestAdvanced;
|
|
await tester.runAsync(() => account.catalogClient!.refreshUser());
|
|
await tester.pumpAndSettle();
|
|
|
|
expect(find.text('Destination server'), findsOneWidget);
|
|
expect(find.text('Radarr Main'), findsOneWidget);
|
|
|
|
permissions = 0;
|
|
await tester.runAsync(() => account.catalogClient!.refreshUser());
|
|
await tester.pumpAndSettle();
|
|
|
|
expect(find.byType(SeerrRequestSheet), findsNothing);
|
|
expect(find.text(t.seerr.permissionRevoked), findsOneWidget);
|
|
});
|
|
|
|
testWidgets('an anime series seeds the advanced pickers from the Sonarr anime defaults', (tester) async {
|
|
Map<String, dynamic>? postedBody;
|
|
final mock = MockClient((request) async {
|
|
switch (request.url.path) {
|
|
case '/api/v1/settings/public':
|
|
return _json(_publicSettings());
|
|
case '/api/v1/tv/46260':
|
|
return _json(_tvDetails(anime: true));
|
|
case '/api/v1/service/sonarr':
|
|
// The list endpoint reports no usable tags (`activeTags: []`).
|
|
return _json([
|
|
{..._sonarrServer(), 'activeTags': <int>[]}..remove('activeAnimeTags'),
|
|
]);
|
|
case '/api/v1/service/sonarr/0':
|
|
return _json(_sonarrDetail());
|
|
case '/api/v1/request':
|
|
postedBody = jsonDecode(request.body) as Map<String, dynamic>;
|
|
return _json({'id': 12, 'status': 2}, status: 201);
|
|
}
|
|
fail('unexpected request ${request.url.path}');
|
|
});
|
|
final source = _source(mock, permissions: SeerrPermission.request | SeerrPermission.requestAdvanced);
|
|
|
|
// Season, 4K-less advanced pickers, tags, note, and the button need
|
|
// more than the default 600px test viewport's 75% sheet cap.
|
|
tester.view.physicalSize = const Size(800, 1600);
|
|
tester.view.devicePixelRatio = 1.0;
|
|
addTearDown(tester.view.reset);
|
|
await _pumpSheet(tester, source: source, kind: MediaKind.show, tmdbId: 46260, title: 'Naruto');
|
|
|
|
expect(find.text('Anime (Default)'), findsOneWidget);
|
|
expect(find.text('/anime (Default)'), findsOneWidget);
|
|
expect(find.text('Japanese (Default)'), findsOneWidget);
|
|
expect(find.text('anime'), findsOneWidget);
|
|
expect(find.text('This series is an anime.'), findsOneWidget);
|
|
|
|
await tester.tap(find.text('Season 1'));
|
|
await tester.pump();
|
|
await tester.tap(find.widgetWithText(FilledButton, 'Request'));
|
|
await tester.pumpAndSettle();
|
|
|
|
expect(postedBody, {
|
|
'mediaType': 'tv',
|
|
'mediaId': 46260,
|
|
'seasons': [1],
|
|
'is4k': false,
|
|
'serverId': 0,
|
|
'profileId': 2,
|
|
'rootFolder': '/anime',
|
|
'languageProfileId': 2,
|
|
'tags': [5],
|
|
});
|
|
});
|
|
|
|
testWidgets('a non-anime series keeps the standard defaults and lets the user edit tags', (tester) async {
|
|
Map<String, dynamic>? postedBody;
|
|
final mock = MockClient((request) async {
|
|
switch (request.url.path) {
|
|
case '/api/v1/settings/public':
|
|
return _json(_publicSettings());
|
|
case '/api/v1/tv/46260':
|
|
return _json(_tvDetails(anime: false));
|
|
case '/api/v1/service/sonarr':
|
|
return _json([_sonarrServer()]);
|
|
case '/api/v1/service/sonarr/0':
|
|
return _json(_sonarrDetail());
|
|
case '/api/v1/request':
|
|
postedBody = jsonDecode(request.body) as Map<String, dynamic>;
|
|
return _json({'id': 12, 'status': 2}, status: 201);
|
|
}
|
|
fail('unexpected request ${request.url.path}');
|
|
});
|
|
final source = _source(mock, permissions: SeerrPermission.request | SeerrPermission.requestAdvanced);
|
|
|
|
tester.view.physicalSize = const Size(800, 1600);
|
|
tester.view.devicePixelRatio = 1.0;
|
|
addTearDown(tester.view.reset);
|
|
await _pumpSheet(tester, source: source, kind: MediaKind.show, tmdbId: 46260, title: 'Naruto');
|
|
|
|
expect(find.text('TV (Default)'), findsOneWidget);
|
|
expect(find.text('/tv (Default)'), findsOneWidget);
|
|
expect(find.text('This series is an anime.'), findsNothing);
|
|
expect(find.text('tv'), findsOneWidget);
|
|
// Collapsed until the row is tapped: only the season checkboxes exist.
|
|
expect(find.byType(CheckboxListTile), findsNWidgets(2));
|
|
|
|
// Selections made before the tag list opens must survive it: the tag
|
|
// list is inline, so no page swap can rebuild the sheet from scratch.
|
|
await tester.tap(find.text('Season 1'));
|
|
await tester.pump();
|
|
|
|
await tester.tap(find.text('Tags'));
|
|
await tester.pumpAndSettle();
|
|
expect(find.byType(CheckboxListTile), findsNWidgets(5));
|
|
await tester.tap(find.text('uhd'));
|
|
await tester.pump(); // summary is now "tv, uhd", so 'tv' names only the row
|
|
await tester.tap(find.text('tv'));
|
|
await tester.pumpAndSettle();
|
|
// Summary reflects the edit ('tv' now names only the deselected row).
|
|
expect(find.text('uhd'), findsNWidgets(2));
|
|
await tester.tap(find.text('Tags'));
|
|
await tester.pumpAndSettle();
|
|
expect(find.byType(CheckboxListTile), findsNWidgets(2));
|
|
expect(find.text('uhd'), findsOneWidget);
|
|
|
|
await tester.tap(find.widgetWithText(FilledButton, 'Request'));
|
|
await tester.pumpAndSettle();
|
|
|
|
expect(postedBody, {
|
|
'mediaType': 'tv',
|
|
'mediaId': 46260,
|
|
'seasons': [1],
|
|
'is4k': false,
|
|
'serverId': 0,
|
|
'profileId': 1,
|
|
'rootFolder': '/tv',
|
|
'languageProfileId': 1,
|
|
'tags': [9],
|
|
});
|
|
});
|
|
|
|
testWidgets('a slow detail load for a server the user left never replaces the current server', (tester) async {
|
|
Map<String, dynamic>? postedBody;
|
|
final pendingAlt = <Completer<http.Response>>[];
|
|
final main = _radarrServer(id: 0, name: 'Radarr Main');
|
|
final alt = _radarrServer(id: 1, name: 'Radarr Alt', profileId: 8, folder: '/alt');
|
|
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([main, alt]);
|
|
case '/api/v1/service/radarr/0':
|
|
return _json(_radarrDetail(main, profileName: '1080p'));
|
|
case '/api/v1/service/radarr/1':
|
|
return _gate(pendingAlt);
|
|
case '/api/v1/request':
|
|
postedBody = jsonDecode(request.body) as Map<String, dynamic>;
|
|
return _json({'id': 11, 'status': 2}, status: 201);
|
|
}
|
|
fail('unexpected request ${request.url.path}');
|
|
});
|
|
final source = _source(mock, permissions: SeerrPermission.request | SeerrPermission.requestAdvanced);
|
|
|
|
tester.view.physicalSize = const Size(800, 1600);
|
|
tester.view.devicePixelRatio = 1.0;
|
|
addTearDown(tester.view.reset);
|
|
await _pumpSheet(tester, source: source, kind: MediaKind.movie, tmdbId: 550, title: 'Fight Club');
|
|
expect(find.text('1080p (Default)'), findsOneWidget);
|
|
|
|
await _pickServer(tester, 'Radarr Alt');
|
|
expect(find.byType(LoadingIndicatorBox), findsOneWidget);
|
|
expect(find.text('1080p (Default)'), findsNothing);
|
|
|
|
await _pickServer(tester, 'Radarr Main');
|
|
expect(find.byType(LoadingIndicatorBox), findsNothing);
|
|
expect(find.text('1080p (Default)'), findsOneWidget);
|
|
|
|
pendingAlt.single.complete(_json(_radarrDetail(alt, profileName: 'Alt 720p')));
|
|
await tester.pumpAndSettle();
|
|
expect(find.byType(LoadingIndicatorBox), findsNothing);
|
|
expect(find.text('1080p (Default)'), findsOneWidget);
|
|
expect(find.text('Alt 720p (Default)'), findsNothing);
|
|
expect(find.text('/alt (Default)'), findsNothing);
|
|
|
|
await tester.tap(find.widgetWithText(FilledButton, 'Request'));
|
|
await tester.pumpAndSettle();
|
|
expect(postedBody, {
|
|
'mediaType': 'movie',
|
|
'mediaId': 550,
|
|
'is4k': false,
|
|
'serverId': 0,
|
|
'profileId': 6,
|
|
'rootFolder': '/movies',
|
|
});
|
|
});
|
|
|
|
testWidgets('re-selecting the same server applies only the newest detail response', (tester) async {
|
|
Map<String, dynamic>? postedBody;
|
|
final pendingMain = <Completer<http.Response>>[];
|
|
var mainCalls = 0;
|
|
final main = _radarrServer(id: 0, name: 'Radarr Main');
|
|
final alt = _radarrServer(id: 1, name: 'Radarr Alt', profileId: 8, folder: '/alt');
|
|
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([main, alt]);
|
|
case '/api/v1/service/radarr/0':
|
|
// The initial load answers at once; every re-selection is gated.
|
|
if (mainCalls++ == 0) return _json(_radarrDetail(main, profileName: 'Initial'));
|
|
return _gate(pendingMain);
|
|
case '/api/v1/service/radarr/1':
|
|
return _json(_radarrDetail(alt, profileName: 'Alt 720p'));
|
|
case '/api/v1/request':
|
|
postedBody = jsonDecode(request.body) as Map<String, dynamic>;
|
|
return _json({'id': 11, 'status': 2}, status: 201);
|
|
}
|
|
fail('unexpected request ${request.url.path}');
|
|
});
|
|
final source = _source(mock, permissions: SeerrPermission.request | SeerrPermission.requestAdvanced);
|
|
|
|
tester.view.physicalSize = const Size(800, 1600);
|
|
tester.view.devicePixelRatio = 1.0;
|
|
addTearDown(tester.view.reset);
|
|
await _pumpSheet(tester, source: source, kind: MediaKind.movie, tmdbId: 550, title: 'Fight Club');
|
|
expect(find.text('Initial (Default)'), findsOneWidget);
|
|
|
|
// Main -> Alt -> Main -> Alt -> Main: two Main loads in flight.
|
|
await _pickServer(tester, 'Radarr Alt');
|
|
await _pickServer(tester, 'Radarr Main');
|
|
await _pickServer(tester, 'Radarr Alt');
|
|
await _pickServer(tester, 'Radarr Main');
|
|
expect(pendingMain, hasLength(2));
|
|
expect(find.byType(LoadingIndicatorBox), findsOneWidget);
|
|
|
|
// The first (abandoned) load lands: same server id, but it must neither
|
|
// clear the spinner nor install its detail over the live load.
|
|
pendingMain[0].complete(_json(_radarrDetail(main, profileName: 'Stale')));
|
|
await _pumpFrames(tester);
|
|
expect(find.byType(LoadingIndicatorBox), findsOneWidget);
|
|
expect(find.text('Stale (Default)'), findsNothing);
|
|
|
|
pendingMain[1].complete(_json(_radarrDetail(main, profileName: 'Fresh')));
|
|
await tester.pumpAndSettle();
|
|
expect(find.byType(LoadingIndicatorBox), findsNothing);
|
|
expect(find.text('Fresh (Default)'), findsOneWidget);
|
|
expect(find.text('Stale (Default)'), findsNothing);
|
|
|
|
await tester.tap(find.widgetWithText(FilledButton, 'Request'));
|
|
await tester.pumpAndSettle();
|
|
expect(postedBody, {
|
|
'mediaType': 'movie',
|
|
'mediaId': 550,
|
|
'is4k': false,
|
|
'serverId': 0,
|
|
'profileId': 6,
|
|
'rootFolder': '/movies',
|
|
});
|
|
});
|
|
|
|
testWidgets('switching to a variant without servers drops the in-flight detail load', (tester) async {
|
|
Map<String, dynamic>? postedBody;
|
|
final pendingMain = <Completer<http.Response>>[];
|
|
var mainCalls = 0;
|
|
final main = _radarrServer(id: 0, name: 'Radarr Main');
|
|
final mock = MockClient((request) async {
|
|
switch (request.url.path) {
|
|
case '/api/v1/settings/public':
|
|
return _json({..._publicSettings(), 'movie4kEnabled': true});
|
|
case '/api/v1/movie/550':
|
|
return _json({'id': 550, 'title': 'Fight Club'});
|
|
case '/api/v1/service/radarr':
|
|
return _json([main]);
|
|
case '/api/v1/service/radarr/0':
|
|
if (mainCalls++ == 0) return _json(_radarrDetail(main, profileName: '1080p'));
|
|
return _gate(pendingMain);
|
|
case '/api/v1/request':
|
|
postedBody = jsonDecode(request.body) as Map<String, dynamic>;
|
|
return _json({'id': 11, 'status': 2}, status: 201);
|
|
}
|
|
fail('unexpected request ${request.url.path}');
|
|
});
|
|
final source = _source(
|
|
mock,
|
|
permissions:
|
|
SeerrPermission.request |
|
|
SeerrPermission.requestAdvanced |
|
|
SeerrPermission.request4k |
|
|
SeerrPermission.request4kMovie,
|
|
);
|
|
|
|
tester.view.physicalSize = const Size(800, 1600);
|
|
tester.view.devicePixelRatio = 1.0;
|
|
addTearDown(tester.view.reset);
|
|
await _pumpSheet(tester, source: source, kind: MediaKind.movie, tmdbId: 550, title: 'Fight Club');
|
|
expect(find.text('1080p (Default)'), findsOneWidget);
|
|
|
|
// 4K on (no 4K instance: nothing selected), off again: a fresh load.
|
|
await tester.tap(find.text('Request in 4K'));
|
|
await tester.pumpAndSettle();
|
|
expect(find.text('Advanced'), findsNothing);
|
|
await tester.tap(find.text('Request in 4K'));
|
|
await _pumpFrames(tester);
|
|
expect(pendingMain, hasLength(1));
|
|
expect(find.byType(LoadingIndicatorBox), findsOneWidget);
|
|
|
|
// Back to 4K while that load is in flight: no spinner anywhere, and the
|
|
// late response must not resurrect the non-4K destination.
|
|
await tester.tap(find.text('Request in 4K'));
|
|
await tester.pumpAndSettle();
|
|
expect(find.byType(LoadingIndicatorBox), findsNothing);
|
|
pendingMain[0].complete(_json(_radarrDetail(main, profileName: '1080p')));
|
|
await tester.pumpAndSettle();
|
|
expect(find.byType(LoadingIndicatorBox), findsNothing);
|
|
expect(find.text('Advanced'), findsNothing);
|
|
expect(find.text('1080p (Default)'), findsNothing);
|
|
|
|
await tester.tap(find.widgetWithText(FilledButton, 'Request'));
|
|
await tester.pumpAndSettle();
|
|
expect(postedBody, {'mediaType': 'movie', 'mediaId': 550, 'is4k': true});
|
|
});
|
|
|
|
testWidgets('tags the user cleared stay empty when an older detail load lands', (tester) async {
|
|
Map<String, dynamic>? postedBody;
|
|
final pendingMain = <Completer<http.Response>>[];
|
|
var mainCalls = 0;
|
|
final mock = MockClient((request) async {
|
|
switch (request.url.path) {
|
|
case '/api/v1/settings/public':
|
|
return _json({..._publicSettings(), 'series4kEnabled': true});
|
|
case '/api/v1/tv/46260':
|
|
return _json(_tvDetails(anime: false));
|
|
case '/api/v1/service/sonarr':
|
|
return _json([_sonarrServer()]);
|
|
case '/api/v1/service/sonarr/0':
|
|
if (mainCalls++ == 0) return _json(_sonarrDetail());
|
|
return _gate(pendingMain);
|
|
case '/api/v1/request':
|
|
postedBody = jsonDecode(request.body) as Map<String, dynamic>;
|
|
return _json({'id': 12, 'status': 2}, status: 201);
|
|
}
|
|
fail('unexpected request ${request.url.path}');
|
|
});
|
|
final source = _source(
|
|
mock,
|
|
permissions:
|
|
SeerrPermission.request |
|
|
SeerrPermission.requestAdvanced |
|
|
SeerrPermission.request4k |
|
|
SeerrPermission.request4kTv,
|
|
);
|
|
|
|
tester.view.physicalSize = const Size(800, 1600);
|
|
tester.view.devicePixelRatio = 1.0;
|
|
addTearDown(tester.view.reset);
|
|
await _pumpSheet(tester, source: source, kind: MediaKind.show, tmdbId: 46260, title: 'Naruto');
|
|
expect(find.text('tv'), findsOneWidget);
|
|
|
|
// Two 4K round-trips leave two loads for the same Sonarr instance in
|
|
// flight; only the second is the live one.
|
|
for (var i = 0; i < 2; i++) {
|
|
await tester.tap(find.text('Request in 4K'));
|
|
await tester.pumpAndSettle();
|
|
await tester.tap(find.text('Request in 4K'));
|
|
await _pumpFrames(tester);
|
|
}
|
|
expect(pendingMain, hasLength(2));
|
|
pendingMain[1].complete(_json(_sonarrDetail()));
|
|
await tester.pumpAndSettle();
|
|
expect(find.text('tv'), findsOneWidget);
|
|
|
|
// The user clears the instance's default tag.
|
|
await tester.tap(find.text('Tags'));
|
|
await tester.pumpAndSettle();
|
|
await tester.tap(find.widgetWithText(CheckboxListTile, 'tv'));
|
|
await tester.pumpAndSettle();
|
|
expect(find.text('No tags'), findsOneWidget);
|
|
|
|
// The abandoned load lands with the default tag: the edit must survive.
|
|
pendingMain[0].complete(_json(_sonarrDetail()));
|
|
await tester.pumpAndSettle();
|
|
expect(find.text('No tags'), findsOneWidget);
|
|
expect(tester.widget<CheckboxListTile>(find.widgetWithText(CheckboxListTile, 'tv')).value, isFalse);
|
|
|
|
await tester.tap(find.text('Season 1'));
|
|
await tester.pump();
|
|
await tester.tap(find.widgetWithText(FilledButton, 'Request'));
|
|
await tester.pumpAndSettle();
|
|
expect(postedBody, {
|
|
'mediaType': 'tv',
|
|
'mediaId': 46260,
|
|
'seasons': [1],
|
|
'is4k': false,
|
|
'serverId': 0,
|
|
'profileId': 1,
|
|
'rootFolder': '/tv',
|
|
'languageProfileId': 1,
|
|
'tags': <int>[],
|
|
});
|
|
});
|
|
}
|