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

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

Two failure paths found while consolidating are fixed here too.

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

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

344 lines
13 KiB
Dart

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);
});
}