Fix tag directory sorting and follow Client-Integrity.
Paginate tag (and ASC) lists via GQL with integrity retry; mint follow integrity from a WebView because Dio /integrity tokens are bad_bot. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
parent
383e786479
commit
3fb393166f
13 changed files with 1197 additions and 207 deletions
|
|
@ -1,3 +1,5 @@
|
|||
import 'dart:convert';
|
||||
|
||||
import 'package:dio/dio.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:frosty/apis/base_api_client.dart';
|
||||
|
|
@ -69,4 +71,32 @@ void main() {
|
|||
await api.unfollowUser(targetId: '999', authToken: 'web-token');
|
||||
});
|
||||
});
|
||||
|
||||
group('fetchIntegrityToken', () {
|
||||
test('rejects bad_bot tokens', () async {
|
||||
final payload = base64Url.encode(
|
||||
utf8.encode(
|
||||
jsonEncode({
|
||||
'is_bad_bot': 'true',
|
||||
'device_id': 'dev',
|
||||
'client_id': 'kimne78kx3ncx6brgo4mv6wki5h1ko',
|
||||
}),
|
||||
),
|
||||
);
|
||||
|
||||
dioAdapter.onPost(
|
||||
'https://gql.twitch.tv/integrity',
|
||||
(server) => server.reply(200, {
|
||||
'token': 'v4.public.$payload.sig',
|
||||
'expiration': DateTime.now().millisecondsSinceEpoch + 600000,
|
||||
}),
|
||||
data: Matchers.any,
|
||||
);
|
||||
|
||||
await expectLater(
|
||||
api.fetchIntegrityToken(authToken: 'web', deviceId: 'dev'),
|
||||
throwsA(isA<IntegrityCheckException>()),
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
|
|
|||
129
test/apis/twitch_gql_streams_test.dart
Normal file
129
test/apis/twitch_gql_streams_test.dart
Normal file
|
|
@ -0,0 +1,129 @@
|
|||
import 'package:dio/dio.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:frosty/apis/base_api_client.dart';
|
||||
import 'package:frosty/apis/twitch_gql_api.dart';
|
||||
import 'package:http_mock_adapter/http_mock_adapter.dart';
|
||||
|
||||
void main() {
|
||||
late Dio dio;
|
||||
late DioAdapter dioAdapter;
|
||||
late TwitchGqlApi api;
|
||||
|
||||
setUp(() {
|
||||
dio = Dio(BaseOptions());
|
||||
dioAdapter = DioAdapter(dio: dio);
|
||||
api = TwitchGqlApi(dio);
|
||||
});
|
||||
|
||||
Map<String, Object?> streamEdge({
|
||||
required String id,
|
||||
required String login,
|
||||
required int viewers,
|
||||
String? cursor,
|
||||
}) {
|
||||
return {
|
||||
'cursor': cursor ?? 'cursor-$id',
|
||||
'node': {
|
||||
'id': id,
|
||||
'title': '$login live',
|
||||
'viewersCount': viewers,
|
||||
'createdAt': '2026-01-01T00:00:00Z',
|
||||
'language': 'en',
|
||||
'game': {'id': '1', 'name': 'Game'},
|
||||
'broadcaster': {
|
||||
'id': id,
|
||||
'login': login,
|
||||
'displayName': login,
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
group('getStreams', () {
|
||||
test('parses freeform tag page and cursor', () async {
|
||||
dioAdapter.onPost(
|
||||
'https://gql.twitch.tv/gql',
|
||||
(server) => server.reply(200, {
|
||||
'data': {
|
||||
'streams': {
|
||||
'edges': [
|
||||
streamEdge(id: '1', login: 'a', viewers: 10),
|
||||
streamEdge(id: '2', login: 'b', viewers: 5),
|
||||
],
|
||||
'pageInfo': {'hasNextPage': true},
|
||||
},
|
||||
},
|
||||
}),
|
||||
data: Matchers.any,
|
||||
);
|
||||
|
||||
final page = await api.getStreams(
|
||||
freeformTags: ['Deutsch'],
|
||||
sort: StreamViewerSort.viewersAsc,
|
||||
);
|
||||
|
||||
expect(page.data.map((s) => s.userLogin), ['a', 'b']);
|
||||
expect(page.data.map((s) => s.viewerCount), [10, 5]);
|
||||
expect(page.pagination['cursor'], 'cursor-2');
|
||||
});
|
||||
|
||||
test('throws IntegrityCheckException on integrity errors', () async {
|
||||
dioAdapter.onPost(
|
||||
'https://gql.twitch.tv/gql',
|
||||
(server) => server.reply(200, {
|
||||
'errors': [
|
||||
{'message': 'failed integrity check'},
|
||||
],
|
||||
'data': {'streams': null},
|
||||
}),
|
||||
data: Matchers.any,
|
||||
);
|
||||
|
||||
await expectLater(
|
||||
api.getStreams(
|
||||
freeformTags: ['Deutsch'],
|
||||
cursor: 'page-2',
|
||||
authToken: 'web',
|
||||
integrityToken: 'stale',
|
||||
deviceId: 'device',
|
||||
),
|
||||
throwsA(isA<IntegrityCheckException>()),
|
||||
);
|
||||
});
|
||||
|
||||
test('sends Client-Integrity and X-Device-Id when provided', () async {
|
||||
dioAdapter.onPost(
|
||||
'https://gql.twitch.tv/gql',
|
||||
(server) => server.reply(200, {
|
||||
'data': {
|
||||
'streams': {
|
||||
'edges': [streamEdge(id: '1', login: 'a', viewers: 1)],
|
||||
'pageInfo': {'hasNextPage': false},
|
||||
},
|
||||
},
|
||||
}),
|
||||
data: Matchers.any,
|
||||
headers: {
|
||||
'Client-ID': Matchers.any,
|
||||
'Content-Type': Matchers.any,
|
||||
'User-Agent': Matchers.any,
|
||||
'Authorization': 'OAuth web-token',
|
||||
'X-Device-Id': 'dev-123',
|
||||
'Client-Integrity': 'integrity-token',
|
||||
},
|
||||
);
|
||||
|
||||
final page = await api.getStreams(
|
||||
freeformTags: ['English'],
|
||||
sort: StreamViewerSort.viewersDesc,
|
||||
cursor: 'after-1',
|
||||
authToken: 'web-token',
|
||||
integrityToken: 'integrity-token',
|
||||
deviceId: 'dev-123',
|
||||
);
|
||||
|
||||
expect(page.data, hasLength(1));
|
||||
expect(page.pagination, isEmpty);
|
||||
});
|
||||
});
|
||||
}
|
||||
42
test/apis/twitch_integrity_test.dart
Normal file
42
test/apis/twitch_integrity_test.dart
Normal file
|
|
@ -0,0 +1,42 @@
|
|||
import 'dart:convert';
|
||||
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:frosty/apis/twitch_integrity.dart';
|
||||
|
||||
String _tokenFor(Map<String, Object?> payload) {
|
||||
final encoded = base64Url.encode(utf8.encode(jsonEncode(payload)));
|
||||
return 'v4.public.$encoded.fakesig';
|
||||
}
|
||||
|
||||
void main() {
|
||||
group('TwitchIntegrity', () {
|
||||
test('detects is_bad_bot string flag', () {
|
||||
final token = _tokenFor({
|
||||
'client_id': 'kimne78kx3ncx6brgo4mv6wki5h1ko',
|
||||
'device_id': 'abc123',
|
||||
'is_bad_bot': 'true',
|
||||
'exp': '2099-01-01T00:00:00Z',
|
||||
});
|
||||
|
||||
expect(TwitchIntegrity.isBadBot(token), isTrue);
|
||||
expect(TwitchIntegrity.deviceId(token), 'abc123');
|
||||
expect(TwitchIntegrity.expirationMs(token), isNotNull);
|
||||
});
|
||||
|
||||
test('detects is_bad_bot bool flag', () {
|
||||
final token = _tokenFor({
|
||||
'is_bad_bot': true,
|
||||
'device_id': 'dev',
|
||||
});
|
||||
expect(TwitchIntegrity.isBadBot(token), isTrue);
|
||||
});
|
||||
|
||||
test('returns false when not flagged', () {
|
||||
final token = _tokenFor({
|
||||
'is_bad_bot': 'false',
|
||||
'device_id': 'dev',
|
||||
});
|
||||
expect(TwitchIntegrity.isBadBot(token), isFalse);
|
||||
});
|
||||
});
|
||||
}
|
||||
203
test/screens/home/stream_list/stream_list_store_test.dart
Normal file
203
test/screens/home/stream_list/stream_list_store_test.dart
Normal file
|
|
@ -0,0 +1,203 @@
|
|||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:frosty/apis/base_api_client.dart';
|
||||
import 'package:frosty/apis/twitch_api.dart';
|
||||
import 'package:frosty/apis/twitch_gql_api.dart';
|
||||
import 'package:frosty/models/stream.dart';
|
||||
import 'package:frosty/models/user.dart';
|
||||
import 'package:frosty/screens/home/stream_list/stream_list_store.dart';
|
||||
import 'package:frosty/screens/settings/stores/auth_store.dart';
|
||||
import 'package:frosty/screens/settings/stores/settings_store.dart';
|
||||
import 'package:mocktail/mocktail.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
|
||||
class MockTwitchApi extends Mock implements TwitchApi {}
|
||||
|
||||
class MockTwitchGqlApi extends Mock implements TwitchGqlApi {}
|
||||
|
||||
void main() {
|
||||
setUpAll(() {
|
||||
registerFallbackValue(StreamViewerSort.viewersDesc);
|
||||
registerFallbackValue(<String>[]);
|
||||
});
|
||||
|
||||
late MockTwitchApi api;
|
||||
late MockTwitchGqlApi gqlApi;
|
||||
late AuthStore authStore;
|
||||
late SettingsStore settingsStore;
|
||||
|
||||
StreamTwitch stream(String id, int viewers) => StreamTwitch(
|
||||
id,
|
||||
'user$id',
|
||||
'User$id',
|
||||
'game',
|
||||
'Game',
|
||||
'title',
|
||||
viewers,
|
||||
'2026-01-01T00:00:00Z',
|
||||
'https://cdn/live_user_$id-{width}x{height}.jpg',
|
||||
);
|
||||
|
||||
Future<void> waitUntilIdle(ListStore store) async {
|
||||
await Future<void>.delayed(Duration.zero);
|
||||
for (var i = 0; i < 50 && store.isLoading; i++) {
|
||||
await Future<void>.delayed(const Duration(milliseconds: 5));
|
||||
}
|
||||
}
|
||||
|
||||
setUp(() {
|
||||
SharedPreferences.setMockInitialValues({});
|
||||
api = MockTwitchApi();
|
||||
gqlApi = MockTwitchGqlApi();
|
||||
authStore = AuthStore(twitchApi: api, twitchGqlApi: gqlApi);
|
||||
settingsStore = SettingsStore()..streamLanguages = [];
|
||||
|
||||
when(() => api.getUserInfo()).thenAnswer(
|
||||
(_) async => const UserTwitch(
|
||||
'123',
|
||||
'me',
|
||||
'Me',
|
||||
'https://cdn/me.png',
|
||||
),
|
||||
);
|
||||
when(() => api.getUserBlockedList(id: any(named: 'id'))).thenAnswer(
|
||||
(_) async => <UserBlockedTwitch>[],
|
||||
);
|
||||
});
|
||||
|
||||
group('ListStore tag GQL pagination', () {
|
||||
test('keeps server ASC order across GQL pages', () async {
|
||||
when(
|
||||
() => gqlApi.getStreams(
|
||||
freeformTags: any(named: 'freeformTags'),
|
||||
languages: any(named: 'languages'),
|
||||
sort: any(named: 'sort'),
|
||||
cursor: any(named: 'cursor'),
|
||||
first: any(named: 'first'),
|
||||
authToken: any(named: 'authToken'),
|
||||
integrityToken: any(named: 'integrityToken'),
|
||||
deviceId: any(named: 'deviceId'),
|
||||
),
|
||||
).thenAnswer((invocation) async {
|
||||
final cursor = invocation.namedArguments[#cursor] as String?;
|
||||
if (cursor == null) {
|
||||
return StreamsTwitch(
|
||||
[stream('1', 3), stream('2', 7)],
|
||||
const {'cursor': 'c1'},
|
||||
);
|
||||
}
|
||||
expect(cursor, 'c1');
|
||||
return StreamsTwitch(
|
||||
[stream('3', 12), stream('4', 20)],
|
||||
const {},
|
||||
);
|
||||
});
|
||||
|
||||
settingsStore.streamViewerSort = StreamViewerSort.viewersAsc;
|
||||
|
||||
final store = ListStore(
|
||||
authStore: authStore,
|
||||
settingsStore: settingsStore,
|
||||
twitchApi: api,
|
||||
twitchGqlApi: gqlApi,
|
||||
listType: ListType.tag,
|
||||
freeformTag: 'Deutsch',
|
||||
);
|
||||
|
||||
await waitUntilIdle(store);
|
||||
|
||||
expect(store.streams.map((s) => s.userId), ['1', '2']);
|
||||
expect(store.hasMore, isTrue);
|
||||
|
||||
await store.getStreams();
|
||||
await waitUntilIdle(store);
|
||||
|
||||
expect(store.streams.map((s) => s.userId), ['1', '2', '3', '4']);
|
||||
expect(store.streams.map((s) => s.viewerCount), [3, 7, 12, 20]);
|
||||
expect(store.hasMore, isFalse);
|
||||
verifyNever(
|
||||
() => api.getDirectoryStreams(
|
||||
languages: any(named: 'languages'),
|
||||
gameId: any(named: 'gameId'),
|
||||
freeformTag: any(named: 'freeformTag'),
|
||||
cursor: any(named: 'cursor'),
|
||||
minCount: any(named: 'minCount'),
|
||||
maxPages: any(named: 'maxPages'),
|
||||
),
|
||||
);
|
||||
|
||||
store.dispose();
|
||||
});
|
||||
|
||||
test('falls back to Helix when GQL page-2 integrity fails', () async {
|
||||
var gqlCalls = 0;
|
||||
when(
|
||||
() => gqlApi.getStreams(
|
||||
freeformTags: any(named: 'freeformTags'),
|
||||
languages: any(named: 'languages'),
|
||||
sort: any(named: 'sort'),
|
||||
cursor: any(named: 'cursor'),
|
||||
first: any(named: 'first'),
|
||||
authToken: any(named: 'authToken'),
|
||||
integrityToken: any(named: 'integrityToken'),
|
||||
deviceId: any(named: 'deviceId'),
|
||||
),
|
||||
).thenAnswer((invocation) async {
|
||||
gqlCalls++;
|
||||
final cursor = invocation.namedArguments[#cursor] as String?;
|
||||
if (cursor == null) {
|
||||
return StreamsTwitch(
|
||||
[stream('1', 100)],
|
||||
const {'cursor': 'c1'},
|
||||
);
|
||||
}
|
||||
throw const IntegrityCheckException('failed integrity check');
|
||||
});
|
||||
|
||||
when(
|
||||
() => api.getDirectoryStreams(
|
||||
languages: any(named: 'languages'),
|
||||
gameId: any(named: 'gameId'),
|
||||
freeformTag: any(named: 'freeformTag'),
|
||||
cursor: any(named: 'cursor'),
|
||||
minCount: any(named: 'minCount'),
|
||||
maxPages: any(named: 'maxPages'),
|
||||
),
|
||||
).thenAnswer(
|
||||
(_) async => StreamsTwitch(
|
||||
[stream('1', 100), stream('9', 50)],
|
||||
const {},
|
||||
),
|
||||
);
|
||||
|
||||
final store = ListStore(
|
||||
authStore: authStore,
|
||||
settingsStore: settingsStore,
|
||||
twitchApi: api,
|
||||
twitchGqlApi: gqlApi,
|
||||
listType: ListType.tag,
|
||||
freeformTag: 'Deutsch',
|
||||
);
|
||||
|
||||
await waitUntilIdle(store);
|
||||
expect(store.streams.map((s) => s.userId), ['1']);
|
||||
|
||||
await store.getStreams();
|
||||
await waitUntilIdle(store);
|
||||
|
||||
expect(gqlCalls, greaterThanOrEqualTo(2));
|
||||
expect(store.streams.map((s) => s.userId), ['1', '9']);
|
||||
verify(
|
||||
() => api.getDirectoryStreams(
|
||||
languages: any(named: 'languages'),
|
||||
gameId: any(named: 'gameId'),
|
||||
freeformTag: any(named: 'freeformTag'),
|
||||
cursor: any(named: 'cursor'),
|
||||
minCount: any(named: 'minCount'),
|
||||
maxPages: any(named: 'maxPages'),
|
||||
),
|
||||
).called(1);
|
||||
|
||||
store.dispose();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
|
@ -1,7 +1,10 @@
|
|||
import 'dart:convert';
|
||||
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:frosty/apis/base_api_client.dart';
|
||||
import 'package:frosty/apis/twitch_api.dart';
|
||||
import 'package:frosty/apis/twitch_gql_api.dart';
|
||||
import 'package:frosty/apis/twitch_integrity.dart';
|
||||
import 'package:frosty/models/user.dart';
|
||||
import 'package:frosty/screens/settings/stores/user_store.dart';
|
||||
import 'package:mocktail/mocktail.dart';
|
||||
|
|
@ -178,5 +181,88 @@ void main() {
|
|||
|
||||
expect(userStore.cachedIsFollowing('999'), isFalse);
|
||||
});
|
||||
|
||||
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');
|
||||
});
|
||||
});
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue