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>
This commit is contained in:
copilot 2026-10-07 03:06:05 +02:00
parent 318dc84dc5
commit 30848e6fe8
15 changed files with 835 additions and 41 deletions

View file

@ -587,4 +587,55 @@ void main() {
);
});
});
group('isFollowingChannel', () {
test('returns true when Helix lists the broadcaster', () async {
dioAdapter.onGet(
'https://api.twitch.tv/helix/channels/followed',
(server) => server.reply(200, {
'total': 1,
'data': [
{
'broadcaster_id': '999',
'broadcaster_login': 'streamer',
'broadcaster_name': 'Streamer',
'followed_at': '2024-01-01T00:00:00Z',
},
],
'pagination': <String, dynamic>{},
}),
queryParameters: {
'user_id': '12345',
'broadcaster_id': '999',
'first': '1',
},
);
expect(
await api.isFollowingChannel(userId: '12345', broadcasterId: '999'),
isTrue,
);
});
test('returns false when not following', () async {
dioAdapter.onGet(
'https://api.twitch.tv/helix/channels/followed',
(server) => server.reply(200, {
'total': 0,
'data': <dynamic>[],
'pagination': <String, dynamic>{},
}),
queryParameters: {
'user_id': '12345',
'broadcaster_id': '999',
'first': '1',
},
);
expect(
await api.isFollowingChannel(userId: '12345', broadcasterId: '999'),
isFalse,
);
});
});
}

View file

@ -0,0 +1,72 @@
import 'package:dio/dio.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:frosty/apis/base_api_client.dart';
import 'package:frosty/apis/twitch_gql_api.dart';
import 'package:http_mock_adapter/http_mock_adapter.dart';
void main() {
late Dio dio;
late DioAdapter dioAdapter;
late TwitchGqlApi api;
setUp(() {
dio = Dio(BaseOptions());
dioAdapter = DioAdapter(dio: dio);
api = TwitchGqlApi(dio);
});
group('followUser', () {
test('posts FollowButton_FollowUser persisted mutation', () async {
dioAdapter.onPost(
'https://gql.twitch.tv/gql',
(server) => server.reply(200, {
'data': {
'followUser': {
'follow': {
'user': {'id': '999', 'displayName': 'Streamer'},
},
},
},
}),
data: Matchers.any,
);
await api.followUser(targetId: '999', authToken: 'web-token');
});
test('throws IntegrityCheckException on integrity errors', () async {
dioAdapter.onPost(
'https://gql.twitch.tv/gql',
(server) => server.reply(200, {
'errors': [
{'message': 'failed integrity check'},
],
}),
data: Matchers.any,
);
await expectLater(
api.followUser(targetId: '999', authToken: 'web-token'),
throwsA(isA<IntegrityCheckException>()),
);
});
});
group('unfollowUser', () {
test('posts FollowButton_UnfollowUser persisted mutation', () async {
dioAdapter.onPost(
'https://gql.twitch.tv/gql',
(server) => server.reply(200, {
'data': {
'unfollowUser': {
'follow': null,
},
},
}),
data: Matchers.any,
);
await api.unfollowUser(targetId: '999', authToken: 'web-token');
});
});
}

View file

@ -1,16 +1,24 @@
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',
@ -27,42 +35,39 @@ void main() {
);
});
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 store = UserStore(
twitchApi: api,
hasModeratorScopes: () => false,
);
final userStore = makeStore(hasModeratorScopes: false);
await store.init();
await userStore.init();
await Future<void>.delayed(Duration.zero);
verifyNever(() => api.getModeratedChannels(id: any(named: 'id')));
expect(store.moderatedChannels, isEmpty);
expect(userStore.moderatedChannels, isEmpty);
});
test('init fetches moderated-channels when moderator scopes exist', () async {
final store = UserStore(
twitchApi: api,
hasModeratorScopes: () => true,
);
final userStore = makeStore(hasModeratorScopes: true);
await store.init();
await userStore.init();
await Future<void>.delayed(Duration.zero);
verify(() => api.getModeratedChannels(id: '12345')).called(1);
expect(store.moderatedChannels, ['999']);
expect(userStore.moderatedChannels, ['999']);
});
test('refreshModeratedChannels no-ops without moderator scopes', () async {
final store = UserStore(
twitchApi: api,
hasModeratorScopes: () => false,
);
await store.init();
final userStore = makeStore(hasModeratorScopes: false);
await userStore.init();
clearInteractions(api);
await store.refreshModeratedChannels();
await userStore.refreshModeratedChannels();
verifyNever(() => api.getModeratedChannels(id: any(named: 'id')));
});
@ -75,13 +80,10 @@ void main() {
),
).thenAnswer((_) async => true);
final store = UserStore(
twitchApi: api,
hasModeratorScopes: () => false,
);
await store.init();
final userStore = makeStore(hasModeratorScopes: false);
await userStore.init();
final result = await store.deleteMessage(
final result = await userStore.deleteMessage(
broadcasterId: '12345',
messageId: 'msg',
);
@ -96,4 +98,85 @@ void main() {
);
});
});
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);
});
});
}