Files
plezy/test/providers/trackers_provider_test.dart
T
edde746 eaa1736c4e feat(mdblist): sync watched history, scrobbles and ratings with MDBList
Connects MDBList through its OAuth device-code grant, registered as a
Device Code app so no client secret or redirect URI ships in the binary
and TV, mobile and desktop all use the same flow.

MDBList omits `verification_uri_complete`, but its device page seeds the
code field from a `user_code` query parameter and the sign-in redirect
preserves the query string, so the activation link is built locally and
the dialog's open button lands on a filled-in form instead of an empty
one. A server-supplied complete URL still wins if one ever appears.

Poll state is read from the response body rather than the status code:
`authorization_pending` and `slow_down` both arrive as HTTP 400, and a
missing grant answers 404 `device_not_found`.

Writes go out as real-time `/scrobble/*` reports plus `/sync/watched`
for the marks that never pass through the player, with ratings on
`/sync/ratings`. Matching uses IMDb and TMDb only — MDBList's id block
has no `tvdb` field, so a TVDB-only item is skipped rather than written
under an empty id block.
2026-08-05 12:03:05 +02:00

537 lines
20 KiB
Dart

import 'dart:async';
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/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/mdblist/mdblist_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';
final _malStore = trackerAccountStore(TrackerService.mal);
final _anilistStore = trackerAccountStore(TrackerService.anilist);
final _simklStore = trackerAccountStore(TrackerService.simkl);
final _traktStore = trackerAccountStore(TrackerService.trakt);
final _mdblistStore = trackerAccountStore(TrackerService.mdblist);
TrackerSession _mal({String? username}) => TrackerSession(
accessToken: 'mal-at',
refreshToken: 'mal-rt',
expiresAt: DateTime.now().millisecondsSinceEpoch ~/ 1000 + 3600,
createdAt: DateTime.now().millisecondsSinceEpoch ~/ 1000,
username: username,
);
TrackerSession _anilist({String? username}) => TrackerSession(
accessToken: 'anilist-at',
expiresAt: DateTime.now().millisecondsSinceEpoch ~/ 1000 + 3600,
createdAt: DateTime.now().millisecondsSinceEpoch ~/ 1000,
username: username,
);
TrackerSession _simkl({String? username}) => TrackerSession(
accessToken: 'simkl-at',
createdAt: DateTime.now().millisecondsSinceEpoch ~/ 1000,
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();
_resetTrackerBindings();
});
tearDown(_resetTrackerBindings);
group('TrackersProvider', () {
test('starts with all trackers disconnected', () {
final p = TrackersProvider();
expect(p.mal, isNull);
expect(p.anilist, isNull);
expect(p.simkl, isNull);
expect(p.trakt, isNull);
expect(p.mdblist, isNull);
expect(p.isMalConnected, isFalse);
expect(p.isAnilistConnected, isFalse);
expect(p.isSimklConnected, isFalse);
expect(p.isTraktConnected, isFalse);
expect(p.isMdblistConnected, isFalse);
expect(p.malUsername, isNull);
expect(p.anilistUsername, isNull);
expect(p.simklUsername, isNull);
expect(p.traktUsername, isNull);
expect(p.mdblistUsername, 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);
expect(p.isConnecting(TrackerService.mdblist), isFalse);
p.dispose();
});
test('forTesting owns one fresh client per eager auth owner', () {
final clients = <FakeHttpClient>[];
final p = TrackersProvider.forTesting(
connectPipeline: _ControlledConnectPipeline(_mal()).call,
httpClientFactory: () {
final client = FakeHttpClient(200, const <int>[]);
clients.add(client);
return client;
},
);
expect(clients, hasLength(6));
expect(clients.toSet(), hasLength(6));
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 sessions from per-profile stores', () async {
const uuid = 'profile-1';
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();
final p = TrackersProvider();
var notified = 0;
p.addListener(() => notified++);
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();
});
test('onActiveProfileChanged switching to empty profile clears all sessions', () async {
const uuid = 'profile-1';
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 _bindProfile(p, uuid);
expect(p.isMalConnected, isTrue);
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, Simkl, and Trakt remain absent.
await _anilistStore.save(uuid, _anilist(username: 'bob'));
BaseSharedPreferencesService.resetForTesting();
final p = TrackersProvider();
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();
});
test('disconnectMal clears stored session and notifies', () async {
const uuid = 'profile-3';
await _malStore.save(uuid, _mal(username: 'alice'));
BaseSharedPreferencesService.resetForTesting();
final p = TrackersProvider();
await _bindProfile(p, uuid);
expect(p.isMalConnected, isTrue);
var notified = 0;
p.addListener(() => notified++);
await p.disconnectMal();
expect(p.isMalConnected, isFalse);
expect(p.mal, isNull);
// _clearAndRebind notifies once.
expect(notified, 1);
// Persistence is cleared too.
expect(await _malStore.load(uuid), isNull);
p.dispose();
});
test('disconnectAnilist clears anilist while leaving MAL intact', () async {
const uuid = 'profile-4';
await _malStore.save(uuid, _mal(username: 'alice'));
await _anilistStore.save(uuid, _anilist(username: 'bob'));
BaseSharedPreferencesService.resetForTesting();
final p = TrackersProvider();
await _bindProfile(p, uuid);
await p.disconnectAnilist();
expect(p.isAnilistConnected, isFalse);
expect(p.isMalConnected, isTrue);
expect(p.malUsername, 'alice');
p.dispose();
});
test('disconnect during an in-flight profile load keeps the other trackers loaded', () async {
const uuid = 'profile-race';
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 = _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/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();
});
test('disconnectSimkl on a profile with no session is safe', () async {
final p = TrackersProvider();
// No `onActiveProfileChanged` — uuid is empty (global slot).
// disconnectSimkl just clears the (already absent) entry and rebinds.
await p.disconnectSimkl();
expect(p.isSimklConnected, isFalse);
p.dispose();
});
test('cancelConnect is a no-op when not connecting', () {
final p = TrackersProvider();
expect(() => p.cancelConnect(), returnsNormally);
expect(p.isConnecting(TrackerService.mal), isFalse);
p.dispose();
});
test('safeNotifyListeners after dispose is a no-op', () async {
final p = TrackersProvider();
p.dispose();
// Post-dispose rebind should not throw.
await _bindProfile(p, 'any-uuid');
});
for (final service in [
TrackerService.mal,
TrackerService.anilist,
TrackerService.simkl,
TrackerService.trakt,
TrackerService.mdblist,
]) {
test('$service stale connect cannot save or replace a newer binding after dispose', () async {
const oldUuid = 'profile-old';
const newUuid = 'profile-new';
final oldSession = _session(service, 'old');
final newSession = _session(service, 'new');
await _store(service).save(newUuid, newSession);
BaseSharedPreferencesService.resetForTesting();
final pipeline = _ControlledConnectPipeline(oldSession);
final oldProvider = TrackersProvider.forTesting(connectPipeline: pipeline.call);
await _bindProfile(oldProvider, oldUuid);
final connect = _connect(oldProvider, service);
await pipeline.beforeSave.future;
oldProvider.dispose();
final newProvider = TrackersProvider();
await _bindProfile(newProvider, newUuid);
final newBinding = _boundClient(service);
expect(newBinding, isNotNull);
expect(_providerSession(newProvider, service)?.accessToken, newSession.accessToken);
pipeline.releaseBeforeSave.complete();
expect(await connect, isFalse);
expect(await _store(service).load(oldUuid), isNull);
expect((await _store(service).load(newUuid))?.accessToken, newSession.accessToken);
expect(_boundClient(service), same(newBinding));
expect(_boundSession(service)?.accessToken, newSession.accessToken);
expect(_providerSession(oldProvider, service), isNull);
newProvider.dispose();
});
}
test('cancel invalidates a connect after authorization and before save', () async {
const uuid = 'profile-cancel';
final pipeline = _ControlledConnectPipeline(_session(TrackerService.mal, 'cancelled'));
final p = TrackersProvider.forTesting(connectPipeline: pipeline.call);
await _bindProfile(p, uuid);
final connect = _connect(p, TrackerService.mal);
await pipeline.beforeSave.future;
p.cancelConnect();
pipeline.releaseBeforeSave.complete();
expect(await connect, isFalse);
expect(p.isConnecting(TrackerService.mal), isFalse);
expect(p.mal, isNull);
expect(await _malStore.load(uuid), isNull);
expect(MalTracker.instance.client, isNull);
p.dispose();
});
test('profile change invalidates the old connect and preserves the new binding', () async {
const oldUuid = 'profile-change-old';
const newUuid = 'profile-change-new';
final oldSession = _session(TrackerService.anilist, 'old');
final newSession = _session(TrackerService.anilist, 'new');
await _anilistStore.save(newUuid, newSession);
BaseSharedPreferencesService.resetForTesting();
final pipeline = _ControlledConnectPipeline(oldSession);
final p = TrackersProvider.forTesting(connectPipeline: pipeline.call);
await _bindProfile(p, oldUuid);
final connect = _connect(p, TrackerService.anilist);
await pipeline.beforeSave.future;
await _bindProfile(p, newUuid);
final newBinding = AnilistTracker.instance.client;
pipeline.releaseBeforeSave.complete();
expect(await connect, isFalse);
expect(await _anilistStore.load(oldUuid), isNull);
expect((await _anilistStore.load(newUuid))?.accessToken, newSession.accessToken);
expect(p.anilist?.accessToken, newSession.accessToken);
expect(AnilistTracker.instance.client, same(newBinding));
expect(AnilistTracker.instance.client?.session.accessToken, newSession.accessToken);
p.dispose();
});
test('same-service disconnect invalidates an in-flight connect', () async {
const uuid = 'profile-same-disconnect';
final pipeline = _ControlledConnectPipeline(_session(TrackerService.simkl, 'late'));
final p = TrackersProvider.forTesting(connectPipeline: pipeline.call);
await _bindProfile(p, uuid);
final connect = _connect(p, TrackerService.simkl);
await pipeline.beforeSave.future;
await p.disconnectSimkl();
pipeline.releaseBeforeSave.complete();
expect(await connect, isFalse);
expect(p.simkl, isNull);
expect(await _simklStore.load(uuid), isNull);
expect(SimklTracker.instance.client, isNull);
p.dispose();
});
test('unrelated disconnect leaves an allowed connect current', () async {
const uuid = 'profile-unrelated-disconnect';
final linkedAnilist = _session(TrackerService.anilist, 'linked');
final connectedMal = _session(TrackerService.mal, 'connected');
await _anilistStore.save(uuid, linkedAnilist);
BaseSharedPreferencesService.resetForTesting();
final pipeline = _ControlledConnectPipeline(connectedMal);
final p = TrackersProvider.forTesting(connectPipeline: pipeline.call);
await _bindProfile(p, uuid);
final connect = _connect(p, TrackerService.mal);
await pipeline.beforeSave.future;
await p.disconnectAnilist();
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);
expect(MalTracker.instance.client?.session.accessToken, connectedMal.accessToken);
p.dispose();
});
test('dispose after save cannot assign or erase a newer same-profile binding', () async {
const uuid = 'profile-save-race';
final staleSession = _session(TrackerService.mal, 'stale');
final freshSession = _session(TrackerService.mal, 'fresh');
final pipeline = _ControlledConnectPipeline(staleSession, pauseAfterSave: true);
final staleProvider = TrackersProvider.forTesting(connectPipeline: pipeline.call);
await _bindProfile(staleProvider, uuid);
final connect = _connect(staleProvider, TrackerService.mal);
await pipeline.beforeSave.future;
pipeline.releaseBeforeSave.complete();
await pipeline.afterSave.future;
staleProvider.dispose();
await _malStore.save(uuid, freshSession);
BaseSharedPreferencesService.resetForTesting();
final freshProvider = TrackersProvider();
await _bindProfile(freshProvider, uuid);
final freshBinding = MalTracker.instance.client;
pipeline.releaseAfterSave.complete();
expect(await connect, isFalse);
expect((await _malStore.load(uuid))?.accessToken, freshSession.accessToken);
expect(MalTracker.instance.client, same(freshBinding));
expect(MalTracker.instance.client?.session.accessToken, freshSession.accessToken);
expect(staleProvider.mal, isNull);
freshProvider.dispose();
});
});
}
void _resetTrackerBindings() {
MalTracker.instance.rebindSession(null, onSessionInvalidated: () {});
AnilistTracker.instance.rebindSession(null, onSessionInvalidated: () {});
SimklTracker.instance.rebindSession(null, onSessionInvalidated: () {});
TraktTracker.instance.rebindSession(null, onSessionInvalidated: () {});
MdblistTracker.instance.rebindSession(null, onSessionInvalidated: () {});
}
TrackerAccountStore _store(TrackerService service) => switch (service) {
TrackerService.mal => _malStore,
TrackerService.anilist => _anilistStore,
TrackerService.simkl => _simklStore,
TrackerService.trakt => _traktStore,
TrackerService.mdblist => _mdblistStore,
};
TrackerSession _session(TrackerService service, String owner) => switch (service) {
TrackerService.mal => TrackerSession(
accessToken: '$owner-mal-at',
refreshToken: '$owner-mal-rt',
expiresAt: 2000000000,
createdAt: 1900000000,
username: owner,
),
TrackerService.anilist => TrackerSession(
accessToken: '$owner-anilist-at',
expiresAt: 2000000000,
createdAt: 1900000000,
username: owner,
),
TrackerService.simkl => TrackerSession(accessToken: '$owner-simkl-at', createdAt: 1900000000, username: owner),
TrackerService.trakt => TrackerSession(
accessToken: '$owner-trakt-at',
refreshToken: '$owner-trakt-rt',
expiresAt: 2000000000,
createdAt: 1900000000,
username: owner,
),
TrackerService.mdblist => TrackerSession(
accessToken: '$owner-mdblist-at',
refreshToken: '$owner-mdblist-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: (_) {}),
TrackerService.trakt => provider.connectTrakt(onCodeReady: (_) {}),
TrackerService.mdblist => provider.connectMdblist(onCodeReady: (_) {}),
};
TrackerSession? _providerSession(TrackersProvider provider, TrackerService service) => switch (service) {
TrackerService.mal => provider.mal,
TrackerService.anilist => provider.anilist,
TrackerService.simkl => provider.simkl,
TrackerService.trakt => provider.trakt,
TrackerService.mdblist => provider.mdblist,
};
Object? _boundClient(TrackerService service) => switch (service) {
TrackerService.mal => MalTracker.instance.client,
TrackerService.anilist => AnilistTracker.instance.client,
TrackerService.simkl => SimklTracker.instance.client,
TrackerService.trakt => TraktTracker.instance.client,
TrackerService.mdblist => MdblistTracker.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,
TrackerService.trakt => TraktTracker.instance.client?.session,
TrackerService.mdblist => MdblistTracker.instance.client?.session,
};
class _ControlledConnectPipeline {
_ControlledConnectPipeline(this.session, {this.pauseAfterSave = false});
final TrackerSession session;
final bool pauseAfterSave;
final beforeSave = Completer<void>();
final releaseBeforeSave = Completer<void>();
final afterSave = Completer<void>();
final releaseAfterSave = Completer<void>();
Future<bool> call({
required String logLabel,
required Future<TrackerSession?> Function() authorize,
required Future<TrackerSession> Function(TrackerSession raw) enrich,
required Future<void> Function(TrackerSession enriched) save,
required void Function(TrackerSession enriched) assign,
}) async {
beforeSave.complete();
await releaseBeforeSave.future;
await save(session);
afterSave.complete();
if (pauseAfterSave) await releaseAfterSave.future;
assign(session);
return true;
}
}