Files
plezy/test/providers/trackers_provider_test.dart
T

475 lines
18 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_session.dart';
import 'package:plezy/services/trackers/mal/mal_tracker.dart';
import 'package:plezy/services/trackers/simkl/simkl_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);
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,
);
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.isMalConnected, isFalse);
expect(p.isAnilistConnected, isFalse);
expect(p.isSimklConnected, isFalse);
expect(p.malUsername, isNull);
expect(p.anilistUsername, isNull);
expect(p.simklUsername, isNull);
expect(p.isConnecting(TrackerService.mal), isFalse);
expect(p.isConnecting(TrackerService.anilist), isFalse);
expect(p.isConnecting(TrackerService.simkl), 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(4));
expect(clients.toSet(), hasLength(4));
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'));
// Reset cached singletons so the provider reads fresh prefs state.
BaseSharedPreferencesService.resetForTesting();
final p = TrackersProvider();
var notified = 0;
p.addListener(() => notified++);
await p.onActiveProfileChanged(uuid);
expect(p.isMalConnected, isTrue);
expect(p.isAnilistConnected, isTrue);
expect(p.isSimklConnected, isTrue);
expect(p.malUsername, 'alice');
expect(p.anilistUsername, 'bob');
expect(p.simklUsername, 'carol');
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'));
BaseSharedPreferencesService.resetForTesting();
final p = TrackersProvider();
await p.onActiveProfileChanged(uuid);
expect(p.isMalConnected, isTrue);
await p.onActiveProfileChanged('other-profile');
expect(p.isMalConnected, isFalse);
expect(p.isAnilistConnected, isFalse);
expect(p.isSimklConnected, 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.
await _anilistStore.save(uuid, _anilist(username: 'bob'));
BaseSharedPreferencesService.resetForTesting();
final p = TrackersProvider();
await p.onActiveProfileChanged(uuid);
expect(p.isAnilistConnected, isTrue);
expect(p.anilistUsername, 'bob');
expect(p.isMalConnected, isFalse);
expect(p.isSimklConnected, 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 p.onActiveProfileChanged(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 p.onActiveProfileChanged(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'));
BaseSharedPreferencesService.resetForTesting();
final p = TrackersProvider();
// Start the load, then disconnect MAL before it resolves.
final load = p.onActiveProfileChanged(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.
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');
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 p.onActiveProfileChanged('any-uuid');
});
for (final service in [TrackerService.mal, TrackerService.anilist, TrackerService.simkl]) {
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 oldProvider.onActiveProfileChanged(oldUuid);
final connect = _connect(oldProvider, service);
await pipeline.beforeSave.future;
oldProvider.dispose();
final newProvider = TrackersProvider();
await newProvider.onActiveProfileChanged(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 p.onActiveProfileChanged(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 p.onActiveProfileChanged(oldUuid);
final connect = _connect(p, TrackerService.anilist);
await pipeline.beforeSave.future;
await p.onActiveProfileChanged(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 p.onActiveProfileChanged(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 p.onActiveProfileChanged(uuid);
final connect = _connect(p, TrackerService.mal);
await pipeline.beforeSave.future;
await p.disconnectAnilist();
pipeline.releaseBeforeSave.complete();
expect(await connect, isTrue);
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 staleProvider.onActiveProfileChanged(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 freshProvider.onActiveProfileChanged(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: () {});
}
TrackerAccountStore _store(TrackerService service) => switch (service) {
TrackerService.mal => _malStore,
TrackerService.anilist => _anilistStore,
TrackerService.simkl => _simklStore,
_ => throw ArgumentError.value(service),
};
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),
_ => throw ArgumentError.value(service),
};
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),
};
TrackerSession? _providerSession(TrackersProvider provider, TrackerService service) => switch (service) {
TrackerService.mal => provider.mal,
TrackerService.anilist => provider.anilist,
TrackerService.simkl => provider.simkl,
_ => throw ArgumentError.value(service),
};
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),
};
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),
};
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;
}
}