Files
plezy/test/services/trakt_client_test.dart
T
edde746 78090d8bd6 refactor(trackers): unify session and account store across services
Replace the per-tracker json_serializable session classes and account stores with a shared TrackerSession (service-aware persisted validation), a single TrackerAccountStore, a shared TrackerHttpClient, and an AnimeListTrackerBase mixin for MAL/AniList. Scope the per-service rebind guard so a disconnect can't abort a racing profile load. Add migration-safety, MAL refresh, episode-count cache, and content-type tests.
2026-06-27 10:05:03 +02:00

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/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();
});
});
}