fix(runtime): harden application service boundaries
This commit is contained in:
@@ -1,17 +1,34 @@
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:drift/native.dart';
|
||||
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/connection/connection.dart';
|
||||
import 'package:plezy/connection/connection_registry.dart';
|
||||
import 'package:plezy/database/app_database.dart';
|
||||
import 'package:plezy/focus/input_mode_tracker.dart';
|
||||
import 'package:plezy/media/ids.dart';
|
||||
import 'package:plezy/profiles/active_profile_binder.dart';
|
||||
import 'package:plezy/profiles/active_profile_provider.dart';
|
||||
import 'package:plezy/profiles/plex_home_service.dart';
|
||||
import 'package:plezy/profiles/profile.dart';
|
||||
import 'package:plezy/profiles/profile_connection.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/screens/settings/add_jellyfin_screen.dart';
|
||||
import 'package:plezy/services/jellyfin_auth_service.dart';
|
||||
import 'package:plezy/services/credential_vault.dart';
|
||||
import 'package:plezy/services/data_aggregation_service.dart';
|
||||
import 'package:plezy/services/jellyfin_lan_discovery_service.dart';
|
||||
import 'package:plezy/services/multi_server_manager.dart';
|
||||
import 'package:plezy/services/storage_service.dart';
|
||||
import 'package:plezy/theme/mono_theme.dart';
|
||||
import 'package:plezy/utils/platform_detector.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
|
||||
import '../../test_helpers/prefs.dart';
|
||||
|
||||
@@ -79,6 +96,219 @@ JellyfinConnectionAuthService _jellyfinAuthServiceForBareHost() {
|
||||
);
|
||||
}
|
||||
|
||||
JellyfinConnectionAuthService _successfulAuthService({required bool quickConnect}) {
|
||||
Map<String, Object?> authResponse() => {
|
||||
'AccessToken': '',
|
||||
'User': {
|
||||
'Id': 'opaque-user',
|
||||
'Name': 'Opaque User',
|
||||
'Policy': {'IsAdministrator': false},
|
||||
},
|
||||
};
|
||||
|
||||
return JellyfinConnectionAuthService(
|
||||
clientName: 'Plezy',
|
||||
clientVersion: 'test',
|
||||
deviceName: 'Opaque Device',
|
||||
testHttpClientFactory: () => MockClient((request) async {
|
||||
switch (request.url.path) {
|
||||
case '/System/Info/Public':
|
||||
return http.Response(
|
||||
jsonEncode({'Id': 'opaque-machine', 'ServerName': 'Opaque Server', 'Version': '10.9.0'}),
|
||||
200,
|
||||
headers: {'content-type': 'application/json'},
|
||||
);
|
||||
case '/QuickConnect/Enabled':
|
||||
return http.Response(jsonEncode(quickConnect), 200, headers: {'content-type': 'application/json'});
|
||||
case '/QuickConnect/Initiate':
|
||||
return http.Response(
|
||||
jsonEncode({'Code': '654321', 'Secret': 'opaque-secret'}),
|
||||
200,
|
||||
headers: {'content-type': 'application/json'},
|
||||
);
|
||||
case '/QuickConnect/Connect':
|
||||
return http.Response(jsonEncode({'Authenticated': true}), 200, headers: {'content-type': 'application/json'});
|
||||
case '/Users/AuthenticateByName':
|
||||
case '/Users/AuthenticateWithQuickConnect':
|
||||
return http.Response(jsonEncode(authResponse()), 200, headers: {'content-type': 'application/json'});
|
||||
}
|
||||
return http.Response('', 404);
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
class _NoTimerPlexHomeService extends PlexHomeService {
|
||||
_NoTimerPlexHomeService({required super.connections, required super.profileConnections, required super.storage});
|
||||
|
||||
@override
|
||||
Future<void> start() async {}
|
||||
|
||||
@override
|
||||
Future<void> reloadFromStorage() async {}
|
||||
}
|
||||
|
||||
class _CountingJellyfinManager extends MultiServerManager {
|
||||
int calls = 0;
|
||||
|
||||
@override
|
||||
Future<bool> addJellyfinConnection(JellyfinConnection connection) async {
|
||||
calls++;
|
||||
updateServerStatus(ServerId(connection.serverMachineId), true);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
class _RouteJoinFailure implements Exception {
|
||||
const _RouteJoinFailure();
|
||||
}
|
||||
|
||||
class _NoWatchActiveProfileProvider extends ActiveProfileProvider {
|
||||
_NoWatchActiveProfileProvider({
|
||||
required super.registry,
|
||||
required super.plexHome,
|
||||
required super.connections,
|
||||
required super.storage,
|
||||
});
|
||||
|
||||
@override
|
||||
Future<void> initialize() async {}
|
||||
}
|
||||
|
||||
class _CountingActiveProfileBinder extends ActiveProfileBinder {
|
||||
_CountingActiveProfileBinder({
|
||||
required super.activeProfile,
|
||||
required super.connections,
|
||||
required super.profileConnections,
|
||||
required super.serverManager,
|
||||
required super.multiServerProvider,
|
||||
required super.pinPrompt,
|
||||
});
|
||||
|
||||
int calls = 0;
|
||||
|
||||
@override
|
||||
Future<void> rebindIfActive(String profileId) async {
|
||||
calls++;
|
||||
}
|
||||
}
|
||||
|
||||
class _FailingRouteJoinRegistry extends ProfileConnectionRegistry {
|
||||
_FailingRouteJoinRegistry(super.db);
|
||||
|
||||
@override
|
||||
Future<void> upsert(ProfileConnection connection, {bool makeDefault = false}) async {
|
||||
await super.upsert(connection, makeDefault: makeDefault);
|
||||
throw const _RouteJoinFailure();
|
||||
}
|
||||
}
|
||||
|
||||
class _RouteHarness {
|
||||
_RouteHarness._({
|
||||
required this.db,
|
||||
required this.storage,
|
||||
required this.profiles,
|
||||
required this.connections,
|
||||
required this.profileConnections,
|
||||
required this.plexHome,
|
||||
required this.activeProfiles,
|
||||
required this.manager,
|
||||
required this.multiServerProvider,
|
||||
required this.binder,
|
||||
});
|
||||
|
||||
final AppDatabase db;
|
||||
final StorageService storage;
|
||||
final ProfileRegistry profiles;
|
||||
final ConnectionRegistry connections;
|
||||
final ProfileConnectionRegistry profileConnections;
|
||||
final PlexHomeService plexHome;
|
||||
final ActiveProfileProvider activeProfiles;
|
||||
final _CountingJellyfinManager manager;
|
||||
final MultiServerProvider multiServerProvider;
|
||||
final _CountingActiveProfileBinder binder;
|
||||
static Future<_RouteHarness> create({bool failJoin = false}) async {
|
||||
final db = AppDatabase.forTesting(NativeDatabase.memory());
|
||||
final storage = await StorageService.getInstance();
|
||||
final profiles = ProfileRegistry(db);
|
||||
final connections = ConnectionRegistry(db);
|
||||
final profileConnections = failJoin ? _FailingRouteJoinRegistry(db) : ProfileConnectionRegistry(db);
|
||||
final plexHome = _NoTimerPlexHomeService(
|
||||
connections: connections,
|
||||
profileConnections: profileConnections,
|
||||
storage: storage,
|
||||
);
|
||||
final activeProfiles = _NoWatchActiveProfileProvider(
|
||||
registry: profiles,
|
||||
plexHome: plexHome,
|
||||
connections: connections,
|
||||
storage: storage,
|
||||
);
|
||||
final manager = _CountingJellyfinManager();
|
||||
final multiServerProvider = MultiServerProvider(manager, DataAggregationService(manager));
|
||||
final binder = _CountingActiveProfileBinder(
|
||||
activeProfile: activeProfiles,
|
||||
connections: connections,
|
||||
profileConnections: profileConnections,
|
||||
serverManager: manager,
|
||||
multiServerProvider: multiServerProvider,
|
||||
pinPrompt: (_, {String? errorMessage}) async => null,
|
||||
);
|
||||
return _RouteHarness._(
|
||||
db: db,
|
||||
storage: storage,
|
||||
profiles: profiles,
|
||||
connections: connections,
|
||||
profileConnections: profileConnections,
|
||||
plexHome: plexHome,
|
||||
activeProfiles: activeProfiles,
|
||||
manager: manager,
|
||||
multiServerProvider: multiServerProvider,
|
||||
binder: binder,
|
||||
);
|
||||
}
|
||||
|
||||
Widget app({required bool quickConnect, required ValueChanged<Future<bool?>> onRoute}) {
|
||||
return MultiProvider(
|
||||
providers: [
|
||||
Provider<AppDatabase>.value(value: db),
|
||||
Provider<StorageService>.value(value: storage),
|
||||
Provider<ProfileRegistry>.value(value: profiles),
|
||||
Provider<ConnectionRegistry>.value(value: connections),
|
||||
Provider<ProfileConnectionRegistry>.value(value: profileConnections),
|
||||
ChangeNotifierProvider<ActiveProfileProvider>.value(value: activeProfiles),
|
||||
Provider<ActiveProfileBinder>.value(value: binder),
|
||||
],
|
||||
child: MaterialApp(
|
||||
theme: monoTheme(dark: true),
|
||||
home: Builder(
|
||||
builder: (context) => TextButton(
|
||||
onPressed: () => onRoute(
|
||||
Navigator.of(context).push<bool>(
|
||||
MaterialPageRoute(
|
||||
builder: (_) => AddJellyfinScreen(
|
||||
authServiceFactory: () => _successfulAuthService(quickConnect: quickConnect),
|
||||
localDiscoveryFactory: _noLocalServers,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
child: const Text('Open route'),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> dispose() async {
|
||||
binder.dispose();
|
||||
multiServerProvider.dispose();
|
||||
await activeProfiles.resetForTesting();
|
||||
activeProfiles.dispose();
|
||||
await plexHome.dispose();
|
||||
await db.close();
|
||||
}
|
||||
}
|
||||
|
||||
Future<List<DiscoveredJellyfinServer>> _noLocalServers() async => const [];
|
||||
|
||||
void main() {
|
||||
@@ -396,6 +626,142 @@ void main() {
|
||||
expect(FocusManager.instance.primaryFocus?.debugLabel, 'AddJellyfin:Discovered:srv-2');
|
||||
});
|
||||
|
||||
testWidgets('password sign-in commits one complete bundle and binds once', (tester) async {
|
||||
resetSharedPreferencesForTest();
|
||||
CredentialVault.resetKeyForTesting();
|
||||
final harness = await _RouteHarness.create();
|
||||
await tester.runAsync(() => CredentialVault.protect('opaque-vault-warmup'));
|
||||
late Future<bool?> routeResult;
|
||||
await tester.pumpWidget(harness.app(quickConnect: false, onRoute: (result) => routeResult = result));
|
||||
await tester.tap(find.text('Open route'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
await tester.enterText(find.byType(TextField).first, 'https://media.invalid');
|
||||
await tester.testTextInput.receiveAction(TextInputAction.go);
|
||||
await tester.pumpAndSettle();
|
||||
await tester.enterText(find.byType(TextField).at(1), 'Opaque User');
|
||||
await tester.enterText(find.byType(TextField).at(2), 'opaque-password');
|
||||
await tester.tap(find.text('Sign in'));
|
||||
await tester.pump();
|
||||
await tester.pump(const Duration(milliseconds: 100));
|
||||
for (var i = 0; i < 10; i++) {
|
||||
await tester.pump(const Duration(milliseconds: 100));
|
||||
}
|
||||
expect(harness.binder.calls, 1);
|
||||
expect(find.text('Open route'), findsOneWidget);
|
||||
|
||||
expect(await routeResult, isTrue);
|
||||
final bundle = await tester.runAsync(() async {
|
||||
return (
|
||||
profiles: await harness.profiles.list(),
|
||||
connections: await harness.connections.list(),
|
||||
joins: await harness.profileConnections.listAll(),
|
||||
);
|
||||
});
|
||||
expect(bundle!.profiles, hasLength(1));
|
||||
expect(bundle.connections, hasLength(1));
|
||||
expect(bundle.joins, hasLength(1));
|
||||
expect(bundle.joins.single.profileId, bundle.profiles.single.id);
|
||||
expect(bundle.joins.single.connectionId, bundle.connections.single.id);
|
||||
expect(harness.storage.getActiveProfileId(), bundle.profiles.single.id);
|
||||
expect(harness.activeProfiles.activeId, bundle.profiles.single.id);
|
||||
expect(harness.binder.calls, 1);
|
||||
|
||||
await tester.pumpWidget(const SizedBox.shrink());
|
||||
await harness.dispose();
|
||||
});
|
||||
|
||||
testWidgets('Quick Connect commits one complete bundle and binds once', (tester) async {
|
||||
resetSharedPreferencesForTest();
|
||||
CredentialVault.resetKeyForTesting();
|
||||
final harness = await _RouteHarness.create();
|
||||
await tester.runAsync(() => CredentialVault.protect('opaque-vault-warmup'));
|
||||
late Future<bool?> routeResult;
|
||||
await tester.pumpWidget(harness.app(quickConnect: true, onRoute: (result) => routeResult = result));
|
||||
await tester.tap(find.text('Open route'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
await tester.enterText(find.byType(TextField).first, 'https://media.invalid');
|
||||
await tester.testTextInput.receiveAction(TextInputAction.go);
|
||||
await tester.pumpAndSettle();
|
||||
await tester.tap(find.text('Use Quick Connect'));
|
||||
await tester.pump();
|
||||
await tester.pump(const Duration(milliseconds: 100));
|
||||
await tester.pump();
|
||||
for (var i = 0; i < 10; i++) {
|
||||
await tester.pump(const Duration(milliseconds: 100));
|
||||
}
|
||||
await tester.pump(const Duration(milliseconds: 100));
|
||||
|
||||
expect(await routeResult, isTrue);
|
||||
final bundle = await tester.runAsync(() async {
|
||||
return (
|
||||
profiles: await harness.profiles.list(),
|
||||
connections: await harness.connections.list(),
|
||||
joins: await harness.profileConnections.listAll(),
|
||||
);
|
||||
});
|
||||
expect(bundle!.profiles, hasLength(1));
|
||||
expect(bundle.connections, hasLength(1));
|
||||
expect(bundle.joins, hasLength(1));
|
||||
expect(bundle.joins.single.profileId, bundle.profiles.single.id);
|
||||
expect(bundle.joins.single.connectionId, bundle.connections.single.id);
|
||||
expect(harness.storage.getActiveProfileId(), bundle.profiles.single.id);
|
||||
expect(harness.activeProfiles.activeId, bundle.profiles.single.id);
|
||||
expect(harness.binder.calls, 1);
|
||||
|
||||
await tester.pumpWidget(const SizedBox.shrink());
|
||||
await harness.dispose();
|
||||
});
|
||||
|
||||
testWidgets('join failure leaves route open, state unchanged, and never binds', (tester) async {
|
||||
resetSharedPreferencesForTest();
|
||||
CredentialVault.resetKeyForTesting();
|
||||
final harness = await _RouteHarness.create(failJoin: true);
|
||||
await tester.runAsync(() => CredentialVault.protect('opaque-vault-warmup'));
|
||||
var routeCompleted = false;
|
||||
late Future<bool?> routeResult;
|
||||
await tester.pumpWidget(
|
||||
harness.app(
|
||||
quickConnect: false,
|
||||
onRoute: (result) {
|
||||
routeResult = result;
|
||||
result.then((_) => routeCompleted = true);
|
||||
},
|
||||
),
|
||||
);
|
||||
await tester.tap(find.text('Open route'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
await tester.enterText(find.byType(TextField).first, 'https://media.invalid');
|
||||
await tester.testTextInput.receiveAction(TextInputAction.go);
|
||||
await tester.pumpAndSettle();
|
||||
await tester.enterText(find.byType(TextField).at(1), 'Opaque User');
|
||||
await tester.enterText(find.byType(TextField).at(2), 'opaque-password');
|
||||
await tester.tap(find.text('Sign in'));
|
||||
await tester.pump();
|
||||
await tester.pump(const Duration(milliseconds: 100));
|
||||
|
||||
expect(routeCompleted, isFalse);
|
||||
expect(find.textContaining('Sign-in failed'), findsOneWidget);
|
||||
final bundle = await tester.runAsync(() async {
|
||||
return (
|
||||
profiles: await harness.profiles.list(),
|
||||
connections: await harness.connections.list(),
|
||||
joins: await harness.profileConnections.listAll(),
|
||||
);
|
||||
});
|
||||
expect(bundle!.profiles, isEmpty);
|
||||
expect(bundle.connections, isEmpty);
|
||||
expect(bundle.joins, isEmpty);
|
||||
expect(harness.storage.getActiveProfileId(), isNull);
|
||||
expect(harness.binder.calls, 0);
|
||||
|
||||
await tester.pumpWidget(const SizedBox.shrink());
|
||||
routeResult.ignore();
|
||||
await harness.dispose();
|
||||
});
|
||||
|
||||
group('Jellyfin profile binding decisions', () {
|
||||
test('creates a local profile only on true first-run with no profiles', () {
|
||||
expect(shouldCreateLocalJellyfinProfile(targetProfile: null, activeProfile: null, hasProfiles: false), isTrue);
|
||||
|
||||
@@ -0,0 +1,472 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:drift/native.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:plezy/connection/connection.dart';
|
||||
import 'package:plezy/connection/connection_registry.dart';
|
||||
import 'package:plezy/database/app_database.dart';
|
||||
import 'package:plezy/profiles/active_profile_provider.dart';
|
||||
import 'package:plezy/profiles/plex_home_service.dart';
|
||||
import 'package:plezy/profiles/profile.dart';
|
||||
import 'package:plezy/profiles/profile_connection.dart';
|
||||
import 'package:plezy/profiles/profile_connection_registry.dart';
|
||||
import 'package:plezy/profiles/profile_registry.dart';
|
||||
import 'package:plezy/screens/settings/connection_persistence.dart';
|
||||
import 'package:plezy/services/credential_vault.dart';
|
||||
import 'package:plezy/services/storage_service.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
|
||||
import '../../test_helpers/prefs.dart';
|
||||
|
||||
final class _AfterStatementFailure implements Exception {
|
||||
const _AfterStatementFailure(this.stage);
|
||||
|
||||
final String stage;
|
||||
|
||||
@override
|
||||
String toString() => 'after $stage statement';
|
||||
}
|
||||
|
||||
class _FailingProfileRegistry extends ProfileRegistry {
|
||||
_FailingProfileRegistry(super.db);
|
||||
|
||||
@override
|
||||
Future<void> upsert(Profile profile) async {
|
||||
await super.upsert(profile);
|
||||
throw const _AfterStatementFailure('profile');
|
||||
}
|
||||
}
|
||||
|
||||
class _FailingConnectionRegistry extends ConnectionRegistry {
|
||||
_FailingConnectionRegistry(super.db);
|
||||
|
||||
@override
|
||||
Future<void> upsert(Connection connection) async {
|
||||
await super.upsert(connection);
|
||||
throw const _AfterStatementFailure('connection');
|
||||
}
|
||||
}
|
||||
|
||||
class _FailingProfileConnectionRegistry extends ProfileConnectionRegistry {
|
||||
_FailingProfileConnectionRegistry(super.db);
|
||||
|
||||
@override
|
||||
Future<void> upsert(ProfileConnection connection, {bool makeDefault = false}) async {
|
||||
await super.upsert(connection, makeDefault: makeDefault);
|
||||
throw const _AfterStatementFailure('join');
|
||||
}
|
||||
}
|
||||
|
||||
class _GatedProfileConnectionRegistry extends ProfileConnectionRegistry {
|
||||
_GatedProfileConnectionRegistry(super.db, {required this.fail});
|
||||
|
||||
final bool fail;
|
||||
final started = Completer<void>();
|
||||
final release = Completer<void>();
|
||||
|
||||
@override
|
||||
Future<void> upsert(ProfileConnection connection, {bool makeDefault = false}) async {
|
||||
await super.upsert(connection, makeDefault: makeDefault);
|
||||
started.complete();
|
||||
await release.future;
|
||||
if (fail) throw const _AfterStatementFailure('gated join');
|
||||
}
|
||||
}
|
||||
|
||||
class _RejectingActiveProfileProvider extends ActiveProfileProvider {
|
||||
_RejectingActiveProfileProvider({
|
||||
required super.registry,
|
||||
required super.plexHome,
|
||||
required super.connections,
|
||||
required super.storage,
|
||||
});
|
||||
|
||||
@override
|
||||
Future<bool> activate(Profile profile, {String? pin}) async => false;
|
||||
}
|
||||
|
||||
class _ThrowingActiveProfileProvider extends ActiveProfileProvider {
|
||||
_ThrowingActiveProfileProvider({
|
||||
required super.registry,
|
||||
required super.plexHome,
|
||||
required super.connections,
|
||||
required super.storage,
|
||||
});
|
||||
|
||||
@override
|
||||
Future<bool> activate(Profile profile, {String? pin}) async {
|
||||
await super.activate(profile, pin: pin);
|
||||
throw const _AfterStatementFailure('active marker');
|
||||
}
|
||||
}
|
||||
|
||||
class _NoTimerPlexHomeService extends PlexHomeService {
|
||||
_NoTimerPlexHomeService({required super.connections, required super.profileConnections, required super.storage});
|
||||
|
||||
@override
|
||||
Future<void> start() async {}
|
||||
|
||||
@override
|
||||
Future<void> reloadFromStorage() async {}
|
||||
}
|
||||
|
||||
JellyfinConnection _connection({String token = 'opaque-token-current', String userName = 'Fixture User'}) {
|
||||
return JellyfinConnection(
|
||||
id: 'fixture-machine/fixture-user',
|
||||
baseUrl: 'https://media.invalid',
|
||||
serverName: 'Fixture Server',
|
||||
serverMachineId: 'fixture-machine',
|
||||
userId: 'fixture-user',
|
||||
userName: userName,
|
||||
accessToken: token,
|
||||
deviceId: 'fixture-device',
|
||||
createdAt: DateTime.utc(2026, 1, 2),
|
||||
);
|
||||
}
|
||||
|
||||
Profile _profile(String id, {String name = 'Fixture Profile'}) {
|
||||
return Profile.local(id: id, displayName: name, createdAt: DateTime.utc(2026, 1, 1));
|
||||
}
|
||||
|
||||
Future<Object?> _runProvisioning(WidgetTester tester, Future<bool> Function() command) {
|
||||
return tester.runAsync<Object?>(() async {
|
||||
try {
|
||||
return await command();
|
||||
} catch (error) {
|
||||
return error;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
ProfileConnection _join(Profile profile, JellyfinConnection connection) {
|
||||
return ProfileConnection(
|
||||
profileId: profile.id,
|
||||
connectionId: connection.id,
|
||||
userToken: connection.accessToken,
|
||||
userIdentifier: connection.userId,
|
||||
tokenAcquiredAt: DateTime.utc(2026, 1, 2),
|
||||
);
|
||||
}
|
||||
|
||||
void main() {
|
||||
late AppDatabase db;
|
||||
late StorageService storage;
|
||||
late ProfileRegistry profiles;
|
||||
late ConnectionRegistry connections;
|
||||
late ProfileConnectionRegistry profileConnections;
|
||||
late PlexHomeService plexHome;
|
||||
late ActiveProfileProvider activeProfiles;
|
||||
BuildContext? hostContext;
|
||||
|
||||
Future<void> mountHost(
|
||||
WidgetTester tester, {
|
||||
ProfileRegistry? profileRegistry,
|
||||
ConnectionRegistry? connectionRegistry,
|
||||
ProfileConnectionRegistry? joinRegistry,
|
||||
bool initializeActive = false,
|
||||
ActiveProfileProvider Function(
|
||||
ProfileRegistry profiles,
|
||||
PlexHomeService plexHome,
|
||||
ConnectionRegistry connections,
|
||||
StorageService storage,
|
||||
)?
|
||||
activeFactory,
|
||||
}) async {
|
||||
await tester.runAsync(() => CredentialVault.protect('opaque-vault-warmup'));
|
||||
profiles = profileRegistry ?? ProfileRegistry(db);
|
||||
connections = connectionRegistry ?? ConnectionRegistry(db);
|
||||
profileConnections = joinRegistry ?? ProfileConnectionRegistry(db);
|
||||
plexHome = _NoTimerPlexHomeService(
|
||||
connections: connections,
|
||||
profileConnections: profileConnections,
|
||||
storage: storage,
|
||||
);
|
||||
activeProfiles =
|
||||
activeFactory?.call(profiles, plexHome, connections, storage) ??
|
||||
ActiveProfileProvider(registry: profiles, plexHome: plexHome, connections: connections, storage: storage);
|
||||
if (initializeActive) await tester.runAsync(activeProfiles.initialize);
|
||||
await tester.pumpWidget(
|
||||
MultiProvider(
|
||||
providers: [
|
||||
Provider<AppDatabase>.value(value: db),
|
||||
Provider<StorageService>.value(value: storage),
|
||||
Provider<ProfileRegistry>.value(value: profiles),
|
||||
Provider<ConnectionRegistry>.value(value: connections),
|
||||
Provider<ProfileConnectionRegistry>.value(value: profileConnections),
|
||||
ChangeNotifierProvider<ActiveProfileProvider>.value(value: activeProfiles),
|
||||
],
|
||||
child: MaterialApp(
|
||||
home: Builder(
|
||||
builder: (context) {
|
||||
hostContext = context;
|
||||
return const SizedBox.shrink();
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> expectEmptyAttempt(Profile profile, JellyfinConnection connection) async {
|
||||
expect(await ProfileRegistry(db).get(profile.id), isNull);
|
||||
expect(await ConnectionRegistry(db).get(connection.id), isNull);
|
||||
expect(await ProfileConnectionRegistry(db).get(profile.id, connection.id), isNull);
|
||||
expect(storage.getActiveProfileId(), isNull);
|
||||
expect(storage.getProfileLastUsed(profile.id), isNull);
|
||||
}
|
||||
|
||||
setUp(() async {
|
||||
resetSharedPreferencesForTest();
|
||||
CredentialVault.resetKeyForTesting();
|
||||
db = AppDatabase.forTesting(NativeDatabase.memory());
|
||||
storage = await StorageService.getInstance();
|
||||
hostContext = null;
|
||||
});
|
||||
|
||||
tearDown(() async {
|
||||
if (hostContext != null) {
|
||||
await activeProfiles.resetForTesting();
|
||||
activeProfiles.dispose();
|
||||
await plexHome.dispose();
|
||||
}
|
||||
await db.close();
|
||||
});
|
||||
|
||||
testWidgets('profile statement failure rolls back the complete first-run bundle', (tester) async {
|
||||
final profile = _profile('fixture-new-profile');
|
||||
final connection = _connection();
|
||||
var runtimeAdds = 0;
|
||||
await mountHost(tester, profileRegistry: _FailingProfileRegistry(db));
|
||||
|
||||
final error = await _runProvisioning(
|
||||
tester,
|
||||
() => persistAndBindConnection(
|
||||
context: hostContext!,
|
||||
connection: connection,
|
||||
bindToProfile: _join(profile, connection),
|
||||
firstRunProfile: profile,
|
||||
addToManager: () async {
|
||||
runtimeAdds++;
|
||||
return true;
|
||||
},
|
||||
),
|
||||
);
|
||||
expect(error, isA<_AfterStatementFailure>());
|
||||
|
||||
await expectEmptyAttempt(profile, connection);
|
||||
await tester.pumpWidget(const SizedBox.shrink());
|
||||
await tester.pump();
|
||||
expect(runtimeAdds, 0);
|
||||
});
|
||||
|
||||
testWidgets('connection statement failure rolls back the complete first-run bundle', (tester) async {
|
||||
final profile = _profile('fixture-new-profile');
|
||||
final connection = _connection();
|
||||
await mountHost(tester, connectionRegistry: _FailingConnectionRegistry(db));
|
||||
|
||||
final error = await _runProvisioning(
|
||||
tester,
|
||||
() => persistAndBindConnection(
|
||||
context: hostContext!,
|
||||
connection: connection,
|
||||
bindToProfile: _join(profile, connection),
|
||||
firstRunProfile: profile,
|
||||
addToManager: null,
|
||||
),
|
||||
);
|
||||
expect(error, isA<_AfterStatementFailure>());
|
||||
|
||||
await expectEmptyAttempt(profile, connection);
|
||||
await tester.pumpWidget(const SizedBox.shrink());
|
||||
await tester.pump();
|
||||
});
|
||||
|
||||
testWidgets('join statement failure rolls back the complete first-run bundle', (tester) async {
|
||||
final profile = _profile('fixture-new-profile');
|
||||
final connection = _connection();
|
||||
await mountHost(tester, joinRegistry: _FailingProfileConnectionRegistry(db));
|
||||
|
||||
final error = await _runProvisioning(
|
||||
tester,
|
||||
() => persistAndBindConnection(
|
||||
context: hostContext!,
|
||||
connection: connection,
|
||||
bindToProfile: _join(profile, connection),
|
||||
firstRunProfile: profile,
|
||||
addToManager: null,
|
||||
),
|
||||
);
|
||||
expect(error, isA<_AfterStatementFailure>());
|
||||
|
||||
await expectEmptyAttempt(profile, connection);
|
||||
await tester.pumpWidget(const SizedBox.shrink());
|
||||
await tester.pump();
|
||||
});
|
||||
|
||||
testWidgets('existing connection update is restored when join statement fails', (tester) async {
|
||||
final target = _profile('fixture-existing-profile');
|
||||
final priorConnection = _connection(token: 'opaque-token-prior', userName: 'Prior User');
|
||||
final updatedConnection = _connection(token: 'opaque-token-updated', userName: 'Updated User');
|
||||
await ProfileRegistry(db).upsert(target);
|
||||
await tester.runAsync(() => ConnectionRegistry(db).upsert(priorConnection));
|
||||
await storage.setActiveProfileId(target.id);
|
||||
await mountHost(tester, joinRegistry: _FailingProfileConnectionRegistry(db));
|
||||
|
||||
final error = await _runProvisioning(
|
||||
tester,
|
||||
() => persistAndBindConnection(
|
||||
context: hostContext!,
|
||||
connection: updatedConnection,
|
||||
bindToProfile: _join(target, updatedConnection),
|
||||
addToManager: null,
|
||||
),
|
||||
);
|
||||
expect(error, isA<_AfterStatementFailure>());
|
||||
|
||||
final restored = await tester.runAsync(() => ConnectionRegistry(db).get(priorConnection.id)) as JellyfinConnection;
|
||||
expect(restored.accessToken, priorConnection.accessToken);
|
||||
expect(restored.userName, priorConnection.userName);
|
||||
expect(await ProfileRegistry(db).get(target.id), isNotNull);
|
||||
expect(await ProfileConnectionRegistry(db).get(target.id, priorConnection.id), isNull);
|
||||
expect(storage.getActiveProfileId(), target.id);
|
||||
await tester.pumpWidget(const SizedBox.shrink());
|
||||
await tester.pump();
|
||||
});
|
||||
|
||||
testWidgets('activation rejection compensates relational and preference state', (tester) async {
|
||||
final priorProfile = _profile('fixture-prior-profile', name: 'Prior Profile');
|
||||
final newProfile = _profile('fixture-new-profile');
|
||||
final connection = _connection();
|
||||
await ProfileRegistry(db).upsert(priorProfile);
|
||||
await storage.setActiveProfileId(priorProfile.id);
|
||||
await mountHost(
|
||||
tester,
|
||||
initializeActive: true,
|
||||
activeFactory: (profiles, plexHome, connections, storage) => _RejectingActiveProfileProvider(
|
||||
registry: profiles,
|
||||
plexHome: plexHome,
|
||||
connections: connections,
|
||||
storage: storage,
|
||||
),
|
||||
);
|
||||
|
||||
final error = await _runProvisioning(
|
||||
tester,
|
||||
() => persistAndBindConnection(
|
||||
context: hostContext!,
|
||||
connection: connection,
|
||||
bindToProfile: _join(newProfile, connection),
|
||||
firstRunProfile: newProfile,
|
||||
addToManager: null,
|
||||
),
|
||||
);
|
||||
expect(error, isA<StateError>());
|
||||
|
||||
expect(await ProfileRegistry(db).get(newProfile.id), isNull);
|
||||
expect(await ConnectionRegistry(db).get(connection.id), isNull);
|
||||
expect(await ProfileConnectionRegistry(db).get(newProfile.id, connection.id), isNull);
|
||||
expect(storage.getProfileLastUsed(newProfile.id), isNull);
|
||||
expect(storage.getActiveProfileId(), priorProfile.id);
|
||||
expect(activeProfiles.activeId, priorProfile.id);
|
||||
await tester.pumpWidget(const SizedBox.shrink());
|
||||
await tester.pump();
|
||||
});
|
||||
|
||||
testWidgets('activation throw restores a prior same-id connection and original active state', (tester) async {
|
||||
final priorProfile = _profile('fixture-prior-profile', name: 'Prior Profile');
|
||||
final newProfile = _profile('fixture-new-profile');
|
||||
final priorConnection = _connection(token: 'opaque-token-prior', userName: 'Prior User');
|
||||
final updatedConnection = _connection(token: 'opaque-token-updated', userName: 'Updated User');
|
||||
await ProfileRegistry(db).upsert(priorProfile);
|
||||
await tester.runAsync(() => ConnectionRegistry(db).upsert(priorConnection));
|
||||
await storage.setActiveProfileId(priorProfile.id);
|
||||
await mountHost(
|
||||
tester,
|
||||
initializeActive: true,
|
||||
activeFactory: (profiles, plexHome, connections, storage) => _ThrowingActiveProfileProvider(
|
||||
registry: profiles,
|
||||
plexHome: plexHome,
|
||||
connections: connections,
|
||||
storage: storage,
|
||||
),
|
||||
);
|
||||
|
||||
final error = await _runProvisioning(
|
||||
tester,
|
||||
() => persistAndBindConnection(
|
||||
context: hostContext!,
|
||||
connection: updatedConnection,
|
||||
bindToProfile: _join(newProfile, updatedConnection),
|
||||
firstRunProfile: newProfile,
|
||||
addToManager: null,
|
||||
),
|
||||
);
|
||||
expect(error, isA<_AfterStatementFailure>());
|
||||
|
||||
final restored = await tester.runAsync(() => ConnectionRegistry(db).get(priorConnection.id)) as JellyfinConnection;
|
||||
expect(restored.accessToken, priorConnection.accessToken);
|
||||
expect(restored.userName, priorConnection.userName);
|
||||
expect(await ProfileRegistry(db).get(newProfile.id), isNull);
|
||||
expect(await ProfileConnectionRegistry(db).get(newProfile.id, priorConnection.id), isNull);
|
||||
expect(storage.getProfileLastUsed(newProfile.id), isNull);
|
||||
expect(storage.getActiveProfileId(), priorProfile.id);
|
||||
expect(activeProfiles.activeId, priorProfile.id);
|
||||
await tester.pumpWidget(const SizedBox.shrink());
|
||||
await tester.pump();
|
||||
});
|
||||
|
||||
testWidgets('durable command completes after route unmount', (tester) async {
|
||||
final profile = _profile('fixture-new-profile');
|
||||
final connection = _connection(token: '');
|
||||
final gated = _GatedProfileConnectionRegistry(db, fail: false);
|
||||
var runtimeAdds = 0;
|
||||
await mountHost(tester, joinRegistry: gated);
|
||||
|
||||
final pending = persistAndBindConnection(
|
||||
context: hostContext!,
|
||||
connection: connection,
|
||||
bindToProfile: _join(profile, connection),
|
||||
firstRunProfile: profile,
|
||||
addToManager: () async {
|
||||
runtimeAdds++;
|
||||
return true;
|
||||
},
|
||||
);
|
||||
await gated.started.future;
|
||||
await tester.pumpWidget(const SizedBox.shrink());
|
||||
gated.release.complete();
|
||||
|
||||
expect(await pending, isFalse);
|
||||
expect(await ProfileRegistry(db).get(profile.id), isNotNull);
|
||||
expect(await ConnectionRegistry(db).get(connection.id), isNotNull);
|
||||
expect(await ProfileConnectionRegistry(db).get(profile.id, connection.id), isNotNull);
|
||||
expect(storage.getActiveProfileId(), profile.id);
|
||||
expect(runtimeAdds, 0);
|
||||
await tester.pumpWidget(const SizedBox.shrink());
|
||||
await tester.pump();
|
||||
});
|
||||
|
||||
testWidgets('failed durable command rolls back after route unmount', (tester) async {
|
||||
final profile = _profile('fixture-new-profile');
|
||||
final connection = _connection(token: '');
|
||||
final gated = _GatedProfileConnectionRegistry(db, fail: true);
|
||||
await mountHost(tester, joinRegistry: gated);
|
||||
|
||||
final pending = persistAndBindConnection(
|
||||
context: hostContext!,
|
||||
connection: connection,
|
||||
bindToProfile: _join(profile, connection),
|
||||
firstRunProfile: profile,
|
||||
addToManager: null,
|
||||
).then<Object?>((value) => value, onError: (Object error, StackTrace _) => error);
|
||||
await gated.started.future;
|
||||
await tester.pumpWidget(const SizedBox.shrink());
|
||||
gated.release.complete();
|
||||
|
||||
expect(await pending, isA<_AfterStatementFailure>());
|
||||
await expectEmptyAttempt(profile, connection);
|
||||
await tester.pumpWidget(const SizedBox.shrink());
|
||||
await tester.pump();
|
||||
});
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:file_picker/file_picker.dart';
|
||||
import 'package:drift/native.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
@@ -14,6 +15,7 @@ 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/libraries_provider.dart';
|
||||
import 'package:plezy/providers/download_provider.dart';
|
||||
import 'package:plezy/providers/seerr_account_provider.dart';
|
||||
import 'package:plezy/providers/theme_provider.dart';
|
||||
import 'package:plezy/providers/trackers_provider.dart';
|
||||
@@ -21,6 +23,7 @@ import 'package:plezy/providers/trakt_account_provider.dart';
|
||||
import 'package:plezy/screens/settings/settings_screen.dart';
|
||||
import 'package:plezy/services/donation_service.dart';
|
||||
import 'package:plezy/services/download_storage_service.dart';
|
||||
import 'package:plezy/services/download_manager_service.dart';
|
||||
import 'package:plezy/services/settings_service.dart';
|
||||
import 'package:plezy/services/update_service.dart';
|
||||
import 'package:plezy/theme/mono_theme.dart';
|
||||
@@ -38,6 +41,7 @@ void main() {
|
||||
TestWidgetsFlutterBinding.ensureInitialized();
|
||||
|
||||
late PathProviderPlatform originalPathProvider;
|
||||
late _FakeDirectoryPicker directoryPicker;
|
||||
late Directory temporaryDirectory;
|
||||
|
||||
setUpAll(() {
|
||||
@@ -53,6 +57,8 @@ void main() {
|
||||
PathProviderPlatform.instance = FakePathProvider(temporaryDirectory);
|
||||
TvDetectionService.debugSetAppleTVOverride(false);
|
||||
PlatformDetector.debugSetIsDesktopOSOverride(false);
|
||||
directoryPicker = _FakeDirectoryPicker();
|
||||
FilePicker.platform = directoryPicker;
|
||||
await SettingsService.getInstance();
|
||||
});
|
||||
|
||||
@@ -144,14 +150,14 @@ void main() {
|
||||
await tester.pump();
|
||||
expect(relayMaterialTile.focusNode!.hasFocus, isTrue);
|
||||
await tester.sendKeyEvent(LogicalKeyboardKey.enter);
|
||||
await tester.pumpAndSettle();
|
||||
await _pumpUi(tester);
|
||||
expect(find.byType(AlertDialog), findsOneWidget);
|
||||
expect(find.text(t.settings.watchTogetherRelay), findsWidgets);
|
||||
Navigator.of(tester.element(find.byType(AlertDialog))).pop();
|
||||
await tester.pumpAndSettle();
|
||||
await _pumpUi(tester);
|
||||
|
||||
await tester.tap(find.text(t.settings.clearCache));
|
||||
await tester.pumpAndSettle();
|
||||
await _pumpUi(tester);
|
||||
expect(find.byType(AlertDialog), findsOneWidget);
|
||||
expect(find.text(t.settings.clearCache), findsWidgets);
|
||||
});
|
||||
@@ -182,11 +188,11 @@ void main() {
|
||||
expect(downloadTile.onTap, isNotNull);
|
||||
|
||||
await tester.tap(find.text(t.settings.downloadLocationDefault));
|
||||
await tester.pumpAndSettle();
|
||||
await _pumpUi(tester);
|
||||
expect(find.byType(AlertDialog), findsOneWidget);
|
||||
expect(find.text(t.settings.downloadLocationDescription), findsOneWidget);
|
||||
Navigator.of(tester.element(find.byType(AlertDialog))).pop();
|
||||
await tester.pumpAndSettle();
|
||||
await _pumpUi(tester);
|
||||
}
|
||||
|
||||
if (!UpdateService.isUpdateCheckEnabled) {
|
||||
@@ -219,12 +225,68 @@ void main() {
|
||||
// indicator and its callback disabled while a request is in flight.
|
||||
expect(materialUpdateTile.focusNode, isNotNull);
|
||||
});
|
||||
|
||||
testWidgets('folder replacement uses the provider coordinator', (tester) async {
|
||||
final selectedDirectory = Directory('${temporaryDirectory.path}/selected-downloads');
|
||||
directoryPicker.directoryPath = selectedDirectory.path;
|
||||
final harness = await _pumpSettingsScreen(tester);
|
||||
addTearDown(() => harness.dispose(tester));
|
||||
|
||||
await tester.tap(find.text(t.settings.downloadLocationDefault));
|
||||
await _pumpUi(tester);
|
||||
await tester.tap(find.text(t.settings.selectFolder));
|
||||
await _pumpUi(tester);
|
||||
|
||||
expect(harness.locationEvents, ['path:${selectedDirectory.path}', 'type:file', 'refresh']);
|
||||
expect(SettingsService.instance.read(SettingsService.customDownloadPath), selectedDirectory.path);
|
||||
});
|
||||
|
||||
testWidgets('download location reset uses the provider coordinator', (tester) async {
|
||||
await SettingsService.instance.write(
|
||||
SettingsService.customDownloadPath,
|
||||
'${temporaryDirectory.path}/old-downloads',
|
||||
);
|
||||
await SettingsService.instance.write(SettingsService.customDownloadPathType, 'file');
|
||||
final harness = await _pumpSettingsScreen(tester);
|
||||
addTearDown(() => harness.dispose(tester));
|
||||
|
||||
await tester.tap(find.text(t.settings.downloadLocationCustom));
|
||||
await _pumpUi(tester);
|
||||
await tester.tap(find.text(t.settings.resetToDefault));
|
||||
await _pumpUi(tester);
|
||||
|
||||
expect(harness.locationEvents, ['path:null', 'type:null', 'refresh']);
|
||||
expect(SettingsService.instance.read(SettingsService.customDownloadPath), isNull);
|
||||
});
|
||||
|
||||
testWidgets('Reset All resets download location through the provider first', (tester) async {
|
||||
await SettingsService.instance.write(
|
||||
SettingsService.customDownloadPath,
|
||||
'${temporaryDirectory.path}/old-downloads',
|
||||
);
|
||||
await SettingsService.instance.write(SettingsService.customDownloadPathType, 'file');
|
||||
final harness = await _pumpSettingsScreen(tester);
|
||||
addTearDown(() => harness.dispose(tester));
|
||||
|
||||
await tester.tap(find.text(t.settings.resetSettings));
|
||||
await _pumpUi(tester);
|
||||
await tester.tap(find.text(t.common.reset));
|
||||
await _pumpUi(tester);
|
||||
|
||||
expect(harness.locationEvents.take(3), ['path:null', 'type:null', 'refresh']);
|
||||
expect(SettingsService.instance.read(SettingsService.customDownloadPath), isNull);
|
||||
expect(find.text(t.settings.resetSettingsSuccess), findsOneWidget);
|
||||
});
|
||||
}
|
||||
|
||||
Finder _navigationTileFor(String title) =>
|
||||
find.ancestor(of: find.text(title), matching: find.byType(SettingNavigationTile));
|
||||
|
||||
Finder _focusableTileFor(String title) => find.ancestor(of: find.text(title), matching: find.byType(FocusableListTile));
|
||||
Future<void> _pumpUi(WidgetTester tester) async {
|
||||
await tester.pump();
|
||||
await tester.pump(const Duration(milliseconds: 300));
|
||||
}
|
||||
|
||||
Finder _focusableTileWithin(Finder navigationTile) =>
|
||||
find.descendant(of: navigationTile, matching: find.byType(FocusableListTile));
|
||||
@@ -248,6 +310,9 @@ class _SettingsHarness {
|
||||
required this.trakt,
|
||||
required this.trackers,
|
||||
required this.seerr,
|
||||
required this.downloadManager,
|
||||
required this.downloadProvider,
|
||||
required this.locationEvents,
|
||||
});
|
||||
|
||||
final AppDatabase database;
|
||||
@@ -258,10 +323,15 @@ class _SettingsHarness {
|
||||
final TraktAccountProvider trakt;
|
||||
final TrackersProvider trackers;
|
||||
final SeerrAccountProvider seerr;
|
||||
final DownloadManagerService downloadManager;
|
||||
final DownloadProvider downloadProvider;
|
||||
final List<String> locationEvents;
|
||||
|
||||
Future<void> dispose(WidgetTester tester) async {
|
||||
await tester.pumpWidget(const SizedBox.shrink());
|
||||
await tester.pump();
|
||||
downloadProvider.dispose();
|
||||
downloadManager.dispose();
|
||||
libraries.dispose();
|
||||
theme.dispose();
|
||||
trakt.dispose();
|
||||
@@ -294,6 +364,32 @@ Future<_SettingsHarness> _pumpSettingsScreen(WidgetTester tester) async {
|
||||
final trakt = TraktAccountProvider();
|
||||
final trackers = TrackersProvider();
|
||||
final seerr = SeerrAccountProvider();
|
||||
final settingsService = SettingsService.instance;
|
||||
final storageService = DownloadStorageService.instance;
|
||||
await tester.runAsync(() => storageService.initialize(settingsService));
|
||||
final locationEvents = <String>[];
|
||||
final downloadManager = DownloadManagerService(
|
||||
database: database,
|
||||
storageService: storageService,
|
||||
clientResolver: (_, {clientScopeId}) => null,
|
||||
downloadsSupportedOverride: false,
|
||||
downloadLocationReader: () => (
|
||||
path: settingsService.read(SettingsService.customDownloadPath),
|
||||
type: settingsService.read(SettingsService.customDownloadPathType),
|
||||
),
|
||||
downloadPathWriter: (value) async {
|
||||
locationEvents.add('path:$value');
|
||||
await settingsService.write(SettingsService.customDownloadPath, value);
|
||||
},
|
||||
downloadPathTypeWriter: (value) async {
|
||||
locationEvents.add('type:$value');
|
||||
await settingsService.write(SettingsService.customDownloadPathType, value);
|
||||
},
|
||||
downloadStorageRefresher: () async {
|
||||
locationEvents.add('refresh');
|
||||
},
|
||||
)..recoveryFuture = Future<void>.value();
|
||||
final downloadProvider = DownloadProvider.forTesting(downloadManager: downloadManager, database: database);
|
||||
final harness = _SettingsHarness(
|
||||
database: database,
|
||||
plexHome: plexHome,
|
||||
@@ -303,6 +399,9 @@ Future<_SettingsHarness> _pumpSettingsScreen(WidgetTester tester) async {
|
||||
trakt: trakt,
|
||||
trackers: trackers,
|
||||
seerr: seerr,
|
||||
downloadManager: downloadManager,
|
||||
downloadProvider: downloadProvider,
|
||||
locationEvents: locationEvents,
|
||||
);
|
||||
|
||||
await tester.pumpWidget(
|
||||
@@ -315,14 +414,29 @@ Future<_SettingsHarness> _pumpSettingsScreen(WidgetTester tester) async {
|
||||
ChangeNotifierProvider<TraktAccountProvider>.value(value: trakt),
|
||||
ChangeNotifierProvider<TrackersProvider>.value(value: trackers),
|
||||
ChangeNotifierProvider<SeerrAccountProvider>.value(value: seerr),
|
||||
ChangeNotifierProvider<DownloadProvider>.value(value: downloadProvider),
|
||||
],
|
||||
child: MaterialApp(
|
||||
theme: monoTheme(dark: true).copyWith(platform: TargetPlatform.android),
|
||||
home: const SettingsScreen(),
|
||||
home: SettingsScreen(downloadDirectoryWritableChecker: (_) async => true),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
await tester.pump();
|
||||
await tester.pump(const Duration(milliseconds: 300));
|
||||
return harness;
|
||||
}
|
||||
|
||||
class _FakeDirectoryPicker extends FilePicker {
|
||||
String? directoryPath;
|
||||
|
||||
@override
|
||||
Future<String?> getDirectoryPath({
|
||||
String? dialogTitle,
|
||||
String? initialDirectory,
|
||||
bool lockParentWindow = false,
|
||||
}) async {
|
||||
return directoryPath;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user