689 lines
26 KiB
Dart
689 lines
26 KiB
Dart
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<void> start() async {}
|
|
|
|
@override
|
|
Future<void> reloadFromStorage() async {}
|
|
|
|
@override
|
|
Future<void> dispose() async {}
|
|
}
|
|
|
|
class _Binder implements ActiveProfileBinder {
|
|
_Binder(this.events);
|
|
final List<String> 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<String> events;
|
|
final Completer<void> rebindStarted = Completer<void>();
|
|
final Completer<void> allowRebind = Completer<void>();
|
|
final Completer<void> targetBindingStarted = Completer<void>();
|
|
final Completer<void>? targetBindingRelease;
|
|
String? boundClientProfileId;
|
|
bool _targetFailed = false;
|
|
final Set<String> _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<void> 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.storage,
|
|
super.activeProfileIdWriter,
|
|
});
|
|
|
|
bool throwOnActivation = false;
|
|
|
|
@override
|
|
Future<bool> 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<void>();
|
|
final allowOwnerClear = Completer<void>();
|
|
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<void>();
|
|
final allowTargetClear = Completer<void>();
|
|
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<void>();
|
|
final allowFirstTargetClear = Completer<void>();
|
|
final secondTargetClearStarted = Completer<void>();
|
|
final allowSecondTargetClear = Completer<void>();
|
|
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 = <String?>[];
|
|
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<void>();
|
|
final allowTargetWriteToFail = Completer<void>();
|
|
final newerWriteStarted = Completer<void>();
|
|
final identityWrites = <String>[];
|
|
Future<void> 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,
|
|
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<StateError>()));
|
|
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<String> events;
|
|
final PlexHomeService plexHome;
|
|
final _RollbackBinder? rollbackBinder;
|
|
final AppDatabase database;
|
|
final Completer<void> restoreWriteStarted;
|
|
final Completer<void> allowRestoreWrite;
|
|
final List<String> identityWrites;
|
|
|
|
Future<void> 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 = <String>[];
|
|
final identityWrites = <String>[];
|
|
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<void>();
|
|
final allowRestoreWrite = Completer<void>();
|
|
Future<void> 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,
|
|
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<void>() : 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<ActiveProfileProvider>.value(value: active),
|
|
Provider<ActiveProfileBinder>.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,
|
|
);
|
|
}
|