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}