Queued tracker writes carried no account identity, so rows created under account A replayed against account B after a reconnect; explicit disconnect now purges that service's queue. An in-flight markWatched/reconcile that failed after the purge was re-enqueued anyway (the write scope only tracked profile generation) and replayed into whichever account connected next: Tracker.accountBinding (client identity, rebound synchronously on every disconnect) is captured before each write and re-checked before queueing a retry, and because the check sits in the same synchronous segment as the enqueue, a row that passes it is claimed ahead of the purge in the queue mutex and is still removed by it. The memoized anime-list snapshot survived writes, so sequential rewatch updates computed from pre-write state; successful writes now evict the entry. A device-code dialog dismissed by system back never cancelled the poll (blocking new attempts until the deadline), and a user-cancelled sign-in showed the connection-failed snackbar; both now route through one cancel path.
381 lines
14 KiB
Dart
381 lines
14 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('disconnect purge drops one service rows so nothing replays, leaving other services queued', () async {
|
|
final queue = TrackerWriteQueue();
|
|
final traktCtx = _episode(ratingKey: 'trakt-episode', external: const ExternalIds(tvdb: 100));
|
|
final traktKey = trackerItemCoalesceKey(
|
|
TrackerService.trakt,
|
|
traktCtx,
|
|
trackerExternalRowIdentity(traktCtx.external),
|
|
)!;
|
|
final malCtx = _episode(ratingKey: 'mal-episode', external: const ExternalIds(tvdb: 200));
|
|
await queue.enqueue('user-a', _item(ctx: traktCtx, coalesceKey: traktKey));
|
|
await queue.enqueue(
|
|
'user-a',
|
|
_item(
|
|
ctx: malCtx,
|
|
coalesceKey: trackerSeriesCoalesceKey(TrackerService.mal, 42),
|
|
service: TrackerService.mal,
|
|
progressClaim: 5,
|
|
),
|
|
);
|
|
|
|
await queue.removeService('user-a', TrackerService.trakt);
|
|
|
|
final survivors = await queue.load('user-a');
|
|
expect(survivors.map((item) => item.service), [TrackerService.mal], reason: 'other services keep their rows');
|
|
|
|
final sent = <TrackerWriteQueueItem>[];
|
|
await queue.flush(
|
|
'user-a',
|
|
send: (item) async {
|
|
sent.add(item);
|
|
return TrackerWriteDisposition.done;
|
|
},
|
|
);
|
|
|
|
expect(sent.map((item) => item.service), [TrackerService.mal], reason: 'the purged service must not dispatch');
|
|
});
|
|
|
|
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);
|
|
});
|
|
}
|