Main coves client
1import 'package:coves_flutter/providers/auth_provider.dart';
2import 'package:coves_flutter/providers/vote_provider.dart';
3import 'package:coves_flutter/services/api_exceptions.dart';
4import 'package:coves_flutter/services/vote_service.dart';
5import 'package:flutter_test/flutter_test.dart';
6import 'package:mockito/annotations.dart';
7import 'package:mockito/mockito.dart';
8
9import 'vote_provider_test.mocks.dart';
10
11// Generate mocks for VoteService and AuthProvider
12@GenerateMocks([VoteService, AuthProvider])
13void main() {
14 TestWidgetsFlutterBinding.ensureInitialized();
15
16 group('VoteProvider', () {
17 late VoteProvider voteProvider;
18 late MockVoteService mockVoteService;
19 late MockAuthProvider mockAuthProvider;
20
21 setUp(() {
22 mockVoteService = MockVoteService();
23 mockAuthProvider = MockAuthProvider();
24
25 // Default: user is authenticated
26 when(mockAuthProvider.isAuthenticated).thenReturn(true);
27
28 voteProvider = VoteProvider(
29 voteService: mockVoteService,
30 authProvider: mockAuthProvider,
31 );
32 });
33
34 tearDown(() {
35 voteProvider.dispose();
36 });
37
38 group('toggleVote', () {
39 const testPostUri = 'at://did:plc:test/social.coves.post.record/123';
40 const testPostCid = 'bafy2bzacepostcid123';
41
42 test('should create vote with optimistic update', () async {
43 // Mock successful API response
44 when(
45 mockVoteService.createVote(
46 postUri: anyNamed('postUri'),
47 postCid: anyNamed('postCid'),
48 direction: anyNamed('direction'),
49 existingVoteRkey: anyNamed('existingVoteRkey'),
50 existingVoteDirection: anyNamed('existingVoteDirection'),
51 ),
52 ).thenAnswer(
53 (_) async => const VoteResponse(
54 uri: 'at://did:plc:test/social.coves.feed.vote/456',
55 cid: 'bafy123',
56 rkey: '456',
57 deleted: false,
58 ),
59 );
60
61 var notificationCount = 0;
62 voteProvider.addListener(() {
63 notificationCount++;
64 });
65
66 // Initially not liked
67 expect(voteProvider.isLiked(testPostUri), false);
68
69 // Toggle vote
70 final wasLiked = await voteProvider.toggleVote(
71 postUri: testPostUri,
72 postCid: testPostCid,
73 );
74
75 // Should return true (vote created)
76 expect(wasLiked, true);
77
78 // Should be liked now
79 expect(voteProvider.isLiked(testPostUri), true);
80
81 // Should have notified listeners twice (optimistic + server response)
82 expect(notificationCount, greaterThanOrEqualTo(2));
83
84 // Vote state should be correct
85 final voteState = voteProvider.getVoteState(testPostUri);
86 expect(voteState?.direction, 'up');
87 expect(voteState?.uri, 'at://did:plc:test/social.coves.feed.vote/456');
88 expect(voteState?.deleted, false);
89 });
90
91 test('should remove vote when toggled off', () async {
92 // First, set up initial vote state
93 voteProvider.setInitialVoteState(
94 postUri: testPostUri,
95 voteDirection: 'up',
96 voteUri: 'at://did:plc:test/social.coves.feed.vote/456',
97 );
98
99 expect(voteProvider.isLiked(testPostUri), true);
100
101 // Mock API response for toggling off
102 when(
103 mockVoteService.createVote(
104 postUri: anyNamed('postUri'),
105 postCid: anyNamed('postCid'),
106 direction: anyNamed('direction'),
107 existingVoteRkey: anyNamed('existingVoteRkey'),
108 existingVoteDirection: anyNamed('existingVoteDirection'),
109 ),
110 ).thenAnswer((_) async => const VoteResponse(deleted: true));
111
112 // Toggle vote off
113 final wasLiked = await voteProvider.toggleVote(
114 postUri: testPostUri,
115 postCid: testPostCid,
116 );
117
118 // Should return false (vote removed)
119 expect(wasLiked, false);
120
121 // Should not be liked anymore
122 expect(voteProvider.isLiked(testPostUri), false);
123
124 // Vote state should be marked as deleted
125 final voteState = voteProvider.getVoteState(testPostUri);
126 expect(voteState?.deleted, true);
127 });
128
129 test('should rollback on API error', () async {
130 // Set up initial state (not voted)
131 expect(voteProvider.isLiked(testPostUri), false);
132
133 // Mock API failure
134 when(
135 mockVoteService.createVote(
136 postUri: anyNamed('postUri'),
137 postCid: anyNamed('postCid'),
138 direction: anyNamed('direction'),
139 existingVoteRkey: anyNamed('existingVoteRkey'),
140 existingVoteDirection: anyNamed('existingVoteDirection'),
141 ),
142 ).thenThrow(ApiException('Network error', statusCode: 500));
143
144 var notificationCount = 0;
145 voteProvider.addListener(() {
146 notificationCount++;
147 });
148
149 // Try to toggle vote
150 expect(
151 () => voteProvider.toggleVote(
152 postUri: testPostUri,
153 postCid: testPostCid,
154 ),
155 throwsA(isA<ApiException>()),
156 );
157
158 // Should rollback to initial state (not liked)
159 await Future.delayed(Duration.zero); // Wait for async completion
160 expect(voteProvider.isLiked(testPostUri), false);
161 expect(voteProvider.getVoteState(testPostUri), null);
162
163 // Should have notified listeners (optimistic + rollback)
164 expect(notificationCount, greaterThanOrEqualTo(2));
165 });
166
167 test('should rollback to previous state on error', () async {
168 // Set up initial voted state
169 voteProvider.setInitialVoteState(
170 postUri: testPostUri,
171 voteDirection: 'up',
172 voteUri: 'at://did:plc:test/social.coves.feed.vote/456',
173 );
174
175 final initialState = voteProvider.getVoteState(testPostUri);
176 expect(voteProvider.isLiked(testPostUri), true);
177
178 // Mock API failure when trying to toggle off
179 when(
180 mockVoteService.createVote(
181 postUri: anyNamed('postUri'),
182 postCid: anyNamed('postCid'),
183 direction: anyNamed('direction'),
184 existingVoteRkey: anyNamed('existingVoteRkey'),
185 existingVoteDirection: anyNamed('existingVoteDirection'),
186 ),
187 ).thenThrow(NetworkException('Connection failed'));
188
189 // Try to toggle vote off
190 expect(
191 () => voteProvider.toggleVote(
192 postUri: testPostUri,
193 postCid: testPostCid,
194 ),
195 throwsA(isA<ApiException>()),
196 );
197
198 // Should rollback to initial liked state
199 await Future.delayed(Duration.zero); // Wait for async completion
200 expect(voteProvider.isLiked(testPostUri), true);
201 expect(voteProvider.getVoteState(testPostUri)?.uri, initialState?.uri);
202 });
203
204 test('should prevent concurrent requests for same post', () async {
205 // Mock slow API response
206 when(
207 mockVoteService.createVote(
208 postUri: anyNamed('postUri'),
209 postCid: anyNamed('postCid'),
210 direction: anyNamed('direction'),
211 existingVoteRkey: anyNamed('existingVoteRkey'),
212 existingVoteDirection: anyNamed('existingVoteDirection'),
213 ),
214 ).thenAnswer((_) async {
215 await Future.delayed(const Duration(milliseconds: 100));
216 return const VoteResponse(
217 uri: 'at://did:plc:test/social.coves.feed.vote/456',
218 cid: 'bafy123',
219 rkey: '456',
220 deleted: false,
221 );
222 });
223
224 // Start first request
225 final future1 = voteProvider.toggleVote(
226 postUri: testPostUri,
227 postCid: testPostCid,
228 );
229
230 // Try to start second request before first completes
231 final result2 = await voteProvider.toggleVote(
232 postUri: testPostUri,
233 postCid: testPostCid,
234 );
235
236 // Second request should be ignored
237 expect(result2, false);
238
239 // First request should complete normally
240 final result1 = await future1;
241 expect(result1, true);
242
243 // Should have only called API once
244 verify(
245 mockVoteService.createVote(
246 postUri: anyNamed('postUri'),
247 postCid: anyNamed('postCid'),
248 direction: anyNamed('direction'),
249 existingVoteRkey: anyNamed('existingVoteRkey'),
250 existingVoteDirection: anyNamed('existingVoteDirection'),
251 ),
252 ).called(1);
253 });
254
255 test('should handle downvote direction', () async {
256 when(
257 mockVoteService.createVote(
258 postUri: anyNamed('postUri'),
259 postCid: anyNamed('postCid'),
260 direction: anyNamed('direction'),
261 existingVoteRkey: anyNamed('existingVoteRkey'),
262 existingVoteDirection: anyNamed('existingVoteDirection'),
263 ),
264 ).thenAnswer(
265 (_) async => const VoteResponse(
266 uri: 'at://did:plc:test/social.coves.feed.vote/456',
267 cid: 'bafy123',
268 rkey: '456',
269 deleted: false,
270 ),
271 );
272
273 await voteProvider.toggleVote(
274 postUri: testPostUri,
275 postCid: testPostCid,
276 direction: 'down',
277 );
278
279 final voteState = voteProvider.getVoteState(testPostUri);
280 expect(voteState?.direction, 'down');
281 expect(voteState?.deleted, false);
282
283 // Should not be "liked" (isLiked checks for 'up' direction)
284 expect(voteProvider.isLiked(testPostUri), false);
285 });
286 });
287
288 group('setInitialVoteState', () {
289 const testPostUri = 'at://did:plc:test/social.coves.post.record/123';
290
291 test('should set initial vote state from API data', () {
292 voteProvider.setInitialVoteState(
293 postUri: testPostUri,
294 voteDirection: 'up',
295 voteUri: 'at://did:plc:test/social.coves.feed.vote/456',
296 );
297
298 expect(voteProvider.isLiked(testPostUri), true);
299
300 final voteState = voteProvider.getVoteState(testPostUri);
301 expect(voteState?.direction, 'up');
302 expect(voteState?.uri, 'at://did:plc:test/social.coves.feed.vote/456');
303 expect(voteState?.deleted, false);
304 });
305
306 test('should remove vote state when voteDirection is null', () {
307 // First set a vote
308 voteProvider.setInitialVoteState(
309 postUri: testPostUri,
310 voteDirection: 'up',
311 voteUri: 'at://did:plc:test/social.coves.feed.vote/456',
312 );
313
314 expect(voteProvider.isLiked(testPostUri), true);
315
316 // Then clear it
317 voteProvider.setInitialVoteState(postUri: testPostUri);
318
319 expect(voteProvider.isLiked(testPostUri), false);
320 expect(voteProvider.getVoteState(testPostUri), null);
321 });
322
323 test('should not notify listeners when setting initial state', () {
324 var notificationCount = 0;
325 voteProvider
326 ..addListener(() {
327 notificationCount++;
328 })
329 ..setInitialVoteState(
330 postUri: testPostUri,
331 voteDirection: 'up',
332 voteUri: 'at://did:plc:test/social.coves.feed.vote/456',
333 );
334
335 // Should NOT notify listeners (silent initialization)
336 expect(notificationCount, 0);
337 });
338 });
339
340 group('clear', () {
341 test('should clear all vote state', () {
342 const post1 = 'at://did:plc:test/social.coves.post.record/1';
343 const post2 = 'at://did:plc:test/social.coves.post.record/2';
344
345 // Set up multiple votes
346 voteProvider
347 ..setInitialVoteState(
348 postUri: post1,
349 voteDirection: 'up',
350 voteUri: 'at://did:plc:test/social.coves.feed.vote/1',
351 )
352 ..setInitialVoteState(
353 postUri: post2,
354 voteDirection: 'up',
355 voteUri: 'at://did:plc:test/social.coves.feed.vote/2',
356 );
357
358 expect(voteProvider.isLiked(post1), true);
359 expect(voteProvider.isLiked(post2), true);
360
361 // Clear all
362 voteProvider.clear();
363
364 // Should have no votes
365 expect(voteProvider.isLiked(post1), false);
366 expect(voteProvider.isLiked(post2), false);
367 expect(voteProvider.getVoteState(post1), null);
368 expect(voteProvider.getVoteState(post2), null);
369 });
370
371 test('should notify listeners when cleared', () {
372 var notificationCount = 0;
373 voteProvider
374 ..addListener(() {
375 notificationCount++;
376 })
377 ..clear();
378
379 expect(notificationCount, 1);
380 });
381 });
382
383 group('isPending', () {
384 const testPostUri = 'at://did:plc:test/social.coves.post.record/123';
385 const testPostCid = 'bafy2bzacepostcid123';
386
387 test('should return true while request is in progress', () async {
388 // Mock slow API response
389 when(
390 mockVoteService.createVote(
391 postUri: anyNamed('postUri'),
392 postCid: anyNamed('postCid'),
393 direction: anyNamed('direction'),
394 existingVoteRkey: anyNamed('existingVoteRkey'),
395 existingVoteDirection: anyNamed('existingVoteDirection'),
396 ),
397 ).thenAnswer((_) async {
398 await Future.delayed(const Duration(milliseconds: 50));
399 return const VoteResponse(
400 uri: 'at://did:plc:test/social.coves.feed.vote/456',
401 cid: 'bafy123',
402 rkey: '456',
403 deleted: false,
404 );
405 });
406
407 expect(voteProvider.isPending(testPostUri), false);
408
409 // Start request
410 final future = voteProvider.toggleVote(
411 postUri: testPostUri,
412 postCid: testPostCid,
413 );
414
415 // Give it time to set pending flag
416 await Future.delayed(const Duration(milliseconds: 10));
417
418 // Should be pending now
419 expect(voteProvider.isPending(testPostUri), true);
420
421 // Wait for completion
422 await future;
423
424 // Should not be pending anymore
425 expect(voteProvider.isPending(testPostUri), false);
426 });
427
428 test('should return false for posts with no pending request', () {
429 const testPostUri = 'at://did:plc:test/social.coves.post.record/123';
430 expect(voteProvider.isPending(testPostUri), false);
431 });
432 });
433
434 group('Score adjustments', () {
435 const testPostUri = 'at://did:plc:test/social.coves.post.record/123';
436 const testPostCid = 'bafy2bzacepostcid123';
437
438 test('should adjust score when creating upvote', () async {
439 when(
440 mockVoteService.createVote(
441 postUri: anyNamed('postUri'),
442 postCid: anyNamed('postCid'),
443 direction: anyNamed('direction'),
444 existingVoteRkey: anyNamed('existingVoteRkey'),
445 existingVoteDirection: anyNamed('existingVoteDirection'),
446 ),
447 ).thenAnswer(
448 (_) async => const VoteResponse(
449 uri: 'at://did:plc:test/social.coves.feed.vote/456',
450 cid: 'bafy123',
451 rkey: '456',
452 deleted: false,
453 ),
454 );
455
456 // Initial score from server
457 const serverScore = 10;
458
459 // Before vote, adjustment should be 0
460 expect(voteProvider.getAdjustedScore(testPostUri, serverScore), 10);
461
462 // Create upvote
463 await voteProvider.toggleVote(
464 postUri: testPostUri,
465 postCid: testPostCid,
466 );
467
468 // Should have +1 adjustment (upvote added)
469 expect(voteProvider.getAdjustedScore(testPostUri, serverScore), 11);
470 });
471
472 test('should adjust score when removing upvote', () async {
473 // Set initial state with upvote
474 voteProvider.setInitialVoteState(
475 postUri: testPostUri,
476 voteDirection: 'up',
477 voteUri: 'at://did:plc:test/social.coves.feed.vote/456',
478 );
479
480 when(
481 mockVoteService.createVote(
482 postUri: anyNamed('postUri'),
483 postCid: anyNamed('postCid'),
484 direction: anyNamed('direction'),
485 existingVoteRkey: anyNamed('existingVoteRkey'),
486 existingVoteDirection: anyNamed('existingVoteDirection'),
487 ),
488 ).thenAnswer((_) async => const VoteResponse(deleted: true));
489
490 const serverScore = 10;
491
492 // Before removing, adjustment should be 0 (server knows about upvote)
493 expect(voteProvider.getAdjustedScore(testPostUri, serverScore), 10);
494
495 // Remove upvote
496 await voteProvider.toggleVote(
497 postUri: testPostUri,
498 postCid: testPostCid,
499 );
500
501 // Should have -1 adjustment (upvote removed)
502 expect(voteProvider.getAdjustedScore(testPostUri, serverScore), 9);
503 });
504
505 test('should adjust score when creating downvote', () async {
506 when(
507 mockVoteService.createVote(
508 postUri: anyNamed('postUri'),
509 postCid: anyNamed('postCid'),
510 direction: anyNamed('direction'),
511 existingVoteRkey: anyNamed('existingVoteRkey'),
512 existingVoteDirection: anyNamed('existingVoteDirection'),
513 ),
514 ).thenAnswer(
515 (_) async => const VoteResponse(
516 uri: 'at://did:plc:test/social.coves.feed.vote/456',
517 cid: 'bafy123',
518 rkey: '456',
519 deleted: false,
520 ),
521 );
522
523 const serverScore = 10;
524
525 // Create downvote
526 await voteProvider.toggleVote(
527 postUri: testPostUri,
528 postCid: testPostCid,
529 direction: 'down',
530 );
531
532 // Should have -1 adjustment (downvote added)
533 expect(voteProvider.getAdjustedScore(testPostUri, serverScore), 9);
534 });
535
536 test(
537 'should adjust score when switching from upvote to downvote',
538 () async {
539 // Set initial state with upvote
540 voteProvider.setInitialVoteState(
541 postUri: testPostUri,
542 voteDirection: 'up',
543 voteUri: 'at://did:plc:test/social.coves.feed.vote/456',
544 );
545
546 when(
547 mockVoteService.createVote(
548 postUri: anyNamed('postUri'),
549 postCid: anyNamed('postCid'),
550 direction: anyNamed('direction'),
551 existingVoteRkey: anyNamed('existingVoteRkey'),
552 existingVoteDirection: anyNamed('existingVoteDirection'),
553 ),
554 ).thenAnswer(
555 (_) async => const VoteResponse(
556 uri: 'at://did:plc:test/social.coves.feed.vote/789',
557 cid: 'bafy789',
558 rkey: '789',
559 deleted: false,
560 ),
561 );
562
563 const serverScore = 10;
564
565 // Switch to downvote
566 await voteProvider.toggleVote(
567 postUri: testPostUri,
568 postCid: testPostCid,
569 direction: 'down',
570 );
571
572 // Should have -2 adjustment (remove +1, add -1)
573 expect(voteProvider.getAdjustedScore(testPostUri, serverScore), 8);
574 },
575 );
576
577 test(
578 'should adjust score when switching from downvote to upvote',
579 () async {
580 // Set initial state with downvote
581 voteProvider.setInitialVoteState(
582 postUri: testPostUri,
583 voteDirection: 'down',
584 voteUri: 'at://did:plc:test/social.coves.feed.vote/456',
585 );
586
587 when(
588 mockVoteService.createVote(
589 postUri: anyNamed('postUri'),
590 postCid: anyNamed('postCid'),
591 direction: anyNamed('direction'),
592 existingVoteRkey: anyNamed('existingVoteRkey'),
593 existingVoteDirection: anyNamed('existingVoteDirection'),
594 ),
595 ).thenAnswer(
596 (_) async => const VoteResponse(
597 uri: 'at://did:plc:test/social.coves.feed.vote/789',
598 cid: 'bafy789',
599 rkey: '789',
600 deleted: false,
601 ),
602 );
603
604 const serverScore = 10;
605
606 // Switch to upvote
607 await voteProvider.toggleVote(
608 postUri: testPostUri,
609 postCid: testPostCid,
610 );
611
612 // Should have +2 adjustment (remove -1, add +1)
613 expect(voteProvider.getAdjustedScore(testPostUri, serverScore), 12);
614 },
615 );
616
617 test('should rollback score adjustment on error', () async {
618 const serverScore = 10;
619
620 when(
621 mockVoteService.createVote(
622 postUri: anyNamed('postUri'),
623 postCid: anyNamed('postCid'),
624 direction: anyNamed('direction'),
625 existingVoteRkey: anyNamed('existingVoteRkey'),
626 existingVoteDirection: anyNamed('existingVoteDirection'),
627 ),
628 ).thenThrow(ApiException('Network error', statusCode: 500));
629
630 // Try to vote (will fail)
631 expect(
632 () => voteProvider.toggleVote(
633 postUri: testPostUri,
634 postCid: testPostCid,
635 ),
636 throwsA(isA<ApiException>()),
637 );
638
639 await Future.delayed(Duration.zero);
640
641 // Adjustment should be rolled back to 0
642 expect(voteProvider.getAdjustedScore(testPostUri, serverScore), 10);
643 });
644
645 test('should clear score adjustments when clearing all state', () {
646 const testPostUri1 = 'at://did:plc:test/social.coves.post.record/1';
647 const testPostUri2 = 'at://did:plc:test/social.coves.post.record/2';
648
649 // Manually set some adjustments (simulating votes)
650 voteProvider
651 ..setInitialVoteState(
652 postUri: testPostUri1,
653 voteDirection: 'up',
654 voteUri: 'at://did:plc:test/social.coves.feed.vote/1',
655 )
656 ..clear();
657
658 // Adjustments should be cleared (back to 0)
659 expect(voteProvider.getAdjustedScore(testPostUri1, 10), 10);
660 expect(voteProvider.getAdjustedScore(testPostUri2, 5), 5);
661 });
662 });
663
664 group('Auth state listener', () {
665 test('should clear votes when user signs out', () {
666 const testPostUri = 'at://did:plc:test/social.coves.post.record/123';
667
668 // Set up vote state
669 voteProvider.setInitialVoteState(
670 postUri: testPostUri,
671 voteDirection: 'up',
672 voteUri: 'at://did:plc:test/social.coves.feed.vote/456',
673 );
674
675 expect(voteProvider.isLiked(testPostUri), true);
676
677 // Simulate sign out by changing auth state
678 when(mockAuthProvider.isAuthenticated).thenReturn(false);
679
680 // Trigger the auth listener by calling it directly
681 // (In real app, this would be triggered by
682 // AuthProvider.notifyListeners)
683 voteProvider.clear();
684
685 // Votes should be cleared
686 expect(voteProvider.isLiked(testPostUri), false);
687 expect(voteProvider.getVoteState(testPostUri), null);
688 });
689
690 test('should not clear votes when user is still authenticated', () {
691 const testPostUri = 'at://did:plc:test/social.coves.post.record/123';
692
693 // Set up vote state
694 voteProvider.setInitialVoteState(
695 postUri: testPostUri,
696 voteDirection: 'up',
697 voteUri: 'at://did:plc:test/social.coves.feed.vote/456',
698 );
699
700 expect(voteProvider.isLiked(testPostUri), true);
701
702 // Auth state remains authenticated
703 when(mockAuthProvider.isAuthenticated).thenReturn(true);
704
705 // Votes should NOT be cleared
706 expect(voteProvider.isLiked(testPostUri), true);
707 });
708 });
709 });
710}