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.
271 lines
9.5 KiB
Dart
271 lines
9.5 KiB
Dart
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(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 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');
|
|
});
|
|
});
|
|
}
|