frosty/test/apis/twitch_api_test.dart
Tommy Chow 64c748d0b6 fix(chat): paginate moderated channels and refresh mod status on entry
- getModeratedChannels now follows the `after` cursor so moderators of
  more than 100 channels aren't silently truncated at the first page
  (mirrors getUserBlockedList's throttled recursion)
- add refreshModeratedChannels() and fire it on channel entry so mod
  status reflects grants/revokes made since login
- regenerate user_store.g.dart, which also restores #576's moderation
  action wrappers and the _moderatedChannels atom (its generated file
  was never regenerated when #576 merged)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-02 18:51:04 -04:00

589 lines
17 KiB
Dart

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_api.dart';
import 'package:frosty/models/emotes.dart';
import 'package:http_mock_adapter/http_mock_adapter.dart';
import '../fixtures/api_responses.dart';
void main() {
late Dio dio;
late DioAdapter dioAdapter;
late TwitchApi api;
setUp(() {
dio = Dio(BaseOptions());
dioAdapter = DioAdapter(dio: dio);
api = TwitchApi(dio);
});
group('getEmotesGlobal', () {
test('returns list of global Twitch emotes', () async {
dioAdapter.onGet(
'https://api.twitch.tv/helix/chat/emotes/global',
(server) => server.reply(200, twitchEmotesGlobalResponse),
);
final emotes = await api.getEmotesGlobal();
expect(emotes, hasLength(2));
expect(emotes[0].name, 'Kappa');
expect(emotes[0].type, EmoteType.twitchGlobal);
expect(emotes[0].url, contains('25'));
expect(emotes[1].name, 'Keepo');
});
});
group('getBadgesGlobal', () {
test('returns map of badge set/version to ChatBadge', () async {
dioAdapter.onGet(
'https://api.twitch.tv/helix/chat/badges/global',
(server) => server.reply(200, twitchBadgesGlobalResponse),
);
final badges = await api.getBadgesGlobal();
expect(badges, hasLength(1));
expect(badges['subscriber/0'], isNotNull);
expect(badges['subscriber/0']!.name, 'Subscriber');
expect(badges['subscriber/0']!.url, contains('4x.png'));
});
test('handles multiple versions per badge set', () async {
dioAdapter.onGet(
'https://api.twitch.tv/helix/chat/badges/global',
(server) => server.reply(200, {
'data': [
{
'set_id': 'subscriber',
'versions': [
{
'id': '0',
'image_url_1x': 'https://cdn/1x.png',
'image_url_2x': 'https://cdn/2x.png',
'image_url_4x': 'https://cdn/4x.png',
'title': 'Subscriber',
'description': '0 months',
},
{
'id': '3',
'image_url_1x': 'https://cdn/3mo/1x.png',
'image_url_2x': 'https://cdn/3mo/2x.png',
'image_url_4x': 'https://cdn/3mo/4x.png',
'title': '3-Month Subscriber',
'description': '3 months',
},
],
},
],
}),
);
final badges = await api.getBadgesGlobal();
expect(badges, hasLength(2));
expect(badges['subscriber/0']!.name, 'Subscriber');
expect(badges['subscriber/3']!.name, '3-Month Subscriber');
});
});
group('getUser', () {
test('returns user by login', () async {
dioAdapter.onGet(
'https://api.twitch.tv/helix/users',
(server) => server.reply(200, twitchUserResponse),
queryParameters: {'login': 'testuser'},
);
final user = await api.getUser(userLogin: 'testuser');
expect(user.id, '12345');
expect(user.login, 'testuser');
expect(user.displayName, 'TestUser');
expect(user.profileImageUrl, 'https://cdn/profile.png');
});
test('returns user by id', () async {
dioAdapter.onGet(
'https://api.twitch.tv/helix/users',
(server) => server.reply(200, twitchUserResponse),
queryParameters: {'id': '12345'},
);
final user = await api.getUser(id: '12345');
expect(user.id, '12345');
});
test('throws NotFoundException for unknown user', () {
dioAdapter.onGet(
'https://api.twitch.tv/helix/users',
(server) => server.reply(200, {
'data': <dynamic>[],
}),
queryParameters: {'login': 'doesnotexist'},
);
expect(
() => api.getUser(userLogin: 'doesnotexist'),
throwsA(isA<NotFoundException>()),
);
});
});
group('getStream', () {
test('returns stream for online user', () async {
dioAdapter.onGet(
'https://api.twitch.tv/helix/streams',
(server) => server.reply(200, twitchStreamResponse),
queryParameters: {'user_login': 'testuser'},
);
final stream = await api.getStream(userLogin: 'testuser');
expect(stream.userName, 'TestUser');
expect(stream.gameName, 'Just Chatting');
expect(stream.viewerCount, 1000);
});
test('throws ApiException for offline user', () {
dioAdapter.onGet(
'https://api.twitch.tv/helix/streams',
(server) => server.reply(200, {
'data': <dynamic>[],
'pagination': <String, String>{},
}),
queryParameters: {'user_login': 'offlineuser'},
);
expect(
() => api.getStream(userLogin: 'offlineuser'),
throwsA(
isA<ApiException>().having(
(e) => e.message,
'message',
contains('offlineuser'),
),
),
);
});
});
group('getStreamsByIds', () {
test('builds query string for multiple user IDs', () async {
// getStreamsByIds manually builds URL: /streams?user_id=1&user_id=2&first=100
dioAdapter.onGet(
'https://api.twitch.tv/helix/streams?user_id=111&user_id=222&first=100',
(server) => server.reply(200, {
'data': [
{
'user_id': '111',
'user_login': 'user1',
'user_name': 'User1',
'game_id': '1',
'game_name': 'Game',
'title': 'Stream 1',
'viewer_count': 100,
'started_at': '2024-01-01T00:00:00Z',
'thumbnail_url': 'https://cdn/1.jpg',
},
{
'user_id': '222',
'user_login': 'user2',
'user_name': 'User2',
'game_id': '2',
'game_name': 'Game 2',
'title': 'Stream 2',
'viewer_count': 200,
'started_at': '2024-01-01T00:00:00Z',
'thumbnail_url': 'https://cdn/2.jpg',
},
],
'pagination': <String, String>{},
}),
);
final result = await api.getStreamsByIds(
userIds: ['111', '222'],
);
expect(result.data, hasLength(2));
expect(result.data[0].userName, 'User1');
expect(result.data[1].userName, 'User2');
});
});
group('validateToken', () {
test('returns true for valid token', () async {
dioAdapter.onGet(
'https://id.twitch.tv/oauth2/validate',
(server) => server.reply(200, twitchValidateTokenResponse),
);
final isValid = await api.validateToken(token: 'valid_token');
expect(isValid, isTrue);
});
test('returns false for expired token (401)', () async {
dioAdapter.onGet(
'https://id.twitch.tv/oauth2/validate',
(server) => server.reply(401, {'message': 'invalid access token'}),
);
final isValid = await api.validateToken(token: 'expired_token');
expect(isValid, isFalse);
});
test('rethrows on server error', () {
dioAdapter.onGet(
'https://id.twitch.tv/oauth2/validate',
(server) => server.reply(500, null),
);
expect(
() => api.validateToken(token: 'some_token'),
throwsA(isA<ServerException>()),
);
});
});
group('getUserBlockedList', () {
test('returns blocked users from single page', () async {
dioAdapter.onGet(
'https://api.twitch.tv/helix/users/blocks',
(server) => server.reply(200, {
'data': [
{
'user_id': '99999',
'user_login': 'blockeduser',
'display_name': 'BlockedUser',
},
],
'pagination': {'cursor': null},
}),
queryParameters: {'first': '100', 'broadcaster_id': '12345'},
);
final blocked = await api.getUserBlockedList(id: '12345');
expect(blocked, hasLength(1));
expect(blocked.first.userId, '99999');
expect(blocked.first.userLogin, 'blockeduser');
});
test('returns empty list when no users blocked', () async {
dioAdapter.onGet(
'https://api.twitch.tv/helix/users/blocks',
(server) => server.reply(200, {
'data': <dynamic>[],
'pagination': {'cursor': null},
}),
queryParameters: {'first': '100', 'broadcaster_id': '12345'},
);
final blocked = await api.getUserBlockedList(id: '12345');
expect(blocked, isEmpty);
});
});
group('getModeratedChannels', () {
test('returns broadcaster IDs from a single page', () async {
dioAdapter.onGet(
'https://api.twitch.tv/helix/moderation/channels',
(server) => server.reply(200, {
'data': [
{'broadcaster_id': '111', 'broadcaster_login': 'a'},
{'broadcaster_id': '222', 'broadcaster_login': 'b'},
],
'pagination': {'cursor': null},
}),
queryParameters: {'user_id': '12345', 'first': '100'},
);
final channels = await api.getModeratedChannels(id: '12345');
expect(channels, ['111', '222']);
});
test('follows the pagination cursor across pages', () async {
dioAdapter
..onGet(
'https://api.twitch.tv/helix/moderation/channels',
(server) => server.reply(200, {
'data': [
{'broadcaster_id': '111'},
],
'pagination': {'cursor': 'page2'},
}),
queryParameters: {'user_id': '12345', 'first': '100'},
)
..onGet(
'https://api.twitch.tv/helix/moderation/channels',
(server) => server.reply(200, {
'data': [
{'broadcaster_id': '222'},
],
'pagination': {'cursor': null},
}),
queryParameters: {
'user_id': '12345',
'first': '100',
'after': 'page2',
},
);
final channels = await api.getModeratedChannels(id: '12345');
expect(channels, ['111', '222']);
});
test('returns an empty list when the scope is missing', () async {
dioAdapter.onGet(
'https://api.twitch.tv/helix/moderation/channels',
(server) => server.reply(401, {'message': 'Missing scope'}),
queryParameters: {'user_id': '12345', 'first': '100'},
);
final channels = await api.getModeratedChannels(id: '12345');
expect(channels, isEmpty);
});
});
group('getTopStreams', () {
test('returns streams with pagination', () async {
dioAdapter.onGet(
'https://api.twitch.tv/helix/streams',
(server) => server.reply(200, twitchStreamResponse),
);
final streams = await api.getTopStreams();
expect(streams.data, hasLength(1));
expect(streams.data.first.userName, 'TestUser');
expect(streams.pagination['cursor'], 'next_cursor');
});
test('passes cursor for pagination', () async {
dioAdapter.onGet(
'https://api.twitch.tv/helix/streams',
(server) => server.reply(200, {
'data': <dynamic>[],
'pagination': <String, String>{},
}),
queryParameters: {'after': 'page2_cursor'},
);
final streams = await api.getTopStreams(cursor: 'page2_cursor');
expect(streams.data, isEmpty);
});
});
group('getChannel', () {
test('returns channel info', () async {
dioAdapter.onGet(
'https://api.twitch.tv/helix/channels',
(server) => server.reply(200, twitchChannelResponse),
queryParameters: {'broadcaster_id': '12345'},
);
final channel = await api.getChannel(userId: '12345');
expect(channel.broadcasterId, '12345');
expect(channel.broadcasterName, 'TestUser');
expect(channel.title, 'Test Stream');
expect(channel.gameName, 'Just Chatting');
});
test('throws ApiException for nonexistent channel', () {
dioAdapter.onGet(
'https://api.twitch.tv/helix/channels',
(server) => server.reply(200, {
'data': <dynamic>[],
}),
queryParameters: {'broadcaster_id': '99999'},
);
expect(
() => api.getChannel(userId: '99999'),
throwsA(isA<ApiException>()),
);
});
});
group('getEmotesChannel', () {
test('maps emote types correctly', () async {
dioAdapter.onGet(
'https://api.twitch.tv/helix/chat/emotes',
(server) => server.reply(200, {
'data': [
{
'id': '1',
'name': 'BitsEmote',
'emote_type': 'bitstier',
'owner_id': null,
},
{
'id': '2',
'name': 'FollowerEmote',
'emote_type': 'follower',
'owner_id': null,
},
{
'id': '3',
'name': 'SubEmote',
'emote_type': 'subscriptions',
'owner_id': '12345',
},
],
}),
queryParameters: {'broadcaster_id': '12345'},
);
final emotes = await api.getEmotesChannel(id: '12345');
expect(emotes, hasLength(3));
expect(emotes[0].type, EmoteType.twitchBits);
expect(emotes[1].type, EmoteType.twitchFollower);
expect(emotes[2].type, EmoteType.twitchChannel);
});
});
group('banUser', () {
test('returns true on success', () async {
dioAdapter.onPost(
'https://api.twitch.tv/helix/moderation/bans',
(server) => server.reply(200, {
'data': [
{'user_id': '3'},
],
}),
data: {
'data': {'user_id': '3'},
},
queryParameters: {'broadcaster_id': '1', 'moderator_id': '2'},
);
final result = await api.banUser(
broadcasterId: '1',
moderatorId: '2',
userIdToBan: '3',
);
expect(result, isTrue);
});
test('propagates the Twitch message when the target cannot be banned '
'(e.g. another moderator)', () async {
dioAdapter.onPost(
'https://api.twitch.tv/helix/moderation/bans',
(server) => server.reply(400, {
'message': 'The user specified in the user_id field is a moderator.',
}),
data: {
'data': {'user_id': '3'},
},
queryParameters: {'broadcaster_id': '1', 'moderator_id': '2'},
);
expect(
() => api.banUser(
broadcasterId: '1',
moderatorId: '2',
userIdToBan: '3',
),
throwsA(
isA<ApiException>().having(
(e) => e.message,
'message',
'The user specified in the user_id field is a moderator.',
),
),
);
});
});
group('deleteChatMessage', () {
test('propagates the Twitch message when the message cannot be deleted '
"(e.g. another moderator's or the broadcaster's)", () async {
dioAdapter.onDelete(
'https://api.twitch.tv/helix/moderation/chat',
(server) => server.reply(400, {
'message': "You cannot delete the broadcaster's messages.",
}),
queryParameters: {
'broadcaster_id': '1',
'moderator_id': '2',
'message_id': 'm',
},
);
expect(
() => api.deleteChatMessage(
broadcasterId: '1',
moderatorId: '2',
messageId: 'm',
),
throwsA(
isA<ApiException>().having(
(e) => e.message,
'message',
"You cannot delete the broadcaster's messages.",
),
),
);
});
});
group('unbanUser', () {
test('returns true on success (removes a ban or timeout)', () async {
dioAdapter.onDelete(
'https://api.twitch.tv/helix/moderation/bans',
(server) => server.reply(204, null),
queryParameters: {
'broadcaster_id': '1',
'moderator_id': '2',
'user_id': '3',
},
);
final result = await api.unbanUser(
broadcasterId: '1',
moderatorId: '2',
userId: '3',
);
expect(result, isTrue);
});
test('propagates the Twitch message when the user is not banned', () async {
dioAdapter.onDelete(
'https://api.twitch.tv/helix/moderation/bans',
(server) => server.reply(400, {
'message': 'The user in the user_id query parameter is not banned.',
}),
queryParameters: {
'broadcaster_id': '1',
'moderator_id': '2',
'user_id': '3',
},
);
expect(
() => api.unbanUser(broadcasterId: '1', moderatorId: '2', userId: '3'),
throwsA(
isA<ApiException>().having(
(e) => e.message,
'message',
'The user in the user_id query parameter is not banned.',
),
),
);
});
});
}