Main coves client
1import 'package:coves_flutter/models/post.dart';
2import 'package:coves_flutter/providers/auth_provider.dart';
3import 'package:coves_flutter/providers/feed_provider.dart';
4import 'package:coves_flutter/services/coves_api_service.dart';
5import 'package:flutter_test/flutter_test.dart';
6import 'package:mockito/annotations.dart';
7import 'package:mockito/mockito.dart';
8
9import 'feed_provider_test.mocks.dart';
10
11// Generate mocks
12@GenerateMocks([AuthProvider, CovesApiService])
13void main() {
14 group('FeedProvider', () {
15 late FeedProvider feedProvider;
16 late MockAuthProvider mockAuthProvider;
17 late MockCovesApiService mockApiService;
18
19 setUp(() {
20 mockAuthProvider = MockAuthProvider();
21 mockApiService = MockCovesApiService();
22
23 // Mock default auth state
24 when(mockAuthProvider.isAuthenticated).thenReturn(false);
25
26 // Mock the token getter
27 when(
28 mockAuthProvider.getAccessToken(),
29 ).thenAnswer((_) async => 'test-token');
30
31 // Create feed provider with injected mock service
32 feedProvider = FeedProvider(mockAuthProvider, apiService: mockApiService);
33 });
34
35 tearDown(() {
36 feedProvider.dispose();
37 });
38
39 group('loadFeed', () {
40 test('should load timeline when authenticated', () async {
41 when(mockAuthProvider.isAuthenticated).thenReturn(true);
42
43 final mockResponse = TimelineResponse(
44 feed: [_createMockPost()],
45 cursor: 'next-cursor',
46 );
47
48 when(
49 mockApiService.getTimeline(
50 sort: anyNamed('sort'),
51 timeframe: anyNamed('timeframe'),
52 limit: anyNamed('limit'),
53 cursor: anyNamed('cursor'),
54 ),
55 ).thenAnswer((_) async => mockResponse);
56
57 await feedProvider.loadFeed(refresh: true);
58
59 expect(feedProvider.posts.length, 1);
60 expect(feedProvider.error, null);
61 expect(feedProvider.isLoading, false);
62 });
63
64 test('should load discover feed when not authenticated', () async {
65 when(mockAuthProvider.isAuthenticated).thenReturn(false);
66
67 final mockResponse = TimelineResponse(
68 feed: [_createMockPost()],
69 cursor: 'next-cursor',
70 );
71
72 when(
73 mockApiService.getDiscover(
74 sort: anyNamed('sort'),
75 timeframe: anyNamed('timeframe'),
76 limit: anyNamed('limit'),
77 cursor: anyNamed('cursor'),
78 ),
79 ).thenAnswer((_) async => mockResponse);
80
81 await feedProvider.loadFeed(refresh: true);
82
83 expect(feedProvider.posts.length, 1);
84 expect(feedProvider.error, null);
85 });
86 });
87
88 group('fetchTimeline', () {
89 test('should fetch timeline successfully', () async {
90 final mockResponse = TimelineResponse(
91 feed: [_createMockPost(), _createMockPost()],
92 cursor: 'next-cursor',
93 );
94
95 when(
96 mockApiService.getTimeline(
97 sort: anyNamed('sort'),
98 timeframe: anyNamed('timeframe'),
99 limit: anyNamed('limit'),
100 cursor: anyNamed('cursor'),
101 ),
102 ).thenAnswer((_) async => mockResponse);
103
104 await feedProvider.fetchTimeline(refresh: true);
105
106 expect(feedProvider.posts.length, 2);
107 expect(feedProvider.hasMore, true);
108 expect(feedProvider.error, null);
109 });
110
111 test('should handle network errors', () async {
112 when(
113 mockApiService.getTimeline(
114 sort: anyNamed('sort'),
115 timeframe: anyNamed('timeframe'),
116 limit: anyNamed('limit'),
117 cursor: anyNamed('cursor'),
118 ),
119 ).thenThrow(Exception('Network error'));
120
121 await feedProvider.fetchTimeline(refresh: true);
122
123 expect(feedProvider.error, isNotNull);
124 expect(feedProvider.isLoading, false);
125 });
126
127 test('should append posts when not refreshing', () async {
128 // First load
129 final firstResponse = TimelineResponse(
130 feed: [_createMockPost()],
131 cursor: 'cursor-1',
132 );
133
134 when(
135 mockApiService.getTimeline(
136 sort: anyNamed('sort'),
137 timeframe: anyNamed('timeframe'),
138 limit: anyNamed('limit'),
139 cursor: anyNamed('cursor'),
140 ),
141 ).thenAnswer((_) async => firstResponse);
142
143 await feedProvider.fetchTimeline(refresh: true);
144 expect(feedProvider.posts.length, 1);
145
146 // Second load (pagination)
147 final secondResponse = TimelineResponse(
148 feed: [_createMockPost()],
149 cursor: 'cursor-2',
150 );
151
152 when(
153 mockApiService.getTimeline(
154 sort: anyNamed('sort'),
155 timeframe: anyNamed('timeframe'),
156 limit: anyNamed('limit'),
157 cursor: 'cursor-1',
158 ),
159 ).thenAnswer((_) async => secondResponse);
160
161 await feedProvider.fetchTimeline();
162 expect(feedProvider.posts.length, 2);
163 });
164
165 test('should replace posts when refreshing', () async {
166 // First load
167 final firstResponse = TimelineResponse(
168 feed: [_createMockPost()],
169 cursor: 'cursor-1',
170 );
171
172 when(
173 mockApiService.getTimeline(
174 sort: anyNamed('sort'),
175 timeframe: anyNamed('timeframe'),
176 limit: anyNamed('limit'),
177 cursor: anyNamed('cursor'),
178 ),
179 ).thenAnswer((_) async => firstResponse);
180
181 await feedProvider.fetchTimeline(refresh: true);
182 expect(feedProvider.posts.length, 1);
183
184 // Refresh
185 final refreshResponse = TimelineResponse(
186 feed: [_createMockPost(), _createMockPost()],
187 cursor: 'cursor-2',
188 );
189
190 when(
191 mockApiService.getTimeline(
192 sort: anyNamed('sort'),
193 timeframe: anyNamed('timeframe'),
194 limit: anyNamed('limit'),
195 ),
196 ).thenAnswer((_) async => refreshResponse);
197
198 await feedProvider.fetchTimeline(refresh: true);
199 expect(feedProvider.posts.length, 2);
200 });
201
202 test('should set hasMore to false when no cursor', () async {
203 final response = TimelineResponse(feed: [_createMockPost()]);
204
205 when(
206 mockApiService.getTimeline(
207 sort: anyNamed('sort'),
208 timeframe: anyNamed('timeframe'),
209 limit: anyNamed('limit'),
210 cursor: anyNamed('cursor'),
211 ),
212 ).thenAnswer((_) async => response);
213
214 await feedProvider.fetchTimeline(refresh: true);
215
216 expect(feedProvider.hasMore, false);
217 });
218 });
219
220 group('fetchDiscover', () {
221 test('should fetch discover feed successfully', () async {
222 final mockResponse = TimelineResponse(
223 feed: [_createMockPost()],
224 cursor: 'next-cursor',
225 );
226
227 when(
228 mockApiService.getDiscover(
229 sort: anyNamed('sort'),
230 timeframe: anyNamed('timeframe'),
231 limit: anyNamed('limit'),
232 cursor: anyNamed('cursor'),
233 ),
234 ).thenAnswer((_) async => mockResponse);
235
236 await feedProvider.fetchDiscover(refresh: true);
237
238 expect(feedProvider.posts.length, 1);
239 expect(feedProvider.error, null);
240 });
241
242 test('should handle empty feed', () async {
243 final emptyResponse = TimelineResponse(feed: []);
244
245 when(
246 mockApiService.getDiscover(
247 sort: anyNamed('sort'),
248 timeframe: anyNamed('timeframe'),
249 limit: anyNamed('limit'),
250 cursor: anyNamed('cursor'),
251 ),
252 ).thenAnswer((_) async => emptyResponse);
253
254 await feedProvider.fetchDiscover(refresh: true);
255
256 expect(feedProvider.posts.isEmpty, true);
257 expect(feedProvider.hasMore, false);
258 });
259 });
260
261 group('loadMore', () {
262 test('should load more posts', () async {
263 when(mockAuthProvider.isAuthenticated).thenReturn(true);
264
265 // Initial load
266 final firstResponse = TimelineResponse(
267 feed: [_createMockPost()],
268 cursor: 'cursor-1',
269 );
270
271 when(
272 mockApiService.getTimeline(
273 sort: anyNamed('sort'),
274 timeframe: anyNamed('timeframe'),
275 limit: anyNamed('limit'),
276 ),
277 ).thenAnswer((_) async => firstResponse);
278
279 await feedProvider.loadFeed(refresh: true);
280
281 // Load more
282 final secondResponse = TimelineResponse(
283 feed: [_createMockPost()],
284 cursor: 'cursor-2',
285 );
286
287 when(
288 mockApiService.getTimeline(
289 sort: anyNamed('sort'),
290 timeframe: anyNamed('timeframe'),
291 limit: anyNamed('limit'),
292 cursor: 'cursor-1',
293 ),
294 ).thenAnswer((_) async => secondResponse);
295
296 await feedProvider.loadMore();
297
298 expect(feedProvider.posts.length, 2);
299 });
300
301 test('should not load more if already loading', () async {
302 when(mockAuthProvider.isAuthenticated).thenReturn(true);
303
304 final response = TimelineResponse(
305 feed: [_createMockPost()],
306 cursor: 'cursor-1',
307 );
308
309 when(
310 mockApiService.getTimeline(
311 sort: anyNamed('sort'),
312 timeframe: anyNamed('timeframe'),
313 limit: anyNamed('limit'),
314 cursor: anyNamed('cursor'),
315 ),
316 ).thenAnswer((_) async => response);
317
318 await feedProvider.fetchTimeline(refresh: true);
319 await feedProvider.loadMore();
320
321 // Should not make additional calls while loading
322 });
323
324 test('should not load more if hasMore is false', () async {
325 final response = TimelineResponse(feed: [_createMockPost()]);
326
327 when(
328 mockApiService.getTimeline(
329 sort: anyNamed('sort'),
330 timeframe: anyNamed('timeframe'),
331 limit: anyNamed('limit'),
332 cursor: anyNamed('cursor'),
333 ),
334 ).thenAnswer((_) async => response);
335
336 await feedProvider.fetchTimeline(refresh: true);
337 expect(feedProvider.hasMore, false);
338
339 await feedProvider.loadMore();
340 // Should not attempt to load more
341 });
342 });
343
344 group('retry', () {
345 test('should retry after error', () async {
346 when(mockAuthProvider.isAuthenticated).thenReturn(true);
347
348 // Simulate error
349 when(
350 mockApiService.getTimeline(
351 sort: anyNamed('sort'),
352 timeframe: anyNamed('timeframe'),
353 limit: anyNamed('limit'),
354 cursor: anyNamed('cursor'),
355 ),
356 ).thenThrow(Exception('Network error'));
357
358 await feedProvider.loadFeed(refresh: true);
359 expect(feedProvider.error, isNotNull);
360
361 // Retry
362 final successResponse = TimelineResponse(
363 feed: [_createMockPost()],
364 cursor: 'cursor',
365 );
366
367 when(
368 mockApiService.getTimeline(
369 sort: anyNamed('sort'),
370 timeframe: anyNamed('timeframe'),
371 limit: anyNamed('limit'),
372 cursor: anyNamed('cursor'),
373 ),
374 ).thenAnswer((_) async => successResponse);
375
376 await feedProvider.retry();
377
378 expect(feedProvider.error, null);
379 expect(feedProvider.posts.length, 1);
380 });
381 });
382
383 group('State Management', () {
384 test('should notify listeners on state change', () async {
385 var notificationCount = 0;
386 feedProvider.addListener(() {
387 notificationCount++;
388 });
389
390 final mockResponse = TimelineResponse(
391 feed: [_createMockPost()],
392 cursor: 'cursor',
393 );
394
395 when(
396 mockApiService.getTimeline(
397 sort: anyNamed('sort'),
398 timeframe: anyNamed('timeframe'),
399 limit: anyNamed('limit'),
400 cursor: anyNamed('cursor'),
401 ),
402 ).thenAnswer((_) async => mockResponse);
403
404 await feedProvider.fetchTimeline(refresh: true);
405
406 expect(notificationCount, greaterThan(0));
407 });
408
409 test('should manage loading states correctly', () async {
410 final mockResponse = TimelineResponse(
411 feed: [_createMockPost()],
412 cursor: 'cursor',
413 );
414
415 when(
416 mockApiService.getTimeline(
417 sort: anyNamed('sort'),
418 timeframe: anyNamed('timeframe'),
419 limit: anyNamed('limit'),
420 cursor: anyNamed('cursor'),
421 ),
422 ).thenAnswer((_) async {
423 await Future.delayed(const Duration(milliseconds: 100));
424 return mockResponse;
425 });
426
427 final loadFuture = feedProvider.fetchTimeline(refresh: true);
428
429 // Should be loading
430 expect(feedProvider.isLoading, true);
431
432 await loadFuture;
433
434 // Should not be loading anymore
435 expect(feedProvider.isLoading, false);
436 });
437 });
438 });
439}
440
441// Helper function to create mock posts
442FeedViewPost _createMockPost() {
443 return FeedViewPost(
444 post: PostView(
445 uri: 'at://did:plc:test/app.bsky.feed.post/test',
446 cid: 'test-cid',
447 rkey: 'test-rkey',
448 author: AuthorView(
449 did: 'did:plc:author',
450 handle: 'test.user',
451 displayName: 'Test User',
452 ),
453 community: CommunityRef(did: 'did:plc:community', name: 'test-community'),
454 createdAt: DateTime.parse('2025-01-01T12:00:00Z'),
455 indexedAt: DateTime.parse('2025-01-01T12:00:00Z'),
456 text: 'Test body',
457 title: 'Test Post',
458 stats: PostStats(score: 42, upvotes: 50, downvotes: 8, commentCount: 5),
459 facets: [],
460 ),
461 );
462}