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': [], }), queryParameters: {'login': 'doesnotexist'}, ); expect( () => api.getUser(userLogin: 'doesnotexist'), throwsA(isA()), ); }); }); 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': [], 'pagination': {}, }), queryParameters: {'user_login': 'offlineuser'}, ); expect( () => api.getStream(userLogin: 'offlineuser'), throwsA( isA().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': {}, }), ); 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 scopes and user id for valid token', () async { dioAdapter.onGet( 'https://id.twitch.tv/oauth2/validate', (server) => server.reply(200, twitchValidateTokenResponse), ); final info = await api.validateToken(token: 'valid_token'); expect(info?.scopes, ['chat:read', 'chat:edit']); expect(info?.userId, '12345'); }); test('returns null for expired token (401)', () async { dioAdapter.onGet( 'https://id.twitch.tv/oauth2/validate', (server) => server.reply(401, {'message': 'invalid access token'}), ); final info = await api.validateToken(token: 'expired_token'); expect(info, isNull); }); 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()), ); }); }); 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': [], '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': [], 'pagination': {}, }), 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': [], }), queryParameters: {'broadcaster_id': '99999'}, ); expect( () => api.getChannel(userId: '99999'), throwsA(isA()), ); }); }); 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().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().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().having( (e) => e.message, 'message', 'The user in the user_id query parameter is not banned.', ), ), ); }); }); }