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.
This commit is contained in:
@@ -20,7 +20,7 @@ const _fixedEndpointSourcePaths = <String>[
|
||||
'lib/services/plex_auth_service.dart',
|
||||
'lib/services/plex_discover_client.dart',
|
||||
'lib/services/plex_client/parts/live_tv.dart',
|
||||
'lib/services/trakt/trakt_constants.dart',
|
||||
'lib/services/trackers/trakt/trakt_constants.dart',
|
||||
'lib/services/trackers/mal/mal_constants.dart',
|
||||
'lib/services/trackers/anilist/anilist_constants.dart',
|
||||
'lib/services/trackers/simkl/simkl_constants.dart',
|
||||
|
||||
@@ -62,9 +62,9 @@ void main() {
|
||||
final companionProviders = <CompanionRemoteProvider>[];
|
||||
final disposedActiveIds = <String>[];
|
||||
final trackerHttpClients = <FakeHttpClient>[];
|
||||
// The probe instantiates TrackersProvider (four eager auth owners); the
|
||||
// separate Trakt provider remains lazy in this reduced shell.
|
||||
const trackerAuthClientsPerProfile = 4;
|
||||
// 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);
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import 'package:connectivity_plus/connectivity_plus.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:plezy/media/ids.dart';
|
||||
import 'package:http/http.dart' as http;
|
||||
@@ -188,6 +189,54 @@ void main() {
|
||||
multi.dispose();
|
||||
manager.dispose();
|
||||
});
|
||||
|
||||
group('connectivity transitions', () {
|
||||
test('regaining any network notifies even while servers stay unreachable', () async {
|
||||
final manager = MultiServerManager();
|
||||
final multi = testMultiServerProvider(manager);
|
||||
final p = OfflineModeProvider(manager, multiServerProvider: multi);
|
||||
// Settle visibility with nothing reachable, so offline is owned by
|
||||
// `noServerConnection` rather than the network flag or startup warmup.
|
||||
multi.setExpectedVisibleServerIds({'plex-server'});
|
||||
multi.setVisibleServerIds({'plex-server'});
|
||||
await Future<void>.delayed(Duration.zero);
|
||||
p.applyConnectivityResults(const [ConnectivityResult.none]);
|
||||
expect(p.hasNetworkConnection, isFalse);
|
||||
expect(p.isOffline, isTrue);
|
||||
|
||||
var notifications = 0;
|
||||
p.addListener(() => notifications++);
|
||||
|
||||
// Cellular comes back but no server is reachable, so neither the composite
|
||||
// offline state nor the WiFi/Ethernet flag moves. Consumers that only need
|
||||
// the internet — queued tracker history writes — still have to hear it.
|
||||
p.applyConnectivityResults(const [ConnectivityResult.mobile]);
|
||||
|
||||
expect(p.hasNetworkConnection, isTrue);
|
||||
expect(p.hasWifiOrEthernet, isFalse);
|
||||
expect(p.isOffline, isTrue, reason: 'servers are still unreachable');
|
||||
expect(notifications, 1);
|
||||
|
||||
p.dispose();
|
||||
multi.dispose();
|
||||
manager.dispose();
|
||||
});
|
||||
|
||||
test('an unchanged connectivity snapshot notifies nobody', () async {
|
||||
final manager = MultiServerManager();
|
||||
final p = OfflineModeProvider(manager);
|
||||
p.applyConnectivityResults(const [ConnectivityResult.wifi]);
|
||||
|
||||
var notifications = 0;
|
||||
p.addListener(() => notifications++);
|
||||
p.applyConnectivityResults(const [ConnectivityResult.wifi]);
|
||||
|
||||
expect(notifications, isZero);
|
||||
|
||||
p.dispose();
|
||||
manager.dispose();
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -6,9 +6,11 @@ import 'package:plezy/services/base_shared_preferences_service.dart';
|
||||
import 'package:plezy/services/trackers/anilist/anilist_tracker.dart';
|
||||
import 'package:plezy/services/trackers/tracker_account_store.dart';
|
||||
import 'package:plezy/services/trackers/tracker_constants.dart';
|
||||
import 'package:plezy/services/trackers/tracker_coordinator.dart';
|
||||
import 'package:plezy/services/trackers/tracker_session.dart';
|
||||
import 'package:plezy/services/trackers/mal/mal_tracker.dart';
|
||||
import 'package:plezy/services/trackers/simkl/simkl_tracker.dart';
|
||||
import 'package:plezy/services/trackers/trakt/trakt_tracker.dart';
|
||||
|
||||
import '../test_helpers/io_fakes.dart';
|
||||
import '../test_helpers/prefs.dart';
|
||||
@@ -16,6 +18,7 @@ import '../test_helpers/prefs.dart';
|
||||
final _malStore = trackerAccountStore(TrackerService.mal);
|
||||
final _anilistStore = trackerAccountStore(TrackerService.anilist);
|
||||
final _simklStore = trackerAccountStore(TrackerService.simkl);
|
||||
final _traktStore = trackerAccountStore(TrackerService.trakt);
|
||||
|
||||
TrackerSession _mal({String? username}) => TrackerSession(
|
||||
accessToken: 'mal-at',
|
||||
@@ -38,6 +41,19 @@ TrackerSession _simkl({String? username}) => TrackerSession(
|
||||
username: username,
|
||||
);
|
||||
|
||||
TrackerSession _trakt({String? username}) => TrackerSession(
|
||||
accessToken: 'trakt-at',
|
||||
refreshToken: 'trakt-rt',
|
||||
expiresAt: DateTime.now().millisecondsSinceEpoch ~/ 1000 + 3600,
|
||||
createdAt: DateTime.now().millisecondsSinceEpoch ~/ 1000,
|
||||
username: username,
|
||||
);
|
||||
|
||||
Future<void> _bindProfile(TrackersProvider provider, String? userUuid) async {
|
||||
await provider.onActiveProfileChanged(userUuid);
|
||||
await TrackerCoordinator.instance.flushWriteQueue();
|
||||
}
|
||||
|
||||
void main() {
|
||||
setUp(() {
|
||||
resetSharedPreferencesForTest();
|
||||
@@ -51,15 +67,19 @@ void main() {
|
||||
expect(p.mal, isNull);
|
||||
expect(p.anilist, isNull);
|
||||
expect(p.simkl, isNull);
|
||||
expect(p.trakt, isNull);
|
||||
expect(p.isMalConnected, isFalse);
|
||||
expect(p.isAnilistConnected, isFalse);
|
||||
expect(p.isSimklConnected, isFalse);
|
||||
expect(p.isTraktConnected, isFalse);
|
||||
expect(p.malUsername, isNull);
|
||||
expect(p.anilistUsername, isNull);
|
||||
expect(p.simklUsername, isNull);
|
||||
expect(p.traktUsername, isNull);
|
||||
expect(p.isConnecting(TrackerService.mal), isFalse);
|
||||
expect(p.isConnecting(TrackerService.anilist), isFalse);
|
||||
expect(p.isConnecting(TrackerService.simkl), isFalse);
|
||||
expect(p.isConnecting(TrackerService.trakt), isFalse);
|
||||
p.dispose();
|
||||
});
|
||||
|
||||
@@ -74,8 +94,8 @@ void main() {
|
||||
},
|
||||
);
|
||||
|
||||
expect(clients, hasLength(4));
|
||||
expect(clients.toSet(), hasLength(4));
|
||||
expect(clients, hasLength(5));
|
||||
expect(clients.toSet(), hasLength(5));
|
||||
for (final client in clients) {
|
||||
expect(client.closeCount, 0);
|
||||
}
|
||||
@@ -93,6 +113,7 @@ void main() {
|
||||
await _malStore.save(uuid, _mal(username: 'alice'));
|
||||
await _anilistStore.save(uuid, _anilist(username: 'bob'));
|
||||
await _simklStore.save(uuid, _simkl(username: 'carol'));
|
||||
await _traktStore.save(uuid, _trakt(username: 'dave'));
|
||||
|
||||
// Reset cached singletons so the provider reads fresh prefs state.
|
||||
BaseSharedPreferencesService.resetForTesting();
|
||||
@@ -101,13 +122,15 @@ void main() {
|
||||
var notified = 0;
|
||||
p.addListener(() => notified++);
|
||||
|
||||
await p.onActiveProfileChanged(uuid);
|
||||
await _bindProfile(p, uuid);
|
||||
expect(p.isMalConnected, isTrue);
|
||||
expect(p.isAnilistConnected, isTrue);
|
||||
expect(p.isSimklConnected, isTrue);
|
||||
expect(p.isTraktConnected, isTrue);
|
||||
expect(p.malUsername, 'alice');
|
||||
expect(p.anilistUsername, 'bob');
|
||||
expect(p.simklUsername, 'carol');
|
||||
expect(p.traktUsername, 'dave');
|
||||
expect(notified, greaterThanOrEqualTo(1));
|
||||
|
||||
p.dispose();
|
||||
@@ -118,32 +141,35 @@ void main() {
|
||||
await _malStore.save(uuid, _mal(username: 'alice'));
|
||||
await _anilistStore.save(uuid, _anilist(username: 'bob'));
|
||||
await _simklStore.save(uuid, _simkl(username: 'carol'));
|
||||
await _traktStore.save(uuid, _trakt(username: 'dave'));
|
||||
BaseSharedPreferencesService.resetForTesting();
|
||||
|
||||
final p = TrackersProvider();
|
||||
await p.onActiveProfileChanged(uuid);
|
||||
await _bindProfile(p, uuid);
|
||||
expect(p.isMalConnected, isTrue);
|
||||
|
||||
await p.onActiveProfileChanged('other-profile');
|
||||
await _bindProfile(p, 'other-profile');
|
||||
expect(p.isMalConnected, isFalse);
|
||||
expect(p.isAnilistConnected, isFalse);
|
||||
expect(p.isSimklConnected, isFalse);
|
||||
expect(p.isTraktConnected, isFalse);
|
||||
|
||||
p.dispose();
|
||||
});
|
||||
|
||||
test('onActiveProfileChanged loads only the populated stores', () async {
|
||||
const uuid = 'profile-2';
|
||||
// Only AniList is set up — MAL and Simkl remain absent.
|
||||
// Only AniList is set up — MAL, Simkl, and Trakt remain absent.
|
||||
await _anilistStore.save(uuid, _anilist(username: 'bob'));
|
||||
BaseSharedPreferencesService.resetForTesting();
|
||||
|
||||
final p = TrackersProvider();
|
||||
await p.onActiveProfileChanged(uuid);
|
||||
await _bindProfile(p, uuid);
|
||||
expect(p.isAnilistConnected, isTrue);
|
||||
expect(p.anilistUsername, 'bob');
|
||||
expect(p.isMalConnected, isFalse);
|
||||
expect(p.isSimklConnected, isFalse);
|
||||
expect(p.isTraktConnected, isFalse);
|
||||
p.dispose();
|
||||
});
|
||||
|
||||
@@ -153,7 +179,7 @@ void main() {
|
||||
BaseSharedPreferencesService.resetForTesting();
|
||||
|
||||
final p = TrackersProvider();
|
||||
await p.onActiveProfileChanged(uuid);
|
||||
await _bindProfile(p, uuid);
|
||||
expect(p.isMalConnected, isTrue);
|
||||
|
||||
var notified = 0;
|
||||
@@ -178,7 +204,7 @@ void main() {
|
||||
BaseSharedPreferencesService.resetForTesting();
|
||||
|
||||
final p = TrackersProvider();
|
||||
await p.onActiveProfileChanged(uuid);
|
||||
await _bindProfile(p, uuid);
|
||||
|
||||
await p.disconnectAnilist();
|
||||
expect(p.isAnilistConnected, isFalse);
|
||||
@@ -193,22 +219,25 @@ void main() {
|
||||
await _malStore.save(uuid, _mal(username: 'alice'));
|
||||
await _anilistStore.save(uuid, _anilist(username: 'bob'));
|
||||
await _simklStore.save(uuid, _simkl(username: 'carol'));
|
||||
await _traktStore.save(uuid, _trakt(username: 'dave'));
|
||||
BaseSharedPreferencesService.resetForTesting();
|
||||
|
||||
final p = TrackersProvider();
|
||||
// Start the load, then disconnect MAL before it resolves.
|
||||
final load = p.onActiveProfileChanged(uuid);
|
||||
final load = _bindProfile(p, uuid);
|
||||
await p.disconnectMal();
|
||||
await load;
|
||||
|
||||
// MAL stays disconnected (and cleared) — the racing load must not
|
||||
// resurrect it — but it also must not drop AniList/Simkl.
|
||||
// resurrect it — but it also must not drop AniList/Simkl/Trakt.
|
||||
expect(p.isMalConnected, isFalse);
|
||||
expect(await _malStore.load(uuid), isNull);
|
||||
expect(p.isAnilistConnected, isTrue);
|
||||
expect(p.anilistUsername, 'bob');
|
||||
expect(p.isSimklConnected, isTrue);
|
||||
expect(p.simklUsername, 'carol');
|
||||
expect(p.isTraktConnected, isTrue);
|
||||
expect(p.traktUsername, 'dave');
|
||||
|
||||
p.dispose();
|
||||
});
|
||||
@@ -233,9 +262,9 @@ void main() {
|
||||
final p = TrackersProvider();
|
||||
p.dispose();
|
||||
// Post-dispose rebind should not throw.
|
||||
await p.onActiveProfileChanged('any-uuid');
|
||||
await _bindProfile(p, 'any-uuid');
|
||||
});
|
||||
for (final service in [TrackerService.mal, TrackerService.anilist, TrackerService.simkl]) {
|
||||
for (final service in [TrackerService.mal, TrackerService.anilist, TrackerService.simkl, TrackerService.trakt]) {
|
||||
test('$service stale connect cannot save or replace a newer binding after dispose', () async {
|
||||
const oldUuid = 'profile-old';
|
||||
const newUuid = 'profile-new';
|
||||
@@ -246,13 +275,13 @@ void main() {
|
||||
|
||||
final pipeline = _ControlledConnectPipeline(oldSession);
|
||||
final oldProvider = TrackersProvider.forTesting(connectPipeline: pipeline.call);
|
||||
await oldProvider.onActiveProfileChanged(oldUuid);
|
||||
await _bindProfile(oldProvider, oldUuid);
|
||||
final connect = _connect(oldProvider, service);
|
||||
await pipeline.beforeSave.future;
|
||||
|
||||
oldProvider.dispose();
|
||||
final newProvider = TrackersProvider();
|
||||
await newProvider.onActiveProfileChanged(newUuid);
|
||||
await _bindProfile(newProvider, newUuid);
|
||||
final newBinding = _boundClient(service);
|
||||
expect(newBinding, isNotNull);
|
||||
expect(_providerSession(newProvider, service)?.accessToken, newSession.accessToken);
|
||||
@@ -273,7 +302,7 @@ void main() {
|
||||
const uuid = 'profile-cancel';
|
||||
final pipeline = _ControlledConnectPipeline(_session(TrackerService.mal, 'cancelled'));
|
||||
final p = TrackersProvider.forTesting(connectPipeline: pipeline.call);
|
||||
await p.onActiveProfileChanged(uuid);
|
||||
await _bindProfile(p, uuid);
|
||||
|
||||
final connect = _connect(p, TrackerService.mal);
|
||||
await pipeline.beforeSave.future;
|
||||
@@ -298,11 +327,11 @@ void main() {
|
||||
|
||||
final pipeline = _ControlledConnectPipeline(oldSession);
|
||||
final p = TrackersProvider.forTesting(connectPipeline: pipeline.call);
|
||||
await p.onActiveProfileChanged(oldUuid);
|
||||
await _bindProfile(p, oldUuid);
|
||||
final connect = _connect(p, TrackerService.anilist);
|
||||
await pipeline.beforeSave.future;
|
||||
|
||||
await p.onActiveProfileChanged(newUuid);
|
||||
await _bindProfile(p, newUuid);
|
||||
final newBinding = AnilistTracker.instance.client;
|
||||
pipeline.releaseBeforeSave.complete();
|
||||
|
||||
@@ -319,7 +348,7 @@ void main() {
|
||||
const uuid = 'profile-same-disconnect';
|
||||
final pipeline = _ControlledConnectPipeline(_session(TrackerService.simkl, 'late'));
|
||||
final p = TrackersProvider.forTesting(connectPipeline: pipeline.call);
|
||||
await p.onActiveProfileChanged(uuid);
|
||||
await _bindProfile(p, uuid);
|
||||
final connect = _connect(p, TrackerService.simkl);
|
||||
await pipeline.beforeSave.future;
|
||||
|
||||
@@ -342,7 +371,7 @@ void main() {
|
||||
|
||||
final pipeline = _ControlledConnectPipeline(connectedMal);
|
||||
final p = TrackersProvider.forTesting(connectPipeline: pipeline.call);
|
||||
await p.onActiveProfileChanged(uuid);
|
||||
await _bindProfile(p, uuid);
|
||||
final connect = _connect(p, TrackerService.mal);
|
||||
await pipeline.beforeSave.future;
|
||||
|
||||
@@ -350,6 +379,7 @@ void main() {
|
||||
pipeline.releaseBeforeSave.complete();
|
||||
|
||||
expect(await connect, isTrue);
|
||||
await TrackerCoordinator.instance.flushWriteQueue();
|
||||
expect(p.anilist, isNull);
|
||||
expect(p.mal?.accessToken, connectedMal.accessToken);
|
||||
expect((await _malStore.load(uuid))?.accessToken, connectedMal.accessToken);
|
||||
@@ -363,7 +393,7 @@ void main() {
|
||||
final freshSession = _session(TrackerService.mal, 'fresh');
|
||||
final pipeline = _ControlledConnectPipeline(staleSession, pauseAfterSave: true);
|
||||
final staleProvider = TrackersProvider.forTesting(connectPipeline: pipeline.call);
|
||||
await staleProvider.onActiveProfileChanged(uuid);
|
||||
await _bindProfile(staleProvider, uuid);
|
||||
final connect = _connect(staleProvider, TrackerService.mal);
|
||||
await pipeline.beforeSave.future;
|
||||
pipeline.releaseBeforeSave.complete();
|
||||
@@ -373,7 +403,7 @@ void main() {
|
||||
await _malStore.save(uuid, freshSession);
|
||||
BaseSharedPreferencesService.resetForTesting();
|
||||
final freshProvider = TrackersProvider();
|
||||
await freshProvider.onActiveProfileChanged(uuid);
|
||||
await _bindProfile(freshProvider, uuid);
|
||||
final freshBinding = MalTracker.instance.client;
|
||||
pipeline.releaseAfterSave.complete();
|
||||
|
||||
@@ -391,13 +421,14 @@ void _resetTrackerBindings() {
|
||||
MalTracker.instance.rebindSession(null, onSessionInvalidated: () {});
|
||||
AnilistTracker.instance.rebindSession(null, onSessionInvalidated: () {});
|
||||
SimklTracker.instance.rebindSession(null, onSessionInvalidated: () {});
|
||||
TraktTracker.instance.rebindSession(null, onSessionInvalidated: () {});
|
||||
}
|
||||
|
||||
TrackerAccountStore _store(TrackerService service) => switch (service) {
|
||||
TrackerService.mal => _malStore,
|
||||
TrackerService.anilist => _anilistStore,
|
||||
TrackerService.simkl => _simklStore,
|
||||
_ => throw ArgumentError.value(service),
|
||||
TrackerService.trakt => _traktStore,
|
||||
};
|
||||
|
||||
TrackerSession _session(TrackerService service, String owner) => switch (service) {
|
||||
@@ -415,35 +446,41 @@ TrackerSession _session(TrackerService service, String owner) => switch (service
|
||||
username: owner,
|
||||
),
|
||||
TrackerService.simkl => TrackerSession(accessToken: '$owner-simkl-at', createdAt: 1900000000, username: owner),
|
||||
_ => throw ArgumentError.value(service),
|
||||
TrackerService.trakt => TrackerSession(
|
||||
accessToken: '$owner-trakt-at',
|
||||
refreshToken: '$owner-trakt-rt',
|
||||
expiresAt: 2000000000,
|
||||
createdAt: 1900000000,
|
||||
username: owner,
|
||||
),
|
||||
};
|
||||
|
||||
Future<bool> _connect(TrackersProvider provider, TrackerService service) => switch (service) {
|
||||
TrackerService.mal => provider.connectMal(onCodeReady: (_) {}),
|
||||
TrackerService.anilist => provider.connectAnilist(onCodeReady: (_) {}),
|
||||
TrackerService.simkl => provider.connectSimkl(onCodeReady: (_) {}),
|
||||
_ => throw ArgumentError.value(service),
|
||||
TrackerService.trakt => provider.connectTrakt(onCodeReady: (_) {}),
|
||||
};
|
||||
|
||||
TrackerSession? _providerSession(TrackersProvider provider, TrackerService service) => switch (service) {
|
||||
TrackerService.mal => provider.mal,
|
||||
TrackerService.anilist => provider.anilist,
|
||||
TrackerService.simkl => provider.simkl,
|
||||
_ => throw ArgumentError.value(service),
|
||||
TrackerService.trakt => provider.trakt,
|
||||
};
|
||||
|
||||
Object? _boundClient(TrackerService service) => switch (service) {
|
||||
TrackerService.mal => MalTracker.instance.client,
|
||||
TrackerService.anilist => AnilistTracker.instance.client,
|
||||
TrackerService.simkl => SimklTracker.instance.client,
|
||||
_ => throw ArgumentError.value(service),
|
||||
TrackerService.trakt => TraktTracker.instance.client,
|
||||
};
|
||||
|
||||
TrackerSession? _boundSession(TrackerService service) => switch (service) {
|
||||
TrackerService.mal => MalTracker.instance.client?.session,
|
||||
TrackerService.anilist => AnilistTracker.instance.client?.session,
|
||||
TrackerService.simkl => SimklTracker.instance.client?.session,
|
||||
_ => throw ArgumentError.value(service),
|
||||
TrackerService.trakt => TraktTracker.instance.client?.session,
|
||||
};
|
||||
|
||||
class _ControlledConnectPipeline {
|
||||
|
||||
@@ -0,0 +1,270 @@
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:plezy/providers/trackers_provider.dart';
|
||||
import 'package:plezy/services/base_shared_preferences_service.dart';
|
||||
import 'package:plezy/services/trackers/tracker_account_store.dart';
|
||||
import 'package:plezy/services/trackers/tracker_constants.dart';
|
||||
import 'package:plezy/services/trackers/tracker_coordinator.dart';
|
||||
import 'package:plezy/services/trackers/tracker_session.dart';
|
||||
import 'package:plezy/services/trackers/trakt/trakt_tracker.dart';
|
||||
|
||||
import '../test_helpers/io_fakes.dart';
|
||||
import '../test_helpers/prefs.dart';
|
||||
|
||||
final _store = trackerAccountStore(TrackerService.trakt);
|
||||
|
||||
TrackerSession _session({String? username, String accessToken = 'at', String refreshToken = 'rt'}) {
|
||||
return TrackerSession(
|
||||
accessToken: accessToken,
|
||||
refreshToken: refreshToken,
|
||||
expiresAt: DateTime.now().millisecondsSinceEpoch ~/ 1000 + 3600,
|
||||
scope: 'public',
|
||||
createdAt: DateTime.now().millisecondsSinceEpoch ~/ 1000,
|
||||
username: username,
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _bindProfile(TrackersProvider provider, String? userUuid) async {
|
||||
await provider.onActiveProfileChanged(userUuid);
|
||||
await TrackerCoordinator.instance.flushWriteQueue();
|
||||
}
|
||||
|
||||
void main() {
|
||||
setUp(() {
|
||||
resetSharedPreferencesForTest();
|
||||
TraktTracker.instance.rebindSession(null, onSessionInvalidated: () {});
|
||||
});
|
||||
tearDown(() => TraktTracker.instance.rebindSession(null, onSessionInvalidated: () {}));
|
||||
|
||||
group('TrackersProvider Trakt account', () {
|
||||
test('starts disconnected with null session and catalog client', () {
|
||||
final p = TrackersProvider();
|
||||
expect(p.trakt, isNull);
|
||||
expect(p.isTraktConnected, isFalse);
|
||||
expect(p.traktUsername, isNull);
|
||||
expect(p.traktCatalogClient, isNull);
|
||||
expect(p.isConnecting(TrackerService.trakt), isFalse);
|
||||
p.dispose();
|
||||
});
|
||||
|
||||
test('owns every injected auth client until disposal', () {
|
||||
final clients = <FakeHttpClient>[];
|
||||
final p = TrackersProvider(
|
||||
httpClientFactory: () {
|
||||
final client = FakeHttpClient(200, const <int>[]);
|
||||
clients.add(client);
|
||||
return client;
|
||||
},
|
||||
);
|
||||
|
||||
expect(clients, hasLength(5));
|
||||
for (final client in clients) {
|
||||
expect(client.closeCount, 0);
|
||||
}
|
||||
|
||||
p.dispose();
|
||||
|
||||
for (final client in clients) {
|
||||
expect(client.closeCount, 1);
|
||||
expect(client.isClosed, isTrue);
|
||||
}
|
||||
});
|
||||
|
||||
test('onActiveProfileChanged loads stored session into the shared Trakt client', () async {
|
||||
const uuid = 'profile-1';
|
||||
await _store.save(uuid, _session(username: 'alice'));
|
||||
BaseSharedPreferencesService.resetForTesting();
|
||||
|
||||
final p = TrackersProvider();
|
||||
var notified = 0;
|
||||
p.addListener(() => notified++);
|
||||
|
||||
await _bindProfile(p, uuid);
|
||||
|
||||
expect(p.isTraktConnected, isTrue);
|
||||
expect(p.traktUsername, 'alice');
|
||||
expect(p.trakt?.accessToken, 'at');
|
||||
expect(p.traktCatalogClient, same(TraktTracker.instance.client));
|
||||
expect(p.traktCatalogClient?.session.accessToken, 'at');
|
||||
expect(notified, greaterThanOrEqualTo(1));
|
||||
|
||||
p.dispose();
|
||||
});
|
||||
|
||||
test('onActiveProfileChanged with unknown uuid clears the Trakt binding', () async {
|
||||
const uuid = 'profile-1';
|
||||
await _store.save(uuid, _session(username: 'alice'));
|
||||
BaseSharedPreferencesService.resetForTesting();
|
||||
|
||||
final p = TrackersProvider();
|
||||
await _bindProfile(p, uuid);
|
||||
expect(p.isTraktConnected, isTrue);
|
||||
|
||||
await _bindProfile(p, 'other-profile');
|
||||
|
||||
expect(p.isTraktConnected, isFalse);
|
||||
expect(p.traktUsername, isNull);
|
||||
expect(p.traktCatalogClient, isNull);
|
||||
expect(TraktTracker.instance.client, isNull);
|
||||
|
||||
p.dispose();
|
||||
});
|
||||
|
||||
test('a profile switch detaches the previous session before the new one loads', () async {
|
||||
const uuid = 'profile-1';
|
||||
await _store.save(uuid, _session(username: 'alice'));
|
||||
BaseSharedPreferencesService.resetForTesting();
|
||||
|
||||
final p = TrackersProvider();
|
||||
await _bindProfile(p, uuid);
|
||||
expect(TraktTracker.instance.client, isNotNull);
|
||||
|
||||
var observedWhileDetached = 0;
|
||||
p.addListener(() {
|
||||
if (!p.isTraktConnected) observedWhileDetached++;
|
||||
});
|
||||
|
||||
// Not awaited: the store load has not resolved yet. No tracker may still be
|
||||
// holding the previous profile's account at this point, or a write landing
|
||||
// in the gap would reach it under the new profile's identity.
|
||||
final pending = p.onActiveProfileChanged('other-profile');
|
||||
|
||||
expect(TraktTracker.instance.client, isNull);
|
||||
expect(p.isTraktConnected, isFalse);
|
||||
expect(observedWhileDetached, greaterThan(0), reason: 'consumers must see the detach before hydration finishes');
|
||||
|
||||
await pending;
|
||||
// The provider fires a queue flush per bind; settle it before the prefs
|
||||
// mock is torn down.
|
||||
await TrackerCoordinator.instance.flushWriteQueue();
|
||||
p.dispose();
|
||||
});
|
||||
|
||||
test('onActiveProfileChanged with null uuid loads from empty global slot', () async {
|
||||
final p = TrackersProvider();
|
||||
await _bindProfile(p, null);
|
||||
expect(p.isTraktConnected, isFalse);
|
||||
p.dispose();
|
||||
});
|
||||
|
||||
test('connectTrakt assigns and persists the session through the shared pipeline', () async {
|
||||
const uuid = 'profile-connect';
|
||||
final connected = _session(username: 'alice', accessToken: 'connected-at');
|
||||
final p = TrackersProvider.forTesting(
|
||||
connectPipeline:
|
||||
({required logLabel, required authorize, required enrich, required save, required assign}) async {
|
||||
await save(connected);
|
||||
assign(connected);
|
||||
return true;
|
||||
},
|
||||
);
|
||||
await _bindProfile(p, uuid);
|
||||
|
||||
expect(await p.connectTrakt(onCodeReady: (_) {}), isTrue);
|
||||
await TrackerCoordinator.instance.flushWriteQueue();
|
||||
expect(p.trakt, same(connected));
|
||||
expect(p.traktCatalogClient, same(TraktTracker.instance.client));
|
||||
expect((await _store.load(uuid))?.accessToken, 'connected-at');
|
||||
|
||||
p.dispose();
|
||||
});
|
||||
|
||||
test('disconnect with no session clears state and notifies', () async {
|
||||
final p = TrackersProvider();
|
||||
var notified = 0;
|
||||
p.addListener(() => notified++);
|
||||
|
||||
await p.disconnectTrakt();
|
||||
|
||||
expect(p.isTraktConnected, isFalse);
|
||||
expect(p.trakt, isNull);
|
||||
expect(notified, 1);
|
||||
|
||||
p.dispose();
|
||||
});
|
||||
|
||||
test('late refresh update after disconnect does not restore the Trakt session', () async {
|
||||
const uuid = 'profile-disconnect';
|
||||
await _store.save(uuid, _session(username: 'alice'));
|
||||
BaseSharedPreferencesService.resetForTesting();
|
||||
|
||||
final p = TrackersProvider();
|
||||
await _bindProfile(p, uuid);
|
||||
final staleClient = TraktTracker.instance.client!;
|
||||
|
||||
await p.disconnectTrakt();
|
||||
expect(p.isTraktConnected, isFalse);
|
||||
expect(await _store.load(uuid), isNull);
|
||||
|
||||
staleClient.onSessionUpdated?.call(_session(accessToken: 'late-at', refreshToken: 'late-rt', username: 'alice'));
|
||||
await Future<void>.delayed(Duration.zero);
|
||||
|
||||
expect(p.isTraktConnected, isFalse);
|
||||
expect(await _store.load(uuid), isNull);
|
||||
expect(TraktTracker.instance.client, isNull);
|
||||
|
||||
p.dispose();
|
||||
});
|
||||
|
||||
test('stale callbacks after a profile switch cannot replace or clear the new binding', () async {
|
||||
const oldUuid = 'profile-old';
|
||||
const newUuid = 'profile-new';
|
||||
final oldSession = _session(username: 'old', accessToken: 'old-at');
|
||||
final newSession = _session(username: 'new', accessToken: 'new-at');
|
||||
await _store.save(oldUuid, oldSession);
|
||||
await _store.save(newUuid, newSession);
|
||||
BaseSharedPreferencesService.resetForTesting();
|
||||
|
||||
final p = TrackersProvider();
|
||||
await _bindProfile(p, oldUuid);
|
||||
final staleClient = TraktTracker.instance.client!;
|
||||
await _bindProfile(p, newUuid);
|
||||
final currentClient = TraktTracker.instance.client;
|
||||
|
||||
staleClient.onSessionUpdated?.call(_session(username: 'late', accessToken: 'late-at'));
|
||||
staleClient.onSessionInvalidated();
|
||||
await Future<void>.delayed(Duration.zero);
|
||||
|
||||
expect(p.trakt?.accessToken, 'new-at');
|
||||
expect(p.traktUsername, 'new');
|
||||
expect(TraktTracker.instance.client, same(currentClient));
|
||||
expect((await _store.load(newUuid))?.accessToken, 'new-at');
|
||||
|
||||
p.dispose();
|
||||
});
|
||||
|
||||
test('current refresh update persists rotated tokens without replacing the shared client', () async {
|
||||
const uuid = 'profile-refresh';
|
||||
await _store.save(uuid, _session(username: 'alice'));
|
||||
BaseSharedPreferencesService.resetForTesting();
|
||||
|
||||
final p = TrackersProvider();
|
||||
await _bindProfile(p, uuid);
|
||||
final client = TraktTracker.instance.client!;
|
||||
final rotated = _session(username: 'alice', accessToken: 'rotated-at', refreshToken: 'rotated-rt');
|
||||
|
||||
client.updateSession(rotated);
|
||||
client.onSessionUpdated?.call(rotated);
|
||||
await Future<void>.delayed(Duration.zero);
|
||||
|
||||
expect(p.trakt?.accessToken, 'rotated-at');
|
||||
expect((await _store.load(uuid))?.refreshToken, 'rotated-rt');
|
||||
expect(TraktTracker.instance.client, same(client));
|
||||
expect(p.traktCatalogClient, same(client));
|
||||
|
||||
p.dispose();
|
||||
});
|
||||
|
||||
test('cancelConnect is a no-op when not connecting', () {
|
||||
final p = TrackersProvider();
|
||||
expect(() => p.cancelConnect(), returnsNormally);
|
||||
expect(p.isConnecting(TrackerService.trakt), isFalse);
|
||||
p.dispose();
|
||||
});
|
||||
|
||||
test('onActiveProfileChanged after dispose is a no-op', () async {
|
||||
final p = TrackersProvider();
|
||||
p.dispose();
|
||||
await p.onActiveProfileChanged('any-uuid');
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -1,163 +0,0 @@
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:plezy/providers/trakt_account_provider.dart';
|
||||
import 'package:plezy/services/base_shared_preferences_service.dart';
|
||||
import 'package:plezy/services/trackers/tracker_account_store.dart';
|
||||
import 'package:plezy/services/trackers/tracker_constants.dart';
|
||||
import 'package:plezy/services/trackers/tracker_session.dart';
|
||||
import 'package:plezy/services/trakt/trakt_sync_service.dart';
|
||||
|
||||
import '../test_helpers/io_fakes.dart';
|
||||
import '../test_helpers/prefs.dart';
|
||||
|
||||
final _store = trackerAccountStore(TrackerService.trakt);
|
||||
|
||||
TrackerSession _session({String? username, String accessToken = 'at', String refreshToken = 'rt'}) {
|
||||
return TrackerSession(
|
||||
accessToken: accessToken,
|
||||
refreshToken: refreshToken,
|
||||
expiresAt: DateTime.now().millisecondsSinceEpoch ~/ 1000 + 3600,
|
||||
scope: 'public',
|
||||
createdAt: DateTime.now().millisecondsSinceEpoch ~/ 1000,
|
||||
username: username,
|
||||
);
|
||||
}
|
||||
|
||||
void main() {
|
||||
setUp(resetSharedPreferencesForTest);
|
||||
|
||||
group('TraktAccountProvider', () {
|
||||
test('starts disconnected with null session', () {
|
||||
final p = TraktAccountProvider();
|
||||
expect(p.session, isNull);
|
||||
expect(p.isConnected, isFalse);
|
||||
expect(p.username, isNull);
|
||||
expect(p.isConnecting, isFalse);
|
||||
p.dispose();
|
||||
});
|
||||
|
||||
test('owns the injected auth client until disposal', () {
|
||||
final clients = <FakeHttpClient>[];
|
||||
final p = TraktAccountProvider(
|
||||
httpClientFactory: () {
|
||||
final client = FakeHttpClient(200, const <int>[]);
|
||||
clients.add(client);
|
||||
return client;
|
||||
},
|
||||
);
|
||||
|
||||
expect(clients, hasLength(1));
|
||||
expect(clients.single.closeCount, 0);
|
||||
|
||||
p.dispose();
|
||||
|
||||
expect(clients.single.closeCount, 1);
|
||||
expect(clients.single.isClosed, isTrue);
|
||||
});
|
||||
|
||||
test('onActiveProfileChanged loads stored session and notifies', () async {
|
||||
// Pre-seed the store for a specific profile uuid.
|
||||
const uuid = 'profile-1';
|
||||
await _store.save(uuid, _session(username: 'alice'));
|
||||
|
||||
// Reset cached singletons so the provider reads fresh prefs state.
|
||||
BaseSharedPreferencesService.resetForTesting();
|
||||
|
||||
final p = TraktAccountProvider();
|
||||
var notified = 0;
|
||||
p.addListener(() => notified++);
|
||||
|
||||
await p.onActiveProfileChanged(uuid);
|
||||
await TraktSyncService.instance.flushQueue();
|
||||
expect(p.isConnected, isTrue);
|
||||
expect(p.username, 'alice');
|
||||
expect(p.session?.accessToken, 'at');
|
||||
// _setSessionAndRebind notifies once.
|
||||
expect(notified, greaterThanOrEqualTo(1));
|
||||
|
||||
p.dispose();
|
||||
});
|
||||
|
||||
test('onActiveProfileChanged with unknown uuid clears session', () async {
|
||||
const uuid = 'profile-1';
|
||||
await _store.save(uuid, _session(username: 'alice'));
|
||||
BaseSharedPreferencesService.resetForTesting();
|
||||
|
||||
final p = TraktAccountProvider();
|
||||
await p.onActiveProfileChanged(uuid);
|
||||
expect(p.isConnected, isTrue);
|
||||
|
||||
// Switch to a profile with no stored session.
|
||||
await p.onActiveProfileChanged('other-profile');
|
||||
await TraktSyncService.instance.flushQueue();
|
||||
expect(p.isConnected, isFalse);
|
||||
expect(p.username, isNull);
|
||||
|
||||
p.dispose();
|
||||
});
|
||||
|
||||
test('onActiveProfileChanged with null uuid loads from empty/global slot', () async {
|
||||
final p = TraktAccountProvider();
|
||||
await p.onActiveProfileChanged(null);
|
||||
expect(p.isConnected, isFalse);
|
||||
p.dispose();
|
||||
});
|
||||
|
||||
test('disconnect with no session clears state and notifies', () async {
|
||||
final p = TraktAccountProvider();
|
||||
var notified = 0;
|
||||
p.addListener(() => notified++);
|
||||
|
||||
await p.disconnect();
|
||||
expect(p.isConnected, isFalse);
|
||||
expect(p.session, isNull);
|
||||
// _setSessionAndRebind always notifies.
|
||||
expect(notified, 1);
|
||||
|
||||
p.dispose();
|
||||
});
|
||||
|
||||
test('late refresh update after disconnect does not restore session', () async {
|
||||
const uuid = 'profile-1';
|
||||
await _store.save(uuid, _session(username: 'alice'));
|
||||
BaseSharedPreferencesService.resetForTesting();
|
||||
|
||||
final p = TraktAccountProvider();
|
||||
await p.onActiveProfileChanged(uuid);
|
||||
final staleGeneration = p.debugBindingGenerationForTesting;
|
||||
|
||||
await p.disconnect();
|
||||
await TraktSyncService.instance.flushQueue();
|
||||
expect(p.isConnected, isFalse);
|
||||
expect(await _store.load(uuid), isNull);
|
||||
|
||||
p.debugHandleSessionUpdatedForTesting(
|
||||
uuid,
|
||||
staleGeneration,
|
||||
_session(accessToken: 'late-at', refreshToken: 'late-rt', username: 'alice'),
|
||||
);
|
||||
await Future<void>.delayed(Duration.zero);
|
||||
|
||||
expect(p.isConnected, isFalse);
|
||||
expect(await _store.load(uuid), isNull);
|
||||
|
||||
p.dispose();
|
||||
});
|
||||
|
||||
test('cancelConnect is a no-op when not connecting', () {
|
||||
final p = TraktAccountProvider();
|
||||
// Should not throw when no completer exists.
|
||||
expect(() => p.cancelConnect(), returnsNormally);
|
||||
expect(p.isConnecting, isFalse);
|
||||
p.dispose();
|
||||
});
|
||||
|
||||
test('safeNotifyListeners after dispose is a no-op', () async {
|
||||
final p = TraktAccountProvider();
|
||||
p.dispose();
|
||||
// After dispose, calling onActiveProfileChanged still runs the rebind
|
||||
// path; safeNotifyListeners must swallow the post-dispose notification
|
||||
// without throwing.
|
||||
await p.onActiveProfileChanged('any-uuid');
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -23,7 +23,6 @@ import 'package:plezy/providers/download_provider.dart';
|
||||
import 'package:plezy/providers/seerr_account_provider.dart';
|
||||
import 'package:plezy/providers/theme_provider.dart';
|
||||
import 'package:plezy/providers/trackers_provider.dart';
|
||||
import 'package:plezy/providers/trakt_account_provider.dart';
|
||||
import 'package:plezy/screens/settings/settings_screen.dart';
|
||||
import 'package:plezy/services/background_work_diagnostics_service.dart';
|
||||
import 'package:plezy/services/donation_service.dart';
|
||||
@@ -579,7 +578,6 @@ class _SettingsHarness {
|
||||
required this.libraries,
|
||||
required this.hiddenLibraries,
|
||||
required this.theme,
|
||||
required this.trakt,
|
||||
required this.trackers,
|
||||
required this.trackerHttpClients,
|
||||
required this.seerr,
|
||||
@@ -594,7 +592,6 @@ class _SettingsHarness {
|
||||
final LibrariesProvider libraries;
|
||||
final HiddenLibrariesProvider hiddenLibraries;
|
||||
final ThemeProvider theme;
|
||||
final TraktAccountProvider trakt;
|
||||
final TrackersProvider trackers;
|
||||
final List<FakeHttpClient> trackerHttpClients;
|
||||
final SeerrAccountProvider seerr;
|
||||
@@ -610,7 +607,6 @@ class _SettingsHarness {
|
||||
hiddenLibraries.dispose();
|
||||
libraries.dispose();
|
||||
theme.dispose();
|
||||
trakt.dispose();
|
||||
trackers.dispose();
|
||||
seerr.dispose();
|
||||
activeProfile.dispose();
|
||||
@@ -658,7 +654,6 @@ Future<_SettingsHarness> _pumpSettingsScreen(
|
||||
return client;
|
||||
}
|
||||
|
||||
final trakt = TraktAccountProvider(httpClientFactory: trackerHttpClientFactory);
|
||||
final trackers = TrackersProvider(httpClientFactory: trackerHttpClientFactory);
|
||||
final seerr = SeerrAccountProvider();
|
||||
final settingsService = SettingsService.instance;
|
||||
@@ -694,7 +689,6 @@ Future<_SettingsHarness> _pumpSettingsScreen(
|
||||
libraries: libraries,
|
||||
hiddenLibraries: hiddenLibraries,
|
||||
theme: theme,
|
||||
trakt: trakt,
|
||||
trackers: trackers,
|
||||
trackerHttpClients: trackerHttpClients,
|
||||
seerr: seerr,
|
||||
@@ -711,7 +705,6 @@ Future<_SettingsHarness> _pumpSettingsScreen(
|
||||
ChangeNotifierProvider<LibrariesProvider>.value(value: libraries),
|
||||
ChangeNotifierProvider<HiddenLibrariesProvider>.value(value: hiddenLibraries),
|
||||
ChangeNotifierProvider<ThemeProvider>.value(value: theme),
|
||||
ChangeNotifierProvider<TraktAccountProvider>.value(value: trakt),
|
||||
ChangeNotifierProvider<TrackersProvider>.value(value: trackers),
|
||||
ChangeNotifierProvider<SeerrAccountProvider>.value(value: seerr),
|
||||
ChangeNotifierProvider<DownloadProvider>.value(value: downloadProvider),
|
||||
|
||||
@@ -11,7 +11,7 @@ import 'package:plezy/models/catalog/catalog_metadata.dart';
|
||||
import 'package:plezy/services/catalog/catalog_source.dart';
|
||||
import 'package:plezy/services/catalog/trakt_catalog_source.dart';
|
||||
import 'package:plezy/services/trackers/tracker_session.dart';
|
||||
import 'package:plezy/services/trakt/trakt_client.dart';
|
||||
import 'package:plezy/services/trackers/trakt/trakt_client.dart';
|
||||
|
||||
TrackerSession _session() {
|
||||
final now = DateTime.now().millisecondsSinceEpoch ~/ 1000;
|
||||
|
||||
@@ -10,7 +10,7 @@ import 'package:plezy/services/trackers/mal/mal_tracker.dart';
|
||||
import 'package:plezy/services/trackers/simkl/simkl_tracker.dart';
|
||||
import 'package:plezy/services/trackers/tracker_id_resolver.dart';
|
||||
import 'package:plezy/services/trackers/tracker_session.dart';
|
||||
import 'package:plezy/services/trakt/trakt_scrobble_service.dart';
|
||||
import 'package:plezy/services/trackers/trakt/trakt_tracker.dart';
|
||||
import 'package:plezy/utils/external_ids.dart';
|
||||
|
||||
int _now() => DateTime.now().millisecondsSinceEpoch ~/ 1000;
|
||||
@@ -47,7 +47,7 @@ TrackerRatingContext _ctx({
|
||||
|
||||
void main() {
|
||||
tearDown(() {
|
||||
TraktScrobbleService.instance.rebindToProfile(null, onSessionInvalidated: () {});
|
||||
TraktTracker.instance.rebindSession(null, onSessionInvalidated: () {});
|
||||
SimklTracker.instance.rebindSession(null, onSessionInvalidated: () {});
|
||||
MalTracker.instance.rebindSession(null, onSessionInvalidated: () {});
|
||||
AnilistTracker.instance.rebindSession(null, onSessionInvalidated: () {});
|
||||
@@ -70,11 +70,9 @@ void main() {
|
||||
200,
|
||||
);
|
||||
});
|
||||
TraktScrobbleService.instance.rebindToProfile(_traktSession(), onSessionInvalidated: () {}, httpClient: client);
|
||||
TraktTracker.instance.rebindSession(_traktSession(), onSessionInvalidated: () {}, httpClient: client);
|
||||
|
||||
final score = await TraktScrobbleService.instance.getRating(
|
||||
_ctx(kind: MediaKind.episode, season: 1, episodeNumber: 2),
|
||||
);
|
||||
final score = await TraktTracker.instance.getRating(_ctx(kind: MediaKind.episode, season: 1, episodeNumber: 2));
|
||||
|
||||
expect(score, 8);
|
||||
});
|
||||
|
||||
@@ -14,8 +14,8 @@ import 'package:plezy/services/trackers/tracker_connect_runner.dart';
|
||||
import 'package:plezy/services/trackers/tracker_exceptions.dart';
|
||||
import 'package:plezy/services/trackers/tracker_constants.dart';
|
||||
import 'package:plezy/services/trackers/tracker_session.dart';
|
||||
import 'package:plezy/services/trakt/trakt_auth_service.dart';
|
||||
import 'package:plezy/services/trakt/trakt_client.dart';
|
||||
import 'package:plezy/services/trackers/trakt/trakt_auth_service.dart';
|
||||
import 'package:plezy/services/trackers/trakt/trakt_client.dart';
|
||||
import 'package:plezy/utils/app_logger.dart';
|
||||
import 'package:plezy/utils/log_redaction_manager.dart';
|
||||
|
||||
|
||||
@@ -9,7 +9,7 @@ import 'package:plezy/services/trackers/simkl/simkl_client.dart';
|
||||
import 'package:plezy/services/trackers/simkl/simkl_constants.dart';
|
||||
import 'package:plezy/services/trackers/tracker_exceptions.dart';
|
||||
import 'package:plezy/services/trackers/tracker_session.dart';
|
||||
import 'package:plezy/services/trakt/trakt_client.dart';
|
||||
import 'package:plezy/services/trackers/trakt/trakt_client.dart';
|
||||
|
||||
TrackerSession _session({String refreshToken = 'refresh-old'}) {
|
||||
final now = DateTime.now().millisecondsSinceEpoch ~/ 1000;
|
||||
|
||||
@@ -0,0 +1,343 @@
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:plezy/models/trackers/tracker_context.dart';
|
||||
import 'package:plezy/profiles/profile.dart';
|
||||
import 'package:plezy/services/base_shared_preferences_service.dart';
|
||||
import 'package:plezy/services/trackers/tracker_constants.dart';
|
||||
import 'package:plezy/services/trackers/tracker_write_queue.dart';
|
||||
import 'package:plezy/utils/external_ids.dart';
|
||||
|
||||
import '../../test_helpers/prefs.dart';
|
||||
|
||||
const _watchedAt = '2026-05-12T00:00:00.000Z';
|
||||
|
||||
TrackerContext _episode({
|
||||
String ratingKey = 'episode-1',
|
||||
String? libraryGlobalKey = 'server-1:7',
|
||||
ExternalIds external = const ExternalIds(tvdb: 123),
|
||||
int season = 1,
|
||||
int episodeNumber = 2,
|
||||
}) => TrackerContext.episode(
|
||||
external: external,
|
||||
anime: null,
|
||||
ratingKey: ratingKey,
|
||||
libraryGlobalKey: libraryGlobalKey,
|
||||
season: season,
|
||||
episodeNumber: episodeNumber,
|
||||
);
|
||||
|
||||
TrackerContext _movie({
|
||||
String ratingKey = 'movie-1',
|
||||
String? libraryGlobalKey = 'server-1:8',
|
||||
ExternalIds external = const ExternalIds(tmdb: 456),
|
||||
}) => TrackerContext.movie(external: external, anime: null, ratingKey: ratingKey, libraryGlobalKey: libraryGlobalKey);
|
||||
|
||||
TrackerWriteQueueItem _item({
|
||||
required TrackerContext ctx,
|
||||
required String coalesceKey,
|
||||
TrackerService service = TrackerService.trakt,
|
||||
bool watched = true,
|
||||
int? progressClaim,
|
||||
String watchedAtIso = _watchedAt,
|
||||
int attempts = 0,
|
||||
}) => TrackerWriteQueueItem(
|
||||
service: service,
|
||||
watched: watched,
|
||||
ctx: ctx,
|
||||
coalesceKey: coalesceKey,
|
||||
progressClaim: progressClaim,
|
||||
watchedAtIso: watchedAtIso,
|
||||
attempts: attempts,
|
||||
);
|
||||
|
||||
void main() {
|
||||
setUp(resetSharedPreferencesForTest);
|
||||
|
||||
test('done flush sends and removes the queued write', () async {
|
||||
final queue = TrackerWriteQueue();
|
||||
final ctx = _episode();
|
||||
final key = trackerItemCoalesceKey(TrackerService.trakt, ctx, trackerExternalRowIdentity(ctx.external))!;
|
||||
await queue.enqueue('user-a', _item(ctx: ctx, coalesceKey: key));
|
||||
|
||||
final sent = <TrackerWriteQueueItem>[];
|
||||
await queue.flush(
|
||||
'user-a',
|
||||
send: (item) async {
|
||||
sent.add(item);
|
||||
return TrackerWriteDisposition.done;
|
||||
},
|
||||
);
|
||||
|
||||
expect(sent, hasLength(1));
|
||||
expect(sent.single.ctx.ratingKey, 'episode-1');
|
||||
expect(sent.single.watched, isTrue);
|
||||
expect(await queue.load('user-a'), isEmpty);
|
||||
});
|
||||
|
||||
test('failed flush increments attempts and a later flush drops an exhausted write without sending', () async {
|
||||
final queue = TrackerWriteQueue();
|
||||
final ctx = _episode();
|
||||
final key = trackerItemCoalesceKey(TrackerService.trakt, ctx, trackerExternalRowIdentity(ctx.external))!;
|
||||
await queue.enqueue('user-a', _item(ctx: ctx, coalesceKey: key, attempts: TrackerWriteQueue.maxAttempts - 1));
|
||||
|
||||
var sendCalls = 0;
|
||||
await queue.flush(
|
||||
'user-a',
|
||||
send: (item) async {
|
||||
sendCalls++;
|
||||
return TrackerWriteDisposition.failed;
|
||||
},
|
||||
);
|
||||
final exhausted = await queue.load('user-a');
|
||||
expect(sendCalls, 1);
|
||||
expect(exhausted.single.attempts, TrackerWriteQueue.maxAttempts);
|
||||
|
||||
await queue.flush(
|
||||
'user-a',
|
||||
send: (item) async {
|
||||
sendCalls++;
|
||||
return TrackerWriteDisposition.done;
|
||||
},
|
||||
);
|
||||
expect(sendCalls, 1);
|
||||
expect(await queue.load('user-a'), isEmpty);
|
||||
});
|
||||
|
||||
test('skipped flush keeps the write without burning an attempt', () async {
|
||||
final queue = TrackerWriteQueue();
|
||||
final ctx = _episode();
|
||||
final key = trackerItemCoalesceKey(TrackerService.trakt, ctx, trackerExternalRowIdentity(ctx.external))!;
|
||||
await queue.enqueue('user-a', _item(ctx: ctx, coalesceKey: key, attempts: 2));
|
||||
|
||||
await queue.flush('user-a', send: (item) async => TrackerWriteDisposition.skipped);
|
||||
|
||||
final remaining = await queue.load('user-a');
|
||||
expect(remaining, hasLength(1));
|
||||
expect(remaining.single.attempts, 2);
|
||||
});
|
||||
|
||||
test('newer per-item history intent replaces the older intent for its coalesce key', () async {
|
||||
final queue = TrackerWriteQueue();
|
||||
final ctx = _episode();
|
||||
final key = trackerItemCoalesceKey(TrackerService.trakt, ctx, trackerExternalRowIdentity(ctx.external))!;
|
||||
await queue.enqueue('user-a', _item(ctx: ctx, coalesceKey: key));
|
||||
await queue.enqueue(
|
||||
'user-a',
|
||||
_item(ctx: ctx, coalesceKey: key, watched: false, watchedAtIso: '2026-05-13T00:00:00.000Z'),
|
||||
);
|
||||
|
||||
final remaining = await queue.load('user-a');
|
||||
expect(remaining, hasLength(1));
|
||||
expect(remaining.single.watched, isFalse);
|
||||
expect(remaining.single.watchedAtIso, '2026-05-13T00:00:00.000Z');
|
||||
});
|
||||
|
||||
test('series progress coalescing retains the greatest monotonic claim', () async {
|
||||
final queue = TrackerWriteQueue();
|
||||
final ctx = _episode();
|
||||
final key = trackerSeriesCoalesceKey(TrackerService.mal, 42);
|
||||
await queue.enqueue('user-a', _item(ctx: ctx, coalesceKey: key, service: TrackerService.mal, progressClaim: 5));
|
||||
await queue.enqueue('user-a', _item(ctx: ctx, coalesceKey: key, service: TrackerService.mal, progressClaim: 6));
|
||||
expect((await queue.load('user-a')).single.progressClaim, 6);
|
||||
|
||||
await queue.enqueue('user-a', _item(ctx: ctx, coalesceKey: key, service: TrackerService.mal, progressClaim: 5));
|
||||
final remaining = await queue.load('user-a');
|
||||
expect(remaining, hasLength(1));
|
||||
expect(remaining.single.progressClaim, 6);
|
||||
});
|
||||
|
||||
test('invalidate drops outright or only claims covered by applied progress', () async {
|
||||
final queue = TrackerWriteQueue();
|
||||
final ctx = _episode();
|
||||
final key = trackerSeriesCoalesceKey(TrackerService.anilist, 42);
|
||||
TrackerWriteQueueItem claim(int progress) =>
|
||||
_item(ctx: ctx, coalesceKey: key, service: TrackerService.anilist, progressClaim: progress);
|
||||
|
||||
await queue.enqueue('user-a', claim(5));
|
||||
await queue.invalidate('user-a', key);
|
||||
expect(await queue.load('user-a'), isEmpty);
|
||||
|
||||
await queue.enqueue('user-a', claim(5));
|
||||
await queue.invalidate('user-a', key, appliedProgress: 6);
|
||||
expect(await queue.load('user-a'), isEmpty);
|
||||
|
||||
await queue.enqueue('user-a', claim(7));
|
||||
await queue.invalidate('user-a', key, appliedProgress: 6);
|
||||
final remaining = await queue.load('user-a');
|
||||
expect(remaining, hasLength(1));
|
||||
expect(remaining.single.progressClaim, 7);
|
||||
});
|
||||
|
||||
test('external identity and media coordinates prevent server-local rating-key collisions', () async {
|
||||
final queue = TrackerWriteQueue();
|
||||
final first = _episode(ratingKey: 'shared-rating-key', external: const ExternalIds(tvdb: 100));
|
||||
final second = _episode(ratingKey: 'shared-rating-key', external: const ExternalIds(tvdb: 200));
|
||||
final sameRemoteEpisode = _episode(ratingKey: 'different-local-key', external: const ExternalIds(tvdb: 100));
|
||||
final movie = _movie(ratingKey: 'shared-rating-key', external: const ExternalIds(tvdb: 100));
|
||||
|
||||
final firstKey = trackerItemCoalesceKey(TrackerService.trakt, first, trackerExternalRowIdentity(first.external))!;
|
||||
final secondKey = trackerItemCoalesceKey(
|
||||
TrackerService.trakt,
|
||||
second,
|
||||
trackerExternalRowIdentity(second.external),
|
||||
)!;
|
||||
expect(firstKey, isNot(secondKey));
|
||||
expect(
|
||||
trackerItemCoalesceKey(
|
||||
TrackerService.trakt,
|
||||
sameRemoteEpisode,
|
||||
trackerExternalRowIdentity(sameRemoteEpisode.external),
|
||||
),
|
||||
firstKey,
|
||||
);
|
||||
expect(
|
||||
trackerItemCoalesceKey(TrackerService.trakt, movie, trackerExternalRowIdentity(movie.external)),
|
||||
isNot(firstKey),
|
||||
);
|
||||
|
||||
await queue.enqueue('user-a', _item(ctx: first, coalesceKey: firstKey));
|
||||
await queue.enqueue('user-a', _item(ctx: second, coalesceKey: secondKey));
|
||||
expect(await queue.load('user-a'), hasLength(2));
|
||||
});
|
||||
|
||||
test('profile queues are isolated and flushing one never sends another profile writes', () async {
|
||||
final queue = TrackerWriteQueue();
|
||||
final first = _episode(ratingKey: 'first', external: const ExternalIds(tvdb: 100));
|
||||
final second = _episode(ratingKey: 'second', external: const ExternalIds(tvdb: 200));
|
||||
await queue.enqueue(
|
||||
'user-a',
|
||||
_item(
|
||||
ctx: first,
|
||||
coalesceKey: trackerItemCoalesceKey(TrackerService.trakt, first, trackerExternalRowIdentity(first.external))!,
|
||||
),
|
||||
);
|
||||
await queue.enqueue(
|
||||
'user-b',
|
||||
_item(
|
||||
ctx: second,
|
||||
coalesceKey: trackerItemCoalesceKey(TrackerService.trakt, second, trackerExternalRowIdentity(second.external))!,
|
||||
),
|
||||
);
|
||||
|
||||
expect(await queue.load('user-a'), hasLength(1));
|
||||
expect(await queue.load('user-b'), hasLength(1));
|
||||
final sent = <String>[];
|
||||
await queue.flush(
|
||||
'user-a',
|
||||
send: (item) async {
|
||||
sent.add(item.ctx.ratingKey);
|
||||
return TrackerWriteDisposition.done;
|
||||
},
|
||||
);
|
||||
|
||||
expect(sent, ['first']);
|
||||
expect(await queue.load('user-a'), isEmpty);
|
||||
expect((await queue.load('user-b')).single.ctx.ratingKey, 'second');
|
||||
});
|
||||
|
||||
test('legacy Trakt rows migrate once with their intent and episode metadata intact', () async {
|
||||
final prefs = await BaseSharedPreferencesService.sharedCache();
|
||||
const user = 'legacy-user';
|
||||
final legacyKey = profileScopedPrefsKey(user, 'trakt_sync_queue');
|
||||
await prefs.setString(
|
||||
legacyKey,
|
||||
json.encode([
|
||||
{
|
||||
'op': 'add',
|
||||
'ratingKey': 'legacy-episode',
|
||||
'serverId': 'server-1',
|
||||
'libraryGlobalKey': 'server-1:7',
|
||||
'kind': 'episode',
|
||||
'ids': {'tvdb': 123, 'tmdb': 456, 'imdb': 'tt789'},
|
||||
'season': 3,
|
||||
'number': 4,
|
||||
'watchedAtIso': '2026-05-12T00:00:00.000Z',
|
||||
'attempts': 2,
|
||||
},
|
||||
{
|
||||
'op': 'remove',
|
||||
'ratingKey': 'legacy-movie',
|
||||
'serverId': 'server-2',
|
||||
'libraryGlobalKey': 'server-2:8',
|
||||
'kind': 'movie',
|
||||
'ids': {'tmdb': 999},
|
||||
'watchedAtIso': '2026-05-13T00:00:00.000Z',
|
||||
'attempts': 0,
|
||||
},
|
||||
]),
|
||||
);
|
||||
|
||||
final queue = TrackerWriteQueue();
|
||||
final sent = <TrackerWriteQueueItem>[];
|
||||
await queue.flush(
|
||||
user,
|
||||
send: (item) async {
|
||||
sent.add(item);
|
||||
return TrackerWriteDisposition.done;
|
||||
},
|
||||
);
|
||||
|
||||
expect(sent, hasLength(2));
|
||||
expect(sent.map((item) => item.service), everyElement(TrackerService.trakt));
|
||||
expect(sent[0].watched, isTrue);
|
||||
expect(sent[0].ctx.season, 3);
|
||||
expect(sent[0].ctx.episodeNumber, 4);
|
||||
expect(sent[0].watchedAtIso, '2026-05-12T00:00:00.000Z');
|
||||
expect(sent[0].attempts, 2);
|
||||
expect(sent[1].watched, isFalse);
|
||||
expect(sent[1].ctx.isMovie, isTrue);
|
||||
expect(sent[1].watchedAtIso, '2026-05-13T00:00:00.000Z');
|
||||
expect(prefs.getString(legacyKey), isNull);
|
||||
expect(await queue.load(user), isEmpty);
|
||||
|
||||
const malformedUser = 'malformed-legacy-user';
|
||||
final malformedKey = profileScopedPrefsKey(malformedUser, 'trakt_sync_queue');
|
||||
await prefs.setString(malformedKey, '{not valid json');
|
||||
expect(await queue.load(malformedUser), isEmpty);
|
||||
expect(prefs.getString(malformedKey), isNull);
|
||||
expect(await queue.load(malformedUser), isEmpty);
|
||||
});
|
||||
|
||||
test('a legacy queue left behind by an interrupted migration does not duplicate rows', () async {
|
||||
final prefs = await BaseSharedPreferencesService.sharedCache();
|
||||
const user = 'interrupted-user';
|
||||
final legacyRow = {
|
||||
'op': 'add',
|
||||
'ratingKey': 'legacy-episode',
|
||||
'serverId': 'server-1',
|
||||
'libraryGlobalKey': 'server-1:7',
|
||||
'kind': 'episode',
|
||||
'ids': {'tvdb': 123},
|
||||
'season': 3,
|
||||
'number': 4,
|
||||
'watchedAtIso': '2026-05-12T00:00:00.000Z',
|
||||
'attempts': 0,
|
||||
};
|
||||
await prefs.setString(profileScopedPrefsKey(user, 'trakt_sync_queue'), json.encode([legacyRow]));
|
||||
|
||||
// First pass converts the row. A fresh queue instance then finds the legacy
|
||||
// key again, as it would after a crash between the write and the removal.
|
||||
expect(await TrackerWriteQueue().load(user), hasLength(1));
|
||||
await prefs.setString(profileScopedPrefsKey(user, 'trakt_sync_queue'), json.encode([legacyRow]));
|
||||
|
||||
final migrated = await TrackerWriteQueue().load(user);
|
||||
|
||||
expect(migrated, hasLength(1), reason: 'the row is replaced, not appended a second time');
|
||||
expect(migrated.single.ctx.episodeNumber, 4);
|
||||
});
|
||||
|
||||
test('corrupt tracker queue payload is archived and discarded without throwing', () async {
|
||||
final prefs = await BaseSharedPreferencesService.sharedCache();
|
||||
const user = 'corrupt-user';
|
||||
const corruptPayload = '{not valid json';
|
||||
final queueKey = profileScopedPrefsKey(user, 'tracker_write_queue');
|
||||
final archiveKey = profileScopedPrefsKey(user, 'tracker_write_queue_corrupt');
|
||||
await prefs.setString(queueKey, corruptPayload);
|
||||
|
||||
final queue = TrackerWriteQueue();
|
||||
expect(await queue.load(user), isEmpty);
|
||||
expect(prefs.getString(queueKey), isNull);
|
||||
expect(prefs.getString(archiveKey), corruptPayload);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,673 @@
|
||||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:http/http.dart' as http;
|
||||
import 'package:http/testing.dart';
|
||||
import 'package:plezy/media/ids.dart';
|
||||
import 'package:plezy/media/media_backend.dart';
|
||||
import 'package:plezy/media/media_item.dart';
|
||||
import 'package:plezy/media/media_kind.dart';
|
||||
import 'package:plezy/media/media_server_client.dart';
|
||||
import 'package:plezy/models/trackers/anime_lists_mapping.dart';
|
||||
import 'package:plezy/models/trackers/fribb_mapping_row.dart';
|
||||
import 'package:plezy/models/trackers/tracker_context.dart';
|
||||
import 'package:plezy/services/trackers/anilist/anilist_tracker.dart';
|
||||
import 'package:plezy/services/trackers/anime_episode_progress_resolver.dart';
|
||||
import 'package:plezy/services/trackers/anime_lists_mapping_store.dart';
|
||||
import 'package:plezy/services/trackers/fribb_mapping_store.dart';
|
||||
import 'package:plezy/services/trackers/mal/mal_tracker.dart';
|
||||
import 'package:plezy/services/trackers/simkl/simkl_tracker.dart';
|
||||
import 'package:plezy/services/trackers/tracker_constants.dart';
|
||||
import 'package:plezy/services/trackers/tracker_exceptions.dart';
|
||||
import 'package:plezy/services/trackers/tracker_coordinator.dart';
|
||||
import 'package:plezy/services/trackers/tracker_session.dart';
|
||||
import 'package:plezy/services/trackers/tracker_write_queue.dart';
|
||||
import 'package:plezy/services/trackers/trakt/trakt_tracker.dart';
|
||||
import 'package:plezy/utils/external_ids.dart';
|
||||
|
||||
import '../../test_helpers/media_items.dart';
|
||||
import '../../test_helpers/prefs.dart';
|
||||
|
||||
/// Media server that only answers what the tracker resolver asks for.
|
||||
class _FakeMediaServerClient implements MediaServerClient {
|
||||
@override
|
||||
final ServerId serverId;
|
||||
@override
|
||||
String? get serverName => null;
|
||||
|
||||
final Map<String, ExternalIds> externalIdsByItem;
|
||||
|
||||
@override
|
||||
final double watchedThreshold;
|
||||
|
||||
_FakeMediaServerClient({required this.externalIdsByItem, this.watchedThreshold = 0.9})
|
||||
: serverId = ServerId('server-1');
|
||||
|
||||
@override
|
||||
MediaBackend get backend => MediaBackend.plex;
|
||||
|
||||
@override
|
||||
Future<ExternalIds> fetchExternalIds(String itemId) async => externalIdsByItem[itemId] ?? const ExternalIds();
|
||||
|
||||
@override
|
||||
Future<List<MediaItem>> fetchChildren(String parentId) async => const [];
|
||||
|
||||
@override
|
||||
Future<List<MediaItem>> fetchPlayableDescendants(String parentId) async => const [];
|
||||
|
||||
@override
|
||||
dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation);
|
||||
}
|
||||
|
||||
class _FakeFribbLookup implements FribbMappingLookup {
|
||||
const _FakeFribbLookup(this.rows);
|
||||
|
||||
final List<FribbMappingRow> rows;
|
||||
|
||||
/// Filters by tvdb id so distinct shows map to distinct anime entries, which is
|
||||
/// what makes their queued rows distinct.
|
||||
@override
|
||||
Future<List<FribbMappingRow>> lookup({int? tvdbId, int? tmdbId, String? imdbId}) async =>
|
||||
rows.where((row) => tvdbId == null || row.tvdbId == tvdbId).toList();
|
||||
|
||||
@override
|
||||
Future<FribbMappingRow?> lookupByMal(int malId) async => rows.where((row) => row.malId == malId).firstOrNull;
|
||||
}
|
||||
|
||||
/// Rollup resolution has its own suite; here the episode's own number is the claim.
|
||||
class _FakeAnimeProgressLookup implements AnimeEpisodeProgressLookup {
|
||||
const _FakeAnimeProgressLookup();
|
||||
|
||||
@override
|
||||
Future<ResolvedAnimeProgress?> resolve(
|
||||
MediaItem episode, {
|
||||
required AnimeProgressScope scope,
|
||||
AnimeEpisodeMatch? animeMatch,
|
||||
Future<AnimeEpisodeMatch?> Function(MediaItem episode)? episodeMatcher,
|
||||
bool includeCurrentEpisode = true,
|
||||
}) async => null;
|
||||
|
||||
@override
|
||||
void clearCache() {}
|
||||
}
|
||||
|
||||
class _FakeAnimeListsLookup implements AnimeListsMappingLookup {
|
||||
const _FakeAnimeListsLookup();
|
||||
|
||||
@override
|
||||
Future<AnimeEpisodeMatch?> lookupEpisode({int? tvdbId, int? tmdbId, int? season, int? episodeNumber}) async => null;
|
||||
|
||||
@override
|
||||
Future<Set<int>> lookupAnimeIdsForSeason({int? tvdbId, int? tmdbId, required int season}) async => const <int>{};
|
||||
|
||||
@override
|
||||
Future<Set<int>> lookupAnimeIdsForShow({int? tvdbId, int? tmdbId}) async => const <int>{};
|
||||
}
|
||||
|
||||
/// MAL posts form-encoded list updates; every other service posts JSON.
|
||||
Map<String, dynamic> _decodeBody(String body) {
|
||||
if (body.isEmpty) return <String, dynamic>{};
|
||||
if (body.startsWith('{') || body.startsWith('[')) {
|
||||
final decoded = json.decode(body);
|
||||
return decoded is Map ? decoded.cast<String, dynamic>() : <String, dynamic>{'body': decoded};
|
||||
}
|
||||
return Uri.splitQueryString(body);
|
||||
}
|
||||
|
||||
/// Records every write and can hold one in flight, which is how request ordering
|
||||
/// is driven without leaning on wall-clock timing.
|
||||
class _Recorder {
|
||||
final List<String> paths = [];
|
||||
final List<Map<String, dynamic>> bodies = [];
|
||||
Completer<void>? gate;
|
||||
int status = 200;
|
||||
|
||||
http.Client get client => MockClient((request) async {
|
||||
paths.add(request.url.path);
|
||||
bodies.add(_decodeBody(request.body));
|
||||
final pending = gate;
|
||||
if (pending != null) await pending.future;
|
||||
return http.Response('{}', status);
|
||||
});
|
||||
}
|
||||
|
||||
/// Stands in for an endpoint that cannot be reached at all, as opposed to one
|
||||
/// that answers with an error.
|
||||
http.Client _unreachableClient() => MockClient((_) async => throw http.ClientException('no route to host'));
|
||||
|
||||
MediaItem _episodeItem(int number) => testMediaItem(
|
||||
id: 'episode-1-$number',
|
||||
backend: MediaBackend.plex,
|
||||
kind: MediaKind.episode,
|
||||
title: 'Episode $number',
|
||||
serverId: ServerId('server-1'),
|
||||
libraryId: 'lib-1',
|
||||
parentIndex: 1,
|
||||
index: number,
|
||||
grandparentId: 'show-1',
|
||||
);
|
||||
|
||||
MediaItem _episodeItemOfShow(String showId, int number) => testMediaItem(
|
||||
id: '$showId-episode-$number',
|
||||
backend: MediaBackend.plex,
|
||||
kind: MediaKind.episode,
|
||||
title: 'Episode $number',
|
||||
serverId: ServerId('server-1'),
|
||||
libraryId: 'lib-1',
|
||||
parentIndex: 1,
|
||||
index: number,
|
||||
grandparentId: showId,
|
||||
);
|
||||
|
||||
MediaItem _movieItem({int? viewOffsetMs, int? durationMs}) => testMediaItem(
|
||||
id: 'movie-1',
|
||||
backend: MediaBackend.plex,
|
||||
kind: MediaKind.movie,
|
||||
title: 'Movie 1',
|
||||
serverId: ServerId('server-1'),
|
||||
libraryId: 'lib-1',
|
||||
viewOffsetMs: viewOffsetMs,
|
||||
durationMs: durationMs,
|
||||
);
|
||||
|
||||
/// Shows with their own tvdb id, each mapping to its own anime entry below.
|
||||
const _showTvdbIds = {'show-a': 20001, 'show-b': 20002, 'show-c': 20003};
|
||||
|
||||
_FakeMediaServerClient _client({double watchedThreshold = 0.9}) => _FakeMediaServerClient(
|
||||
externalIdsByItem: {
|
||||
'show-1': const ExternalIds(tvdb: 12345),
|
||||
'movie-1': const ExternalIds(tmdb: 603),
|
||||
for (final show in _showTvdbIds.entries) show.key: ExternalIds(tvdb: show.value),
|
||||
},
|
||||
watchedThreshold: watchedThreshold,
|
||||
);
|
||||
|
||||
TrackerSession _session() =>
|
||||
TrackerSession(accessToken: 'token', createdAt: DateTime.now().millisecondsSinceEpoch ~/ 1000);
|
||||
|
||||
/// The list-status writes MAL received, newest last.
|
||||
List<int> _malProgressWrites(_Recorder recorder) => [
|
||||
for (var i = 0; i < recorder.paths.length; i++)
|
||||
if (recorder.paths[i].contains('my_list_status')) int.parse(recorder.bodies[i]['num_watched_episodes'].toString()),
|
||||
];
|
||||
|
||||
void main() {
|
||||
final coordinator = TrackerCoordinator.instance;
|
||||
final mal = MalTracker.instance;
|
||||
final anilist = AnilistTracker.instance;
|
||||
final simkl = SimklTracker.instance;
|
||||
final trakt = TraktTracker.instance;
|
||||
|
||||
setUp(() async {
|
||||
resetSharedPreferencesForTest();
|
||||
coordinator.onActiveProfileChanged('user-a');
|
||||
coordinator.debugUseResolverDependencies(
|
||||
store: const _FakeFribbLookup([
|
||||
FribbMappingRow(tvdbId: 12345, malId: 101, anilistId: 201, type: 'TV'),
|
||||
FribbMappingRow(tvdbId: 20001, malId: 301, anilistId: 401, type: 'TV'),
|
||||
FribbMappingRow(tvdbId: 20002, malId: 302, anilistId: 402, type: 'TV'),
|
||||
FribbMappingRow(tvdbId: 20003, malId: 303, anilistId: 403, type: 'TV'),
|
||||
]),
|
||||
animeLists: const _FakeAnimeListsLookup(),
|
||||
animeProgress: const _FakeAnimeProgressLookup(),
|
||||
);
|
||||
await anilist.setEnabled(false);
|
||||
await simkl.setEnabled(false);
|
||||
await trakt.setEnabled(false);
|
||||
await trakt.setWatchedSyncEnabled(false);
|
||||
await mal.setEnabled(true);
|
||||
});
|
||||
|
||||
tearDown(() async {
|
||||
coordinator.cancelInFlight();
|
||||
coordinator.debugUseResolverDependencies();
|
||||
coordinator.onActiveProfileChanged('');
|
||||
mal.rebindSession(null, onSessionInvalidated: () {});
|
||||
anilist.rebindSession(null, onSessionInvalidated: () {});
|
||||
simkl.rebindSession(null, onSessionInvalidated: () {});
|
||||
trakt.rebindSession(null, onSessionInvalidated: () {});
|
||||
await mal.setEnabled(false);
|
||||
await trakt.setWatchedSyncEnabled(false);
|
||||
});
|
||||
|
||||
group('failed watched writes are retried', () {
|
||||
test('a failed series-progress write is replayed on the next flush', () async {
|
||||
final recorder = _Recorder()..status = 500;
|
||||
mal.rebindSession(_session(), onSessionInvalidated: () {}, httpClient: recorder.client);
|
||||
|
||||
await coordinator.markWatched(_episodeItem(5), _client());
|
||||
expect(_malProgressWrites(recorder), [5], reason: 'the first attempt goes out and fails');
|
||||
|
||||
recorder.status = 200;
|
||||
await coordinator.flushWriteQueue();
|
||||
|
||||
expect(_malProgressWrites(recorder), [5, 5], reason: 'the queued claim is replayed once the service recovers');
|
||||
});
|
||||
|
||||
test('a replayed write that already succeeded is not sent twice', () async {
|
||||
final recorder = _Recorder();
|
||||
mal.rebindSession(_session(), onSessionInvalidated: () {}, httpClient: recorder.client);
|
||||
|
||||
await coordinator.markWatched(_episodeItem(5), _client());
|
||||
await coordinator.flushWriteQueue();
|
||||
|
||||
expect(_malProgressWrites(recorder), [5]);
|
||||
});
|
||||
|
||||
test('a newer completed claim drops the stale queued one', () async {
|
||||
final recorder = _Recorder()..status = 500;
|
||||
mal.rebindSession(_session(), onSessionInvalidated: () {}, httpClient: recorder.client);
|
||||
|
||||
// Episode 5 fails and is queued as a claim of 5.
|
||||
await coordinator.markWatched(_episodeItem(5), _client());
|
||||
recorder.status = 200;
|
||||
// Episode 6 lands directly: the queued claim is now behind the counter.
|
||||
await coordinator.markWatched(_episodeItem(6), _client());
|
||||
await coordinator.flushWriteQueue();
|
||||
|
||||
expect(_malProgressWrites(recorder), [
|
||||
5,
|
||||
6,
|
||||
], reason: 'replaying the claim of 5 after 6 landed would walk the list backwards');
|
||||
});
|
||||
|
||||
test('a queued claim still ahead of the applied progress survives', () async {
|
||||
final recorder = _Recorder()..status = 500;
|
||||
mal.rebindSession(_session(), onSessionInvalidated: () {}, httpClient: recorder.client);
|
||||
|
||||
await coordinator.markWatched(_episodeItem(6), _client());
|
||||
recorder.status = 200;
|
||||
await coordinator.markWatched(_episodeItem(5), _client());
|
||||
await coordinator.flushWriteQueue();
|
||||
|
||||
expect(_malProgressWrites(recorder), [6, 5, 6], reason: 'the queued 6 is a pending advance, not a stale claim');
|
||||
});
|
||||
});
|
||||
|
||||
group('a failure racing a newer write is not persisted', () {
|
||||
test('an older history failure never replaces a newer one', () async {
|
||||
await mal.setEnabled(false);
|
||||
await trakt.setEnabled(true);
|
||||
await trakt.setWatchedSyncEnabled(true);
|
||||
final recorder = _Recorder()..status = 500;
|
||||
trakt.rebindSession(_session(), onSessionInvalidated: () {}, httpClient: recorder.client);
|
||||
|
||||
final client = _client();
|
||||
// Both fail, newest last: the queue must end up holding the un-watch.
|
||||
await coordinator.markWatched(_movieItem(), client);
|
||||
await coordinator.markUnwatched(_movieItem(), client);
|
||||
|
||||
recorder
|
||||
..status = 200
|
||||
..paths.clear();
|
||||
await coordinator.flushWriteQueue();
|
||||
|
||||
expect(recorder.paths, ['/sync/history/remove'], reason: 'the newest intent for the row is the only one queued');
|
||||
});
|
||||
|
||||
test('a queued higher claim survives a newer lower one', () async {
|
||||
final recorder = _Recorder()..status = 500;
|
||||
mal.rebindSession(_session(), onSessionInvalidated: () {}, httpClient: recorder.client);
|
||||
|
||||
// Episode 6 first, then 5: progress claims are monotonic, so the queue must
|
||||
// keep the higher one whichever order the failures arrive in.
|
||||
await coordinator.markWatched(_episodeItem(6), _client());
|
||||
await coordinator.markWatched(_episodeItem(5), _client());
|
||||
|
||||
recorder.status = 200;
|
||||
await coordinator.flushWriteQueue();
|
||||
|
||||
expect(_malProgressWrites(recorder).last, 6);
|
||||
});
|
||||
});
|
||||
|
||||
group('a rate-limited service', () {
|
||||
test('one back-off answer stops the drain asking again for that service', () async {
|
||||
final recorder = _Recorder()..status = 429;
|
||||
mal.rebindSession(_session(), onSessionInvalidated: () {}, httpClient: recorder.client);
|
||||
|
||||
// Three separate shows, so three queued rows for one service.
|
||||
for (final show in ['show-a', 'show-b', 'show-c']) {
|
||||
await coordinator.markWatched(_episodeItemOfShow(show, 5), _client());
|
||||
}
|
||||
expect(_malProgressWrites(recorder), hasLength(3), reason: 'each live write tried once');
|
||||
|
||||
recorder.paths.clear();
|
||||
await coordinator.flushWriteQueue();
|
||||
|
||||
expect(
|
||||
_malProgressWrites(recorder),
|
||||
hasLength(1),
|
||||
reason: 'after the first 429 the drain leaves the rest of the service alone',
|
||||
);
|
||||
|
||||
// Once it recovers, every row still drains.
|
||||
final recovered = _Recorder();
|
||||
mal.rebindSession(_session(), onSessionInvalidated: () {}, httpClient: recovered.client);
|
||||
await coordinator.flushWriteQueue();
|
||||
|
||||
expect(_malProgressWrites(recovered), hasLength(3));
|
||||
});
|
||||
|
||||
test('a coalesced second flush does not re-ask during the same burst', () async {
|
||||
final recorder = _Recorder()..status = 429;
|
||||
mal.rebindSession(_session(), onSessionInvalidated: () {}, httpClient: recorder.client);
|
||||
for (final show in ['show-a', 'show-b']) {
|
||||
await coordinator.markWatched(_episodeItemOfShow(show, 5), _client());
|
||||
}
|
||||
|
||||
recorder
|
||||
..paths.clear()
|
||||
..gate = Completer<void>();
|
||||
// Two triggers landing together — network restore and app resume can — so
|
||||
// the second coalesces onto the running drain and re-enters its loop.
|
||||
final first = coordinator.flushWriteQueue();
|
||||
await pumpEventQueue();
|
||||
final second = coordinator.flushWriteQueue();
|
||||
recorder.gate!.complete();
|
||||
await first;
|
||||
await second;
|
||||
|
||||
expect(_malProgressWrites(recorder), hasLength(1), reason: 'the deferral spans the whole burst');
|
||||
});
|
||||
});
|
||||
|
||||
group('isTrackerFailureTransient', () {
|
||||
test('separates non-verdicts from an answer about the write', () {
|
||||
// Never reached the service.
|
||||
expect(isTrackerFailureTransient(TimeoutException('timed out')), isTrue);
|
||||
expect(isTrackerFailureTransient(const SocketException('no route')), isTrue);
|
||||
expect(isTrackerFailureTransient(http.ClientException('closed')), isTrue);
|
||||
|
||||
// The service asked us to come back.
|
||||
expect(
|
||||
isTrackerFailureTransient(
|
||||
const TrackerRateLimitException(service: TrackerService.trakt, retryAfterSeconds: 60),
|
||||
),
|
||||
isTrue,
|
||||
);
|
||||
expect(
|
||||
isTrackerFailureTransient(const TrackerApiException(service: TrackerService.mal, statusCode: 429)),
|
||||
isTrue,
|
||||
reason: 'MAL and Simkl surface a 429 untyped',
|
||||
);
|
||||
|
||||
// The service broke on its own side.
|
||||
expect(
|
||||
isTrackerFailureTransient(const TrackerApiException(service: TrackerService.simkl, statusCode: 500)),
|
||||
isTrue,
|
||||
);
|
||||
expect(
|
||||
isTrackerFailureTransient(const TrackerApiException(service: TrackerService.simkl, statusCode: 503)),
|
||||
isTrue,
|
||||
);
|
||||
|
||||
// A refresh that can still succeed, versus a session that is really gone.
|
||||
expect(
|
||||
isTrackerFailureTransient(
|
||||
const TrackerAuthException(service: TrackerService.mal, message: 'Refresh failed: HTTP 503', statusCode: 503),
|
||||
),
|
||||
isTrue,
|
||||
);
|
||||
expect(
|
||||
isTrackerFailureTransient(
|
||||
const TrackerAuthException(
|
||||
service: TrackerService.mal,
|
||||
message: 'Session invalidated (401)',
|
||||
statusCode: 401,
|
||||
isPermanent: true,
|
||||
),
|
||||
),
|
||||
isFalse,
|
||||
);
|
||||
|
||||
// Answers about the write itself.
|
||||
for (final status in [400, 401, 403, 404, 409, 422]) {
|
||||
expect(
|
||||
isTrackerFailureTransient(TrackerApiException(service: TrackerService.trakt, statusCode: status)),
|
||||
isFalse,
|
||||
reason: 'HTTP $status is the service answering about this write',
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
group('a service that cannot answer for the write', () {
|
||||
test('a rate limit never spends the retry budget', () async {
|
||||
final recorder = _Recorder()..status = 429;
|
||||
mal.rebindSession(_session(), onSessionInvalidated: () {}, httpClient: recorder.client);
|
||||
await coordinator.markWatched(_episodeItem(5), _client());
|
||||
|
||||
for (var i = 0; i < TrackerWriteQueue.maxAttempts + 2; i++) {
|
||||
await coordinator.flushWriteQueue();
|
||||
}
|
||||
|
||||
final recovered = _Recorder();
|
||||
mal.rebindSession(_session(), onSessionInvalidated: () {}, httpClient: recovered.client);
|
||||
await coordinator.flushWriteQueue();
|
||||
|
||||
expect(_malProgressWrites(recovered), [5], reason: 'a 429 is explicitly retryable, not a verdict on the write');
|
||||
});
|
||||
|
||||
test('a server-side failure never spends the retry budget', () async {
|
||||
final recorder = _Recorder()..status = 503;
|
||||
mal.rebindSession(_session(), onSessionInvalidated: () {}, httpClient: recorder.client);
|
||||
await coordinator.markWatched(_episodeItem(5), _client());
|
||||
|
||||
for (var i = 0; i < TrackerWriteQueue.maxAttempts + 2; i++) {
|
||||
await coordinator.flushWriteQueue();
|
||||
}
|
||||
|
||||
final recovered = _Recorder();
|
||||
mal.rebindSession(_session(), onSessionInvalidated: () {}, httpClient: recovered.client);
|
||||
await coordinator.flushWriteQueue();
|
||||
|
||||
expect(_malProgressWrites(recovered), [5], reason: 'a bad hour for the service is not a bad watch');
|
||||
});
|
||||
|
||||
test('a rejected write is dropped once its attempts are spent', () async {
|
||||
// 422 is the service answering about this write: asking again cannot help,
|
||||
// so the item must not be retried forever.
|
||||
final recorder = _Recorder()..status = 422;
|
||||
mal.rebindSession(_session(), onSessionInvalidated: () {}, httpClient: recorder.client);
|
||||
await coordinator.markWatched(_episodeItem(5), _client());
|
||||
|
||||
for (var i = 0; i < TrackerWriteQueue.maxAttempts + 1; i++) {
|
||||
await coordinator.flushWriteQueue();
|
||||
}
|
||||
|
||||
final recovered = _Recorder();
|
||||
mal.rebindSession(_session(), onSessionInvalidated: () {}, httpClient: recovered.client);
|
||||
await coordinator.flushWriteQueue();
|
||||
|
||||
expect(_malProgressWrites(recovered), isEmpty, reason: 'the answered rejection exhausted the budget');
|
||||
});
|
||||
|
||||
test('an unreachable service never spends the retry budget', () async {
|
||||
final recorder = _Recorder()..status = 500;
|
||||
mal.rebindSession(_session(), onSessionInvalidated: () {}, httpClient: recorder.client);
|
||||
await coordinator.markWatched(_episodeItem(5), _client());
|
||||
expect(_malProgressWrites(recorder), [5], reason: 'the first attempt is answered and fails');
|
||||
|
||||
// Now the endpoint is unreachable rather than answering. A connectivity flap
|
||||
// can drive many flushes; none of them may exhaust the item's attempts,
|
||||
// because nothing was learned about the write.
|
||||
mal.rebindSession(_session(), onSessionInvalidated: () {}, httpClient: _unreachableClient());
|
||||
for (var i = 0; i < TrackerWriteQueue.maxAttempts + 2; i++) {
|
||||
await coordinator.flushWriteQueue();
|
||||
}
|
||||
|
||||
// The service answers again: the watch is still queued and still lands.
|
||||
final recovered = _Recorder();
|
||||
mal.rebindSession(_session(), onSessionInvalidated: () {}, httpClient: recovered.client);
|
||||
await coordinator.flushWriteQueue();
|
||||
|
||||
expect(_malProgressWrites(recovered), [5], reason: 'the queued claim survived every unreachable flush');
|
||||
});
|
||||
});
|
||||
|
||||
group('writes to one remote row are serialised', () {
|
||||
test('a replay already in flight cannot land after a newer direct write', () async {
|
||||
final recorder = _Recorder()..status = 500;
|
||||
mal.rebindSession(_session(), onSessionInvalidated: () {}, httpClient: recorder.client);
|
||||
await coordinator.markWatched(_episodeItem(5), _client());
|
||||
expect(_malProgressWrites(recorder), [5]);
|
||||
|
||||
// Hold the replay's request open, then let a direct write for the same
|
||||
// entry arrive while it is still on the wire.
|
||||
recorder
|
||||
..status = 200
|
||||
..gate = Completer<void>();
|
||||
final flush = coordinator.flushWriteQueue();
|
||||
await pumpEventQueue();
|
||||
expect(_malProgressWrites(recorder), [5, 5], reason: 'the replay is in flight');
|
||||
|
||||
final live = coordinator.markWatched(_episodeItem(6), _client());
|
||||
await pumpEventQueue();
|
||||
expect(_malProgressWrites(recorder), [5, 5], reason: 'the direct write waits for the row to be free');
|
||||
|
||||
recorder.gate!.complete();
|
||||
await flush;
|
||||
await live;
|
||||
|
||||
expect(_malProgressWrites(recorder).last, 6, reason: 'the newest write is the last one to reach the service');
|
||||
});
|
||||
});
|
||||
|
||||
group('queued rows a completed write already covers', () {
|
||||
final ctx = TrackerContext.episode(
|
||||
external: const ExternalIds(tvdb: 12345),
|
||||
anime: null,
|
||||
ratingKey: 'episode-1-5',
|
||||
libraryGlobalKey: 'server-1:lib-1',
|
||||
season: 1,
|
||||
episodeNumber: 5,
|
||||
);
|
||||
|
||||
TrackerWriteQueueItem item(String key) => TrackerWriteQueueItem(
|
||||
service: TrackerService.mal,
|
||||
watched: true,
|
||||
ctx: ctx,
|
||||
coalesceKey: key,
|
||||
progressClaim: 5,
|
||||
watchedAtIso: '2026-05-12T00:00:00.000Z',
|
||||
);
|
||||
|
||||
test('a marked row is reported superseded until its marker is cleared', () async {
|
||||
final queue = TrackerWriteQueue();
|
||||
final key = trackerSeriesCoalesceKey(TrackerService.mal, 101);
|
||||
final queued = item(key);
|
||||
|
||||
expect(queue.isSuperseded('user-a', queued), isFalse);
|
||||
|
||||
final token = queue.noteDirectWrite('user-a', key, appliedProgress: 6);
|
||||
expect(queue.isSuperseded('user-a', queued), isTrue, reason: 'progress 6 covers a claim of 5');
|
||||
|
||||
queue.clearDirectWrite('user-a', key, token);
|
||||
expect(queue.isSuperseded('user-a', queued), isFalse);
|
||||
});
|
||||
|
||||
test('a marker only covers claims at or below the progress it applied', () async {
|
||||
final queue = TrackerWriteQueue();
|
||||
final key = trackerSeriesCoalesceKey(TrackerService.mal, 101);
|
||||
queue.noteDirectWrite('user-a', key, appliedProgress: 4);
|
||||
|
||||
expect(queue.isSuperseded('user-a', item(key)), isFalse, reason: 'a claim of 5 is still a pending advance');
|
||||
});
|
||||
|
||||
test('one profile\'s marker never covers another profile\'s queued row', () async {
|
||||
final queue = TrackerWriteQueue();
|
||||
final key = trackerSeriesCoalesceKey(TrackerService.mal, 101);
|
||||
queue.noteDirectWrite('user-a', key, appliedProgress: 6);
|
||||
|
||||
expect(queue.isSuperseded('user-b', item(key)), isFalse);
|
||||
expect(queue.isSuperseded('user-a', item(key)), isTrue);
|
||||
});
|
||||
|
||||
test('a stale marker cannot be cleared by an older write finishing', () async {
|
||||
final queue = TrackerWriteQueue();
|
||||
final key = trackerSeriesCoalesceKey(TrackerService.mal, 101);
|
||||
final first = queue.noteDirectWrite('user-a', key, appliedProgress: 6);
|
||||
queue.noteDirectWrite('user-a', key, appliedProgress: 7);
|
||||
|
||||
queue.clearDirectWrite('user-a', key, first);
|
||||
|
||||
expect(queue.isSuperseded('user-a', item(key)), isTrue, reason: 'the newer marker must survive');
|
||||
});
|
||||
});
|
||||
|
||||
group('scrobbling turned off mid-playback', () {
|
||||
test('the watch still reaches history when the owner can no longer report', () async {
|
||||
await mal.setEnabled(false);
|
||||
await trakt.setEnabled(true);
|
||||
await trakt.setWatchedSyncEnabled(true);
|
||||
final recorder = _Recorder();
|
||||
trakt.rebindSession(_session(), onSessionInvalidated: () {}, httpClient: recorder.client);
|
||||
|
||||
final client = _client(watchedThreshold: 0.5);
|
||||
await coordinator.startPlayback(_movieItem(durationMs: 100000), client);
|
||||
coordinator.updateDuration(const Duration(milliseconds: 100000));
|
||||
// Crossing the threshold hands the watch to Trakt's own stop...
|
||||
coordinator.updatePosition(const Duration(milliseconds: 60000));
|
||||
await pumpEventQueue();
|
||||
// ...and then the user turns scrobbling off, so that stop never goes out.
|
||||
await trakt.setEnabled(false);
|
||||
recorder.paths.clear();
|
||||
await coordinator.stopPlayback();
|
||||
await pumpEventQueue();
|
||||
|
||||
expect(recorder.paths, isNot(contains('/scrobble/stop')));
|
||||
expect(
|
||||
recorder.paths,
|
||||
contains('/sync/history'),
|
||||
reason: 'neither the crossing nor the stop recorded it, so the fallback must',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
group('a terminal stop the service never acknowledged', () {
|
||||
test('Simkl records the watch through history when the stop fails', () async {
|
||||
await mal.setEnabled(false);
|
||||
await simkl.setEnabled(true);
|
||||
final recorder = _Recorder();
|
||||
simkl.rebindSession(_session(), onSessionInvalidated: () {}, httpClient: recorder.client);
|
||||
|
||||
final client = _client();
|
||||
await coordinator.startPlayback(_movieItem(durationMs: 100000), client);
|
||||
// Past the server threshold, and past Simkl's own 80% completion rule, so a
|
||||
// confirmed stop would have recorded the watch by itself.
|
||||
coordinator.updateDuration(const Duration(milliseconds: 100000));
|
||||
coordinator.updatePosition(const Duration(milliseconds: 95000));
|
||||
recorder.status = 500;
|
||||
await coordinator.stopPlayback();
|
||||
recorder.status = 200;
|
||||
await pumpEventQueue();
|
||||
|
||||
expect(recorder.paths, contains('/scrobble/stop'));
|
||||
expect(
|
||||
recorder.paths.where((path) => path == '/sync/history'),
|
||||
hasLength(1),
|
||||
reason: 'nothing on Simkl saw the item finish, so the watch falls back to history',
|
||||
);
|
||||
});
|
||||
|
||||
test('a confirmed stop above the completion rule writes no history', () async {
|
||||
await mal.setEnabled(false);
|
||||
await simkl.setEnabled(true);
|
||||
final recorder = _Recorder();
|
||||
simkl.rebindSession(_session(), onSessionInvalidated: () {}, httpClient: recorder.client);
|
||||
|
||||
final client = _client();
|
||||
await coordinator.startPlayback(_movieItem(durationMs: 100000), client);
|
||||
coordinator.updateDuration(const Duration(milliseconds: 100000));
|
||||
coordinator.updatePosition(const Duration(milliseconds: 95000));
|
||||
await coordinator.stopPlayback();
|
||||
await pumpEventQueue();
|
||||
|
||||
expect(recorder.paths, contains('/scrobble/stop'));
|
||||
expect(recorder.paths, isNot(contains('/sync/history')), reason: 'the stop already recorded the watch');
|
||||
});
|
||||
});
|
||||
}
|
||||
+2
-2
@@ -7,8 +7,8 @@ import 'package:plezy/models/trakt/trakt_catalog_entry.dart';
|
||||
import 'package:plezy/models/trakt/trakt_catalog_media.dart';
|
||||
import 'package:plezy/models/trakt/trakt_images.dart';
|
||||
import 'package:plezy/services/trackers/tracker_session.dart';
|
||||
import 'package:plezy/services/trakt/trakt_client.dart';
|
||||
import 'package:plezy/services/trakt/trakt_constants.dart';
|
||||
import 'package:plezy/services/trackers/trakt/trakt_client.dart';
|
||||
import 'package:plezy/services/trackers/trakt/trakt_constants.dart';
|
||||
|
||||
int _now() => DateTime.now().millisecondsSinceEpoch ~/ 1000;
|
||||
|
||||
+1
-1
@@ -6,7 +6,7 @@ import 'package:http/http.dart' as http;
|
||||
import 'package:http/testing.dart';
|
||||
import 'package:plezy/services/trackers/tracker_exceptions.dart';
|
||||
import 'package:plezy/services/trackers/tracker_session.dart';
|
||||
import 'package:plezy/services/trakt/trakt_client.dart';
|
||||
import 'package:plezy/services/trackers/trakt/trakt_client.dart';
|
||||
|
||||
int _now() => DateTime.now().millisecondsSinceEpoch ~/ 1000;
|
||||
|
||||
@@ -0,0 +1,355 @@
|
||||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:http/http.dart' as http;
|
||||
import 'package:http/testing.dart';
|
||||
import 'package:plezy/media/ids.dart';
|
||||
import 'package:plezy/media/media_backend.dart';
|
||||
import 'package:plezy/media/media_item.dart';
|
||||
import 'package:plezy/media/media_kind.dart';
|
||||
import 'package:plezy/media/media_server_client.dart';
|
||||
import 'package:plezy/services/settings_service.dart';
|
||||
import 'package:plezy/services/trackers/anilist/anilist_tracker.dart';
|
||||
import 'package:plezy/services/trackers/mal/mal_tracker.dart';
|
||||
import 'package:plezy/services/trackers/simkl/simkl_tracker.dart';
|
||||
import 'package:plezy/services/trackers/tracker_coordinator.dart';
|
||||
import 'package:plezy/services/trackers/tracker_session.dart';
|
||||
import 'package:plezy/services/trackers/trakt/trakt_tracker.dart';
|
||||
import 'package:plezy/utils/external_ids.dart';
|
||||
|
||||
import '../../test_helpers/media_items.dart';
|
||||
import '../../test_helpers/prefs.dart';
|
||||
|
||||
class _FakeMediaServerClient implements MediaServerClient {
|
||||
@override
|
||||
final ServerId serverId;
|
||||
@override
|
||||
String? get serverName => null;
|
||||
|
||||
final Map<String, ExternalIds> externalIdsByItem;
|
||||
|
||||
@override
|
||||
final double watchedThreshold;
|
||||
|
||||
_FakeMediaServerClient({required this.externalIdsByItem, this.watchedThreshold = 0.9})
|
||||
: serverId = ServerId('server-1');
|
||||
|
||||
@override
|
||||
MediaBackend get backend => MediaBackend.plex;
|
||||
|
||||
@override
|
||||
Future<ExternalIds> fetchExternalIds(String itemId) async => externalIdsByItem[itemId] ?? const ExternalIds();
|
||||
|
||||
@override
|
||||
Future<List<MediaItem>> fetchPlayableDescendants(String parentId) async => const [];
|
||||
|
||||
@override
|
||||
dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation);
|
||||
}
|
||||
|
||||
class _Call {
|
||||
final String path;
|
||||
final Map<String, dynamic> body;
|
||||
|
||||
_Call(this.path, this.body);
|
||||
|
||||
@override
|
||||
String toString() => '$path ${json.encode(body)}';
|
||||
}
|
||||
|
||||
class _TraktRecorder {
|
||||
final List<_Call> calls = [];
|
||||
final Map<String, int> statuses = {};
|
||||
Completer<void>? gate;
|
||||
|
||||
http.Client get client => MockClient((request) async {
|
||||
final body = request.body.isEmpty
|
||||
? <String, dynamic>{}
|
||||
: (json.decode(request.body) as Map).cast<String, dynamic>();
|
||||
calls.add(_Call(request.url.path, body));
|
||||
final pending = gate;
|
||||
if (pending != null) await pending.future;
|
||||
return http.Response('{}', statuses[request.url.path] ?? 200);
|
||||
});
|
||||
|
||||
List<String> get paths => calls.map((call) => call.path).toList();
|
||||
|
||||
List<_Call> callsFor(String path) => calls.where((call) => call.path == path).toList();
|
||||
|
||||
_Call callFor(String path) => calls.firstWhere((call) => call.path == path, orElse: () => fail('no $path in $paths'));
|
||||
}
|
||||
|
||||
MediaItem _episode({int? viewOffsetMs, int? durationMs}) => testMediaItem(
|
||||
id: 'episode-1-3',
|
||||
backend: MediaBackend.plex,
|
||||
kind: MediaKind.episode,
|
||||
title: 'Episode 3',
|
||||
serverId: ServerId('server-1'),
|
||||
libraryId: 'lib-1',
|
||||
parentIndex: 1,
|
||||
index: 3,
|
||||
grandparentId: 'show-1',
|
||||
viewOffsetMs: viewOffsetMs,
|
||||
durationMs: durationMs,
|
||||
);
|
||||
|
||||
TrackerSession _session([String token = 'token']) =>
|
||||
TrackerSession(accessToken: token, createdAt: DateTime(2026, 7, 30).millisecondsSinceEpoch ~/ 1000);
|
||||
|
||||
_FakeMediaServerClient _client({double watchedThreshold = 0.9}) => _FakeMediaServerClient(
|
||||
externalIdsByItem: {'show-1': const ExternalIds(tvdb: 12345)},
|
||||
watchedThreshold: watchedThreshold,
|
||||
);
|
||||
|
||||
void main() {
|
||||
final coordinator = TrackerCoordinator.instance;
|
||||
final trakt = TraktTracker.instance;
|
||||
final simkl = SimklTracker.instance;
|
||||
final mal = MalTracker.instance;
|
||||
final anilist = AnilistTracker.instance;
|
||||
|
||||
late _TraktRecorder recorder;
|
||||
late DateTime now;
|
||||
|
||||
setUp(() async {
|
||||
resetSharedPreferencesForTest();
|
||||
SettingsService.resetForTesting();
|
||||
await SettingsService.getInstance();
|
||||
|
||||
recorder = _TraktRecorder();
|
||||
now = DateTime(2026, 7, 30, 12);
|
||||
coordinator.debugUseScrobbleClock(() => now);
|
||||
|
||||
simkl.rebindSession(null, onSessionInvalidated: () {});
|
||||
mal.rebindSession(null, onSessionInvalidated: () {});
|
||||
anilist.rebindSession(null, onSessionInvalidated: () {});
|
||||
trakt.rebindSession(_session(), onSessionInvalidated: () {}, httpClient: recorder.client);
|
||||
|
||||
await simkl.setEnabled(false);
|
||||
await mal.setEnabled(false);
|
||||
await anilist.setEnabled(false);
|
||||
await trakt.setEnabled(true);
|
||||
await trakt.setWatchedSyncEnabled(true);
|
||||
});
|
||||
|
||||
tearDown(() async {
|
||||
if (recorder.gate?.isCompleted == false) recorder.gate!.complete();
|
||||
coordinator.cancelInFlight();
|
||||
coordinator.debugUseResolverDependencies();
|
||||
coordinator.debugUseScrobbleClock(null);
|
||||
|
||||
trakt.rebindSession(null, onSessionInvalidated: () {});
|
||||
simkl.rebindSession(null, onSessionInvalidated: () {});
|
||||
mal.rebindSession(null, onSessionInvalidated: () {});
|
||||
anilist.rebindSession(null, onSessionInvalidated: () {});
|
||||
|
||||
await trakt.setEnabled(false);
|
||||
await trakt.setWatchedSyncEnabled(false);
|
||||
await simkl.setEnabled(false);
|
||||
await mal.setEnabled(false);
|
||||
await anilist.setEnabled(false);
|
||||
SettingsService.resetForTesting();
|
||||
});
|
||||
|
||||
void moveWithoutSeeking(Duration target) {
|
||||
for (var milliseconds = 5000; milliseconds < target.inMilliseconds; milliseconds += 5000) {
|
||||
coordinator.updatePosition(Duration(milliseconds: milliseconds));
|
||||
}
|
||||
coordinator.updatePosition(target);
|
||||
}
|
||||
|
||||
Future<void> startAtZero({_FakeMediaServerClient? client}) async {
|
||||
await coordinator.startPlayback(_episode(durationMs: 100000), client ?? _client());
|
||||
await pumpEventQueue();
|
||||
}
|
||||
|
||||
group('Trakt real-time playback', () {
|
||||
test('start posts the resume offset and episode identity', () async {
|
||||
await coordinator.startPlayback(
|
||||
_episode(viewOffsetMs: const Duration(minutes: 10).inMilliseconds, durationMs: 2000000),
|
||||
_client(),
|
||||
);
|
||||
await pumpEventQueue();
|
||||
|
||||
expect(recorder.paths, ['/scrobble/start']);
|
||||
expect(recorder.calls.single.body, {
|
||||
'progress': 30.0,
|
||||
'show': {
|
||||
'ids': {'tvdb': 12345},
|
||||
},
|
||||
'episode': {'season': 1, 'number': 3},
|
||||
});
|
||||
});
|
||||
|
||||
test('pause checkpoints progress and resume starts again', () async {
|
||||
await startAtZero();
|
||||
moveWithoutSeeking(const Duration(seconds: 40));
|
||||
|
||||
await coordinator.pausePlayback();
|
||||
await coordinator.resumePlayback();
|
||||
await pumpEventQueue();
|
||||
|
||||
expect(recorder.paths, ['/scrobble/start', '/scrobble/pause', '/scrobble/start']);
|
||||
expect(recorder.calls[1].body['progress'], 40.0);
|
||||
expect(recorder.calls[2].body['progress'], 40.0);
|
||||
});
|
||||
|
||||
test('same-state start obeys the thirty-second resend throttle', () async {
|
||||
await startAtZero();
|
||||
|
||||
now = now.add(const Duration(seconds: 5));
|
||||
await coordinator.resumePlayback();
|
||||
await pumpEventQueue();
|
||||
expect(recorder.paths, ['/scrobble/start']);
|
||||
|
||||
now = now.add(const Duration(seconds: 25));
|
||||
await coordinator.resumePlayback();
|
||||
await pumpEventQueue();
|
||||
expect(recorder.paths, ['/scrobble/start', '/scrobble/start']);
|
||||
});
|
||||
|
||||
test('seek checkpoints are throttled and ignored while paused', () async {
|
||||
await startAtZero();
|
||||
coordinator.updatePosition(const Duration(seconds: 4));
|
||||
coordinator.updatePosition(const Duration(seconds: 40));
|
||||
await pumpEventQueue();
|
||||
|
||||
expect(recorder.paths, ['/scrobble/start', '/scrobble/pause', '/scrobble/start']);
|
||||
expect(recorder.calls[1].body['progress'], 40.0);
|
||||
expect(recorder.calls[2].body['progress'], 40.0);
|
||||
|
||||
coordinator.updatePosition(const Duration(seconds: 70));
|
||||
await pumpEventQueue();
|
||||
expect(recorder.paths, ['/scrobble/start', '/scrobble/pause', '/scrobble/start']);
|
||||
|
||||
now = now.add(const Duration(seconds: 5));
|
||||
coordinator.updatePosition(const Duration(seconds: 20));
|
||||
await pumpEventQueue();
|
||||
expect(recorder.paths, [
|
||||
'/scrobble/start',
|
||||
'/scrobble/pause',
|
||||
'/scrobble/start',
|
||||
'/scrobble/pause',
|
||||
'/scrobble/start',
|
||||
]);
|
||||
expect(recorder.calls[3].body['progress'], 20.0);
|
||||
expect(recorder.calls[4].body['progress'], 20.0);
|
||||
|
||||
await coordinator.pausePlayback();
|
||||
final callsBeforePausedJump = recorder.calls.length;
|
||||
coordinator.updatePosition(const Duration(seconds: 80));
|
||||
await pumpEventQueue();
|
||||
expect(recorder.calls, hasLength(callsBeforePausedJump));
|
||||
});
|
||||
|
||||
test('stop reports measured progress without inflating it to watched', () async {
|
||||
await startAtZero();
|
||||
moveWithoutSeeking(const Duration(seconds: 60));
|
||||
|
||||
await coordinator.stopPlayback();
|
||||
|
||||
expect(recorder.paths, ['/scrobble/start', '/scrobble/stop']);
|
||||
expect(recorder.callFor('/scrobble/stop').body['progress'], 60.0);
|
||||
});
|
||||
|
||||
test('a low server watched threshold falls back to Trakt history', () async {
|
||||
await startAtZero(client: _client(watchedThreshold: 0.5));
|
||||
moveWithoutSeeking(const Duration(seconds: 60));
|
||||
await pumpEventQueue();
|
||||
|
||||
await coordinator.stopPlayback();
|
||||
await pumpEventQueue();
|
||||
|
||||
expect(recorder.paths, ['/scrobble/start', '/scrobble/stop', '/sync/history']);
|
||||
expect(recorder.callFor('/scrobble/stop').body['progress'], 60.0);
|
||||
expect(recorder.callFor('/sync/history').body, {
|
||||
'shows': [
|
||||
{
|
||||
'ids': {'tvdb': 12345},
|
||||
'seasons': [
|
||||
{
|
||||
'number': 1,
|
||||
'episodes': [
|
||||
{'number': 3},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
test('an unconfirmed completed stop falls back to Trakt history', () async {
|
||||
recorder.statuses['/scrobble/stop'] = 500;
|
||||
await startAtZero(client: _client(watchedThreshold: 0.8));
|
||||
moveWithoutSeeking(const Duration(seconds: 85));
|
||||
await pumpEventQueue();
|
||||
|
||||
await coordinator.stopPlayback();
|
||||
await pumpEventQueue();
|
||||
|
||||
expect(recorder.paths, ['/scrobble/start', '/scrobble/stop', '/sync/history']);
|
||||
expect(recorder.callFor('/scrobble/stop').body['progress'], 85.0);
|
||||
expect(recorder.callsFor('/sync/history'), hasLength(1));
|
||||
});
|
||||
|
||||
Future<void> crossThresholdThenStopBelowTraktRule() async {
|
||||
await startAtZero();
|
||||
moveWithoutSeeking(const Duration(seconds: 95));
|
||||
await pumpEventQueue();
|
||||
coordinator.updateDuration(const Duration(seconds: 200));
|
||||
await coordinator.stopPlayback();
|
||||
await pumpEventQueue();
|
||||
}
|
||||
|
||||
test('scrobble and watched sync together record one watch', () async {
|
||||
await crossThresholdThenStopBelowTraktRule();
|
||||
|
||||
expect(recorder.paths, ['/scrobble/start', '/scrobble/stop', '/sync/history']);
|
||||
expect(recorder.callFor('/scrobble/stop').body['progress'], 47.5);
|
||||
expect(recorder.callsFor('/sync/history'), hasLength(1));
|
||||
});
|
||||
|
||||
test('watched sync works with real-time scrobbling disabled', () async {
|
||||
await trakt.setEnabled(false);
|
||||
|
||||
await crossThresholdThenStopBelowTraktRule();
|
||||
|
||||
expect(recorder.paths.where((path) => path.startsWith('/scrobble/')), isEmpty);
|
||||
expect(recorder.paths, ['/sync/history']);
|
||||
});
|
||||
|
||||
test('real-time scrobbling never writes history when watched sync is disabled', () async {
|
||||
await trakt.setWatchedSyncEnabled(false);
|
||||
|
||||
await crossThresholdThenStopBelowTraktRule();
|
||||
|
||||
expect(recorder.paths, ['/scrobble/start', '/scrobble/stop']);
|
||||
expect(recorder.callFor('/scrobble/stop').body['progress'], 47.5);
|
||||
expect(recorder.callsFor('/sync/history'), isEmpty);
|
||||
});
|
||||
|
||||
test('disabling both Trakt toggles suppresses every request', () async {
|
||||
await trakt.setEnabled(false);
|
||||
await trakt.setWatchedSyncEnabled(false);
|
||||
|
||||
await crossThresholdThenStopBelowTraktRule();
|
||||
|
||||
expect(recorder.calls, isEmpty);
|
||||
});
|
||||
|
||||
test('an account rebind before stop keeps the terminal report off the new account', () async {
|
||||
await startAtZero();
|
||||
moveWithoutSeeking(const Duration(seconds: 40));
|
||||
|
||||
final replacement = _TraktRecorder();
|
||||
trakt.rebindSession(_session('replacement-token'), onSessionInvalidated: () {}, httpClient: replacement.client);
|
||||
await coordinator.stopPlayback();
|
||||
await pumpEventQueue();
|
||||
|
||||
expect(recorder.paths, ['/scrobble/start']);
|
||||
expect(replacement.calls, isEmpty);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -1,25 +0,0 @@
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:plezy/models/trakt/trakt_ids.dart';
|
||||
import 'package:plezy/services/trakt/trakt_constants.dart';
|
||||
import 'package:plezy/services/trakt/trakt_sync_queue.dart';
|
||||
|
||||
void main() {
|
||||
test('TraktSyncQueueItem preserves library context in JSON', () {
|
||||
const item = TraktSyncQueueItem(
|
||||
op: TraktSyncOp.add,
|
||||
ratingKey: 'episode-1',
|
||||
serverId: 'server-1',
|
||||
libraryGlobalKey: 'server-1:7',
|
||||
kind: TraktMediaKind.episode,
|
||||
ids: TraktIds(tvdb: 123),
|
||||
watchedAtIso: '2026-05-12T00:00:00.000Z',
|
||||
season: 1,
|
||||
number: 2,
|
||||
);
|
||||
|
||||
final decoded = TraktSyncQueueItem.fromJson(item.toJson());
|
||||
|
||||
expect(decoded.libraryGlobalKey, 'server-1:7');
|
||||
expect(decoded.incrementAttempts().libraryGlobalKey, 'server-1:7');
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user