Files
plezy/test/navigation/profile_session_screen_test.dart
T
edde746 4c8272d5b1 refactor(trackers): drive Trakt through the tracker coordinator
Trakt was the one service outside the tracker abstraction. TraktScrobbleService
re-implemented the whole playback lifecycle beside TrackerCoordinator, and
TraktSyncService pushed watched state from its own WatchStateNotifier
subscription, so the player called two objects at every lifecycle point and one
watch could be written twice. TraktTracker now implements RealtimeScrobbleTracker
like Simkl; the duplicated player call sites collapse to one each, and Trakt
shares the coordinator's ID resolver instead of re-fetching show ids every
episode.

Capabilities are split so a tracker declares what it is rather than being
special-cased: ScrobblePolicy carries each service's own resend/seek rules,
EpisodeHistoryTracker names the remote row a per-item history write targets, and
SeriesProgressTracker covers one-counter-per-series services. Writes from all
four trackers go through a shared TrackerWriteQueue, generalised from the
Trakt-only queue, with the legacy Trakt payload migrated on load. Trakt becomes
the fourth TrackersProvider slot and TraktAccountProvider is deleted, so one
object owns the active session per profile.

Two failure paths found while consolidating are fixed here too.

The queue's retries only ran on profile bind, connect and app foreground, so a
network blip mid-session left queued watches waiting for the next foreground.
OfflineModeProvider now notifies on connectivity changes, not just offline-state
or WiFi-flag changes, and main.dart flushes the queue when the network returns.

The queue also counted every failure toward the five attempts that permanently
drop an item, so a rate limit or a service having a bad hour could discard a
pending watch - the loss the queue exists to prevent. Only an answer about the
write itself now spends an attempt: 4xx counts, while rate limits, 5xx,
recoverable token-refresh failures and requests that never arrived do not. A
back-off answer also defers that service for the rest of the flush, so a queue
holding many rows does not fire all of them at a service that just asked for
quiet.
2026-07-30 14:51:32 +02:00

289 lines
12 KiB
Dart

import 'package:drift/native.dart';
import 'package:flutter/material.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/models/plex/plex_home_user.dart';
import 'package:plezy/navigation/profile_navigation_scope.dart';
import 'package:plezy/navigation/profile_session_screen.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_registry.dart';
import 'package:plezy/profiles/profile_registry.dart';
import 'package:plezy/providers/companion_remote_provider.dart';
import 'package:plezy/providers/discover_provider.dart';
import 'package:plezy/providers/hidden_libraries_provider.dart';
import 'package:plezy/providers/multi_server_provider.dart';
import 'package:plezy/providers/trackers_provider.dart';
import 'package:plezy/services/multi_server_manager.dart';
import 'package:plezy/services/offline_watch_sync_service.dart';
import 'package:plezy/services/storage_service.dart';
import 'package:plezy/services/system_shelf_service.dart';
import 'package:provider/provider.dart';
import '../test_helpers/io_fakes.dart';
import '../test_helpers/multi_server_fixtures.dart';
import '../test_helpers/prefs.dart';
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
setUp(() async {
resetSharedPreferencesForTest();
await SystemShelfService().debugReset();
});
testWidgets('profile switch disposes the profile navigator, routes, and providers', (tester) async {
final db = AppDatabase.forTesting(NativeDatabase.memory());
final profileRegistry = ProfileRegistry(db);
final connectionRegistry = ConnectionRegistry(db);
final profileConnectionRegistry = ProfileConnectionRegistry(db);
final storage = await StorageService.getInstance();
final plexHome = _FakePlexHomeService(
connections: connectionRegistry,
profileConnections: profileConnectionRegistry,
storage: storage,
);
final activeProfile = ActiveProfileProvider(
registry: profileRegistry,
plexHome: plexHome,
connections: connectionRegistry,
storage: storage,
);
final serverManager = MultiServerManager();
final multiServer = testMultiServerProvider(serverManager);
// The session tree instantiates MusicPlaybackServiceImpl (the mini-player
// overlay watches it), which needs the database + offline watch service.
final offlineWatch = OfflineWatchSyncService(database: db, serverManager: serverManager);
final discoverProviders = <DiscoverProvider>[];
final hiddenProviders = <HiddenLibrariesProvider>[];
final trackerProviders = <TrackersProvider>[];
final companionProviders = <CompanionRemoteProvider>[];
final disposedActiveIds = <String>[];
final trackerHttpClients = <FakeHttpClient>[];
// TrackersProvider owns five eager auth HTTP clients across the four
// services (MAL's proxy and token exchange use separate clients).
const trackerAuthClientsPerProfile = 5;
FakeHttpClient trackerHttpClientFactory() {
final client = FakeHttpClient(200, const <int>[]);
trackerHttpClients.add(client);
return client;
}
addTearDown(() async {
await tester.pumpWidget(const SizedBox.shrink());
await tester.pumpAndSettle();
await activeProfile.resetForTesting();
activeProfile.dispose();
multiServer.dispose();
serverManager.dispose();
await plexHome.dispose();
offlineWatch.dispose();
await db.close();
});
final owner = Profile.local(id: 'local-owner', displayName: 'Owner', createdAt: DateTime(2026, 1, 1));
final kids = Profile.local(id: 'local-kids', displayName: 'Kids', createdAt: DateTime(2026, 1, 2));
await profileRegistry.upsert(owner);
await profileRegistry.upsert(kids);
await storage.saveHiddenLibrariesForProfile(owner.id, {'srv:owner'});
await storage.saveHiddenLibrariesForProfile(kids.id, {'srv:kids'});
await storage.setActiveProfileId(owner.id);
await activeProfile.initialize();
await tester.pumpWidget(
MultiProvider(
providers: [
Provider<StorageService>.value(value: storage),
Provider<AppDatabase>.value(value: db),
Provider<ConnectionRegistry>.value(value: connectionRegistry),
Provider<ProfileConnectionRegistry>.value(value: profileConnectionRegistry),
Provider<PlexHomeService>.value(value: plexHome),
ChangeNotifierProvider<ActiveProfileProvider>.value(value: activeProfile),
ChangeNotifierProvider<MultiServerProvider>.value(value: multiServer),
ChangeNotifierProvider<OfflineWatchSyncService>.value(value: offlineWatch),
],
child: MaterialApp(
home: ProfileSessionScreen.forTesting(
initialPromptHandled: true,
httpClientFactory: trackerHttpClientFactory,
profileShellBuilder: (context) => _ProfileProbeShell(
discoverProviders: discoverProviders,
hiddenProviders: hiddenProviders,
trackerProviders: trackerProviders,
companionProviders: companionProviders,
disposedActiveIds: disposedActiveIds,
),
),
),
),
);
await tester.pumpAndSettle();
expect(trackerHttpClients, hasLength(trackerAuthClientsPerProfile));
final ownerHttpClients = List<FakeHttpClient>.of(trackerHttpClients);
_expectCloseCount(ownerHttpClients, 0);
expect(find.text('active:local-owner'), findsOneWidget);
expect(SystemShelfService().debugActiveOwner, owner.id);
expect(discoverProviders.single.profileId, owner.id);
expect(discoverProviders, hasLength(1));
expect(hiddenProviders, hasLength(1));
expect(companionProviders, hasLength(1));
final ownerNavigator = profileNavigationRegistry.navigator;
final ownerDiscover = discoverProviders.single;
final ownerHidden = hiddenProviders.single;
final ownerTrackers = trackerProviders.single;
final ownerCompanion = companionProviders.single;
await ownerHidden.ensureInitialized();
expect(ownerHidden.profileId, owner.id);
expect(ownerHidden.hiddenLibraryKeys, {'srv:owner'});
await tester.tap(find.byKey(const ValueKey('push-profile-route')));
await tester.pumpAndSettle();
expect(find.text('old profile route'), findsOneWidget);
expect(await activeProfile.activate(kids), isTrue);
await tester.pumpAndSettle();
expect(trackerHttpClients, hasLength(trackerAuthClientsPerProfile * 2));
final kidsHttpClients = trackerHttpClients.sublist(ownerHttpClients.length);
_expectCloseCount(ownerHttpClients, 1);
_expectCloseCount(kidsHttpClients, 0);
expect(find.text('old profile route'), findsNothing);
expect(find.text('active:local-kids'), findsOneWidget);
expect(disposedActiveIds, contains('local-owner'));
expect(discoverProviders, hasLength(2));
expect(discoverProviders.last, isNot(same(ownerDiscover)));
expect(hiddenProviders, hasLength(2));
expect(hiddenProviders.last, isNot(same(ownerHidden)));
expect(trackerProviders, hasLength(2));
expect(trackerProviders.last, isNot(same(ownerTrackers)));
expect(ownerTrackers.isDisposed, isTrue);
expect(companionProviders, hasLength(2));
expect(companionProviders.last, isNot(same(ownerCompanion)));
expect(ownerCompanion.isDisposed, isTrue);
await hiddenProviders.last.ensureInitialized();
expect(hiddenProviders.last.profileId, kids.id);
expect(hiddenProviders.last.hiddenLibraryKeys, {'srv:kids'});
expect(profileNavigationRegistry.navigator, isNot(same(ownerNavigator)));
expect(SystemShelfService().debugActiveOwner, kids.id);
expect(discoverProviders.last.profileId, kids.id);
await activeProfile.clearActiveProfile();
await tester.pumpAndSettle();
expect(trackerHttpClients, hasLength(trackerAuthClientsPerProfile * 3));
final signedOutHttpClients = trackerHttpClients.sublist(ownerHttpClients.length + kidsHttpClients.length);
_expectCloseCount(ownerHttpClients, 1);
_expectCloseCount(kidsHttpClients, 1);
_expectCloseCount(signedOutHttpClients, 0);
expect(SystemShelfService().debugActiveOwner, isNull);
expect(discoverProviders.last.profileId, isNull);
await tester.pumpWidget(const SizedBox.shrink());
await tester.pumpAndSettle();
expect(trackerHttpClients.toSet(), hasLength(trackerAuthClientsPerProfile * 3));
_expectCloseCount(trackerHttpClients, 1);
});
}
void _expectCloseCount(Iterable<FakeHttpClient> clients, int expected) {
for (final client in clients) {
expect(client.closeCount, expected);
}
}
class _ProfileProbeShell extends StatefulWidget {
const _ProfileProbeShell({
required this.discoverProviders,
required this.companionProviders,
required this.hiddenProviders,
required this.disposedActiveIds,
required this.trackerProviders,
});
final List<CompanionRemoteProvider> companionProviders;
final List<DiscoverProvider> discoverProviders;
final List<HiddenLibrariesProvider> hiddenProviders;
final List<TrackersProvider> trackerProviders;
final List<String> disposedActiveIds;
@override
State<_ProfileProbeShell> createState() => _ProfileProbeShellState();
}
class _ProfileProbeShellState extends State<_ProfileProbeShell> {
CompanionRemoteProvider? _companionProvider;
DiscoverProvider? _discoverProvider;
HiddenLibrariesProvider? _hiddenProvider;
TrackersProvider? _trackersProvider;
String _activeId = 'none';
@override
void didChangeDependencies() {
super.didChangeDependencies();
_companionProvider = context.read<CompanionRemoteProvider>();
_discoverProvider = context.read<DiscoverProvider>();
_hiddenProvider = context.read<HiddenLibrariesProvider>();
_trackersProvider = context.read<TrackersProvider>();
_activeId = context.read<ActiveProfileProvider>().activeId ?? 'none';
if (widget.discoverProviders.isEmpty || !identical(widget.discoverProviders.last, _discoverProvider)) {
widget.discoverProviders.add(_discoverProvider!);
}
if (widget.hiddenProviders.isEmpty || !identical(widget.hiddenProviders.last, _hiddenProvider)) {
widget.hiddenProviders.add(_hiddenProvider!);
}
if (widget.trackerProviders.isEmpty || !identical(widget.trackerProviders.last, _trackersProvider)) {
widget.trackerProviders.add(_trackersProvider!);
}
if (widget.companionProviders.isEmpty || !identical(widget.companionProviders.last, _companionProvider)) {
widget.companionProviders.add(_companionProvider!);
}
}
@override
void dispose() {
widget.disposedActiveIds.add(_activeId);
super.dispose();
}
@override
Widget build(BuildContext context) {
final activeId = context.watch<ActiveProfileProvider>().activeId;
return Scaffold(
body: Column(
children: [
Text('active:$activeId'),
ElevatedButton(
key: const ValueKey('push-profile-route'),
onPressed: () {
Navigator.of(context).push(MaterialPageRoute<void>(builder: (_) => const Text('old profile route')));
},
child: const Text('push profile route'),
),
],
),
);
}
}
class _FakePlexHomeService extends PlexHomeService {
_FakePlexHomeService({required super.connections, required super.profileConnections, required StorageService storage})
: super(storage: storage, plexHomeUserFetcher: (_) async => const []);
@override
Map<String, List<PlexHomeUser>> get current => const {};
@override
Stream<Map<String, List<PlexHomeUser>>> get stream => Stream.value(const {});
@override
Future<void> start() async {}
@override
Future<void> reloadFromStorage() async {}
@override
Future<void> dispose() async {}
}