fix(runtime): harden application service boundaries

This commit is contained in:
edde746
2026-07-24 03:46:46 +02:00
parent 658da37b48
commit e0bf66eea8
309 changed files with 32574 additions and 4369 deletions
@@ -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();
});
}
+121 -7
View File
@@ -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;
}
}