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 = {}; final health = {}; final diagnostics = {}; @override Future> ensureSeedProviders() async => const Success(null); @override Future>> 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> getProvider(String id) async { return Success(configs[id]); } @override Future> saveProvider(ContentProviderConfig config) async { configs[config.id] = config; return const Success(null); } @override Future> deleteProvider(String id) async { configs.remove(id); return const Success(null); } @override Future> saveHealth(ProviderHealth value) async { health[value.providerId] = value; return const Success(null); } @override Future> getHealth(String providerId) async { return Success(health[providerId]); } @override Future> 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 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 posts; final bool failSearch; final List suggestions; final Duration suggestionDelay; int suggestionCalls = 0; @override Future checkHealth() async => ProviderHealth( providerId: id, status: ProviderStatus.online, pingMs: 1, lastCheckedAt: DateTime.now(), ); @override Future getPost(String id) async => posts.where((post) => post.id == id).firstOrNull; @override Future> searchPosts({ required List tags, required int page, int limit = 50, String? rating, TopPeriodFilter topPeriod = TopPeriodFilter.none, }) async { if (failSearch) throw Exception('fail'); return posts; } @override Future> suggestTags(String query, {int limit = 20}) async { suggestionCalls++; if (suggestionDelay > Duration.zero) { await Future.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>; 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>; 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>; 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>; 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>; 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>; 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); }); }