import 'dart:convert'; 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/apis/twitch_integrity.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 => [], ); when(() => api.getModeratedChannels(id: any(named: 'id'))).thenAnswer( (_) async => ['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.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.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); }); test('runWithGqlIntegrity refreshes token and retries', () async { var calls = 0; when( () => gqlApi.fetchIntegrityToken( authToken: any(named: 'authToken'), deviceId: any(named: 'deviceId'), ), ).thenAnswer( (_) async => ( token: 'fresh-integrity', expirationMs: DateTime.now().millisecondsSinceEpoch + 600000, ), ); final userStore = makeStore(hasModeratorScopes: false); final result = await userStore.runWithGqlIntegrity( gqlToken: 'web-token', action: ({authToken, integrityToken, deviceId}) async { calls++; expect(authToken, 'web-token'); expect(deviceId, isNotEmpty); if (calls == 1) { throw const IntegrityCheckException('failed integrity check'); } expect(integrityToken, 'fresh-integrity'); return 'ok'; }, ); expect(result, 'ok'); expect(calls, 2); verify( () => gqlApi.fetchIntegrityToken( authToken: 'web-token', deviceId: any(named: 'deviceId'), ), ).called(1); }); test('runWithGqlIntegrity rethrows when no gqlToken', () async { final userStore = makeStore(hasModeratorScopes: false); await expectLater( userStore.runWithGqlIntegrity( gqlToken: null, action: ({authToken, integrityToken, deviceId}) async { throw const IntegrityCheckException('failed integrity check'); }, ), throwsA(isA()), ); verifyNever( () => gqlApi.fetchIntegrityToken( authToken: any(named: 'authToken'), deviceId: any(named: 'deviceId'), ), ); }); test('applyWebIntegrity stores device id for later GQL calls', () async { final userStore = makeStore(hasModeratorScopes: false); await userStore.applyWebIntegrity( GqlIntegrityCredentials( integrityToken: 'v4.public.${base64Url.encode(utf8.encode(jsonEncode({ 'is_bad_bot': 'false', 'device_id': 'webview-device', })))}.sig', deviceId: 'webview-device', expirationMs: DateTime.now().millisecondsSinceEpoch + 600000, ), ); final deviceId = await userStore.runWithGqlIntegrity( gqlToken: 'web-token', action: ({authToken, integrityToken, deviceId}) async { expect(deviceId, 'webview-device'); expect(integrityToken, isNotNull); return deviceId!; }, ); expect(deviceId, 'webview-device'); }); }); }