refactor(profiles): centralize Plex token policy

This commit is contained in:
edde746
2026-07-12 08:42:24 +02:00
parent baf3c41a44
commit 2a128777f1
2 changed files with 435 additions and 229 deletions
@@ -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();