refactor(profiles): centralize Plex token policy
This commit is contained in:
@@ -579,6 +579,175 @@ void main() {
|
||||
expect(multiServerProvider.onlineServerIds, ['srv-1']);
|
||||
});
|
||||
|
||||
group('shared Plex server fetch policy', () {
|
||||
Future<({Profile profile, PlexAccountConnection account})> prepareLocalPlex({
|
||||
required http.Client httpClient,
|
||||
required String? cachedToken,
|
||||
List<PlexServer> cachedServers = const [],
|
||||
MultiServerManager? testManager,
|
||||
}) async {
|
||||
binder.dispose();
|
||||
multiServerProvider.dispose();
|
||||
|
||||
manager = testManager ?? _CapturingMultiServerManager();
|
||||
multiServerProvider = MultiServerProvider(manager, DataAggregationService(manager));
|
||||
binder = ActiveProfileBinder(
|
||||
activeProfile: activeProfile,
|
||||
connections: connections,
|
||||
profileConnections: profileConnections,
|
||||
serverManager: manager,
|
||||
multiServerProvider: multiServerProvider,
|
||||
pinPrompt: (_, {String? errorMessage}) async => null,
|
||||
shouldDeferInitialBind: (_) async => false,
|
||||
plexAuth: PlexAuthService.forTesting(http: MediaServerHttpClient(client: httpClient)),
|
||||
);
|
||||
|
||||
final account = PlexAccountConnection(
|
||||
id: 'plex.policy',
|
||||
accountToken: 'account-token',
|
||||
clientIdentifier: 'client-id',
|
||||
accountLabel: 'Owner',
|
||||
servers: cachedServers,
|
||||
createdAt: DateTime(2026, 1, 1),
|
||||
);
|
||||
await connections.upsert(account);
|
||||
final profile = await createActiveLocalProfile('local-policy');
|
||||
await profileConnections.upsert(
|
||||
ProfileConnection(
|
||||
profileId: profile.id,
|
||||
connectionId: account.id,
|
||||
userToken: cachedToken,
|
||||
userIdentifier: 'home-user-uuid',
|
||||
),
|
||||
);
|
||||
return (profile: profile, account: account);
|
||||
}
|
||||
|
||||
test('cached token success connects the fetched servers without minting', () async {
|
||||
var switchCalls = 0;
|
||||
final capturing = _CapturingMultiServerManager();
|
||||
final prepared = await prepareLocalPlex(
|
||||
cachedToken: 'cached-user-token',
|
||||
testManager: capturing,
|
||||
httpClient: MockClient((request) async {
|
||||
if (request.url.path.endsWith('/home/users/home-user-uuid/switch')) switchCalls++;
|
||||
return http.Response(jsonEncode([_serverJson()]), 200, headers: {'content-type': 'application/json'});
|
||||
}),
|
||||
);
|
||||
|
||||
await binder.rebindActive();
|
||||
|
||||
expect(switchCalls, 0);
|
||||
expect(capturing.refreshCalls, 1);
|
||||
expect(capturing.lastConnection?.servers.single.accessToken, 'server-token');
|
||||
expect((await profileConnections.get(prepared.profile.id, prepared.account.id))?.userToken, 'cached-user-token');
|
||||
expect(activeProfile.lastBindingSucceeded, isTrue);
|
||||
});
|
||||
|
||||
test('missing token mints once before fetching and persists the fresh token', () async {
|
||||
var switchCalls = 0;
|
||||
var resourceCalls = 0;
|
||||
final prepared = await prepareLocalPlex(
|
||||
cachedToken: null,
|
||||
httpClient: MockClient((request) async {
|
||||
if (request.url.path.endsWith('/home/users/home-user-uuid/switch')) {
|
||||
switchCalls++;
|
||||
return http.Response(
|
||||
jsonEncode({'authToken': 'fresh-user-token'}),
|
||||
201,
|
||||
headers: {'content-type': 'application/json'},
|
||||
);
|
||||
}
|
||||
resourceCalls++;
|
||||
return http.Response(jsonEncode([_serverJson()]), 200, headers: {'content-type': 'application/json'});
|
||||
}),
|
||||
);
|
||||
|
||||
await binder.rebindActive();
|
||||
|
||||
expect(switchCalls, 1);
|
||||
expect(resourceCalls, 1);
|
||||
expect((await profileConnections.get(prepared.profile.id, prepared.account.id))?.userToken, 'fresh-user-token');
|
||||
expect(activeProfile.lastBindingSucceeded, isTrue);
|
||||
});
|
||||
|
||||
test('cached auth rejection invalidates and remints, while a fatal error does not', () async {
|
||||
var resourceCalls = 0;
|
||||
var switchCalls = 0;
|
||||
final prepared = await prepareLocalPlex(
|
||||
cachedToken: 'rejected-token',
|
||||
httpClient: MockClient((request) async {
|
||||
if (request.url.path.endsWith('/home/users/home-user-uuid/switch')) {
|
||||
switchCalls++;
|
||||
return http.Response(
|
||||
jsonEncode({'authToken': 'replacement-token'}),
|
||||
201,
|
||||
headers: {'content-type': 'application/json'},
|
||||
);
|
||||
}
|
||||
resourceCalls++;
|
||||
if (resourceCalls == 1) {
|
||||
return http.Response('{}', 401, headers: {'content-type': 'application/json'});
|
||||
}
|
||||
return http.Response('{}', 500, headers: {'content-type': 'application/json'});
|
||||
}),
|
||||
);
|
||||
|
||||
await binder.rebindActive();
|
||||
|
||||
expect(resourceCalls, 2);
|
||||
expect(switchCalls, 1);
|
||||
expect((await profileConnections.get(prepared.profile.id, prepared.account.id))?.userToken, 'replacement-token');
|
||||
expect(activeProfile.lastBindingSucceeded, isFalse);
|
||||
});
|
||||
|
||||
test('cancelled fetch does not invalidate or remint the cached token', () async {
|
||||
var switchCalls = 0;
|
||||
final prepared = await prepareLocalPlex(
|
||||
cachedToken: 'cached-user-token',
|
||||
httpClient: MockClient((request) async {
|
||||
if (request.url.path.endsWith('/home/users/home-user-uuid/switch')) switchCalls++;
|
||||
throw http.RequestAbortedException(request.url);
|
||||
}),
|
||||
);
|
||||
|
||||
await binder.rebindActive();
|
||||
|
||||
expect(switchCalls, 0);
|
||||
expect((await profileConnections.get(prepared.profile.id, prepared.account.id))?.userToken, 'cached-user-token');
|
||||
expect(activeProfile.lastBindingSucceeded, isFalse);
|
||||
});
|
||||
|
||||
test('profile switch cancels stale zero-server handling before invalidation or remint', () async {
|
||||
final requestStarted = Completer<void>();
|
||||
final releaseRequest = Completer<void>();
|
||||
var switchCalls = 0;
|
||||
final prepared = await prepareLocalPlex(
|
||||
cachedToken: 'cached-user-token',
|
||||
httpClient: MockClient((request) async {
|
||||
if (request.url.path.endsWith('/home/users/home-user-uuid/switch')) switchCalls++;
|
||||
if (!requestStarted.isCompleted) requestStarted.complete();
|
||||
await releaseRequest.future;
|
||||
return http.Response('[]', 200, headers: {'content-type': 'application/json'});
|
||||
}),
|
||||
);
|
||||
final nextProfile = Profile.local(id: 'local-next', displayName: 'Next', createdAt: DateTime(2026, 1, 2));
|
||||
await profiles.upsert(nextProfile);
|
||||
await pumpUntil(() async => activeProfile.profiles.any((profile) => profile.id == nextProfile.id));
|
||||
|
||||
binder.start();
|
||||
await requestStarted.future;
|
||||
await activeProfile.activate(nextProfile);
|
||||
releaseRequest.complete();
|
||||
await activeProfile.awaitBindingSettle();
|
||||
|
||||
expect(switchCalls, 0);
|
||||
expect((await profileConnections.get(prepared.profile.id, prepared.account.id))?.userToken, 'cached-user-token');
|
||||
expect(binder.debugLastBoundProfileId, nextProfile.id);
|
||||
expect(activeProfile.lastBindingSucceeded, isTrue);
|
||||
});
|
||||
});
|
||||
|
||||
group('rebind cycle semantics', () {
|
||||
test('queued same-id rebind settles once, after the last pass', () async {
|
||||
binder.dispose();
|
||||
|
||||
Reference in New Issue
Block a user