Files
Prisma/test/backend/provider_manager_test.dart
T

430 lines
12 KiB
Dart

import 'package:flutter_test/flutter_test.dart';
import 'package:gel_rule_app/backend/models/content_provider_config.dart';
import 'package:gel_rule_app/backend/models/post.dart';
import 'package:gel_rule_app/backend/models/provider_diagnostics.dart';
import 'package:gel_rule_app/backend/models/provider_health.dart';
import 'package:gel_rule_app/backend/models/tag_suggestion.dart';
import 'package:gel_rule_app/backend/models/top_period_filter.dart';
import 'package:gel_rule_app/backend/providers/content_provider.dart';
import 'package:gel_rule_app/backend/providers/provider_factory.dart';
import 'package:gel_rule_app/backend/providers/provider_manager.dart';
import 'package:gel_rule_app/backend/repositories/provider_repository.dart';
import 'package:gel_rule_app/core/utils/result.dart';
class FakeProviderRepository implements ProviderRepository {
final configs = <String, ContentProviderConfig>{};
final health = <String, ProviderHealth>{};
final diagnostics = <String, ProviderDiagnostics>{};
@override
Future<Result<void>> ensureSeedProviders() async => const Success(null);
@override
Future<Result<List<ContentProviderConfig>>> getProviders({
bool enabledOnly = false,
}) async {
final values = configs.values
.where((config) => !enabledOnly || config.enabled)
.toList()
..sort((a, b) => a.priority.compareTo(b.priority));
return Success(values);
}
@override
Future<Result<ContentProviderConfig?>> getProvider(String id) async {
return Success(configs[id]);
}
@override
Future<Result<void>> saveProvider(ContentProviderConfig config) async {
configs[config.id] = config;
return const Success(null);
}
@override
Future<Result<void>> deleteProvider(String id) async {
configs.remove(id);
return const Success(null);
}
@override
Future<Result<void>> saveHealth(ProviderHealth value) async {
health[value.providerId] = value;
return const Success(null);
}
@override
Future<Result<ProviderHealth?>> getHealth(String providerId) async {
return Success(health[providerId]);
}
@override
Future<Result<void>> saveDiagnostics(ProviderDiagnostics value) async {
diagnostics[value.providerId] = value;
return const Success(null);
}
@override
dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation);
}
class FakeProviderFactory extends ProviderFactory {
FakeProviderFactory(this.providers);
final Map<String, ContentProvider> providers;
@override
ContentProvider create(ContentProviderConfig config) => providers[config.id]!;
}
class FakeProvider implements ContentProvider, TagSuggestionProvider {
FakeProvider(
this.id,
this.name,
this.posts, {
this.failSearch = false,
this.suggestions = const [],
this.suggestionDelay = Duration.zero,
});
@override
final String id;
@override
final String name;
@override
String get baseUrl => 'https://example.test';
final List<Post> posts;
final bool failSearch;
final List<TagSuggestion> suggestions;
final Duration suggestionDelay;
int suggestionCalls = 0;
@override
Future<ProviderHealth> checkHealth() async => ProviderHealth(
providerId: id,
status: ProviderStatus.online,
pingMs: 1,
lastCheckedAt: DateTime.now(),
);
@override
Future<Post?> getPost(String id) async =>
posts.where((post) => post.id == id).firstOrNull;
@override
Future<List<Post>> searchPosts({
required List<String> tags,
required int page,
int limit = 50,
String? rating,
TopPeriodFilter topPeriod = TopPeriodFilter.none,
}) async {
if (failSearch) throw Exception('fail');
return posts;
}
@override
Future<List<TagSuggestion>> suggestTags(String query,
{int limit = 20}) async {
suggestionCalls++;
if (suggestionDelay > Duration.zero) {
await Future<void>.delayed(suggestionDelay);
}
return suggestions
.where((suggestion) => suggestion.name.startsWith(query))
.take(limit)
.toList(growable: false);
}
}
ContentProviderConfig config(String id, int priority, {bool enabled = true}) {
final now = DateTime.now();
return ContentProviderConfig(
id: id,
name: id,
baseUrl: 'https://example.test',
apiType: 'fake',
enabled: enabled,
priority: priority,
timeoutSeconds: 10,
customHeaders: const {},
createdAt: now,
updatedAt: now,
);
}
Post post(String providerId, String id) => Post(
id: id,
providerId: providerId,
providerName: providerId,
previewUrl: '',
sampleUrl: '',
fileUrl: '',
tags: const [],
rating: 'safe',
width: 0,
height: 0,
createdAt: DateTime.now(),
fileType: 'unknown',
score: 0,
);
void main() {
test('active providers are sorted by priority', () async {
final repository = FakeProviderRepository()
..configs['b'] = config('b', 2)
..configs['a'] = config('a', 1);
final manager = ProviderManager(
repository,
FakeProviderFactory({
'a': FakeProvider('a', 'a', []),
'b': FakeProvider('b', 'b', []),
}),
);
final result =
await manager.activeProviders() as Success<List<ContentProvider>>;
expect(result.data.map((provider) => provider.id), ['a', 'b']);
});
test('saved offline health does not block enabled provider retry', () async {
final repository = FakeProviderRepository()
..configs['a'] = config('a', 0)
..configs['b'] = config('b', 1)
..configs['c'] = config('c', 2)
..health['b'] = ProviderHealth(
providerId: 'b',
status: ProviderStatus.offline,
pingMs: 0,
lastCheckedAt: DateTime.now(),
);
final manager = ProviderManager(
repository,
FakeProviderFactory({
'a': FakeProvider('a', 'a', [post('a', '1')]),
'b': FakeProvider('b', 'b', [post('b', '1')]),
'c': FakeProvider('c', 'c', [], failSearch: true),
}),
);
final result = await manager.searchAcrossProviders(tags: [], page: 0)
as Success<List<Post>>;
expect(result.data.map((item) => item.providerId).toSet(), {'a', 'b'});
expect(repository.health['c']?.status, ProviderStatus.offline);
expect(repository.diagnostics['a']?.lastResultCount, 1);
expect(repository.diagnostics['c']?.lastErrorMessage, 'Search failed');
});
test('all providers results are naturally mixed without provider blocks',
() async {
final repository = FakeProviderRepository()
..configs['a'] = config('a', 0)
..configs['b'] = config('b', 1)
..configs['c'] = config('c', 2);
final manager = ProviderManager(
repository,
FakeProviderFactory({
'a': FakeProvider('a', 'a', [post('a', '1'), post('a', '2')]),
'b': FakeProvider('b', 'b', [post('b', '1'), post('b', '2')]),
'c': FakeProvider('c', 'c', [post('c', '1')]),
}),
);
final result = await manager.searchAcrossProviders(tags: [], page: 0)
as Success<List<Post>>;
final keys = result.data.map((item) => item.cacheKey).toList();
expect(keys.toSet(), {'a:1', 'a:2', 'b:1', 'b:2', 'c:1'});
expect(keys, isNot(['a:1', 'a:2', 'b:1', 'b:2', 'c:1']));
});
test('enable disable provider persists config', () async {
final repository = FakeProviderRepository()..configs['a'] = config('a', 0);
final manager = ProviderManager(
repository,
FakeProviderFactory({'a': FakeProvider('a', 'a', [])}),
);
await manager.enableProvider('a', false);
expect(repository.configs['a']!.enabled, isFalse);
});
test('tag suggestions query every active suggestion provider before limiting',
() async {
final repository = FakeProviderRepository()
..configs['gelbooru'] = config('gelbooru', 0)
..configs['e621'] = config('e621', 1)
..configs['e926'] = config('e926', 2);
final manager = ProviderManager(
repository,
FakeProviderFactory({
'gelbooru': FakeProvider(
'gelbooru',
'Gelbooru',
[],
suggestions: [
for (var i = 0; i < 4; i++)
TagSuggestion(
name: 'cat_$i',
category: TagCategory.general,
postCount: 100 - i,
providerId: 'gelbooru',
),
],
),
'e621': FakeProvider(
'e621',
'e621',
[],
suggestions: const [
TagSuggestion(
name: 'cat_tail',
category: TagCategory.general,
postCount: 1000,
providerId: 'e621',
),
],
),
'e926': FakeProvider(
'e926',
'e926',
[],
suggestions: const [
TagSuggestion(
name: 'cat_ears',
category: TagCategory.general,
postCount: 900,
providerId: 'e926',
),
],
),
}),
);
final result = await manager.suggestTags('cat', limit: 4)
as Success<List<TagSuggestion>>;
expect(result.data.map((item) => item.providerId).toSet(), {
'gelbooru',
'e621',
'e926',
});
});
test('tag suggestions timeout slow providers without blocking fast results',
() async {
final repository = FakeProviderRepository()
..configs['fast'] = config('fast', 0)
..configs['slow'] = config('slow', 1);
final fast = FakeProvider(
'fast',
'fast',
[],
suggestions: const [
TagSuggestion(
name: 'cat_fast',
category: TagCategory.general,
postCount: 20,
providerId: 'fast',
),
],
);
final slow = FakeProvider(
'slow',
'slow',
[],
suggestionDelay: const Duration(seconds: 3),
suggestions: const [
TagSuggestion(
name: 'cat_slow',
category: TagCategory.general,
postCount: 200,
providerId: 'slow',
),
],
);
final manager = ProviderManager(
repository,
FakeProviderFactory({'fast': fast, 'slow': slow}),
);
final startedAt = DateTime.now();
final result = await manager.suggestTags('cat', limit: 4)
as Success<List<TagSuggestion>>;
expect(DateTime.now().difference(startedAt),
lessThan(const Duration(milliseconds: 2500)));
expect(result.data.map((item) => item.name), ['cat_fast']);
});
test('tag suggestions dedupe by name and keep highest post count', () async {
final repository = FakeProviderRepository()
..configs['a'] = config('a', 0)
..configs['b'] = config('b', 1);
final manager = ProviderManager(
repository,
FakeProviderFactory({
'a': FakeProvider(
'a',
'a',
[],
suggestions: const [
TagSuggestion(
name: 'cat',
category: TagCategory.general,
postCount: 10,
providerId: 'a',
),
],
),
'b': FakeProvider(
'b',
'b',
[],
suggestions: const [
TagSuggestion(
name: 'cat',
category: TagCategory.artist,
postCount: 100,
providerId: 'b',
),
],
),
}),
);
final result = await manager.suggestTags('cat', limit: 4)
as Success<List<TagSuggestion>>;
expect(result.data, hasLength(1));
expect(result.data.single.providerId, 'b');
expect(result.data.single.postCount, 100);
});
test('tag suggestions cache repeated prefix results', () async {
final repository = FakeProviderRepository()..configs['a'] = config('a', 0);
final provider = FakeProvider(
'a',
'a',
[],
suggestions: const [
TagSuggestion(
name: 'cat',
category: TagCategory.general,
postCount: 10,
providerId: 'a',
),
],
);
final manager = ProviderManager(
repository,
FakeProviderFactory({'a': provider}),
);
await manager.suggestTags('cat', limit: 4);
await manager.suggestTags('cat', limit: 4);
expect(provider.suggestionCalls, 1);
});
}