2026-10-07 07:32:16 +02:00
|
|
|
import 'dart:convert';
|
|
|
|
|
|
2026-08-09 15:33:06 -07:00
|
|
|
import 'package:flutter_test/flutter_test.dart';
|
2026-10-07 03:06:05 +02:00
|
|
|
import 'package:frosty/apis/base_api_client.dart';
|
2026-08-09 15:33:06 -07:00
|
|
|
import 'package:frosty/apis/twitch_api.dart';
|
2026-10-07 03:06:05 +02:00
|
|
|
import 'package:frosty/apis/twitch_gql_api.dart';
|
2026-10-07 07:32:16 +02:00
|
|
|
import 'package:frosty/apis/twitch_integrity.dart';
|
2026-08-09 15:33:06 -07:00
|
|
|
import 'package:frosty/models/user.dart';
|
|
|
|
|
import 'package:frosty/screens/settings/stores/user_store.dart';
|
|
|
|
|
import 'package:mocktail/mocktail.dart';
|
2026-10-07 03:06:05 +02:00
|
|
|
import 'package:shared_preferences/shared_preferences.dart';
|
2026-08-09 15:33:06 -07:00
|
|
|
|
|
|
|
|
class MockTwitchApi extends Mock implements TwitchApi {}
|
|
|
|
|
|
2026-10-07 03:06:05 +02:00
|
|
|
class MockTwitchGqlApi extends Mock implements TwitchGqlApi {}
|
|
|
|
|
|
2026-08-09 15:33:06 -07:00
|
|
|
void main() {
|
|
|
|
|
late MockTwitchApi api;
|
2026-10-07 03:06:05 +02:00
|
|
|
late MockTwitchGqlApi gqlApi;
|
2026-08-09 15:33:06 -07:00
|
|
|
|
|
|
|
|
setUp(() {
|
2026-10-07 03:06:05 +02:00
|
|
|
SharedPreferences.setMockInitialValues({});
|
2026-08-09 15:33:06 -07:00
|
|
|
api = MockTwitchApi();
|
2026-10-07 03:06:05 +02:00
|
|
|
gqlApi = MockTwitchGqlApi();
|
2026-08-09 15:33:06 -07:00
|
|
|
when(() => api.getUserInfo()).thenAnswer(
|
|
|
|
|
(_) async => const UserTwitch(
|
|
|
|
|
'12345',
|
|
|
|
|
'testuser',
|
|
|
|
|
'TestUser',
|
|
|
|
|
'https://cdn/profile.png',
|
|
|
|
|
),
|
|
|
|
|
);
|
|
|
|
|
when(() => api.getUserBlockedList(id: any(named: 'id'))).thenAnswer(
|
|
|
|
|
(_) async => <UserBlockedTwitch>[],
|
|
|
|
|
);
|
|
|
|
|
when(() => api.getModeratedChannels(id: any(named: 'id'))).thenAnswer(
|
|
|
|
|
(_) async => <String>['999'],
|
|
|
|
|
);
|
|
|
|
|
});
|
|
|
|
|
|
2026-10-07 03:06:05 +02:00
|
|
|
UserStore makeStore({required bool hasModeratorScopes}) => UserStore(
|
|
|
|
|
twitchApi: api,
|
|
|
|
|
twitchGqlApi: gqlApi,
|
|
|
|
|
hasModeratorScopes: () => hasModeratorScopes,
|
|
|
|
|
);
|
|
|
|
|
|
2026-08-09 15:33:06 -07:00
|
|
|
group('UserStore moderated channels gating', () {
|
|
|
|
|
test('init skips moderated-channels without moderator scopes', () async {
|
2026-10-07 03:06:05 +02:00
|
|
|
final userStore = makeStore(hasModeratorScopes: false);
|
2026-08-09 15:33:06 -07:00
|
|
|
|
2026-10-07 03:06:05 +02:00
|
|
|
await userStore.init();
|
2026-08-09 15:33:06 -07:00
|
|
|
await Future<void>.delayed(Duration.zero);
|
|
|
|
|
|
|
|
|
|
verifyNever(() => api.getModeratedChannels(id: any(named: 'id')));
|
2026-10-07 03:06:05 +02:00
|
|
|
expect(userStore.moderatedChannels, isEmpty);
|
2026-08-09 15:33:06 -07:00
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test('init fetches moderated-channels when moderator scopes exist', () async {
|
2026-10-07 03:06:05 +02:00
|
|
|
final userStore = makeStore(hasModeratorScopes: true);
|
2026-08-09 15:33:06 -07:00
|
|
|
|
2026-10-07 03:06:05 +02:00
|
|
|
await userStore.init();
|
2026-08-09 15:33:06 -07:00
|
|
|
await Future<void>.delayed(Duration.zero);
|
|
|
|
|
|
|
|
|
|
verify(() => api.getModeratedChannels(id: '12345')).called(1);
|
2026-10-07 03:06:05 +02:00
|
|
|
expect(userStore.moderatedChannels, ['999']);
|
2026-08-09 15:33:06 -07:00
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test('refreshModeratedChannels no-ops without moderator scopes', () async {
|
2026-10-07 03:06:05 +02:00
|
|
|
final userStore = makeStore(hasModeratorScopes: false);
|
|
|
|
|
await userStore.init();
|
2026-08-09 15:33:06 -07:00
|
|
|
clearInteractions(api);
|
|
|
|
|
|
2026-10-07 03:06:05 +02:00
|
|
|
await userStore.refreshModeratedChannels();
|
2026-08-09 15:33:06 -07:00
|
|
|
verifyNever(() => api.getModeratedChannels(id: any(named: 'id')));
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test('deleteMessage requires moderator scopes', () async {
|
|
|
|
|
when(
|
|
|
|
|
() => api.deleteChatMessage(
|
|
|
|
|
broadcasterId: any(named: 'broadcasterId'),
|
|
|
|
|
moderatorId: any(named: 'moderatorId'),
|
|
|
|
|
messageId: any(named: 'messageId'),
|
|
|
|
|
),
|
|
|
|
|
).thenAnswer((_) async => true);
|
|
|
|
|
|
2026-10-07 03:06:05 +02:00
|
|
|
final userStore = makeStore(hasModeratorScopes: false);
|
|
|
|
|
await userStore.init();
|
2026-08-09 15:33:06 -07:00
|
|
|
|
2026-10-07 03:06:05 +02:00
|
|
|
final result = await userStore.deleteMessage(
|
2026-08-09 15:33:06 -07:00
|
|
|
broadcasterId: '12345',
|
|
|
|
|
messageId: 'msg',
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
expect(result, isFalse);
|
|
|
|
|
verifyNever(
|
|
|
|
|
() => api.deleteChatMessage(
|
|
|
|
|
broadcasterId: any(named: 'broadcasterId'),
|
|
|
|
|
moderatorId: any(named: 'moderatorId'),
|
|
|
|
|
messageId: any(named: 'messageId'),
|
|
|
|
|
),
|
|
|
|
|
);
|
|
|
|
|
});
|
|
|
|
|
});
|
2026-10-07 03:06:05 +02:00
|
|
|
|
|
|
|
|
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);
|
|
|
|
|
});
|
2026-10-07 07:32:16 +02:00
|
|
|
|
|
|
|
|
test('runWithGqlIntegrity refreshes token and retries', () async {
|
|
|
|
|
var calls = 0;
|
|
|
|
|
when(
|
|
|
|
|
() => gqlApi.fetchIntegrityToken(
|
|
|
|
|
authToken: any(named: 'authToken'),
|
|
|
|
|
deviceId: any(named: 'deviceId'),
|
|
|
|
|
),
|
|
|
|
|
).thenAnswer(
|
|
|
|
|
(_) async => (
|
|
|
|
|
token: 'fresh-integrity',
|
|
|
|
|
expirationMs: DateTime.now().millisecondsSinceEpoch + 600000,
|
|
|
|
|
),
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
final userStore = makeStore(hasModeratorScopes: false);
|
|
|
|
|
final result = await userStore.runWithGqlIntegrity<String>(
|
|
|
|
|
gqlToken: 'web-token',
|
|
|
|
|
action: ({authToken, integrityToken, deviceId}) async {
|
|
|
|
|
calls++;
|
|
|
|
|
expect(authToken, 'web-token');
|
|
|
|
|
expect(deviceId, isNotEmpty);
|
|
|
|
|
if (calls == 1) {
|
|
|
|
|
throw const IntegrityCheckException('failed integrity check');
|
|
|
|
|
}
|
|
|
|
|
expect(integrityToken, 'fresh-integrity');
|
|
|
|
|
return 'ok';
|
|
|
|
|
},
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
expect(result, 'ok');
|
|
|
|
|
expect(calls, 2);
|
|
|
|
|
verify(
|
|
|
|
|
() => gqlApi.fetchIntegrityToken(
|
|
|
|
|
authToken: 'web-token',
|
|
|
|
|
deviceId: any(named: 'deviceId'),
|
|
|
|
|
),
|
|
|
|
|
).called(1);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test('runWithGqlIntegrity rethrows when no gqlToken', () async {
|
|
|
|
|
final userStore = makeStore(hasModeratorScopes: false);
|
|
|
|
|
|
|
|
|
|
await expectLater(
|
|
|
|
|
userStore.runWithGqlIntegrity<void>(
|
|
|
|
|
gqlToken: null,
|
|
|
|
|
action: ({authToken, integrityToken, deviceId}) async {
|
|
|
|
|
throw const IntegrityCheckException('failed integrity check');
|
|
|
|
|
},
|
|
|
|
|
),
|
|
|
|
|
throwsA(isA<IntegrityCheckException>()),
|
|
|
|
|
);
|
|
|
|
|
verifyNever(
|
|
|
|
|
() => gqlApi.fetchIntegrityToken(
|
|
|
|
|
authToken: any(named: 'authToken'),
|
|
|
|
|
deviceId: any(named: 'deviceId'),
|
|
|
|
|
),
|
|
|
|
|
);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test('applyWebIntegrity stores device id for later GQL calls', () async {
|
|
|
|
|
final userStore = makeStore(hasModeratorScopes: false);
|
|
|
|
|
await userStore.applyWebIntegrity(
|
|
|
|
|
GqlIntegrityCredentials(
|
|
|
|
|
integrityToken: 'v4.public.${base64Url.encode(utf8.encode(jsonEncode({
|
|
|
|
|
'is_bad_bot': 'false',
|
|
|
|
|
'device_id': 'webview-device',
|
|
|
|
|
})))}.sig',
|
|
|
|
|
deviceId: 'webview-device',
|
|
|
|
|
expirationMs: DateTime.now().millisecondsSinceEpoch + 600000,
|
|
|
|
|
),
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
final deviceId = await userStore.runWithGqlIntegrity<String>(
|
|
|
|
|
gqlToken: 'web-token',
|
|
|
|
|
action: ({authToken, integrityToken, deviceId}) async {
|
|
|
|
|
expect(deviceId, 'webview-device');
|
|
|
|
|
expect(integrityToken, isNotNull);
|
|
|
|
|
return deviceId!;
|
|
|
|
|
},
|
|
|
|
|
);
|
|
|
|
|
expect(deviceId, 'webview-device');
|
|
|
|
|
});
|
2026-10-07 03:06:05 +02:00
|
|
|
});
|
2026-08-09 15:33:06 -07:00
|
|
|
}
|