import 'dart:async'; import 'package:drift/native.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:plezy/connection/connection_registry.dart'; import 'package:plezy/database/app_database.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_activation.dart'; import 'package:plezy/profiles/profile_connection_registry.dart'; import 'package:plezy/profiles/profile_registry.dart'; import 'package:plezy/services/storage_service.dart'; import 'package:plezy/services/system_shelf_service.dart'; import 'package:provider/provider.dart'; import '../test_helpers/prefs.dart'; class _PlexHome extends PlexHomeService { _PlexHome({required super.connections, required super.profileConnections, required super.storage}) : super(plexHomeUserFetcher: (_) async => const []); @override Future start() async {} @override Future reloadFromStorage() async {} @override Future dispose() async {} } class _Binder implements ActiveProfileBinder { _Binder(this.events); final List events; @override void markUserInitiatedActivation(String profileId) => events.add('mark:$profileId'); @override dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation); } class _RollbackBinder implements ActiveProfileBinder { _RollbackBinder(this.active, this.events, {this.targetBindingRelease}) : boundClientProfileId = active.activeId { active.addListener(_handleActiveChanged); } final ActiveProfileProvider active; final List events; final Completer rebindStarted = Completer(); final Completer allowRebind = Completer(); final Completer targetBindingStarted = Completer(); final Completer? targetBindingRelease; String? boundClientProfileId; bool _targetFailed = false; final Set _successfullyBoundProfileIds = {}; @override void markUserInitiatedActivation(String profileId) { events.add('mark:$profileId'); } void _handleActiveChanged() { if (active.activeId == 'target' && !_targetFailed) { _targetFailed = true; boundClientProfileId = null; SystemShelfService().beginProfileSession('target'); active.markBindingStarted(); targetBindingStarted.complete(); final release = targetBindingRelease; if (release == null) { scheduleMicrotask(() => active.markBindingFinished(success: false)); } else { unawaited( release.future.then((_) { if (active.activeId == 'target') { active.markBindingFinished(success: false); } }), ); } return; } final profileId = active.activeId; if ((profileId == 'newer' || profileId == 'latest') && _successfullyBoundProfileIds.add(profileId!)) { boundClientProfileId = profileId; SystemShelfService().beginProfileSession(profileId); active.markBindingStarted(); scheduleMicrotask(() => active.markBindingFinished(success: true)); } } @override Future rebindActive() async { events.add('rebind:${active.activeId}'); active.markBindingStarted(); rebindStarted.complete(); await allowRebind.future; boundClientProfileId = active.activeId; active.markBindingFinished(success: true); } @override void dispose() { active.removeListener(_handleActiveChanged); } @override dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation); } class _ThrowingActiveProfileProvider extends ActiveProfileProvider { _ThrowingActiveProfileProvider({ required super.registry, required super.plexHome, required super.connections, required super.profileConnections, required super.storage, super.activeProfileIdWriter, }); bool throwOnActivation = false; @override Future activate(Profile profile, {String? pin}) { if (throwOnActivation) throw StateError('synthetic activation failure'); return super.activate(profile, pin: pin); } } void main() { TestWidgetsFlutterBinding.ensureInitialized(); const channel = MethodChannel('test/profile_activation_shelf'); final messenger = TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger; setUp(resetSharedPreferencesForTest); tearDown(() { messenger.setMockMethodCallHandler(channel, null); SystemShelfService.debugOverrideInstance(null); }); testWidgets('successful different-profile activation clears old owner before identity publication', (tester) async { final harness = await _pumpHarness(tester, channel); addTearDown(harness.dispose); final events = harness.events; harness.active.addListener(() => events.add('active:${harness.active.activeId}')); final activated = await switchProfileFromUi(harness.context, harness.target); expect(activated, isTrue); expect(events, ['clear:owner', 'mark:target', 'active:target']); expect(SystemShelfService().debugActiveOwner, isNull); }); testWidgets('newer switch overtakes an older switch blocked clearing the same shelf owner', (tester) async { final harness = await _pumpHarness(tester, channel, simulateBindingRollback: true); addTearDown(harness.dispose); final ownerClearStarted = Completer(); final allowOwnerClear = Completer(); messenger.setMockMethodCallHandler(channel, (call) async { if (call.method == 'clear') { final ownerId = (call.arguments as Map)['ownerId']; harness.events.add('clear:$ownerId'); if (ownerId == 'owner') { ownerClearStarted.complete(); await allowOwnerClear.future; } } return true; }); final olderSwitch = switchProfileFromUi(harness.context, harness.target); await ownerClearStarted.future; expect(await switchProfileFromUi(harness.context, harness.newer), isTrue); expect(harness.active.activeId, 'newer'); expect(harness.rollbackBinder!.boundClientProfileId, 'newer'); expect(harness.events, contains('mark:newer')); expect(harness.events, isNot(contains('mark:target'))); allowOwnerClear.complete(); expect(await olderSwitch, isFalse); await tester.pump(); expect(harness.active.activeId, 'newer'); expect((await StorageService.getInstance()).getActiveProfileId(), 'newer'); expect(SystemShelfService().debugActiveOwner, 'newer'); expect(harness.events, isNot(contains('rebind:target'))); expect(find.byType(SnackBar), findsNothing); }); testWidgets('selecting current profile and cancelling PIN verification do not clear', (tester) async { final harness = await _pumpHarness(tester, channel); addTearDown(harness.dispose); expect(await switchProfileFromUi(harness.context, harness.active.active!), isTrue); expect(harness.events, ['mark:owner']); final protected = Profile.local( id: 'protected', displayName: 'Protected', pinHash: computePinHash('1234'), createdAt: DateTime(2026, 1, 3), ); final attempt = switchProfileFromUi(harness.context, protected); await tester.pumpAndSettle(); Navigator.of(harness.context, rootNavigator: true).pop(); await tester.pumpAndSettle(); expect(await attempt, isFalse); expect(harness.events.where((event) => event.startsWith('clear:')), isEmpty); }); testWidgets('activation exception restores the previous shelf owner', (tester) async { final harness = await _pumpHarness(tester, channel, throwOnTargetActivation: true); addTearDown(harness.dispose); final activated = await switchProfileFromUi(harness.context, harness.target); expect(activated, isFalse); expect(harness.active.activeId, 'owner'); expect(SystemShelfService().debugActiveOwner, 'owner'); expect(harness.events, ['clear:owner', 'mark:target']); }); testWidgets('failed target binding explicitly restores prior clients before returning', (tester) async { final harness = await _pumpHarness(tester, channel, simulateBindingRollback: true); addTearDown(harness.dispose); final binder = harness.rollbackBinder!; var switchCompleted = false; final switchFuture = switchProfileFromUi(harness.context, harness.target).then((result) { switchCompleted = true; return result; }); await binder.rebindStarted.future; expect(harness.active.activeId, 'owner'); expect(binder.boundClientProfileId, isNull); expect(switchCompleted, isFalse); binder.allowRebind.complete(); expect(await switchFuture, isFalse); expect(binder.boundClientProfileId, 'owner'); expect(harness.active.lastBindingSucceeded, isTrue); expect(SystemShelfService().debugActiveOwner, 'owner'); }); testWidgets('protected switch rolls back to profile active when its activation is admitted', (tester) async { final harness = await _pumpHarness(tester, channel, simulateBindingRollback: true, gateTargetBindingFailure: true); addTearDown(harness.dispose); final binder = harness.rollbackBinder!; final protectedTarget = Profile.local( id: harness.target.id, displayName: 'Protected Target', pinHash: computePinHash('1234'), createdAt: harness.target.createdAt, ); final pendingProtectedSwitch = switchProfileFromUi(harness.context, protectedTarget); await tester.pumpAndSettle(); expect(find.text('Protected Target'), findsOneWidget); expect(harness.active.activeId, 'owner'); expect(await switchProfileFromUi(harness.context, harness.newer), isTrue); expect(harness.active.activeId, 'newer'); expect(binder.boundClientProfileId, 'newer'); final pinField = find.byType(TextField); if (pinField.evaluate().isNotEmpty) { await tester.enterText(pinField, '1234'); } else { for (final digit in ['1', '2', '3', '4']) { await tester.tap(find.text(digit)); } } await tester.pump(); await binder.targetBindingStarted.future; binder.targetBindingRelease!.complete(); await binder.rebindStarted.future; final restoredProfileId = harness.active.activeId; binder.allowRebind.complete(); expect(await pendingProtectedSwitch, isFalse); await tester.pump(); expect(restoredProfileId, 'newer'); expect(harness.active.activeId, 'newer'); expect(binder.boundClientProfileId, 'newer'); expect(SystemShelfService().debugActiveOwner, 'newer'); expect((await StorageService.getInstance()).getActiveProfileId(), 'newer'); }); testWidgets('newer activation supersedes a failed switch while rollback shelf clear is pending', (tester) async { final harness = await _pumpHarness(tester, channel, simulateBindingRollback: true); addTearDown(harness.dispose); final binder = harness.rollbackBinder!; final targetClearStarted = Completer(); final allowTargetClear = Completer(); messenger.setMockMethodCallHandler(channel, (call) async { if (call.method == 'clear') { final ownerId = (call.arguments as Map)['ownerId']; harness.events.add('clear:$ownerId'); if (ownerId == 'target') { targetClearStarted.complete(); await allowTargetClear.future; } } return true; }); final failedSwitch = switchProfileFromUi(harness.context, harness.target); await targetClearStarted.future; expect(await switchProfileFromUi(harness.context, harness.newer), isTrue); expect(harness.active.activeId, 'newer'); expect(binder.boundClientProfileId, 'newer'); allowTargetClear.complete(); expect(await failedSwitch, isFalse); expect(harness.active.activeId, 'newer'); expect(binder.boundClientProfileId, 'newer'); expect(SystemShelfService().debugActiveOwner, 'newer'); expect(binder.rebindStarted.isCompleted, isFalse); expect(harness.events, isNot(contains('mark:owner'))); expect(harness.events, isNot(contains('rebind:owner'))); expect((await StorageService.getInstance()).getActiveProfileId(), 'newer'); await tester.pump(); expect(find.byType(SnackBar), findsNothing); }); testWidgets('rollback waits for successive reserved switches and never rebinds the stale owner', (tester) async { final harness = await _pumpHarness(tester, channel, simulateBindingRollback: true, gateTargetBindingFailure: true); addTearDown(harness.dispose); final binder = harness.rollbackBinder!; final firstTargetClearStarted = Completer(); final allowFirstTargetClear = Completer(); final secondTargetClearStarted = Completer(); final allowSecondTargetClear = Completer(); var targetClearCount = 0; messenger.setMockMethodCallHandler(channel, (call) async { if (call.method == 'clear') { final ownerId = (call.arguments as Map)['ownerId']; harness.events.add('clear:$ownerId'); if (ownerId == 'target') { targetClearCount++; if (targetClearCount == 1) { firstTargetClearStarted.complete(); await allowFirstTargetClear.future; } else { secondTargetClearStarted.complete(); await allowSecondTargetClear.future; } } } return true; }); final failedSwitch = switchProfileFromUi(harness.context, harness.target); await binder.targetBindingStarted.future; binder.targetBindingRelease!.complete(); await firstTargetClearStarted.future; // Model the still-authoritative target binder reasserting its shelf marker // while the first native clear is pending. The next request now reserves // C synchronously and blocks behind that clear before entering the identity // queue. SystemShelfService().beginProfileSession('target'); final middleSwitch = switchProfileFromUi(harness.context, harness.newer); final middleReservation = harness.active.identityMutationGeneration; final latestSwitch = switchProfileFromUi(harness.context, harness.latest); expect(harness.active.identityMutationGeneration, greaterThan(middleReservation)); expect(await latestSwitch, isTrue); expect(harness.active.activeId, 'latest'); expect(binder.boundClientProfileId, 'latest'); allowFirstTargetClear.complete(); await secondTargetClearStarted.future; expect(await failedSwitch, isFalse); expect(harness.active.activeId, 'latest'); expect(harness.events, isNot(contains('mark:owner'))); expect(harness.events, isNot(contains('rebind:owner'))); allowSecondTargetClear.complete(); expect(await middleSwitch, isFalse); await tester.pump(); expect(targetClearCount, 2); expect((await StorageService.getInstance()).getActiveProfileId(), 'latest'); expect(SystemShelfService().debugActiveOwner, 'latest'); expect(harness.events, isNot(contains('mark:newer'))); expect(find.byType(SnackBar), findsNothing); }); testWidgets('new activation during a held restore write prevents stale prior-profile publication', (tester) async { final harness = await _pumpHarness(tester, channel, simulateBindingRollback: true, gateOwnerRestoreWrite: true); addTearDown(harness.dispose); final binder = harness.rollbackBinder!; final publishedProfileIds = []; harness.active.addListener(() => publishedProfileIds.add(harness.active.activeId)); final failedSwitch = switchProfileFromUi(harness.context, harness.target); await harness.restoreWriteStarted.future; expect(harness.active.activeId, 'target'); final restoreGeneration = harness.active.identityMutationGeneration; final newerSwitch = switchProfileFromUi(harness.context, harness.newer); await tester.pump(); expect(harness.active.identityMutationGeneration, greaterThan(restoreGeneration)); harness.allowRestoreWrite.complete(); expect(await newerSwitch, isTrue); expect(await failedSwitch, isFalse); expect(harness.active.activeId, 'newer'); expect(binder.boundClientProfileId, 'newer'); expect(publishedProfileIds, isNot(contains('owner'))); expect(harness.events, isNot(contains('mark:owner'))); expect(harness.events, isNot(contains('rebind:owner'))); expect((await StorageService.getInstance()).getActiveProfileId(), 'newer'); expect(harness.identityWrites, ['target', 'owner', 'target', 'newer']); }); testWidgets('cancelled newer PIN attempt does not suppress pending failed-switch rollback', (tester) async { final harness = await _pumpHarness(tester, channel, simulateBindingRollback: true, gateTargetBindingFailure: true); addTearDown(harness.dispose); final binder = harness.rollbackBinder!; final failedSwitch = switchProfileFromUi(harness.context, harness.target); await binder.targetBindingStarted.future; final failedGeneration = harness.active.identityMutationGeneration; final protectedNewer = Profile.local( id: 'protected-newer', displayName: 'Protected Newer', pinHash: computePinHash('1234'), createdAt: DateTime(2026, 1, 4), ); final cancelledSwitch = switchProfileFromUi(harness.context, protectedNewer); await tester.pumpAndSettle(); Navigator.of(harness.context, rootNavigator: true).pop(); await tester.pumpAndSettle(); expect(await cancelledSwitch, isFalse); expect(harness.active.identityMutationGeneration, failedGeneration); binder.targetBindingRelease!.complete(); await binder.rebindStarted.future; binder.allowRebind.complete(); expect(await failedSwitch, isFalse); expect(harness.active.activeId, 'owner'); expect(binder.boundClientProfileId, 'owner'); await tester.pump(); expect(find.byType(SnackBar), findsOneWidget); }); testWidgets('failed queued activation lets superseded restore retry the prior profile', (tester) async { final harness = await _pumpHarness( tester, channel, simulateBindingRollback: true, gateOwnerRestoreWrite: true, failNewerIdentityWrite: true, ); addTearDown(harness.dispose); final binder = harness.rollbackBinder!; final failedSwitch = switchProfileFromUi(harness.context, harness.target); await harness.restoreWriteStarted.future; final restoreGeneration = harness.active.identityMutationGeneration; final newerSwitch = switchProfileFromUi(harness.context, harness.newer); await tester.pump(); expect(harness.active.identityMutationGeneration, greaterThan(restoreGeneration)); harness.allowRestoreWrite.complete(); expect(await newerSwitch, isFalse); await binder.rebindStarted.future; binder.allowRebind.complete(); expect(await failedSwitch, isFalse); expect(harness.active.activeId, 'owner'); expect(binder.boundClientProfileId, 'owner'); expect(harness.active.committedIdentityGeneration, greaterThan(restoreGeneration)); expect(harness.events, contains('mark:owner')); expect(harness.events, contains('rebind:owner')); expect((await StorageService.getInstance()).getActiveProfileId(), 'owner'); expect(harness.identityWrites, ['target', 'owner', 'target', 'newer', 'target', 'owner']); }); test('superseded failing profile write cannot overwrite the newer committed profile', () async { final database = AppDatabase.forTesting(NativeDatabase.memory()); final profiles = ProfileRegistry(database); final connections = ConnectionRegistry(database); final profileConnections = ProfileConnectionRegistry(database); final storage = await StorageService.getInstance(); final plexHome = _PlexHome(connections: connections, profileConnections: profileConnections, storage: storage); final targetWriteStarted = Completer(); final allowTargetWriteToFail = Completer(); final newerWriteStarted = Completer(); final identityWrites = []; Future writer(String profileId) async { identityWrites.add(profileId); await storage.setActiveProfileId(profileId); if (profileId == 'target') { targetWriteStarted.complete(); await allowTargetWriteToFail.future; throw StateError('synthetic target persistence failure'); } if (profileId == 'newer') newerWriteStarted.complete(); } final active = _ThrowingActiveProfileProvider( registry: profiles, plexHome: plexHome, connections: connections, profileConnections: profileConnections, storage: storage, activeProfileIdWriter: writer, ); addTearDown(() async { active.dispose(); await plexHome.dispose(); await database.close(); }); final owner = Profile.local(id: 'owner', displayName: 'Owner', createdAt: DateTime(2026, 1, 1)); final target = Profile.local(id: 'target', displayName: 'Target', createdAt: DateTime(2026, 1, 2)); final newer = Profile.local(id: 'newer', displayName: 'Newer', createdAt: DateTime(2026, 1, 3)); await profiles.upsert(owner); await profiles.upsert(target); await profiles.upsert(newer); await storage.setActiveProfileId(owner.id); await active.initialize(); final targetActivation = active.activate(target); final targetFailure = expectLater(targetActivation, throwsA(isA())); await targetWriteStarted.future; final newerActivation = active.activate(newer); expect(newerWriteStarted.isCompleted, isFalse); expect(storage.getActiveProfileId(), target.id); expect(identityWrites, ['target']); allowTargetWriteToFail.complete(); await targetFailure; expect(await newerActivation, isTrue); expect(newerWriteStarted.isCompleted, isTrue); expect(active.activeId, newer.id); expect(storage.getActiveProfileId(), newer.id); expect(identityWrites, ['target', 'owner', 'newer']); }); } class _Harness { _Harness({ required this.context, required this.active, required this.target, required this.newer, required this.events, required this.latest, required this.plexHome, required this.rollbackBinder, required this.database, required this.restoreWriteStarted, required this.allowRestoreWrite, required this.identityWrites, }); final BuildContext context; final ActiveProfileProvider active; final Profile target; final Profile newer; final Profile latest; final List events; final PlexHomeService plexHome; final _RollbackBinder? rollbackBinder; final AppDatabase database; final Completer restoreWriteStarted; final Completer allowRestoreWrite; final List identityWrites; Future dispose() async { rollbackBinder?.dispose(); active.dispose(); await plexHome.dispose(); await database.close(); } } Future<_Harness> _pumpHarness( WidgetTester tester, MethodChannel channel, { bool throwOnTargetActivation = false, bool simulateBindingRollback = false, bool gateTargetBindingFailure = false, bool gateOwnerRestoreWrite = false, bool failNewerIdentityWrite = false, }) async { final events = []; final identityWrites = []; final database = AppDatabase.forTesting(NativeDatabase.memory()); final profiles = ProfileRegistry(database); final connections = ConnectionRegistry(database); final profileConnections = ProfileConnectionRegistry(database); final storage = await StorageService.getInstance(); final restoreWriteStarted = Completer(); final allowRestoreWrite = Completer(); Future activeProfileIdWriter(String profileId) async { identityWrites.add(profileId); await storage.setActiveProfileId(profileId); if (gateOwnerRestoreWrite && profileId == 'owner') { if (!restoreWriteStarted.isCompleted) restoreWriteStarted.complete(); await allowRestoreWrite.future; } if (failNewerIdentityWrite && profileId == 'newer') { throw StateError('synthetic newer identity write failure'); } } final plexHome = _PlexHome(connections: connections, profileConnections: profileConnections, storage: storage); final active = _ThrowingActiveProfileProvider( registry: profiles, plexHome: plexHome, connections: connections, profileConnections: profileConnections, storage: storage, activeProfileIdWriter: gateOwnerRestoreWrite || failNewerIdentityWrite ? activeProfileIdWriter : null, ); final owner = Profile.local(id: 'owner', displayName: 'Owner', createdAt: DateTime(2026, 1, 1)); final target = Profile.local(id: 'target', displayName: 'Target', createdAt: DateTime(2026, 1, 2)); final newer = Profile.local(id: 'newer', displayName: 'Newer', createdAt: DateTime(2026, 1, 3)); final latest = Profile.local(id: 'latest', displayName: 'Latest', createdAt: DateTime(2026, 1, 4)); await profiles.upsert(owner); await profiles.upsert(target); await profiles.upsert(newer); await profiles.upsert(latest); await storage.setActiveProfileId(owner.id); await active.initialize(); active.throwOnActivation = throwOnTargetActivation; final targetBindingRelease = gateTargetBindingFailure ? Completer() : null; final rollbackBinder = simulateBindingRollback ? _RollbackBinder(active, events, targetBindingRelease: targetBindingRelease) : null; final ActiveProfileBinder binder = rollbackBinder ?? _Binder(events); final shelf = SystemShelfService.forTesting(channel: channel, isSupported: () async => true); shelf.beginProfileSession(owner.id); SystemShelfService.debugOverrideInstance(shelf); TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger.setMockMethodCallHandler(channel, (call) async { if (call.method == 'clear') events.add('clear:${(call.arguments as Map)['ownerId']}'); return true; }); BuildContext? captured; await tester.pumpWidget( MultiProvider( providers: [ ChangeNotifierProvider.value(value: active), Provider.value(value: binder), ], child: MaterialApp( home: Scaffold( body: Builder( builder: (context) { captured = context; return const SizedBox.shrink(); }, ), ), ), ), ); await tester.pump(); return _Harness( context: captured!, active: active, target: target, newer: newer, latest: latest, rollbackBinder: rollbackBinder, events: events, plexHome: plexHome, restoreWriteStarted: restoreWriteStarted, allowRestoreWrite: allowRestoreWrite, identityWrites: identityWrites, database: database, ); }