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:
parent
318dc84dc5
commit
30848e6fe8
15 changed files with 835 additions and 41 deletions
|
|
@ -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,
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
|
|
|||
72
test/apis/twitch_gql_follow_test.dart
Normal file
72
test/apis/twitch_gql_follow_test.dart
Normal 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');
|
||||
});
|
||||
});
|
||||
}
|
||||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue