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.
227 lines
7.8 KiB
Dart
227 lines
7.8 KiB
Dart
import 'dart:async';
|
|
import 'dart:convert';
|
|
|
|
import 'package:flutter_test/flutter_test.dart';
|
|
import 'package:http/http.dart' as http;
|
|
import 'package:http/testing.dart';
|
|
import 'package:plezy/services/trackers/tracker_exceptions.dart';
|
|
import 'package:plezy/services/trackers/tracker_session.dart';
|
|
import 'package:plezy/services/trackers/trakt/trakt_client.dart';
|
|
|
|
int _now() => DateTime.now().millisecondsSinceEpoch ~/ 1000;
|
|
|
|
TrackerSession _session({
|
|
String accessToken = 'access-old',
|
|
String refreshToken = 'refresh-old',
|
|
int? expiresAt,
|
|
String? username = 'alice',
|
|
}) {
|
|
final now = _now();
|
|
return TrackerSession(
|
|
accessToken: accessToken,
|
|
refreshToken: refreshToken,
|
|
expiresAt: expiresAt ?? now - 60,
|
|
scope: 'public',
|
|
createdAt: now - 3600,
|
|
username: username,
|
|
);
|
|
}
|
|
|
|
String _tokenBody({String accessToken = 'access-new', String refreshToken = 'refresh-new'}) {
|
|
return json.encode({
|
|
'access_token': accessToken,
|
|
'refresh_token': refreshToken,
|
|
'expires_in': 86400,
|
|
'scope': 'public',
|
|
'created_at': _now(),
|
|
});
|
|
}
|
|
|
|
void main() {
|
|
group('TraktClient refresh', () {
|
|
test('publishes refreshed tokens before retrying the API request', () async {
|
|
final updates = <TrackerSession>[];
|
|
final requests = <http.Request>[];
|
|
final client = TraktClient(
|
|
_session(),
|
|
onSessionInvalidated: () => fail('refresh should not invalidate the session'),
|
|
onSessionUpdated: updates.add,
|
|
httpClient: MockClient((request) async {
|
|
requests.add(request);
|
|
if (request.url.path == '/oauth/token') {
|
|
expect(json.decode(request.body), containsPair('refresh_token', 'refresh-old'));
|
|
return http.Response(_tokenBody(), 200);
|
|
}
|
|
if (request.url.path == '/users/settings') {
|
|
expect(request.headers['Authorization'], 'Bearer access-new');
|
|
return http.Response(
|
|
json.encode({
|
|
'user': {'username': 'alice'},
|
|
}),
|
|
200,
|
|
);
|
|
}
|
|
fail('Unexpected request: ${request.method} ${request.url}');
|
|
}),
|
|
);
|
|
|
|
await client.getUserSettings();
|
|
|
|
expect(requests.map((r) => r.url.path), ['/oauth/token', '/users/settings']);
|
|
expect(updates, hasLength(1));
|
|
expect(updates.single.accessToken, 'access-new');
|
|
expect(updates.single.refreshToken, 'refresh-new');
|
|
expect(updates.single.username, 'alice');
|
|
expect(client.session.accessToken, 'access-new');
|
|
|
|
client.dispose();
|
|
});
|
|
|
|
test('uses a newer broadcast session when another client refreshed first', () async {
|
|
final releaseRefreshResponse = Completer<void>();
|
|
var invalidated = 0;
|
|
late final TraktClient client;
|
|
client = TraktClient(
|
|
_session(),
|
|
onSessionInvalidated: () => invalidated++,
|
|
onSessionUpdated: (_) {},
|
|
httpClient: MockClient((request) async {
|
|
expect(request.url.path, '/oauth/token');
|
|
expect(json.decode(request.body), containsPair('refresh_token', 'refresh-old'));
|
|
await releaseRefreshResponse.future;
|
|
return http.Response(json.encode({'error': 'invalid_grant'}), 400);
|
|
}),
|
|
);
|
|
|
|
final refresh = client.refresh();
|
|
client.updateSession(_session(accessToken: 'access-new', refreshToken: 'refresh-new', expiresAt: _now() + 86400));
|
|
releaseRefreshResponse.complete();
|
|
|
|
final session = await refresh;
|
|
|
|
expect(session.accessToken, 'access-new');
|
|
expect(session.refreshToken, 'refresh-new');
|
|
expect(invalidated, 0);
|
|
|
|
client.dispose();
|
|
});
|
|
|
|
test('coalesces simultaneous refreshes for the same refresh token across clients', () async {
|
|
final releaseRefreshResponse = Completer<void>();
|
|
final updates = <String>[];
|
|
var refreshPosts = 0;
|
|
Future<http.Response> handleRequest(http.Request request) async {
|
|
expect(request.url.path, '/oauth/token');
|
|
refreshPosts++;
|
|
await releaseRefreshResponse.future;
|
|
return http.Response(_tokenBody(), 200);
|
|
}
|
|
|
|
final first = TraktClient(
|
|
_session(),
|
|
onSessionInvalidated: () => fail('first client should not invalidate'),
|
|
onSessionUpdated: (session) => updates.add('first:${session.refreshToken}'),
|
|
httpClient: MockClient(handleRequest),
|
|
);
|
|
final second = TraktClient(
|
|
_session(),
|
|
onSessionInvalidated: () => fail('second client should not invalidate'),
|
|
onSessionUpdated: (session) => updates.add('second:${session.refreshToken}'),
|
|
httpClient: MockClient(handleRequest),
|
|
);
|
|
|
|
final firstRefresh = first.refresh();
|
|
final secondRefresh = second.refresh();
|
|
await Future<void>.delayed(Duration.zero);
|
|
releaseRefreshResponse.complete();
|
|
|
|
final sessions = await Future.wait([firstRefresh, secondRefresh]);
|
|
|
|
expect(refreshPosts, 1);
|
|
expect(sessions.map((s) => s.refreshToken), ['refresh-new', 'refresh-new']);
|
|
expect(updates, ['first:refresh-new', 'second:refresh-new']);
|
|
|
|
first.dispose();
|
|
second.dispose();
|
|
});
|
|
|
|
test('keeps the session connected after retryable refresh failures', () async {
|
|
var invalidated = 0;
|
|
final client = TraktClient(
|
|
_session(),
|
|
onSessionInvalidated: () => invalidated++,
|
|
onSessionUpdated: (_) {},
|
|
httpClient: MockClient((request) async => http.Response('temporary outage', 500)),
|
|
);
|
|
|
|
await expectLater(client.refresh(), throwsA(isA<TrackerAuthException>()));
|
|
|
|
expect(invalidated, 0);
|
|
expect(client.session.refreshToken, 'refresh-old');
|
|
|
|
client.dispose();
|
|
});
|
|
|
|
test('invalidates the session after permanent refresh failures', () async {
|
|
var invalidated = 0;
|
|
final client = TraktClient(
|
|
_session(),
|
|
onSessionInvalidated: () => invalidated++,
|
|
onSessionUpdated: (_) => fail('failed refresh should not publish a session'),
|
|
httpClient: MockClient((request) async => http.Response(json.encode({'error': 'invalid_grant'}), 400)),
|
|
);
|
|
|
|
await expectLater(client.refresh(), throwsA(isA<TrackerAuthException>()));
|
|
|
|
expect(invalidated, 1);
|
|
expect(client.session.refreshToken, 'refresh-old');
|
|
|
|
client.dispose();
|
|
});
|
|
|
|
test('coalesced waiters invalidate after permanent refresh failures', () async {
|
|
final releaseRefreshResponse = Completer<void>();
|
|
var ownerInvalidated = 0;
|
|
var waiterInvalidated = 0;
|
|
var refreshPosts = 0;
|
|
|
|
Future<http.Response> handleRequest(http.Request request) async {
|
|
expect(request.url.path, '/oauth/token');
|
|
refreshPosts++;
|
|
await releaseRefreshResponse.future;
|
|
return http.Response(json.encode({'error': 'invalid_grant'}), 400);
|
|
}
|
|
|
|
final owner = TraktClient(
|
|
_session(),
|
|
onSessionInvalidated: () => ownerInvalidated++,
|
|
onSessionUpdated: (_) => fail('failed refresh should not publish owner session'),
|
|
httpClient: MockClient(handleRequest),
|
|
);
|
|
final waiter = TraktClient(
|
|
_session(),
|
|
onSessionInvalidated: () => waiterInvalidated++,
|
|
onSessionUpdated: (_) => fail('failed refresh should not publish waiter session'),
|
|
httpClient: MockClient(handleRequest),
|
|
);
|
|
|
|
final ownerRefresh = owner.refresh();
|
|
final waiterRefresh = waiter.refresh();
|
|
final ownerExpectation = expectLater(ownerRefresh, throwsA(isA<TrackerAuthException>()));
|
|
final waiterExpectation = expectLater(waiterRefresh, throwsA(isA<TrackerAuthException>()));
|
|
await Future<void>.delayed(Duration.zero);
|
|
releaseRefreshResponse.complete();
|
|
|
|
await ownerExpectation;
|
|
await waiterExpectation;
|
|
|
|
expect(refreshPosts, 1);
|
|
expect(ownerInvalidated, 1);
|
|
expect(waiterInvalidated, 1);
|
|
|
|
owner.dispose();
|
|
waiter.dispose();
|
|
});
|
|
});
|
|
}
|