frosty/test/widgets/follow_channel_controller_test.dart
copilot 9a08c40136 Add Follow/Unfollow button under the channel player.
Show channel info and a follow control between video and chat in portrait, reusing the shared GQL follow path.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-12 09:49:45 +02:00

254 lines
7.8 KiB
Dart

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/auth_store.dart';
import 'package:frosty/screens/settings/stores/user_store.dart';
import 'package:frosty/widgets/follow_channel_controller.dart';
import 'package:mocktail/mocktail.dart';
import 'package:shared_preferences/shared_preferences.dart';
class MockAuthStore extends Mock implements AuthStore {}
class MockTwitchApi extends Mock implements TwitchApi {}
class MockTwitchGqlApi extends Mock implements TwitchGqlApi {}
void main() {
late MockAuthStore authStore;
late MockTwitchApi api;
late MockTwitchGqlApi gqlApi;
late UserStore userStore;
var notifyCount = 0;
setUp(() async {
SharedPreferences.setMockInitialValues({});
authStore = MockAuthStore();
api = MockTwitchApi();
gqlApi = MockTwitchGqlApi();
notifyCount = 0;
when(() => api.getUserInfo()).thenAnswer(
(_) async => const UserTwitch(
'12345',
'testuser',
'TestUser',
'https://cdn/profile.png',
),
);
when(() => api.getUserBlockedList(id: any(named: 'id'))).thenAnswer(
(_) async => <UserBlockedTwitch>[],
);
userStore = UserStore(
twitchApi: api,
twitchGqlApi: gqlApi,
hasModeratorScopes: () => false,
);
await userStore.init();
when(() => authStore.user).thenReturn(userStore);
when(() => authStore.isLoggedIn).thenReturn(true);
when(() => authStore.gqlToken).thenReturn('web-token');
});
FollowChannelController makeController({
String userId = '999',
bool? initialIsFollowing,
}) {
return FollowChannelController(
authStore: authStore,
userId: userId,
initialIsFollowing: initialIsFollowing,
onChanged: () => notifyCount++,
);
}
group('FollowChannelController', () {
test('hides when logged out', () {
when(() => authStore.isLoggedIn).thenReturn(false);
final controller = makeController(initialIsFollowing: false);
controller.init();
expect(controller.canFollow, isFalse);
expect(controller.shouldShow, isFalse);
});
test('hides for own channel', () {
final controller = makeController(userId: '12345');
controller.init();
expect(controller.isSelf, isTrue);
expect(controller.canFollow, isFalse);
expect(controller.shouldShow, isFalse);
});
test('uses initialIsFollowing without Helix lookup', () {
final controller = makeController(initialIsFollowing: true);
controller.init();
expect(controller.isFollowing, isTrue);
expect(controller.shouldShow, isTrue);
verifyNever(
() => api.isFollowingChannel(
userId: any(named: 'userId'),
broadcasterId: any(named: 'broadcasterId'),
),
);
});
test('loads follow state from Helix and caches', () async {
when(
() => api.isFollowingChannel(
userId: '12345',
broadcasterId: '999',
),
).thenAnswer((_) async => false);
final controller = makeController();
controller.init();
await Future<void>.delayed(Duration.zero);
expect(controller.isFollowing, isFalse);
expect(controller.loading, isFalse);
expect(controller.shouldShow, isTrue);
expect(userStore.cachedIsFollowing('999'), isFalse);
});
test('toggle follows then unfollows', () async {
when(
() => gqlApi.followUser(
targetId: any(named: 'targetId'),
authToken: any(named: 'authToken'),
integrityToken: any(named: 'integrityToken'),
deviceId: any(named: 'deviceId'),
),
).thenAnswer((_) async {});
when(
() => gqlApi.unfollowUser(
targetId: any(named: 'targetId'),
authToken: any(named: 'authToken'),
integrityToken: any(named: 'integrityToken'),
deviceId: any(named: 'deviceId'),
),
).thenAnswer((_) async {});
final controller = makeController(initialIsFollowing: false);
controller.init();
expect(await controller.toggle(), isTrue);
expect(controller.isFollowing, isTrue);
expect(userStore.cachedIsFollowing('999'), isTrue);
expect(await controller.toggle(), isFalse);
expect(controller.isFollowing, isFalse);
expect(userStore.cachedIsFollowing('999'), isFalse);
});
test('toggle throws when web session missing', () async {
when(() => authStore.gqlToken).thenReturn(null);
final controller = makeController(initialIsFollowing: false);
controller.init();
await expectLater(
controller.toggle(),
throwsA(isA<FollowNeedsWebSessionException>()),
);
});
test('toggle rethrows integrity and clears action flag', () async {
when(
() => gqlApi.followUser(
targetId: any(named: 'targetId'),
authToken: any(named: 'authToken'),
integrityToken: any(named: 'integrityToken'),
deviceId: any(named: 'deviceId'),
),
).thenThrow(const IntegrityCheckException('failed integrity check'));
when(
() => gqlApi.fetchIntegrityToken(
authToken: any(named: 'authToken'),
deviceId: any(named: 'deviceId'),
),
).thenThrow(const IntegrityCheckException('bad bot'));
final controller = makeController(initialIsFollowing: false);
controller.init();
await expectLater(
controller.toggle(),
throwsA(isA<IntegrityCheckException>()),
);
expect(controller.actionInProgress, isFalse);
expect(controller.isFollowing, isFalse);
});
test('retryAfterIntegrity applies credentials and follows', () async {
when(
() => gqlApi.followUser(
targetId: any(named: 'targetId'),
authToken: any(named: 'authToken'),
integrityToken: any(named: 'integrityToken'),
deviceId: any(named: 'deviceId'),
),
).thenAnswer((_) async {});
final controller = makeController(initialIsFollowing: false);
controller.init();
final okToken =
'v4.public.${base64Url.encode(utf8.encode(jsonEncode({
'is_bad_bot': 'false',
'device_id': 'device-abc',
})))}.sig';
final nowFollowing = await controller.retryAfterIntegrity(
wasFollowing: false,
credentials: GqlIntegrityCredentials(
integrityToken: okToken,
deviceId: 'device-abc',
expirationMs: DateTime.now().millisecondsSinceEpoch + 600000,
),
);
expect(nowFollowing, isTrue);
expect(controller.isFollowing, isTrue);
verify(
() => gqlApi.followUser(
targetId: '999',
authToken: 'web-token',
integrityToken: okToken,
deviceId: 'device-abc',
),
).called(1);
});
test('retryAfterIntegrity rejects bad_bot integrity', () async {
final controller = makeController(initialIsFollowing: false);
controller.init();
final badToken =
'v4.public.${base64Url.encode(utf8.encode(jsonEncode({
'is_bad_bot': 'true',
'device_id': 'device-abc',
})))}.sig';
await expectLater(
controller.retryAfterIntegrity(
wasFollowing: false,
credentials: GqlIntegrityCredentials(
integrityToken: badToken,
deviceId: 'device-abc',
expirationMs: DateTime.now().millisecondsSinceEpoch + 600000,
),
),
throwsA(isA<IntegrityCheckException>()),
);
expect(controller.actionInProgress, isFalse);
});
});
}