overhaul tests

This commit is contained in:
Tommy Chow 2026-02-07 00:51:22 -08:00
parent e58c9875e0
commit baebafea59
22 changed files with 2637 additions and 52 deletions

View file

@ -0,0 +1,403 @@
import 'package:dio/dio.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:frosty/apis/base_api_client.dart';
import 'package:http_mock_adapter/http_mock_adapter.dart';
/// Concrete test subclass to test BaseApiClient through its public interface.
class TestApiClient extends BaseApiClient {
TestApiClient(Dio dio) : super(dio, 'https://api.test.com');
/// Test wrapper that exercises the error handling path through get().
Future<dynamic> testGet(String endpoint) => get(endpoint);
}
void main() {
late Dio dio;
late DioAdapter dioAdapter;
late TestApiClient client;
setUp(() {
dio = Dio(BaseOptions());
dioAdapter = DioAdapter(dio: dio);
client = TestApiClient(dio);
});
group('isSuccessResponse', () {
test('returns true for 200', () {
final response = Response(
requestOptions: RequestOptions(),
statusCode: 200,
);
expect(client.isSuccessResponse(response), isTrue);
});
test('returns true for 201', () {
final response = Response(
requestOptions: RequestOptions(),
statusCode: 201,
);
expect(client.isSuccessResponse(response), isTrue);
});
test('returns true for 204', () {
final response = Response(
requestOptions: RequestOptions(),
statusCode: 204,
);
expect(client.isSuccessResponse(response), isTrue);
});
test('returns false for 300', () {
final response = Response(
requestOptions: RequestOptions(),
statusCode: 300,
);
expect(client.isSuccessResponse(response), isFalse);
});
test('returns false for 400', () {
final response = Response(
requestOptions: RequestOptions(),
statusCode: 400,
);
expect(client.isSuccessResponse(response), isFalse);
});
test('returns false for 500', () {
final response = Response(
requestOptions: RequestOptions(),
statusCode: 500,
);
expect(client.isSuccessResponse(response), isFalse);
});
test('returns false for null statusCode', () {
final response = Response(requestOptions: RequestOptions());
expect(client.isSuccessResponse(response), isFalse);
});
});
group('error handling through get()', () {
test('400 throws ApiException', () async {
dioAdapter.onGet(
'https://api.test.com/test',
(server) => server.reply(400, {'message': 'Bad field'}),
);
expect(
() => client.testGet('/test'),
throwsA(
isA<ApiException>().having((e) => e.message, 'message', 'Bad field'),
),
);
});
test('401 throws UnauthorizedException', () async {
dioAdapter.onGet(
'https://api.test.com/auth',
(server) => server.reply(401, {'message': 'Unauthorized'}),
);
expect(
() => client.testGet('/auth'),
throwsA(isA<UnauthorizedException>()),
);
});
test('404 throws NotFoundException', () async {
dioAdapter.onGet(
'https://api.test.com/missing',
(server) => server.reply(404, {'message': 'User not found'}),
);
expect(
() => client.testGet('/missing'),
throwsA(
isA<NotFoundException>().having(
(e) => e.message,
'message',
'User not found',
),
),
);
});
test('404 with no server message uses default', () async {
dioAdapter.onGet(
'https://api.test.com/empty404',
(server) => server.reply(404, 'not json'),
);
expect(
() => client.testGet('/empty404'),
throwsA(
isA<NotFoundException>().having(
(e) => e.message,
'message',
'Resource not found',
),
),
);
});
test('409 throws ApiException about conflict', () async {
dioAdapter.onGet(
'https://api.test.com/conflict',
(server) => server.reply(409, null),
);
expect(
() => client.testGet('/conflict'),
throwsA(
isA<ApiException>().having(
(e) => e.message,
'message',
contains('Conflict'),
),
),
);
});
test('429 throws ApiException about rate limit', () async {
dioAdapter.onGet(
'https://api.test.com/ratelimit',
(server) => server.reply(429, null),
);
expect(
() => client.testGet('/ratelimit'),
throwsA(
isA<ApiException>().having(
(e) => e.message,
'message',
contains('Rate limit'),
),
),
);
});
test('500 throws ServerException', () async {
dioAdapter.onGet(
'https://api.test.com/server-error',
(server) => server.reply(500, null),
);
expect(
() => client.testGet('/server-error'),
throwsA(
isA<ServerException>().having(
(e) => e.statusCode,
'statusCode',
500,
),
),
);
});
test('502 throws ServerException', () async {
dioAdapter.onGet(
'https://api.test.com/bad-gateway',
(server) => server.reply(502, null),
);
expect(
() => client.testGet('/bad-gateway'),
throwsA(
isA<ServerException>().having(
(e) => e.statusCode,
'statusCode',
502,
),
),
);
});
test('503 throws ServerException', () async {
dioAdapter.onGet(
'https://api.test.com/maintenance',
(server) => server.reply(503, null),
);
expect(
() => client.testGet('/maintenance'),
throwsA(isA<ServerException>()),
);
});
test('504 throws ServerException', () async {
dioAdapter.onGet(
'https://api.test.com/timeout',
(server) => server.reply(504, null),
);
expect(
() => client.testGet('/timeout'),
throwsA(isA<ServerException>()),
);
});
test('422 uses server message from error field', () async {
dioAdapter.onGet(
'https://api.test.com/unprocessable',
(server) => server.reply(422, {'error': 'Invalid email'}),
);
expect(
() => client.testGet('/unprocessable'),
throwsA(
isA<ApiException>().having(
(e) => e.message,
'message',
'Invalid email',
),
),
);
});
test('400 uses error_description field', () async {
dioAdapter.onGet(
'https://api.test.com/oauth-error',
(server) => server.reply(
400,
{'error_description': 'OAuth error description'},
),
);
expect(
() => client.testGet('/oauth-error'),
throwsA(
isA<ApiException>().having(
(e) => e.message,
'message',
'OAuth error description',
),
),
);
});
test('400 prefers message field over error field', () async {
dioAdapter.onGet(
'https://api.test.com/priority',
(server) => server.reply(400, {
'message': 'From message',
'error': 'From error',
}),
);
expect(
() => client.testGet('/priority'),
throwsA(
isA<ApiException>().having(
(e) => e.message,
'message',
'From message',
),
),
);
});
test('403 returns permission error', () async {
dioAdapter.onGet(
'https://api.test.com/forbidden',
(server) => server.reply(403, null),
);
expect(
() => client.testGet('/forbidden'),
throwsA(
isA<ApiException>().having(
(e) => e.message,
'message',
contains('permissions'),
),
),
);
});
test('unknown status code throws ServerException', () async {
dioAdapter.onGet(
'https://api.test.com/teapot',
(server) => server.reply(418, null),
);
expect(
() => client.testGet('/teapot'),
throwsA(
isA<ServerException>().having(
(e) => e.statusCode,
'statusCode',
418,
),
),
);
});
});
group('successful get()', () {
test('returns data on success', () async {
dioAdapter.onGet(
'https://api.test.com/users',
(server) => server.reply(200, {'data': 'test'}),
);
final result = await client.testGet('/users');
expect(result, {'data': 'test'});
});
test('handles absolute URL', () async {
dioAdapter.onGet(
'https://other.com/endpoint',
(server) => server.reply(200, {'result': 'ok'}),
);
final result = await client.testGet('https://other.com/endpoint');
expect(result, {'result': 'ok'});
});
});
group('ApiException', () {
test('toString includes message', () {
const exception = ApiException('Test error');
expect(exception.toString(), 'ApiException: Test error');
});
test('stores status code and type', () {
const exception = ApiException(
'Error',
404,
DioExceptionType.badResponse,
);
expect(exception.statusCode, 404);
expect(exception.type, DioExceptionType.badResponse);
});
});
group('Exception subtypes', () {
test('NetworkException has connectionError type', () {
const exception = NetworkException('No network');
expect(exception.type, DioExceptionType.connectionError);
expect(exception.statusCode, isNull);
});
test('TimeoutException has connectionTimeout type', () {
const exception = TimeoutException('Timed out');
expect(exception.type, DioExceptionType.connectionTimeout);
});
test('ServerException has badResponse type and status code', () {
const exception = ServerException('Server error', 500);
expect(exception.type, DioExceptionType.badResponse);
expect(exception.statusCode, 500);
});
test('NotFoundException has 404 status code', () {
const exception = NotFoundException('Not found');
expect(exception.statusCode, 404);
});
test('UnauthorizedException has 401 status code', () {
const exception = UnauthorizedException('Unauthorized');
expect(exception.statusCode, 401);
});
});
}

View file

@ -0,0 +1,106 @@
import 'package:dio/dio.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:frosty/apis/base_api_client.dart';
import 'package:frosty/apis/bttv_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 BTTVApi api;
setUp(() {
dio = Dio(BaseOptions());
dioAdapter = DioAdapter(dio: dio);
api = BTTVApi(dio);
});
group('getEmotesGlobal', () {
test('returns list of global BTTV emotes', () async {
dioAdapter.onGet(
'https://api.betterttv.net/3/cached/emotes/global',
(server) => server.reply(200, bttvEmotesGlobalResponse),
);
final emotes = await api.getEmotesGlobal();
expect(emotes, hasLength(2));
expect(emotes[0].name, 'SourPls');
expect(emotes[0].type, EmoteType.bttvGlobal);
expect(emotes[0].url, contains('bttv1'));
expect(emotes[1].name, 'catJAM');
});
test('returns empty list for empty response', () async {
dioAdapter.onGet(
'https://api.betterttv.net/3/cached/emotes/global',
(server) => server.reply(200, <dynamic>[]),
);
final emotes = await api.getEmotesGlobal();
expect(emotes, isEmpty);
});
});
group('getEmotesChannel', () {
test('combines channel and shared emotes', () async {
dioAdapter.onGet(
'https://api.betterttv.net/3/cached/users/twitch/12345',
(server) => server.reply(200, bttvEmotesChannelResponse),
);
final emotes = await api.getEmotesChannel(id: '12345');
expect(emotes, hasLength(3));
expect(emotes[0].name, 'ChannelEmote');
expect(emotes[0].type, EmoteType.bttvChannel);
expect(emotes[1].name, 'SharedEmote');
expect(emotes[1].type, EmoteType.bttvShared);
expect(emotes[2].name, 'SharedEmote2');
expect(emotes[2].type, EmoteType.bttvShared);
});
test('throws on 404 for unknown channel', () {
dioAdapter.onGet(
'https://api.betterttv.net/3/cached/users/twitch/99999',
(server) => server.reply(404, {'message': 'user not found'}),
);
expect(
() => api.getEmotesChannel(id: '99999'),
throwsA(isA<NotFoundException>()),
);
});
});
group('getBadges', () {
test('returns map of userId to ChatBadge', () async {
dioAdapter.onGet(
'https://api.betterttv.net/3/cached/badges',
(server) => server.reply(200, bttvBadgesResponse),
);
final badges = await api.getBadges();
expect(badges, hasLength(2));
expect(badges['user123']!.name, 'BTTV Developer');
expect(badges['user123']!.url, contains('dev.svg'));
expect(badges['user456']!.name, 'BTTV Supporter');
});
test('returns empty map for empty response', () async {
dioAdapter.onGet(
'https://api.betterttv.net/3/cached/badges',
(server) => server.reply(200, <dynamic>[]),
);
final badges = await api.getBadges();
expect(badges, isEmpty);
});
});
}

192
test/apis/ffz_api_test.dart Normal file
View file

@ -0,0 +1,192 @@
import 'package:dio/dio.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:frosty/apis/base_api_client.dart';
import 'package:frosty/apis/ffz_api.dart';
import 'package:frosty/models/badges.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 FFZApi api;
setUp(() {
dio = Dio(BaseOptions());
dioAdapter = DioAdapter(dio: dio);
api = FFZApi(dio);
});
group('getEmotesGlobal', () {
test('returns global FFZ emotes from default sets', () async {
dioAdapter.onGet(
'https://api.frankerfacez.com/v1/set/global',
(server) => server.reply(200, ffzEmotesGlobalResponse),
);
final emotes = await api.getEmotesGlobal();
expect(emotes, hasLength(1));
expect(emotes.first.name, 'LULW');
expect(emotes.first.type, EmoteType.ffzGlobal);
expect(emotes.first.ownerDisplayName, 'FFZOwner');
expect(emotes.first.ownerUsername, 'ffzowner');
});
test('returns emotes from multiple default sets', () async {
dioAdapter.onGet(
'https://api.frankerfacez.com/v1/set/global',
(server) => server.reply(200, {
'default_sets': [1, 2],
'sets': {
'1': {
'emoticons': [
{
'name': 'LULW',
'height': 28,
'width': 28,
'owner': {'display_name': 'A', 'name': 'a'},
'urls': {'1': 'https://cdn/1x.png'},
'animated': null,
},
],
},
'2': {
'emoticons': [
{
'name': 'KEKW',
'height': 32,
'width': 32,
'owner': {'display_name': 'B', 'name': 'b'},
'urls': {'1': 'https://cdn/kekw.png'},
'animated': null,
},
],
},
},
}),
);
final emotes = await api.getEmotesGlobal();
expect(emotes, hasLength(2));
expect(emotes[0].name, 'LULW');
expect(emotes[1].name, 'KEKW');
});
});
group('getRoomInfo', () {
test('returns room info and channel emotes', () async {
dioAdapter.onGet(
'https://api.frankerfacez.com/v1/room/id/12345',
(server) => server.reply(200, ffzRoomInfoResponse),
);
final (roomInfo, emotes) = await api.getRoomInfo(id: '12345');
expect(roomInfo.set, 123);
expect(roomInfo.vipBadge, isNull);
expect(roomInfo.modUrls, isNull);
expect(emotes, hasLength(1));
expect(emotes.first.name, 'ChannelFFZ');
expect(emotes.first.type, EmoteType.ffzChannel);
});
test('returns room info with VIP badge', () async {
dioAdapter.onGet(
'https://api.frankerfacez.com/v1/room/id/54321',
(server) => server.reply(200, {
'room': {
'set': 456,
'vip_badge': {
'1': 'https://cdn/vip/1x.png',
'2': 'https://cdn/vip/2x.png',
'4': 'https://cdn/vip/4x.png',
},
'mod_urls': null,
},
'sets': {
'456': {
'emoticons': <dynamic>[],
},
},
}),
);
final (roomInfo, emotes) = await api.getRoomInfo(id: '54321');
expect(roomInfo.vipBadge, isNotNull);
expect(roomInfo.vipBadge!.url1x, 'https://cdn/vip/1x.png');
expect(emotes, isEmpty);
});
test('throws NotFoundException for unknown channel', () {
dioAdapter.onGet(
'https://api.frankerfacez.com/v1/room/id/99999',
(server) => server.reply(404, {'error': 'No such room'}),
);
expect(
() => api.getRoomInfo(id: '99999'),
throwsA(isA<NotFoundException>()),
);
});
});
group('getBadges', () {
test('builds user-to-badge map with reversed badge order', () async {
dioAdapter.onGet(
'https://api.frankerfacez.com/v1/badges/ids',
(server) => server.reply(200, ffzBadgesResponse),
);
final badges = await api.getBadges();
// User 22222 appears in both badge lists, so should have 2 badges.
// Reversed iteration means badge 2 (Supporter) is processed first,
// then badge 1 (Developer).
expect(badges['22222'], hasLength(2));
expect(badges['22222']![0].name, 'FFZ Supporter');
expect(badges['22222']![1].name, 'FFZ Developer');
// User 11111 only appears in badge 1
expect(badges['11111'], hasLength(1));
expect(badges['11111']![0].name, 'FFZ Developer');
// User 33333 only appears in badge 2
expect(badges['33333'], hasLength(1));
expect(badges['33333']![0].name, 'FFZ Supporter');
});
test('badge entries have correct type', () async {
dioAdapter.onGet(
'https://api.frankerfacez.com/v1/badges/ids',
(server) => server.reply(200, ffzBadgesResponse),
);
final badges = await api.getBadges();
for (final badgeList in badges.values) {
for (final badge in badgeList) {
expect(badge.type, BadgeType.ffz);
}
}
});
test('returns empty map when no badges exist', () async {
dioAdapter.onGet(
'https://api.frankerfacez.com/v1/badges/ids',
(server) => server.reply(200, {
'badges': <dynamic>[],
'users': <String, dynamic>{},
}),
);
final badges = await api.getBadges();
expect(badges, isEmpty);
});
});
}

View file

@ -0,0 +1,142 @@
import 'package:dio/dio.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:frosty/apis/base_api_client.dart';
import 'package:frosty/apis/seventv_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 SevenTVApi api;
setUp(() {
dio = Dio(BaseOptions());
dioAdapter = DioAdapter(dio: dio);
api = SevenTVApi(dio);
});
group('getEmotesGlobal', () {
test('returns list of global 7TV emotes', () async {
dioAdapter.onGet(
'https://7tv.io/v3/emote-sets/global',
(server) => server.reply(200, sevenTVEmotesGlobalResponse),
);
final emotes = await api.getEmotesGlobal();
expect(emotes, hasLength(1));
expect(emotes.first.name, 'EZ');
expect(emotes.first.type, EmoteType.sevenTVGlobal);
expect(emotes.first.url, contains('7tv.app'));
expect(emotes.first.url, contains('4x.webp'));
expect(emotes.first.ownerDisplayName, 'Creator');
expect(emotes.first.ownerUsername, 'creator');
});
test('returns empty list for empty emotes', () async {
dioAdapter.onGet(
'https://7tv.io/v3/emote-sets/global',
(server) => server.reply(200, {
'emotes': <dynamic>[],
}),
);
final emotes = await api.getEmotesGlobal();
expect(emotes, isEmpty);
});
});
group('getEmotesChannel', () {
test('returns emote set ID and channel emotes', () async {
dioAdapter.onGet(
'https://7tv.io/v3/users/twitch/12345',
(server) => server.reply(200, sevenTVEmotesChannelResponse),
);
final (emoteSetId, emotes) = await api.getEmotesChannel(id: '12345');
expect(emoteSetId, 'set_abc123');
expect(emotes, hasLength(1));
expect(emotes.first.name, 'Clap');
expect(emotes.first.type, EmoteType.sevenTVChannel);
});
test('filters out emotes with empty URLs', () async {
dioAdapter.onGet(
'https://7tv.io/v3/users/twitch/12345',
(server) => server.reply(200, {
'emote_set': {
'id': 'set_filter',
'emotes': [
{
'id': 'valid',
'name': 'ValidEmote',
'data': {
'id': 'valid',
'name': 'ValidEmote',
'flags': 0,
'owner': null,
'host': {
'url': '//cdn.7tv.app/emote/valid',
'files': [
{
'name': '1x.webp',
'width': 32,
'height': 32,
'format': 'WEBP',
},
],
},
},
},
{
'id': 'empty_url',
'name': 'NoUrlEmote',
'data': {
'id': 'empty_url',
'name': 'NoUrlEmote',
'flags': 0,
'owner': null,
'host': {
'url': '//cdn.7tv.app/emote/empty_url',
// Only AVIF files — these get filtered by lastWhereOrNull,
// resulting in empty URL
'files': [
{
'name': '1x.avif',
'width': 32,
'height': 32,
'format': 'AVIF',
},
],
},
},
},
],
},
}),
);
final (_, emotes) = await api.getEmotesChannel(id: '12345');
expect(emotes, hasLength(1));
expect(emotes.first.name, 'ValidEmote');
});
test('throws on 404 for unknown user', () {
dioAdapter.onGet(
'https://7tv.io/v3/users/twitch/99999',
(server) => server.reply(404, {'message': 'Unknown User'}),
);
expect(
() => api.getEmotesChannel(id: '99999'),
throwsA(isA<NotFoundException>()),
);
});
});
}

View file

@ -0,0 +1,392 @@
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('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);
});
});
}

View file

@ -0,0 +1,95 @@
import 'package:dio/dio.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:frosty/apis/twitch_auth_interceptor.dart';
import 'package:frosty/screens/settings/stores/auth_store.dart';
import 'package:mocktail/mocktail.dart';
class MockAuthStore extends Mock implements AuthStore {}
void main() {
late MockAuthStore mockAuthStore;
late TwitchAuthInterceptor interceptor;
setUp(() {
mockAuthStore = MockAuthStore();
interceptor = TwitchAuthInterceptor(mockAuthStore);
when(() => mockAuthStore.headersTwitch).thenReturn({
'Authorization': 'Bearer test_token',
'Client-Id': 'test_client_id',
});
});
group('TwitchAuthInterceptor', () {
test('adds headers for Twitch Helix API requests', () {
final options = RequestOptions(
path: 'https://api.twitch.tv/helix/users',
);
final handler = RequestInterceptorHandler();
// We test by calling onRequest and checking that headers were added
interceptor.onRequest(options, handler);
expect(options.headers['Authorization'], 'Bearer test_token');
expect(options.headers['Client-Id'], 'test_client_id');
});
test('adds headers for Twitch OAuth requests', () {
final options = RequestOptions(
path: 'https://id.twitch.tv/oauth2/validate',
);
final handler = RequestInterceptorHandler();
interceptor.onRequest(options, handler);
expect(options.headers['Authorization'], 'Bearer test_token');
expect(options.headers['Client-Id'], 'test_client_id');
});
test('does not add headers for BTTV API requests', () {
final options = RequestOptions(
path: 'https://api.betterttv.net/3/cached/emotes/global',
);
final handler = RequestInterceptorHandler();
interceptor.onRequest(options, handler);
expect(options.headers.containsKey('Authorization'), isFalse);
expect(options.headers.containsKey('Client-Id'), isFalse);
});
test('does not add headers for FFZ API requests', () {
final options = RequestOptions(
path: 'https://api.frankerfacez.com/v1/set/global',
);
final handler = RequestInterceptorHandler();
interceptor.onRequest(options, handler);
expect(options.headers.containsKey('Authorization'), isFalse);
});
test('does not add headers for 7TV API requests', () {
final options = RequestOptions(
path: 'https://7tv.io/v3/emote-sets/global',
);
final handler = RequestInterceptorHandler();
interceptor.onRequest(options, handler);
expect(options.headers.containsKey('Authorization'), isFalse);
});
test('does not add headers for other URLs', () {
final options = RequestOptions(
path: 'https://example.com/api/data',
);
final handler = RequestInterceptorHandler();
interceptor.onRequest(options, handler);
expect(options.headers.containsKey('Authorization'), isFalse);
});
});
}

350
test/fixtures/api_responses.dart vendored Normal file
View file

@ -0,0 +1,350 @@
/// Test fixtures containing sample API response data.
/// Reused across model serialization and API service tests.
library;
// ── Twitch API Responses ──
const twitchEmotesGlobalResponse = {
'data': [
{
'id': '25',
'name': 'Kappa',
'emote_type': 'bitstier',
'owner_id': null,
},
{
'id': '1902',
'name': 'Keepo',
'emote_type': 'globals',
'owner_id': null,
},
],
};
const twitchBadgesGlobalResponse = {
'data': [
{
'set_id': 'subscriber',
'versions': [
{
'id': '0',
'image_url_1x': 'https://cdn/sub/1x.png',
'image_url_2x': 'https://cdn/sub/2x.png',
'image_url_4x': 'https://cdn/sub/4x.png',
'title': 'Subscriber',
'description': '0 months',
},
],
},
],
};
const twitchUserResponse = {
'data': [
{
'id': '12345',
'login': 'testuser',
'display_name': 'TestUser',
'profile_image_url': 'https://cdn/profile.png',
},
],
};
const twitchStreamResponse = {
'data': [
{
'user_id': '12345',
'user_login': 'testuser',
'user_name': 'TestUser',
'game_id': '509658',
'game_name': 'Just Chatting',
'title': 'Test Stream',
'viewer_count': 1000,
'started_at': '2024-01-01T00:00:00Z',
'thumbnail_url': 'https://cdn/thumb.jpg',
},
],
'pagination': {'cursor': 'next_cursor'},
};
const twitchCategoryResponse = {
'data': [
{
'box_art_url': 'https://cdn/boxart.jpg',
'id': '509658',
'name': 'Just Chatting',
},
],
'pagination': {'cursor': 'cat_cursor'},
};
const twitchChannelResponse = {
'data': [
{
'broadcaster_id': '12345',
'broadcaster_login': 'testuser',
'broadcaster_name': 'TestUser',
'broadcaster_language': 'en',
'title': 'Test Stream',
'game_id': '509658',
'game_name': 'Just Chatting',
},
],
};
const twitchChannelQueryResponse = {
'data': [
{
'broadcaster_login': 'testuser',
'display_name': 'TestUser',
'id': '12345',
'is_live': true,
'started_at': '2024-01-01T00:00:00Z',
},
],
};
const twitchBlockedUsersResponse = {
'data': [
{
'user_id': '99999',
'user_login': 'blockeduser',
'display_name': 'BlockedUser',
},
],
'pagination': {'cursor': null},
};
const twitchSharedChatSessionResponse = {
'data': [
{
'session_id': 'session_123',
'host_broadcaster_id': '12345',
'participants': [
{'broadcaster_id': '12345'},
{'broadcaster_id': '67890'},
],
'created_at': '2024-01-01T00:00:00Z',
'updated_at': '2024-01-01T01:00:00Z',
},
],
};
const twitchTokenResponse = {
'access_token': 'test_token_abc123',
'token_type': 'bearer',
'expires_in': 5000000,
};
const twitchValidateTokenResponse = {
'client_id': 'test_client_id',
'login': 'testuser',
'scopes': ['chat:read', 'chat:edit'],
'user_id': '12345',
'expires_in': 5000000,
};
// ── BTTV API Responses ──
const bttvEmotesGlobalResponse = [
{'id': 'bttv1', 'code': 'SourPls'},
{'id': 'bttv2', 'code': 'catJAM'},
];
const bttvEmotesChannelResponse = {
'channelEmotes': [
{'id': 'ch1', 'code': 'ChannelEmote'},
],
'sharedEmotes': [
{'id': 'sh1', 'code': 'SharedEmote'},
{'id': 'sh2', 'code': 'SharedEmote2'},
],
};
const bttvBadgesResponse = [
{
'providerId': 'user123',
'badge': {
'description': 'BTTV Developer',
'svg': 'https://cdn.bttv.net/badge/dev.svg',
},
},
{
'providerId': 'user456',
'badge': {
'description': 'BTTV Supporter',
'svg': 'https://cdn.bttv.net/badge/supporter.svg',
},
},
];
// ── FFZ API Responses ──
const ffzEmotesGlobalResponse = {
'default_sets': [1],
'sets': {
'1': {
'emoticons': [
{
'name': 'LULW',
'height': 28,
'width': 28,
'owner': {'display_name': 'FFZOwner', 'name': 'ffzowner'},
'urls': {
'1': 'https://cdn.ffz.net/1x.png',
'2': 'https://cdn.ffz.net/2x.png',
'4': 'https://cdn.ffz.net/4x.png',
},
'animated': null,
},
],
},
},
};
const ffzRoomInfoResponse = {
'room': {
'set': 123,
'vip_badge': null,
'mod_urls': null,
},
'sets': {
'123': {
'emoticons': [
{
'name': 'ChannelFFZ',
'height': 32,
'width': 32,
'owner': {'display_name': 'Streamer', 'name': 'streamer'},
'urls': {
'1': 'https://cdn.ffz.net/ch/1x.png',
'2': 'https://cdn.ffz.net/ch/2x.png',
'4': 'https://cdn.ffz.net/ch/4x.png',
},
'animated': null,
},
],
},
},
};
const ffzBadgesResponse = {
'badges': [
{
'id': 1,
'title': 'FFZ Developer',
'color': '#FF0000',
'urls': {
'1': 'https://cdn.ffz.net/badge/1x.png',
'2': 'https://cdn.ffz.net/badge/2x.png',
'4': 'https://cdn.ffz.net/badge/4x.png',
},
},
{
'id': 2,
'title': 'FFZ Supporter',
'color': '#00FF00',
'urls': {
'1': 'https://cdn.ffz.net/badge2/1x.png',
'2': 'https://cdn.ffz.net/badge2/2x.png',
'4': 'https://cdn.ffz.net/badge2/4x.png',
},
},
],
'users': {
'1': [11111, 22222],
'2': [22222, 33333],
},
};
// ── 7TV API Responses ──
const sevenTVEmotesGlobalResponse = {
'emotes': [
{
'id': '7tv1',
'name': 'EZ',
'data': {
'id': '7tv1',
'name': 'EZ',
'flags': 0,
'owner': {'username': 'creator', 'display_name': 'Creator'},
'host': {
'url': '//cdn.7tv.app/emote/7tv1',
'files': [
{'name': '1x.webp', 'width': 32, 'height': 32, 'format': 'WEBP'},
{'name': '2x.webp', 'width': 64, 'height': 64, 'format': 'WEBP'},
{'name': '3x.webp', 'width': 96, 'height': 96, 'format': 'WEBP'},
{
'name': '4x.webp',
'width': 128,
'height': 128,
'format': 'WEBP',
},
],
},
},
},
],
};
const sevenTVEmotesChannelResponse = {
'emote_set': {
'id': 'set_abc123',
'emotes': [
{
'id': '7tv_ch1',
'name': 'Clap',
'data': {
'id': '7tv_ch1',
'name': 'Clap',
'flags': 0,
'owner': null,
'host': {
'url': '//cdn.7tv.app/emote/7tv_ch1',
'files': [
{
'name': '1x.webp',
'width': 32,
'height': 32,
'format': 'WEBP',
},
{
'name': '4x.webp',
'width': 128,
'height': 128,
'format': 'WEBP',
},
],
},
},
},
],
},
};
// ── Chatters Response ──
const chattersResponse = {
'chatter_count': 5,
'chatters': {
'broadcaster': ['streamer'],
'vips': ['vip1'],
'moderators': ['mod1'],
'staff': <String>[],
'admins': <String>[],
'global_mods': <String>[],
'viewers': ['viewer1', 'viewer2'],
},
};
// ── 7TV Events ──
const sevenTVEventJson = {
'op': 0,
't': 1,
'd': {
'type': 'emote_set.update',
'condition': {'object_id': 'set_abc123'},
},
};

View file

@ -152,6 +152,20 @@ const messageWithEmoji =
'tmi-sent-ts=1234567890123;turbo=0;user-id=14141;user-type= '
':emojiuser!emojiuser@emojiuser.tmi.twitch.tv PRIVMSG #channel :Hello 😀🎉 World';
/// A message with empty body (edge case)
const messageEmptyBody =
'@badge-info=;badges=;color=#FF0000;display-name=EmptyMsg;emotes=;'
'id=empty-123;mod=0;room-id=12345;subscriber=0;'
'tmi-sent-ts=1234567890123;turbo=0;user-id=16161;user-type= '
':emptymsg!emptymsg@emptymsg.tmi.twitch.tv PRIVMSG #channel :';
/// A message containing only emotes (no text words)
const messageOnlyEmotes =
'@badge-info=;badges=;color=#FF0000;display-name=EmoteOnly;'
'emotes=25:0-4,6-10;id=emoteonly-123;mod=0;room-id=12345;subscriber=0;'
'tmi-sent-ts=1234567890123;turbo=0;user-id=17171;user-type= '
':emoteonly!emoteonly@emoteonly.tmi.twitch.tv PRIVMSG #channel :Kappa Kappa';
/// A message with a URL
const messageWithUrl =
'@badge-info=;badges=;color=#9400D3;display-name=LinkUser;emotes=;'

View file

@ -0,0 +1,55 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:frosty/models/category.dart';
void main() {
group('CategoryTwitch', () {
test('fromJson deserializes snake_case fields', () {
final category = CategoryTwitch.fromJson(const {
'box_art_url': 'https://cdn/boxart.jpg',
'id': '509658',
'name': 'Just Chatting',
});
expect(category.boxArtUrl, 'https://cdn/boxart.jpg');
expect(category.id, '509658');
expect(category.name, 'Just Chatting');
});
});
group('CategoriesTwitch', () {
test('fromJson deserializes data list and pagination', () {
final categories = CategoriesTwitch.fromJson(const {
'data': [
{
'box_art_url': 'https://cdn/boxart.jpg',
'id': '509658',
'name': 'Just Chatting',
},
],
'pagination': {'cursor': 'cat_cursor'},
});
expect(categories.data.length, 1);
expect(categories.data.first.name, 'Just Chatting');
expect(categories.pagination?['cursor'], 'cat_cursor');
});
test('fromJson handles empty data list', () {
final categories = CategoriesTwitch.fromJson(const {
'data': <dynamic>[],
'pagination': <String, String>{},
});
expect(categories.data, isEmpty);
});
test('fromJson handles null pagination', () {
final categories = CategoriesTwitch.fromJson(const {
'data': <dynamic>[],
'pagination': null,
});
expect(categories.pagination, isNull);
});
});
}

View file

@ -0,0 +1,49 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:frosty/models/channel.dart';
import '../fixtures/api_responses.dart';
void main() {
group('Channel', () {
test('fromJson deserializes snake_case fields', () {
final channel = Channel.fromJson(
(twitchChannelResponse['data']! as List).first as Map<String, dynamic>,
);
expect(channel.broadcasterId, '12345');
expect(channel.broadcasterLogin, 'testuser');
expect(channel.broadcasterName, 'TestUser');
expect(channel.broadcasterLanguage, 'en');
expect(channel.title, 'Test Stream');
expect(channel.gameId, '509658');
expect(channel.gameName, 'Just Chatting');
});
});
group('ChannelQuery', () {
test('fromJson deserializes snake_case fields', () {
final query = ChannelQuery.fromJson(
(twitchChannelQueryResponse['data']! as List).first
as Map<String, dynamic>,
);
expect(query.broadcasterLogin, 'testuser');
expect(query.displayName, 'TestUser');
expect(query.id, '12345');
expect(query.isLive, isTrue);
expect(query.startedAt, '2024-01-01T00:00:00Z');
});
test('fromJson handles isLive false', () {
final query = ChannelQuery.fromJson(const {
'broadcaster_login': 'offline',
'display_name': 'Offline',
'id': '99999',
'is_live': false,
'started_at': '',
});
expect(query.isLive, isFalse);
});
});
}

View file

@ -0,0 +1,36 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:frosty/models/chatters.dart';
import '../fixtures/api_responses.dart';
void main() {
group('ChatUsers', () {
test('fromJson deserializes chatter count and chatters', () {
final chatUsers = ChatUsers.fromJson(
chattersResponse as Map<String, dynamic>,
);
expect(chatUsers.chatterCount, 5);
expect(chatUsers.chatters.broadcaster, ['streamer']);
expect(chatUsers.chatters.vips, ['vip1']);
expect(chatUsers.chatters.moderators, ['mod1']);
expect(chatUsers.chatters.viewers, ['viewer1', 'viewer2']);
});
});
group('Chatters', () {
test('fromJson handles all role lists', () {
final chatters = Chatters.fromJson(
chattersResponse['chatters'] as Map<String, dynamic>,
);
expect(chatters.broadcaster, hasLength(1));
expect(chatters.vips, hasLength(1));
expect(chatters.moderators, hasLength(1));
expect(chatters.staff, isEmpty);
expect(chatters.admins, isEmpty);
expect(chatters.globalMods, isEmpty);
expect(chatters.viewers, hasLength(2));
});
});
}

View file

@ -34,6 +34,19 @@ void main() {
expect(emote.ownerId, '67890');
});
test('handles null owner ID', () {
final twitchEmote = EmoteTwitch(
'25',
'Kappa',
'bitstier',
null,
);
final emote = Emote.fromTwitch(twitchEmote, EmoteType.twitchGlobal);
expect(emote.ownerId, isNull);
});
});
group('Emote.fromBTTV', () {
@ -47,53 +60,21 @@ void main() {
expect(emote.type, EmoteType.bttvGlobal);
});
test('marks SoSnowy as zero-width', () {
final bttvEmote = EmoteBTTV('snowyid', 'SoSnowy');
final emote = Emote.fromBTTV(bttvEmote, EmoteType.bttvGlobal);
expect(emote.zeroWidth, isTrue);
});
test('marks IceCold as zero-width', () {
final bttvEmote = EmoteBTTV('iceid', 'IceCold');
final emote = Emote.fromBTTV(bttvEmote, EmoteType.bttvChannel);
expect(emote.zeroWidth, isTrue);
});
test('marks SantaHat as zero-width', () {
final bttvEmote = EmoteBTTV('santaid', 'SantaHat');
final emote = Emote.fromBTTV(bttvEmote, EmoteType.bttvShared);
expect(emote.zeroWidth, isTrue);
});
test('marks TopHat as zero-width', () {
final bttvEmote = EmoteBTTV('topid', 'TopHat');
final emote = Emote.fromBTTV(bttvEmote, EmoteType.bttvGlobal);
expect(emote.zeroWidth, isTrue);
});
test('marks cvMask as zero-width', () {
final bttvEmote = EmoteBTTV('maskid', 'cvMask');
final emote = Emote.fromBTTV(bttvEmote, EmoteType.bttvGlobal);
expect(emote.zeroWidth, isTrue);
});
test('marks cvHazmat as zero-width', () {
final bttvEmote = EmoteBTTV('hazmatid', 'cvHazmat');
final emote = Emote.fromBTTV(bttvEmote, EmoteType.bttvGlobal);
expect(emote.zeroWidth, isTrue);
});
for (final name in [
'SoSnowy',
'IceCold',
'SantaHat',
'TopHat',
'ReinDeer',
'CandyCane',
'cvMask',
'cvHazmat',
]) {
test('marks $name as zero-width', () {
final emote = Emote.fromBTTV(EmoteBTTV('id', name), EmoteType.bttvGlobal);
expect(emote.zeroWidth, isTrue);
});
}
test('regular emotes are not zero-width', () {
final bttvEmote = EmoteBTTV('regularid', 'RegularEmote');
@ -214,6 +195,29 @@ void main() {
// Should use animated 2x since animated 4x is not available
expect(emote.url, 'https://cdn.ffz.net/anim/2x.gif');
});
test('animated with only 1x falls back to animated 1x', () {
final ffzEmote = EmoteFFZ(
'AnimOnly1x',
24,
24,
const OwnerFFZ(displayName: 'Owner', name: 'owner'),
const ImagesFFZ(
'https://cdn.ffz.net/static/1x.png',
'https://cdn.ffz.net/static/2x.png',
'https://cdn.ffz.net/static/4x.png',
),
const ImagesFFZ(
'https://cdn.ffz.net/anim/1x.gif',
null, // No animated 2x
null, // No animated 4x
),
);
final emote = Emote.fromFFZ(ffzEmote, EmoteType.ffzGlobal);
expect(emote.url, 'https://cdn.ffz.net/anim/1x.gif');
});
});
group('Emote.from7TV', () {
@ -473,6 +477,27 @@ void main() {
});
});
group('EmoteBTTVChannel', () {
test('combines channel and shared emotes', () {
final json = {
'channelEmotes': [
{'id': 'ch1', 'code': 'ChannelEmote'},
],
'sharedEmotes': [
{'id': 'sh1', 'code': 'SharedEmote1'},
{'id': 'sh2', 'code': 'SharedEmote2'},
],
};
final result = EmoteBTTVChannel.fromJson(json);
expect(result.channelEmotes.length, 1);
expect(result.channelEmotes.first.code, 'ChannelEmote');
expect(result.sharedEmotes.length, 2);
expect(result.sharedEmotes.first.code, 'SharedEmote1');
});
});
group('Emote JSON serialization', () {
test('roundtrip serialization works', () {
const original = Emote(

View file

@ -0,0 +1,110 @@
import 'dart:convert';
import 'package:flutter_test/flutter_test.dart';
import 'package:frosty/models/events.dart';
import '../fixtures/api_responses.dart';
void main() {
group('SevenTVEvent', () {
test('fromJson deserializes op, t, and d fields', () {
final event = SevenTVEvent.fromJson(
sevenTVEventJson as Map<String, dynamic>,
);
expect(event.op, 0);
expect(event.t, 1);
expect(event.d.type, 'emote_set.update');
});
test('toJson roundtrip preserves data', () {
final event = SevenTVEvent.fromJson(
sevenTVEventJson as Map<String, dynamic>,
);
// Use jsonEncode/jsonDecode for a true roundtrip, since toJson()
// doesn't recursively serialize nested objects without explicitToJson.
final json = jsonDecode(jsonEncode(event.toJson()))
as Map<String, dynamic>;
final restored = SevenTVEvent.fromJson(json);
expect(restored.op, event.op);
expect(restored.t, event.t);
expect(restored.d.type, event.d.type);
});
test('fromJson handles null t field', () {
final event = SevenTVEvent.fromJson(const {
'op': 2,
'd': {'type': null},
});
expect(event.op, 2);
expect(event.t, isNull);
});
});
group('SevenTVEventData', () {
test('fromJson deserializes type and condition', () {
final data = SevenTVEventData.fromJson(
(sevenTVEventJson as Map<String, dynamic>)['d']
as Map<String, dynamic>,
);
expect(data.type, 'emote_set.update');
expect(data.condition?['object_id'], 'set_abc123');
});
test('fromJson with null type', () {
final data = SevenTVEventData.fromJson(const {
'type': null,
'condition': null,
});
expect(data.type, isNull);
expect(data.condition, isNull);
});
test('toJson produces valid output', () {
const data = SevenTVEventData(
type: 'emote_set.update',
condition: {'object_id': 'test'},
);
final json = data.toJson();
expect(json['type'], 'emote_set.update');
expect(json['condition'], {'object_id': 'test'});
});
});
group('SevenTVEventEmoteSetBody', () {
test('fromJson deserializes actor and pushed/pulled lists', () {
final body = SevenTVEventEmoteSetBody.fromJson(const {
'actor': {
'username': 'moderator',
'display_name': 'Moderator',
},
'pushed': <dynamic>[],
'pulled': <dynamic>[],
});
expect(body.actor.username, 'moderator');
expect(body.actor.displayName, 'Moderator');
expect(body.pushed, isEmpty);
expect(body.pulled, isEmpty);
});
test('fromJson handles null pushed/pulled', () {
final body = SevenTVEventEmoteSetBody.fromJson(const {
'actor': {
'username': 'mod',
'display_name': 'Mod',
},
});
expect(body.pushed, isNull);
expect(body.pulled, isNull);
});
});
}

View file

@ -1,4 +1,5 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:frosty/constants.dart';
import 'package:frosty/models/irc.dart';
import '../fixtures/irc_messages.dart';
@ -173,7 +174,7 @@ void main() {
expect(msg.mention, isFalse);
});
test('mention is null when userLogin not provided', () {
test('mention is false when userLogin not provided', () {
final msg = IRCMessage.fromString(basicPrivmsg);
expect(msg.mention, isFalse);
});
@ -228,6 +229,17 @@ void main() {
expect(msg.tags['source-room-id'], '99999');
expect(msg.tags['room-id'], '12345');
});
test('parses message with URL in split', () {
final msg = IRCMessage.fromString(messageWithUrl);
expect(msg.message, contains('https://twitch.tv/channel'));
expect(msg.split, isNotNull);
expect(
msg.split!.any((word) => regexLink.hasMatch(word)),
isTrue,
);
});
});
});
@ -456,6 +468,126 @@ void main() {
});
});
group('IRCMessage.clearChat — buffered messages', () {
test('marks buffered messages from banned user', () {
final messages = <IRCMessage>[];
final bufferedMessages = [
IRCMessage(
raw: '',
command: Command.privateMessage,
tags: {'id': 'buf-1'},
user: 'banneduser',
message: 'Buffered msg',
),
IRCMessage(
raw: '',
command: Command.privateMessage,
tags: {'id': 'buf-2'},
user: 'innocentuser',
message: 'Other msg',
),
];
final clearChatMsg = IRCMessage(
raw: '',
command: Command.clearChat,
tags: {'ban-duration': '600'},
message: 'banneduser',
);
IRCMessage.clearChat(
messages: messages,
bufferedMessages: bufferedMessages,
ircMessage: clearChatMsg,
);
expect(bufferedMessages[0].command, Command.clearChat);
expect(bufferedMessages[0].tags['ban-duration'], '600');
expect(bufferedMessages[1].command, Command.privateMessage);
});
test('full chat clear also clears buffered messages', () {
final messages = [
IRCMessage(
raw: '',
command: Command.privateMessage,
tags: {},
message: 'msg',
),
];
final bufferedMessages = [
IRCMessage(
raw: '',
command: Command.privateMessage,
tags: {},
message: 'buffered',
),
];
final clearAllMsg = IRCMessage(
raw: '',
command: Command.clearChat,
tags: {},
);
IRCMessage.clearChat(
messages: messages,
bufferedMessages: bufferedMessages,
ircMessage: clearAllMsg,
);
expect(bufferedMessages, isEmpty);
});
});
group('IRCMessage.clearMessage — buffered messages', () {
test('marks buffered message by ID', () {
final messages = <IRCMessage>[];
final bufferedMessages = [
IRCMessage(
raw: '',
command: Command.privateMessage,
tags: {'id': 'buf-target'},
user: 'someuser',
message: 'To be deleted',
),
];
final clearMsg = IRCMessage(
raw: '',
command: Command.clearMessage,
tags: {'target-msg-id': 'buf-target'},
);
IRCMessage.clearMessage(
messages: messages,
bufferedMessages: bufferedMessages,
ircMessage: clearMsg,
);
expect(bufferedMessages.first.command, Command.clearMessage);
});
});
group('IRCMessage edge cases', () {
test('parses message with empty body', () {
final msg = IRCMessage.fromString(messageEmptyBody);
expect(msg.command, Command.privateMessage);
// Empty trailing content after the colon
expect(msg.message, isEmpty);
});
test('parses emote-only message', () {
final msg = IRCMessage.fromString(messageOnlyEmotes);
expect(msg.command, Command.privateMessage);
expect(msg.message, 'Kappa Kappa');
expect(msg.localEmotes, isNotNull);
expect(msg.localEmotes!.containsKey('Kappa'), isTrue);
});
});
group('USERSTATE', () {
test('creates default USERSTATE', () {
const userstate = USERSTATE();

View file

@ -0,0 +1,41 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:frosty/models/shared_chat_session.dart';
import '../fixtures/api_responses.dart';
void main() {
group('SharedChatSession', () {
test('fromJson deserializes all fields', () {
final session = SharedChatSession.fromJson(
(twitchSharedChatSessionResponse['data']! as List).first
as Map<String, dynamic>,
);
expect(session.sessionId, 'session_123');
expect(session.hostBroadcasterId, '12345');
expect(session.participants.length, 2);
expect(session.createdAt, '2024-01-01T00:00:00Z');
expect(session.updatedAt, '2024-01-01T01:00:00Z');
});
});
group('Participant', () {
test('fromJson deserializes broadcaster_id', () {
final participant = Participant.fromJson(const {
'broadcaster_id': '67890',
});
expect(participant.broadcasterId, '67890');
});
test('participants from shared chat session are correct', () {
final session = SharedChatSession.fromJson(
(twitchSharedChatSessionResponse['data']! as List).first
as Map<String, dynamic>,
);
expect(session.participants[0].broadcasterId, '12345');
expect(session.participants[1].broadcasterId, '67890');
});
});
}

View file

@ -0,0 +1,45 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:frosty/models/stream.dart';
import '../fixtures/api_responses.dart';
void main() {
group('StreamTwitch', () {
test('fromJson deserializes snake_case fields', () {
final stream = StreamTwitch.fromJson(
(twitchStreamResponse['data']! as List).first as Map<String, dynamic>,
);
expect(stream.userId, '12345');
expect(stream.userLogin, 'testuser');
expect(stream.userName, 'TestUser');
expect(stream.gameId, '509658');
expect(stream.gameName, 'Just Chatting');
expect(stream.title, 'Test Stream');
expect(stream.viewerCount, 1000);
expect(stream.startedAt, '2024-01-01T00:00:00Z');
expect(stream.thumbnailUrl, 'https://cdn/thumb.jpg');
});
});
group('StreamsTwitch', () {
test('fromJson deserializes data and pagination', () {
final streams = StreamsTwitch.fromJson(
twitchStreamResponse as Map<String, dynamic>,
);
expect(streams.data.length, 1);
expect(streams.data.first.userName, 'TestUser');
expect(streams.pagination['cursor'], 'next_cursor');
});
test('fromJson handles empty data list', () {
final streams = StreamsTwitch.fromJson(const {
'data': <dynamic>[],
'pagination': <String, String>{},
});
expect(streams.data, isEmpty);
});
});
}

View file

@ -0,0 +1,44 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:frosty/models/user.dart';
import '../fixtures/api_responses.dart';
void main() {
group('UserTwitch', () {
test('fromJson deserializes snake_case fields', () {
final user = UserTwitch.fromJson(
(twitchUserResponse['data']! as List).first as Map<String, dynamic>,
);
expect(user.id, '12345');
expect(user.login, 'testuser');
expect(user.displayName, 'TestUser');
expect(user.profileImageUrl, 'https://cdn/profile.png');
});
});
group('UserBlockedTwitch', () {
test('fromJson deserializes snake_case fields', () {
final blocked = UserBlockedTwitch.fromJson(
(twitchBlockedUsersResponse['data']! as List).first
as Map<String, dynamic>,
);
expect(blocked.userId, '99999');
expect(blocked.userLogin, 'blockeduser');
expect(blocked.displayName, 'BlockedUser');
});
test('fromJson with different values', () {
final blocked = UserBlockedTwitch.fromJson(const {
'user_id': '11111',
'user_login': 'another_blocked',
'display_name': 'AnotherBlocked',
});
expect(blocked.userId, '11111');
expect(blocked.userLogin, 'another_blocked');
expect(blocked.displayName, 'AnotherBlocked');
});
});
}

View file

@ -106,8 +106,10 @@ void main() {
expect(regexLink.hasMatch('localhost'), isFalse);
});
test('does not match email addresses as URLs', () {
expect(regexLink.hasMatch('example.com'), isTrue);
test('matches domain part of email addresses', () {
// The regex matches the domain portion (example.com) within an email
// This is expected behavior — the regex is for URLs, not email filtering
expect(regexLink.hasMatch('user@example.com'), isTrue);
});
});
@ -224,8 +226,11 @@ void main() {
test('handles consecutive emojis', () {
const text = '😀😃😄';
final matches = regexEmoji.allMatches(text);
// May match as one or multiple depending on regex
expect(matches.isNotEmpty, isTrue);
// Consecutive emojis may be grouped into a single match by the regex
expect(matches.length, greaterThanOrEqualTo(1));
// Verify all emoji characters are captured
final matched = matches.map((m) => m.group(0)).join();
expect(matched, text);
});
});
});

View file

@ -0,0 +1,278 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:frosty/screens/settings/stores/settings_store.dart';
void main() {
group('SettingsStore JSON serialization', () {
test('fromJson with empty map returns all defaults', () {
final store = SettingsStore.fromJson({});
expect(store.themeType, ThemeType.system);
expect(store.accentColor, 0xff9146ff);
expect(store.showThumbnails, isTrue);
expect(store.largeStreamCard, isFalse);
expect(store.launchUrlExternal, isFalse);
expect(store.showVideo, isTrue);
expect(store.defaultToHighestQuality, isFalse);
expect(store.useTextureRendering, isTrue);
expect(store.showOverlay, isTrue);
expect(store.toggleableOverlay, isFalse);
expect(store.showLatency, isFalse);
expect(store.badgeScale, 1.0);
expect(store.emoteScale, 1.0);
expect(store.messageScale, 1.0);
expect(store.messageSpacing, 8.0);
expect(store.fontSize, 12.0);
expect(store.showDeletedMessages, isFalse);
expect(store.showChatMessageDividers, isFalse);
expect(store.timestampType, TimestampType.disabled);
expect(store.autoSyncChatDelay, isFalse);
expect(store.chatDelay, 0.0);
expect(store.highlightFirstTimeChatter, isTrue);
expect(store.showUserNotices, isTrue);
expect(store.emoteMenuButtonOnLeft, isFalse);
expect(store.landscapeChatLeftSide, isFalse);
expect(store.landscapeForceVerticalChat, isFalse);
expect(store.landscapeCutout, LandscapeCutoutType.none);
expect(store.chatWidth, 0.2);
expect(store.fullScreenChatOverlayOpacity, 0.5);
expect(store.autocomplete, isTrue);
expect(store.showTwitchEmotes, isTrue);
expect(store.showTwitchBadges, isTrue);
expect(store.show7TVEmotes, isTrue);
expect(store.showBTTVEmotes, isTrue);
expect(store.showBTTVBadges, isTrue);
expect(store.showFFZEmotes, isTrue);
expect(store.showFFZBadges, isTrue);
expect(store.showRecentMessages, isFalse);
expect(store.persistChatTabs, isTrue);
expect(store.secondaryTabs, isEmpty);
expect(store.mutedWords, isEmpty);
expect(store.matchWholeWord, isTrue);
expect(store.shareCrashLogsAndAnalytics, isTrue);
expect(store.fullScreen, isFalse);
expect(store.fullScreenChatOverlay, isFalse);
expect(store.pinnedChannelIds, isEmpty);
});
test('roundtrip serialization preserves non-default values', () {
final store = SettingsStore.fromJson({});
// Set non-default values
store.themeType = ThemeType.dark;
store.accentColor = 0xFF00FF00;
store.showThumbnails = false;
store.fontSize = 16.0;
store.chatDelay = 5.0;
store.showVideo = false;
store.timestampType = TimestampType.twelve;
store.landscapeCutout = LandscapeCutoutType.both;
store.mutedWords = ['spam', 'bad'];
store.pinnedChannelIds = ['ch1', 'ch2'];
final json = store.toJson();
final restored = SettingsStore.fromJson(json);
expect(restored.themeType, ThemeType.dark);
expect(restored.accentColor, 0xFF00FF00);
expect(restored.showThumbnails, isFalse);
expect(restored.fontSize, 16.0);
expect(restored.chatDelay, 5.0);
expect(restored.showVideo, isFalse);
expect(restored.timestampType, TimestampType.twelve);
expect(restored.landscapeCutout, LandscapeCutoutType.both);
expect(restored.mutedWords, ['spam', 'bad']);
expect(restored.pinnedChannelIds, ['ch1', 'ch2']);
});
test('unknown ThemeType enum value falls back to system', () {
final store = SettingsStore.fromJson({
'themeType': 'nonexistent_theme',
});
expect(store.themeType, ThemeType.system);
});
test('unknown TimestampType enum value falls back to disabled', () {
final store = SettingsStore.fromJson({
'timestampType': 'nonexistent_timestamp',
});
expect(store.timestampType, TimestampType.disabled);
});
});
group('SettingsStore reset actions', () {
test('resetGeneralSettings restores general defaults', () {
final store = SettingsStore.fromJson({});
store.themeType = ThemeType.dark;
store.accentColor = 0xFF00FF00;
store.showThumbnails = false;
store.largeStreamCard = true;
store.launchUrlExternal = true;
store.resetGeneralSettings();
expect(store.themeType, ThemeType.system);
expect(store.accentColor, 0xff9146ff);
expect(store.showThumbnails, isTrue);
expect(store.largeStreamCard, isFalse);
expect(store.launchUrlExternal, isFalse);
});
test('resetVideoSettings restores video defaults', () {
final store = SettingsStore.fromJson({});
store.showVideo = false;
store.defaultToHighestQuality = true;
store.useTextureRendering = false;
store.showOverlay = false;
store.toggleableOverlay = true;
store.showLatency = true;
store.resetVideoSettings();
expect(store.showVideo, isTrue);
expect(store.defaultToHighestQuality, isFalse);
expect(store.useTextureRendering, isTrue);
expect(store.showOverlay, isTrue);
expect(store.toggleableOverlay, isFalse);
expect(store.showLatency, isFalse);
});
test('resetChatSettings restores chat defaults', () {
final store = SettingsStore.fromJson({});
store.badgeScale = 2.0;
store.emoteScale = 2.0;
store.fontSize = 20.0;
store.showDeletedMessages = true;
store.timestampType = TimestampType.twentyFour;
store.chatDelay = 10.0;
store.emoteMenuButtonOnLeft = true;
store.landscapeCutout = LandscapeCutoutType.right;
store.chatWidth = 0.5;
store.mutedWords = ['word1'];
store.showBTTVEmotes = false;
store.showFFZBadges = false;
store.resetChatSettings();
expect(store.badgeScale, 1.0);
expect(store.emoteScale, 1.0);
expect(store.fontSize, 12.0);
expect(store.showDeletedMessages, isFalse);
expect(store.timestampType, TimestampType.disabled);
expect(store.chatDelay, 0.0);
expect(store.emoteMenuButtonOnLeft, isFalse);
expect(store.landscapeCutout, LandscapeCutoutType.none);
expect(store.chatWidth, 0.2);
expect(store.mutedWords, isEmpty);
expect(store.showBTTVEmotes, isTrue);
expect(store.showFFZBadges, isTrue);
});
test('resetOtherSettings restores other defaults', () {
final store = SettingsStore.fromJson({});
store.shareCrashLogsAndAnalytics = false;
store.resetOtherSettings();
expect(store.shareCrashLogsAndAnalytics, isTrue);
});
test('resetGlobalConfigs restores global config defaults', () {
final store = SettingsStore.fromJson({});
store.fullScreen = true;
store.fullScreenChatOverlay = true;
store.pinnedChannelIds = ['ch1'];
store.resetGlobalConfigs();
expect(store.fullScreen, isFalse);
expect(store.fullScreenChatOverlay, isFalse);
expect(store.pinnedChannelIds, isEmpty);
});
test('resetAllSettings restores everything to defaults', () {
final store = SettingsStore.fromJson({});
// Modify settings across all categories
store.themeType = ThemeType.light;
store.showVideo = false;
store.fontSize = 20.0;
store.shareCrashLogsAndAnalytics = false;
store.fullScreen = true;
store.resetAllSettings();
expect(store.themeType, ThemeType.system);
expect(store.showVideo, isTrue);
expect(store.fontSize, 12.0);
expect(store.shareCrashLogsAndAnalytics, isTrue);
expect(store.fullScreen, isFalse);
});
test('resetGeneralSettings does not affect video settings', () {
final store = SettingsStore.fromJson({});
store.showVideo = false;
store.themeType = ThemeType.dark;
store.resetGeneralSettings();
expect(store.themeType, ThemeType.system);
expect(store.showVideo, isFalse); // Should remain changed
});
test('resetVideoSettings does not affect chat settings', () {
final store = SettingsStore.fromJson({});
store.fontSize = 20.0;
store.showLatency = true;
store.resetVideoSettings();
expect(store.showLatency, isFalse);
expect(store.fontSize, 20.0); // Should remain changed
});
});
group('Enums', () {
test('ThemeType has expected values', () {
expect(ThemeType.values.length, 3);
expect(ThemeType.values, contains(ThemeType.system));
expect(ThemeType.values, contains(ThemeType.light));
expect(ThemeType.values, contains(ThemeType.dark));
});
test('TimestampType has expected values', () {
expect(TimestampType.values.length, 3);
expect(TimestampType.values, contains(TimestampType.disabled));
expect(TimestampType.values, contains(TimestampType.twelve));
expect(TimestampType.values, contains(TimestampType.twentyFour));
});
test('LandscapeCutoutType has expected values', () {
expect(LandscapeCutoutType.values.length, 4);
expect(LandscapeCutoutType.values, contains(LandscapeCutoutType.none));
expect(LandscapeCutoutType.values, contains(LandscapeCutoutType.left));
expect(LandscapeCutoutType.values, contains(LandscapeCutoutType.right));
expect(LandscapeCutoutType.values, contains(LandscapeCutoutType.both));
});
test('themeNames matches ThemeType order', () {
expect(themeNames.length, ThemeType.values.length);
expect(themeNames[0], 'System');
expect(themeNames[1], 'Light');
expect(themeNames[2], 'Dark');
});
test('timestampNames matches TimestampType order', () {
expect(timestampNames.length, TimestampType.values.length);
expect(timestampNames[0], 'Disabled');
expect(timestampNames[1], '12-hour');
expect(timestampNames[2], '24-hour');
});
});
}

View file

@ -1,3 +1,5 @@
import 'dart:math' as math;
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:frosty/utils.dart';
@ -290,5 +292,48 @@ void main() {
);
}
});
testWidgets('adjusted color meets WCAG AA 4.5:1 contrast ratio',
(tester) async {
late Color adjustedColor;
const darkBackground = Color(0xFF121212);
const darkBlue = Color(0xFF0000AA);
await tester.pumpWidget(
MaterialApp(
theme: ThemeData.dark().copyWith(
scaffoldBackgroundColor: darkBackground,
),
home: Scaffold(
body: Builder(
builder: (context) {
adjustedColor = adjustChatNameColor(context, darkBlue);
return const SizedBox();
},
),
),
),
);
// Compute WCAG contrast ratio manually
double linearize(double c) => c <= 0.03928
? c / 12.92
: math.pow((c + 0.055) / 1.055, 2.4).toDouble();
double luminance(Color c) {
return 0.2126 * linearize(c.r) +
0.7152 * linearize(c.g) +
0.0722 * linearize(c.b);
}
final l1 = luminance(adjustedColor);
final l2 = luminance(darkBackground);
final hi = l1 > l2 ? l1 : l2;
final lo = l1 > l2 ? l2 : l1;
final ratio = (hi + 0.05) / (lo + 0.05);
expect(ratio, greaterThanOrEqualTo(4.5),
reason: 'Adjusted color should meet WCAG AA 4.5:1 contrast ratio');
});
});
}