11import 'package:aimi_app/services/anime_service.dart' ;
2- import 'package:aimi_app/services/caching_service.dart' ;
3- import 'package:aimi_lib/aimi_lib.dart' as lib;
42import 'package:flutter_test/flutter_test.dart' ;
53
6- class FakeMetadataProvider implements lib.IMetadataProvider {
7- final String _name;
8- final Map <int , lib.Media > _animeMap = {};
9-
10- FakeMetadataProvider (this ._name);
11-
12- void addAnime (lib.Media anime) {
13- _animeMap[anime.id] = anime;
14- }
15-
16- @override
17- String get name => _name;
18-
19- @override
20- String get version => '1.0.0' ;
21-
22- @override
23- Future <lib.Media > fetchAnimeById (int id) async {
24- if (_animeMap.containsKey (id)) {
25- return _animeMap[id]! ;
26- }
27- throw Exception ('Anime $id not found in $_name ' );
28- }
29-
30- @override
31- Future <List <lib.Media >> fetchTrending ({int page = 1 }) async {
32- return _animeMap.values.toList ();
33- }
34-
35- @override
36- Future <List <lib.Media >> searchAnime (String query, {int page = 1 }) async {
37- return _animeMap.values
38- .where ((a) => a.title.english? .contains (query) ?? false )
39- .toList ();
40- }
41-
42- @override
43- void dispose () {}
44- }
45-
46- class FakeCachingService extends CachingService {
47- @override
48- Future <void > saveData ({
49- CacheKey ? cacheKey,
50- String ? dynamicKey,
51- required dynamic data,
52- Duration ? expiresIn,
53- String ? providerName,
54- }) async {}
55-
56- @override
57- Future <dynamic > getData ({
58- CacheKey ? cacheKey,
59- String ? dynamicKey,
60- String ? providerName,
61- }) async {
62- return null ;
63- }
64- }
4+ import 'test_helpers.dart' ;
655
666void main () {
67- group ('AnimeService Multi-Provider ' , () {
7+ group ('AnimeService' , () {
688 late AnimeService service;
699 late FakeMetadataProvider providerA;
7010 late FakeMetadataProvider providerB;
@@ -75,66 +15,256 @@ void main() {
7515 providerB = FakeMetadataProvider ('ProviderB' );
7616 cachingService = FakeCachingService ();
7717
78- // Add dummy data
18+ // Add test data
19+ providerA.addAnime (TestAnimeFactory .createMedia (id: 1 , englishTitle: 'Anime A' , romajiTitle: 'Anime A Romaji' ));
7920 providerA.addAnime (
80- lib.Media (
81- id: 1 ,
82- title: lib.AnimeTitle (english: 'Anime A' , native : '' ),
83- type: 'TV' ,
84- status: 'FINISHED' ,
85- description: '' ,
86- countryOfOrigin: 'JP' ,
87- updatedAt: 0 ,
88- coverImage: lib.CoverImage (extraLarge: '' , large: '' ),
89- siteUrl: '' ,
90- ),
21+ TestAnimeFactory .createMedia (id: 2 , englishTitle: 'Another Anime A' , romajiTitle: 'Another A Romaji' ),
9122 );
9223
93- providerB.addAnime (
94- lib.Media (
95- id: 1 , // Same ID but different content/context
96- title: lib.AnimeTitle (english: 'Anime B' , native : '' ),
97- type: 'TV' ,
98- status: 'FINISHED' ,
99- description: '' ,
100- countryOfOrigin: 'JP' ,
101- updatedAt: 0 ,
102- coverImage: lib.CoverImage (extraLarge: '' , large: '' ),
103- siteUrl: '' ,
104- ),
105- );
24+ providerB.addAnime (TestAnimeFactory .createMedia (id: 1 , englishTitle: 'Anime B' , romajiTitle: 'Anime B Romaji' ));
10625
107- service = AnimeService (
108- [providerA, providerB],
109- cachingService,
110- defaultProviderName: 'ProviderA' ,
111- );
26+ service = AnimeService ([providerA, providerB], cachingService, defaultProviderName: 'ProviderA' );
11227 });
11328
114- test ('getById uses default provider when no name specified' , () async {
115- final anime = await service.getById (1 );
116- expect (anime.title.english, 'Anime A' );
29+ tearDown (() {
30+ cachingService.clear ();
11731 });
11832
119- test ('getById uses specified provider' , () async {
120- final anime = await service.getById (1 , providerName: 'ProviderB' );
121- expect (anime.title.english, 'Anime B' );
33+ // =========================================================================
34+ // Construction Tests
35+ // =========================================================================
36+ group ('Construction' , () {
37+ test ('throws if no providers are provided' , () {
38+ expect (
39+ () => AnimeService ([], cachingService),
40+ throwsA (isA <Exception >().having ((e) => e.toString (), 'message' , contains ('At least one provider' ))),
41+ );
42+ });
43+
44+ test ('throws if default provider not found' , () {
45+ expect (
46+ () => AnimeService ([providerA], cachingService, defaultProviderName: 'NonExistent' ),
47+ throwsA (isA <Exception >().having ((e) => e.toString (), 'message' , contains ('Default provider' ))),
48+ );
49+ });
50+
51+ test ('uses first provider as default when not specified' , () {
52+ final svc = AnimeService ([providerB, providerA], cachingService);
53+ expect (svc.providerName, 'ProviderB' );
54+ });
12255 });
12356
124- test ('getById throws if provider not found' , () async {
125- expect (
126- () => service.getById (1 , providerName: 'UnknownProvider' ),
127- throwsException,
128- );
57+ // =========================================================================
58+ // Provider Selection Tests
59+ // =========================================================================
60+ group ('Provider Selection' , () {
61+ test ('providerName returns default provider name' , () {
62+ expect (service.providerName, 'ProviderA' );
63+ });
64+
65+ test ('getById uses default provider when no name specified' , () async {
66+ final anime = await service.getById (1 );
67+ expect (anime.title.english, 'Anime A' );
68+ });
69+
70+ test ('getById uses specified provider' , () async {
71+ final anime = await service.getById (1 , providerName: 'ProviderB' );
72+ expect (anime.title.english, 'Anime B' );
73+ });
74+
75+ test ('getById throws if provider not found' , () async {
76+ expect (() => service.getById (1 , providerName: 'UnknownProvider' ), throwsException);
77+ });
78+ });
79+
80+ // =========================================================================
81+ // Caching Tests
82+ // =========================================================================
83+ group ('Caching' , () {
84+ test ('fetchTrending caches results' , () async {
85+ // First call - should fetch from provider
86+ await service.fetchTrending ();
87+
88+ // Verify data was cached
89+ expect (cachingService.containsKey ('trendingAnime' , providerName: 'ProviderA' ), isTrue);
90+ });
91+
92+ test ('fetchTrending returns cached data on subsequent calls' , () async {
93+ // First call
94+ final first = await service.fetchTrending ();
95+
96+ // Modify provider data
97+ providerA.addAnime (TestAnimeFactory .createMedia (id: 99 , englishTitle: 'New Anime' ));
98+
99+ // Second call should return cached data (no new anime)
100+ final second = await service.fetchTrending ();
101+
102+ expect (first.length, second.length);
103+ });
104+
105+ test ('fetchTrending forceRefresh bypasses cache' , () async {
106+ // First call
107+ await service.fetchTrending ();
108+
109+ // Modify provider data
110+ providerA.addAnime (TestAnimeFactory .createMedia (id: 99 , englishTitle: 'New Anime' ));
111+
112+ // Force refresh should get new data
113+ final refreshed = await service.fetchTrending (forceRefresh: true );
114+ expect (refreshed.any ((a) => a.id == 99 ), isTrue);
115+ });
116+
117+ test ('getById caches individual anime' , () async {
118+ await service.getById (1 );
119+
120+ expect (cachingService.containsKey ('anime_details/1' , providerName: 'ProviderA' ), isTrue);
121+ });
122+
123+ test ('getById forceRefresh bypasses cache' , () async {
124+ // First call
125+ await service.getById (1 );
126+
127+ // Second call with forceRefresh
128+ final refreshed = await service.getById (1 , forceRefresh: true );
129+ expect (refreshed.title.english, 'Anime A' );
130+ });
131+ });
132+
133+ // =========================================================================
134+ // Pagination Tests
135+ // =========================================================================
136+ group ('Pagination' , () {
137+ test ('fetchTrending page 2 appends to cache' , () async {
138+ // First page
139+ await service.fetchTrending (page: 1 );
140+
141+ // Simulate provider returning different data for page 2
142+ providerA.setAnimeList ([TestAnimeFactory .createMedia (id: 3 , englishTitle: 'Page 2 Anime' )]);
143+
144+ // Second page
145+ await service.fetchTrending (page: 2 , forceRefresh: true );
146+
147+ // Verify page 2 data was fetched
148+ // (In real scenario, cache would be appended)
149+ });
129150 });
130151
131- test ('fetchTrending uses default provider' , () async {
132- final trending = await service.fetchTrending ();
133- expect (trending.first.title.english, 'Anime A' );
152+ // =========================================================================
153+ // Search Tests
154+ // =========================================================================
155+ group ('Search' , () {
156+ test ('search returns matching anime' , () async {
157+ final results = await service.search ('Anime A' );
158+ expect (results, isNotEmpty);
159+ expect (results.first.title.english, contains ('Anime A' ));
160+ });
161+
162+ test ('search returns empty list for no matches' , () async {
163+ final results = await service.search ('NonExistent' );
164+ expect (results, isEmpty);
165+ });
166+
167+ test ('search is case insensitive' , () async {
168+ final results = await service.search ('anime a' );
169+ expect (results, isNotEmpty);
170+ });
134171 });
135172
136- test ('providerName returns default provider name' , () {
137- expect (service.providerName, 'ProviderA' );
173+ // =========================================================================
174+ // Search History Tests
175+ // =========================================================================
176+ group ('Search History' , () {
177+ test ('getSearchHistory returns empty list initially' , () async {
178+ final history = await service.getSearchHistory ();
179+ expect (history, isEmpty);
180+ });
181+
182+ test ('addToSearchHistory adds query to history' , () async {
183+ await service.addToSearchHistory ('test query' );
184+
185+ final history = await service.getSearchHistory ();
186+ expect (history, contains ('test query' ));
187+ });
188+
189+ test ('addToSearchHistory moves duplicate to top' , () async {
190+ await service.addToSearchHistory ('first' );
191+ await service.addToSearchHistory ('second' );
192+ await service.addToSearchHistory ('first' ); // Should move to top
193+
194+ final history = await service.getSearchHistory ();
195+ expect (history.first, 'first' );
196+ expect (history.length, 2 );
197+ });
198+
199+ test ('addToSearchHistory limits to 20 items' , () async {
200+ for (int i = 0 ; i < 25 ; i++ ) {
201+ await service.addToSearchHistory ('query $i ' );
202+ }
203+
204+ final history = await service.getSearchHistory ();
205+ expect (history.length, 20 );
206+ expect (history.first, 'query 24' ); // Most recent
207+ });
208+
209+ test ('addToSearchHistory ignores empty queries' , () async {
210+ await service.addToSearchHistory ('' );
211+ await service.addToSearchHistory (' ' );
212+
213+ final history = await service.getSearchHistory ();
214+ expect (history, isEmpty);
215+ });
216+
217+ test ('removeFromSearchHistory removes specific query' , () async {
218+ await service.addToSearchHistory ('keep' );
219+ await service.addToSearchHistory ('remove' );
220+
221+ await service.removeFromSearchHistory ('remove' );
222+
223+ final history = await service.getSearchHistory ();
224+ expect (history, contains ('keep' ));
225+ expect (history, isNot (contains ('remove' )));
226+ });
227+
228+ test ('clearSearchHistory removes all history' , () async {
229+ await service.addToSearchHistory ('one' );
230+ await service.addToSearchHistory ('two' );
231+
232+ await service.clearSearchHistory ();
233+
234+ final history = await service.getSearchHistory ();
235+ expect (history, isEmpty);
236+ });
237+ });
238+
239+ // =========================================================================
240+ // Error Handling Tests
241+ // =========================================================================
242+ group ('Error Handling' , () {
243+ test ('getById throws when anime not found' , () async {
244+ expect (() => service.getById (999 ), throwsException);
245+ });
246+
247+ test ('getById propagates provider errors' , () async {
248+ providerA.shouldThrow = true ;
249+ providerA.errorMessage = 'Provider error' ;
250+
251+ expect (
252+ () => service.getById (1 ),
253+ throwsA (isA <Exception >().having ((e) => e.toString (), 'message' , contains ('Provider error' ))),
254+ );
255+ });
256+
257+ test ('fetchTrending propagates provider errors' , () async {
258+ providerA.shouldThrow = true ;
259+
260+ expect (() => service.fetchTrending (forceRefresh: true ), throwsException);
261+ });
262+
263+ test ('search propagates provider errors' , () async {
264+ providerA.shouldThrow = true ;
265+
266+ expect (() => service.search ('test' ), throwsException);
267+ });
138268 });
139269 });
140270}
0 commit comments