frosty/test/screens/settings/stores/user_store_test.dart
copilot 30848e6fe8 Add follow and unfollow for live and offline channels.
Long-press actions use Helix for follow status and web GQL mutations via the linked session.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-07 03:06:05 +02:00

182 lines
5.5 KiB
Dart

import 'package:flutter_test/flutter_test.dart';
import 'package:frosty/apis/base_api_client.dart';
import 'package:frosty/apis/twitch_api.dart';
import 'package:frosty/apis/twitch_gql_api.dart';
import 'package:frosty/models/user.dart';
import 'package:frosty/screens/settings/stores/user_store.dart';
import 'package:mocktail/mocktail.dart';
import 'package:shared_preferences/shared_preferences.dart';
class MockTwitchApi extends Mock implements TwitchApi {}
class MockTwitchGqlApi extends Mock implements TwitchGqlApi {}
void main() {
late MockTwitchApi api;
late MockTwitchGqlApi gqlApi;
setUp(() {
SharedPreferences.setMockInitialValues({});
api = MockTwitchApi();
gqlApi = MockTwitchGqlApi();
when(() => api.getUserInfo()).thenAnswer(
(_) async => const UserTwitch(
'12345',
'testuser',
'TestUser',
'https://cdn/profile.png',
),
);
when(() => api.getUserBlockedList(id: any(named: 'id'))).thenAnswer(
(_) async => <UserBlockedTwitch>[],
);
when(() => api.getModeratedChannels(id: any(named: 'id'))).thenAnswer(
(_) async => <String>['999'],
);
});
UserStore makeStore({required bool hasModeratorScopes}) => UserStore(
twitchApi: api,
twitchGqlApi: gqlApi,
hasModeratorScopes: () => hasModeratorScopes,
);
group('UserStore moderated channels gating', () {
test('init skips moderated-channels without moderator scopes', () async {
final userStore = makeStore(hasModeratorScopes: false);
await userStore.init();
await Future<void>.delayed(Duration.zero);
verifyNever(() => api.getModeratedChannels(id: any(named: 'id')));
expect(userStore.moderatedChannels, isEmpty);
});
test('init fetches moderated-channels when moderator scopes exist', () async {
final userStore = makeStore(hasModeratorScopes: true);
await userStore.init();
await Future<void>.delayed(Duration.zero);
verify(() => api.getModeratedChannels(id: '12345')).called(1);
expect(userStore.moderatedChannels, ['999']);
});
test('refreshModeratedChannels no-ops without moderator scopes', () async {
final userStore = makeStore(hasModeratorScopes: false);
await userStore.init();
clearInteractions(api);
await userStore.refreshModeratedChannels();
verifyNever(() => api.getModeratedChannels(id: any(named: 'id')));
});
test('deleteMessage requires moderator scopes', () async {
when(
() => api.deleteChatMessage(
broadcasterId: any(named: 'broadcasterId'),
moderatorId: any(named: 'moderatorId'),
messageId: any(named: 'messageId'),
),
).thenAnswer((_) async => true);
final userStore = makeStore(hasModeratorScopes: false);
await userStore.init();
final result = await userStore.deleteMessage(
broadcasterId: '12345',
messageId: 'msg',
);
expect(result, isFalse);
verifyNever(
() => api.deleteChatMessage(
broadcasterId: any(named: 'broadcasterId'),
moderatorId: any(named: 'moderatorId'),
messageId: any(named: 'messageId'),
),
);
});
});
group('UserStore follow', () {
test('isFollowingChannel caches Helix result', () async {
when(
() => api.isFollowingChannel(
userId: '12345',
broadcasterId: '999',
),
).thenAnswer((_) async => true);
final userStore = makeStore(hasModeratorScopes: false);
await userStore.init();
expect(await userStore.isFollowingChannel('999'), isTrue);
expect(await userStore.isFollowingChannel('999'), isTrue);
verify(
() => api.isFollowingChannel(
userId: '12345',
broadcasterId: '999',
),
).called(1);
});
test('follow retries after integrity failure', () async {
var calls = 0;
when(
() => gqlApi.followUser(
targetId: any(named: 'targetId'),
authToken: any(named: 'authToken'),
integrityToken: any(named: 'integrityToken'),
deviceId: any(named: 'deviceId'),
),
).thenAnswer((_) async {
calls++;
if (calls == 1) {
throw const IntegrityCheckException('failed integrity check');
}
});
when(
() => gqlApi.fetchIntegrityToken(
authToken: any(named: 'authToken'),
deviceId: any(named: 'deviceId'),
),
).thenAnswer(
(_) async => (
token: 'integrity',
expirationMs: DateTime.now().millisecondsSinceEpoch + 600000,
),
);
final userStore = makeStore(hasModeratorScopes: false);
await userStore.init();
await userStore.follow(targetId: '999', gqlToken: 'web-token');
expect(userStore.cachedIsFollowing('999'), isTrue);
expect(calls, 2);
verify(
() => gqlApi.fetchIntegrityToken(
authToken: 'web-token',
deviceId: any(named: 'deviceId'),
),
).called(1);
});
test('unfollow updates cache', () async {
when(
() => gqlApi.unfollowUser(
targetId: any(named: 'targetId'),
authToken: any(named: 'authToken'),
integrityToken: any(named: 'integrityToken'),
deviceId: any(named: 'deviceId'),
),
).thenAnswer((_) async {});
final userStore = makeStore(hasModeratorScopes: false);
await userStore.init();
await userStore.unfollow(targetId: '999', gqlToken: 'web-token');
expect(userStore.cachedIsFollowing('999'), isFalse);
});
});
}