fix(runtime): harden application service boundaries
This commit is contained in:
@@ -0,0 +1,688 @@
|
||||
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,
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user