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>
254 lines
7.8 KiB
Dart
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);
|
|
});
|
|
});
|
|
}
|