From b03792444bc5a31dd40bb6cf0c7a0cffb9aeebd6 Mon Sep 17 00:00:00 2001 From: Bretton Date: Tue, 4 Nov 2025 15:42:43 -0800 Subject: [PATCH] feat: update vote lexicon and add optimistic vote updates MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This PR implements several improvements to the voting system: **1. Vote Lexicon Migration** - Migrate from social.coves.interaction.vote to social.coves.feed.vote - Aligns with backend migration (commit 7a87d6b) - Follows atProto conventions (like app.bsky.feed.like) **2. Optimistic Score Updates** - Vote counts update immediately when users vote - Score adjustments tracked per post: - Create upvote: +1 - Remove upvote: -1 - Create downvote: -1 - Remove downvote: +1 - Switch up→down: -2 - Switch down→up: +2 - Automatic rollback on API errors - 7 new tests covering all scenarios **3. Initial Vote State Loading** - Added VoteService.getUserVotes() to query PDS for user's votes - Added VoteProvider.loadInitialVotes() to bulk-load vote state - FeedProvider loads vote state after fetching posts - Hearts now fill correctly on app reload **4. Performance Optimization** (PR Review) - Added vote state cache to avoid O(n) PDS lookups - VoteProvider passes cached state (rkey + direction) to VoteService - Eliminates 5-10 API calls for users with many votes - Performance: O(1) instead of O(n) **5. Code Quality Improvements** (PR Review) - Fix: Unsafe force unwrap in Provider initialization (main.dart:57) - Fix: Added specific catch types (on Exception catch) - Fix: Reset score adjustments when loading votes (prevents double-counting) - All 119 tests passing ✅ **Breaking Changes:** None **Migration Required:** None (backward compatible) **Test Results:** - 119/119 tests passing - 0 errors, 0 warnings - Updated test mocks for new optional parameters 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- DEVELOPMENT_SUMMARY.md | 14 +- lib/main.dart | 17 +- lib/providers/feed_provider.dart | 28 +- lib/providers/vote_provider.dart | 108 ++++++- lib/services/vote_service.dart | 143 ++++++++- lib/widgets/post_card.dart | 6 +- test/providers/vote_provider_test.dart | 319 +++++++++++++++++-- test/providers/vote_provider_test.mocks.dart | 16 + test/services/vote_service_test.dart | 14 +- 9 files changed, 603 insertions(+), 62 deletions(-) diff --git a/DEVELOPMENT_SUMMARY.md b/DEVELOPMENT_SUMMARY.md index ecf2504..bf12a90 100644 --- a/DEVELOPMENT_SUMMARY.md +++ b/DEVELOPMENT_SUMMARY.md @@ -56,12 +56,12 @@ Mobile Client → User's PDS (com.atproto.repo.createRecord) ### Vote Record Schema -**Collection Name**: `social.coves.interaction.vote` +**Collection Name**: `social.coves.feed.vote` **Record Structure** (from backend lexicon): ```json { - "$type": "social.coves.interaction.vote", + "$type": "social.coves.feed.vote", "subject": { "uri": "at://did:plc:community123/social.coves.post.record/3kbx...", "cid": "bafy2bzacepostcid123" @@ -154,7 +154,7 @@ class VoteState { **rkey Extraction**: ```dart -// Extract rkey from URI: at://did:plc:xyz/social.coves.interaction.vote/3kby... +// Extract rkey from URI: at://did:plc:xyz/social.coves.feed.vote/3kby... // Result: "3kby..." final rkey = voteUri.split('/').last; ``` @@ -353,7 +353,7 @@ void _onAuthChanged() { ## 9. Backend Integration Requirements ### Jetstream Listener -The backend must listen for `social.coves.interaction.vote` records from Jetstream: +The backend must listen for `social.coves.feed.vote` records from Jetstream: ```json { @@ -361,11 +361,11 @@ The backend must listen for `social.coves.interaction.vote` records from Jetstre "kind": "commit", "commit": { "operation": "create", - "collection": "social.coves.interaction.vote", + "collection": "social.coves.feed.vote", "rkey": "3kby...", "cid": "bafy2bzacevotecid123", "record": { - "$type": "social.coves.interaction.vote", + "$type": "social.coves.feed.vote", "subject": { "uri": "at://did:plc:community/social.coves.post.record/abc", "cid": "bafy2bzacepostcid123" @@ -397,7 +397,7 @@ Feed endpoints should include viewer state: "viewer": { "vote": { "direction": "up", - "uri": "at://did:plc:user/social.coves.interaction.vote/3kby..." + "uri": "at://did:plc:user/social.coves.feed.vote/3kby..." } } } diff --git a/lib/main.dart b/lib/main.dart index 3e1f8b4..2c646ff 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -40,13 +40,28 @@ void main() async { MultiProvider( providers: [ ChangeNotifierProvider.value(value: authProvider), - ChangeNotifierProvider(create: (_) => FeedProvider(authProvider)), ChangeNotifierProvider( create: (_) => VoteProvider( voteService: voteService, authProvider: authProvider, ), ), + ChangeNotifierProxyProvider2( + create: (context) => FeedProvider( + authProvider, + voteProvider: context.read(), + voteService: voteService, + ), + update: (context, auth, vote, previous) { + // Reuse existing provider to maintain state across rebuilds + return previous ?? + FeedProvider( + auth, + voteProvider: vote, + voteService: voteService, + ); + }, + ), ], child: const CovesApp(), ), diff --git a/lib/providers/feed_provider.dart b/lib/providers/feed_provider.dart index 10ea911..9935e29 100644 --- a/lib/providers/feed_provider.dart +++ b/lib/providers/feed_provider.dart @@ -3,7 +3,9 @@ import 'dart:async'; import 'package:flutter/foundation.dart'; import '../models/post.dart'; import '../services/coves_api_service.dart'; +import '../services/vote_service.dart'; import 'auth_provider.dart'; +import 'vote_provider.dart'; /// Feed Provider /// @@ -14,7 +16,13 @@ import 'auth_provider.dart'; /// tokens before each authenticated request (critical for atProto OAuth /// token rotation). class FeedProvider with ChangeNotifier { - FeedProvider(this._authProvider, {CovesApiService? apiService}) { + FeedProvider( + this._authProvider, { + CovesApiService? apiService, + VoteProvider? voteProvider, + VoteService? voteService, + }) : _voteProvider = voteProvider, + _voteService = voteService { // Use injected service (for testing) or create new one (for production) // Pass token getter to API service for automatic fresh token retrieval _apiService = @@ -54,6 +62,8 @@ class FeedProvider with ChangeNotifier { final AuthProvider _authProvider; late final CovesApiService _apiService; + final VoteProvider? _voteProvider; + final VoteService? _voteService; // Track previous auth state to detect transitions bool _wasAuthenticated = false; @@ -180,6 +190,22 @@ class FeedProvider with ChangeNotifier { if (kDebugMode) { debugPrint('✅ $feedName loaded: ${_posts.length} posts total'); } + + // Load initial vote state from PDS (only if authenticated) + if (_authProvider.isAuthenticated && + _voteProvider != null && + _voteService != null) { + try { + final userVotes = await _voteService.getUserVotes(); + _voteProvider.loadInitialVotes(userVotes); + } on Exception catch (e) { + if (kDebugMode) { + debugPrint('⚠️ Failed to load vote state: $e'); + } + // Don't fail the feed load if vote loading fails + // Keep silent per PR review discussion + } + } } on Exception catch (e) { _error = e.toString(); if (kDebugMode) { diff --git a/lib/providers/vote_provider.dart b/lib/providers/vote_provider.dart index da0561b..83cbf31 100644 --- a/lib/providers/vote_provider.dart +++ b/lib/providers/vote_provider.dart @@ -1,7 +1,7 @@ import 'package:flutter/foundation.dart'; import '../services/api_exceptions.dart'; -import '../services/vote_service.dart'; +import '../services/vote_service.dart' show VoteService, VoteInfo; import 'auth_provider.dart'; /// Vote Provider @@ -46,6 +46,10 @@ class VoteProvider with ChangeNotifier { // Map of post URI -> in-flight request flag final Map _pendingRequests = {}; + // Map of post URI -> score adjustment (for optimistic UI updates) + // Tracks the local delta from the server's score + final Map _scoreAdjustments = {}; + /// Get vote state for a post VoteState? getVoteState(String postUri) => _votes[postUri]; @@ -57,6 +61,21 @@ class VoteProvider with ChangeNotifier { /// Check if a request is pending for a post bool isPending(String postUri) => _pendingRequests[postUri] ?? false; + /// Get adjusted score for a post (server score + local optimistic adjustment) + /// + /// This allows the UI to show immediate feedback when users vote, even before + /// the backend processes the vote and returns updated counts. + /// + /// Parameters: + /// - [postUri]: AT-URI of the post + /// - [serverScore]: The score from the server (upvotes - downvotes) + /// + /// Returns: The adjusted score based on local vote state + int getAdjustedScore(String postUri, int serverScore) { + final adjustment = _scoreAdjustments[postUri] ?? 0; + return serverScore + adjustment; + } + /// Toggle vote (like/unlike) /// /// Uses optimistic updates: @@ -90,8 +109,38 @@ class VoteProvider with ChangeNotifier { // Save current state for rollback on error final previousState = _votes[postUri]; + final previousAdjustment = _scoreAdjustments[postUri] ?? 0; final currentState = previousState; + // Calculate score adjustment for optimistic update + int newAdjustment = previousAdjustment; + + if (currentState?.direction == direction && + !(currentState?.deleted ?? false)) { + // Toggle off - removing vote + if (direction == 'up') { + newAdjustment -= 1; // Remove upvote + } else { + newAdjustment += 1; // Remove downvote + } + } else if (currentState?.direction != null && + currentState?.direction != direction && + !(currentState?.deleted ?? false)) { + // Switching vote direction + if (direction == 'up') { + newAdjustment += 2; // Remove downvote (-1) and add upvote (+1) + } else { + newAdjustment -= 2; // Remove upvote (-1) and add downvote (+1) + } + } else { + // Creating new vote (or re-creating after delete) + if (direction == 'up') { + newAdjustment += 1; // Add upvote + } else { + newAdjustment -= 1; // Add downvote + } + } + // Optimistic update if (currentState?.direction == direction && !(currentState?.deleted ?? false)) { @@ -109,17 +158,22 @@ class VoteProvider with ChangeNotifier { deleted: false, ); } + + // Apply score adjustment + _scoreAdjustments[postUri] = newAdjustment; notifyListeners(); // Mark request as pending _pendingRequests[postUri] = true; try { - // Make API call + // Make API call - pass existing vote info to avoid O(n) PDS lookup final response = await _voteService.createVote( postUri: postUri, postCid: postCid, direction: direction, + existingVoteRkey: currentState?.rkey, + existingVoteDirection: currentState?.direction, ); // Update with server response @@ -152,6 +206,14 @@ class VoteProvider with ChangeNotifier { } else { _votes.remove(postUri); } + + // Rollback score adjustment + if (previousAdjustment != 0) { + _scoreAdjustments[postUri] = previousAdjustment; + } else { + _scoreAdjustments.remove(postUri); + } + notifyListeners(); rethrow; @@ -176,7 +238,7 @@ class VoteProvider with ChangeNotifier { }) { if (voteDirection != null) { // Extract rkey from vote URI if available - // URI format: at://did:plc:xyz/social.coves.interaction.vote/3kby... + // URI format: at://did:plc:xyz/social.coves.feed.vote/3kby... String? rkey; if (voteUri != null) { final parts = voteUri.split('/'); @@ -197,10 +259,48 @@ class VoteProvider with ChangeNotifier { // Don't notify listeners - this is just initial state } + /// Load initial vote states from a map of votes + /// + /// This is used to bulk-load vote state after querying the user's PDS. + /// Typically called after loading feed posts to fill in which posts + /// the user has voted on. + /// + /// IMPORTANT: This clears score adjustments since the server score + /// already reflects the loaded votes. If we kept stale adjustments, + /// we'd double-count votes (server score + our adjustment). + /// + /// Parameters: + /// - [votes]: Map of post URI -> vote info from VoteService.getUserVotes() + void loadInitialVotes(Map votes) { + for (final entry in votes.entries) { + final postUri = entry.key; + final voteInfo = entry.value; + + _votes[postUri] = VoteState( + direction: voteInfo.direction, + uri: voteInfo.voteUri, + rkey: voteInfo.rkey, + deleted: false, + ); + + // Clear any stale score adjustments for this post + // The server score already includes this vote + _scoreAdjustments.remove(postUri); + } + + if (kDebugMode) { + debugPrint('📊 Initialized ${votes.length} vote states'); + } + + // Notify once after loading all votes + notifyListeners(); + } + /// Clear all vote state (e.g., on sign out) void clear() { _votes.clear(); _pendingRequests.clear(); + _scoreAdjustments.clear(); notifyListeners(); } } @@ -224,7 +324,7 @@ class VoteState { /// Record key (rkey) of the vote - needed for deletion /// This is the last segment of the AT-URI (e.g., "3kby..." from - /// "at://did:plc:xyz/social.coves.interaction.vote/3kby...") + /// "at://did:plc:xyz/social.coves.feed.vote/3kby...") final String? rkey; /// Whether the vote has been deleted diff --git a/lib/services/vote_service.dart b/lib/services/vote_service.dart index 5c5bf7f..049d781 100644 --- a/lib/services/vote_service.dart +++ b/lib/services/vote_service.dart @@ -44,12 +44,101 @@ class VoteService { final String? Function()? _pdsUrlGetter; /// Collection name for vote records - static const String voteCollection = 'social.coves.interaction.vote'; + static const String voteCollection = 'social.coves.feed.vote'; + + /// Get all votes for the current user + /// + /// Queries the user's PDS for all their vote records and returns a map + /// of post URI -> vote info. This is used to initialize vote state when + /// loading the feed. + /// + /// Returns: + /// - Map where key is the post URI + /// - Empty map if not authenticated or no votes found + Future> getUserVotes() async { + try { + final userDid = _didGetter?.call(); + if (userDid == null || userDid.isEmpty) { + return {}; + } + + final session = await _sessionGetter?.call(); + if (session == null) { + return {}; + } + + final votes = {}; + String? cursor; + + // Paginate through all vote records + do { + final url = cursor == null + ? '/xrpc/com.atproto.repo.listRecords?repo=$userDid&collection=$voteCollection&limit=100' + : '/xrpc/com.atproto.repo.listRecords?repo=$userDid&collection=$voteCollection&limit=100&cursor=$cursor'; + + final response = await session.fetchHandler(url, method: 'GET'); + + if (response.statusCode != 200) { + if (kDebugMode) { + debugPrint('⚠️ Failed to list votes: ${response.statusCode}'); + } + break; + } + + final data = jsonDecode(response.body) as Map; + final records = data['records'] as List?; + + if (records != null) { + for (final record in records) { + final recordMap = record as Map; + final value = recordMap['value'] as Map?; + final uri = recordMap['uri'] as String?; + + if (value == null || uri == null) { + continue; + } + + final subject = value['subject'] as Map?; + final direction = value['direction'] as String?; + + if (subject == null || direction == null) { + continue; + } + + final subjectUri = subject['uri'] as String?; + if (subjectUri != null) { + // Extract rkey from vote URI + final rkey = uri.split('/').last; + + votes[subjectUri] = VoteInfo( + direction: direction, + voteUri: uri, + rkey: rkey, + ); + } + } + } + + cursor = data['cursor'] as String?; + } while (cursor != null); + + if (kDebugMode) { + debugPrint('📊 Loaded ${votes.length} votes from PDS'); + } + + return votes; + } on Exception catch (e) { + if (kDebugMode) { + debugPrint('⚠️ Failed to load user votes: $e'); + } + return {}; + } + } /// Create or toggle vote /// /// Implements smart toggle logic: - /// 1. Query PDS for existing vote on this post + /// 1. Query PDS for existing vote on this post (or use cached state) /// 2. If exists with same direction → Delete (toggle off) /// 3. If exists with different direction → Delete old + Create new /// 4. If no existing vote → Create new @@ -59,6 +148,8 @@ class VoteService { /// "at://did:plc:xyz/social.coves.post.record/abc123") /// - [postCid]: Content ID of the post (for strong reference) /// - [direction]: Vote direction - "up" for like/upvote, "down" for downvote + /// - [existingVoteRkey]: Optional rkey from cached state (avoids O(n) lookup) + /// - [existingVoteDirection]: Optional direction from cached state /// /// Returns: /// - VoteResponse with uri/cid/rkey if created @@ -70,6 +161,8 @@ class VoteService { required String postUri, required String postCid, String direction = 'up', + String? existingVoteRkey, + String? existingVoteDirection, }) async { try { // Get user's DID and PDS URL @@ -92,10 +185,22 @@ class VoteService { } // Step 1: Check for existing vote - final existingVote = await _findExistingVote( - userDid: userDid, - postUri: postUri, - ); + // Use cached state if available to avoid O(n) PDS lookup + ExistingVote? existingVote; + if (existingVoteRkey != null && existingVoteDirection != null) { + existingVote = ExistingVote( + direction: existingVoteDirection, + rkey: existingVoteRkey, + ); + if (kDebugMode) { + debugPrint(' Using cached vote state (avoiding PDS lookup)'); + } + } else { + existingVote = await _findExistingVote( + userDid: userDid, + postUri: postUri, + ); + } if (existingVote != null) { if (kDebugMode) { @@ -137,7 +242,7 @@ class VoteService { } return response; - } catch (e) { + } on Exception catch (e) { throw ApiException('Failed to create vote: $e'); } } @@ -204,7 +309,7 @@ class VoteService { final uri = recordMap['uri'] as String; // Extract rkey from URI - // Format: at://did:plc:xyz/social.coves.interaction.vote/3kby... + // Format: at://did:plc:xyz/social.coves.feed.vote/3kby... final rkey = uri.split('/').last; return ExistingVote(direction: direction, rkey: rkey); @@ -218,7 +323,7 @@ class VoteService { // Vote not found after searching all pages return null; - } catch (e) { + } on Exception catch (e) { if (kDebugMode) { debugPrint('⚠️ Failed to list votes: $e'); } @@ -363,3 +468,23 @@ class ExistingVote { /// Record key for deletion final String rkey; } + +/// Vote Info +/// +/// Information about a user's vote on a post, returned from getUserVotes(). +class VoteInfo { + const VoteInfo({ + required this.direction, + required this.voteUri, + required this.rkey, + }); + + /// Vote direction ("up" or "down") + final String direction; + + /// AT-URI of the vote record + final String voteUri; + + /// Record key (rkey) - last segment of URI + final String rkey; +} diff --git a/lib/widgets/post_card.dart b/lib/widgets/post_card.dart index d9d0008..62bb74d 100644 --- a/lib/widgets/post_card.dart +++ b/lib/widgets/post_card.dart @@ -218,6 +218,10 @@ class PostCard extends StatelessWidget { Consumer( builder: (context, voteProvider, child) { final isLiked = voteProvider.isLiked(post.post.uri); + final adjustedScore = voteProvider.getAdjustedScore( + post.post.uri, + post.post.stats.score, + ); return InkWell( onTap: () async { @@ -272,7 +276,7 @@ class PostCard extends StatelessWidget { ), const SizedBox(width: 5), Text( - DateTimeUtils.formatCount(post.post.stats.score), + DateTimeUtils.formatCount(adjustedScore), style: TextStyle( color: AppColors.textPrimary .withValues(alpha: 0.6), diff --git a/test/providers/vote_provider_test.dart b/test/providers/vote_provider_test.dart index 8b1a9af..4ee1aa5 100644 --- a/test/providers/vote_provider_test.dart +++ b/test/providers/vote_provider_test.dart @@ -43,12 +43,15 @@ void main() { // Mock successful API response when( mockVoteService.createVote( - postUri: testPostUri, - postCid: testPostCid, + postUri: anyNamed('postUri'), + postCid: anyNamed('postCid'), + direction: anyNamed('direction'), + existingVoteRkey: anyNamed('existingVoteRkey'), + existingVoteDirection: anyNamed('existingVoteDirection'), ), ).thenAnswer( (_) async => const VoteResponse( - uri: 'at://did:plc:test/social.coves.interaction.vote/456', + uri: 'at://did:plc:test/social.coves.feed.vote/456', cid: 'bafy123', rkey: '456', deleted: false, @@ -81,7 +84,7 @@ void main() { // Vote state should be correct final voteState = voteProvider.getVoteState(testPostUri); expect(voteState?.direction, 'up'); - expect(voteState?.uri, 'at://did:plc:test/social.coves.interaction.vote/456'); + expect(voteState?.uri, 'at://did:plc:test/social.coves.feed.vote/456'); expect(voteState?.deleted, false); }); @@ -90,7 +93,7 @@ void main() { voteProvider.setInitialVoteState( postUri: testPostUri, voteDirection: 'up', - voteUri: 'at://did:plc:test/social.coves.interaction.vote/456', + voteUri: 'at://did:plc:test/social.coves.feed.vote/456', ); expect(voteProvider.isLiked(testPostUri), true); @@ -98,8 +101,11 @@ void main() { // Mock API response for toggling off when( mockVoteService.createVote( - postUri: testPostUri, - postCid: testPostCid, + postUri: anyNamed('postUri'), + postCid: anyNamed('postCid'), + direction: anyNamed('direction'), + existingVoteRkey: anyNamed('existingVoteRkey'), + existingVoteDirection: anyNamed('existingVoteDirection'), ), ).thenAnswer( (_) async => const VoteResponse(deleted: true), @@ -129,8 +135,11 @@ void main() { // Mock API failure when( mockVoteService.createVote( - postUri: testPostUri, - postCid: testPostCid, + postUri: anyNamed('postUri'), + postCid: anyNamed('postCid'), + direction: anyNamed('direction'), + existingVoteRkey: anyNamed('existingVoteRkey'), + existingVoteDirection: anyNamed('existingVoteDirection'), ), ).thenThrow( ApiException('Network error', statusCode: 500), @@ -164,7 +173,7 @@ void main() { voteProvider.setInitialVoteState( postUri: testPostUri, voteDirection: 'up', - voteUri: 'at://did:plc:test/social.coves.interaction.vote/456', + voteUri: 'at://did:plc:test/social.coves.feed.vote/456', ); final initialState = voteProvider.getVoteState(testPostUri); @@ -173,8 +182,11 @@ void main() { // Mock API failure when trying to toggle off when( mockVoteService.createVote( - postUri: testPostUri, - postCid: testPostCid, + postUri: anyNamed('postUri'), + postCid: anyNamed('postCid'), + direction: anyNamed('direction'), + existingVoteRkey: anyNamed('existingVoteRkey'), + existingVoteDirection: anyNamed('existingVoteDirection'), ), ).thenThrow( NetworkException('Connection failed'), @@ -199,14 +211,17 @@ void main() { // Mock slow API response when( mockVoteService.createVote( - postUri: testPostUri, - postCid: testPostCid, + postUri: anyNamed('postUri'), + postCid: anyNamed('postCid'), + direction: anyNamed('direction'), + existingVoteRkey: anyNamed('existingVoteRkey'), + existingVoteDirection: anyNamed('existingVoteDirection'), ), ).thenAnswer( (_) async { await Future.delayed(const Duration(milliseconds: 100)); return const VoteResponse( - uri: 'at://did:plc:test/social.coves.interaction.vote/456', + uri: 'at://did:plc:test/social.coves.feed.vote/456', cid: 'bafy123', rkey: '456', deleted: false, @@ -236,8 +251,11 @@ void main() { // Should have only called API once verify( mockVoteService.createVote( - postUri: testPostUri, - postCid: testPostCid, + postUri: anyNamed('postUri'), + postCid: anyNamed('postCid'), + direction: anyNamed('direction'), + existingVoteRkey: anyNamed('existingVoteRkey'), + existingVoteDirection: anyNamed('existingVoteDirection'), ), ).called(1); }); @@ -245,13 +263,15 @@ void main() { test('should handle downvote direction', () async { when( mockVoteService.createVote( - postUri: testPostUri, - postCid: testPostCid, - direction: 'down', + postUri: anyNamed('postUri'), + postCid: anyNamed('postCid'), + direction: anyNamed('direction'), + existingVoteRkey: anyNamed('existingVoteRkey'), + existingVoteDirection: anyNamed('existingVoteDirection'), ), ).thenAnswer( (_) async => const VoteResponse( - uri: 'at://did:plc:test/social.coves.interaction.vote/456', + uri: 'at://did:plc:test/social.coves.feed.vote/456', cid: 'bafy123', rkey: '456', deleted: false, @@ -280,14 +300,14 @@ void main() { voteProvider.setInitialVoteState( postUri: testPostUri, voteDirection: 'up', - voteUri: 'at://did:plc:test/social.coves.interaction.vote/456', + voteUri: 'at://did:plc:test/social.coves.feed.vote/456', ); expect(voteProvider.isLiked(testPostUri), true); final voteState = voteProvider.getVoteState(testPostUri); expect(voteState?.direction, 'up'); - expect(voteState?.uri, 'at://did:plc:test/social.coves.interaction.vote/456'); + expect(voteState?.uri, 'at://did:plc:test/social.coves.feed.vote/456'); expect(voteState?.deleted, false); }); @@ -296,7 +316,7 @@ void main() { voteProvider.setInitialVoteState( postUri: testPostUri, voteDirection: 'up', - voteUri: 'at://did:plc:test/social.coves.interaction.vote/456', + voteUri: 'at://did:plc:test/social.coves.feed.vote/456', ); expect(voteProvider.isLiked(testPostUri), true); @@ -319,7 +339,7 @@ void main() { voteProvider.setInitialVoteState( postUri: testPostUri, voteDirection: 'up', - voteUri: 'at://did:plc:test/social.coves.interaction.vote/456', + voteUri: 'at://did:plc:test/social.coves.feed.vote/456', ); // Should NOT notify listeners (silent initialization) @@ -336,12 +356,12 @@ void main() { voteProvider.setInitialVoteState( postUri: post1, voteDirection: 'up', - voteUri: 'at://did:plc:test/social.coves.interaction.vote/1', + voteUri: 'at://did:plc:test/social.coves.feed.vote/1', ); voteProvider.setInitialVoteState( postUri: post2, voteDirection: 'up', - voteUri: 'at://did:plc:test/social.coves.interaction.vote/2', + voteUri: 'at://did:plc:test/social.coves.feed.vote/2', ); expect(voteProvider.isLiked(post1), true); @@ -377,14 +397,17 @@ void main() { // Mock slow API response when( mockVoteService.createVote( - postUri: testPostUri, - postCid: testPostCid, + postUri: anyNamed('postUri'), + postCid: anyNamed('postCid'), + direction: anyNamed('direction'), + existingVoteRkey: anyNamed('existingVoteRkey'), + existingVoteDirection: anyNamed('existingVoteDirection'), ), ).thenAnswer( (_) async { await Future.delayed(const Duration(milliseconds: 50)); return const VoteResponse( - uri: 'at://did:plc:test/social.coves.interaction.vote/456', + uri: 'at://did:plc:test/social.coves.feed.vote/456', cid: 'bafy123', rkey: '456', deleted: false, @@ -419,6 +442,238 @@ void main() { }); }); + group('Score adjustments', () { + const testPostUri = 'at://did:plc:test/social.coves.post.record/123'; + const testPostCid = 'bafy2bzacepostcid123'; + + test('should adjust score when creating upvote', () async { + when( + mockVoteService.createVote( + postUri: anyNamed('postUri'), + postCid: anyNamed('postCid'), + direction: anyNamed('direction'), + existingVoteRkey: anyNamed('existingVoteRkey'), + existingVoteDirection: anyNamed('existingVoteDirection'), + ), + ).thenAnswer( + (_) async => const VoteResponse( + uri: 'at://did:plc:test/social.coves.feed.vote/456', + cid: 'bafy123', + rkey: '456', + deleted: false, + ), + ); + + // Initial score from server + const serverScore = 10; + + // Before vote, adjustment should be 0 + expect(voteProvider.getAdjustedScore(testPostUri, serverScore), 10); + + // Create upvote + await voteProvider.toggleVote( + postUri: testPostUri, + postCid: testPostCid, + ); + + // Should have +1 adjustment (upvote added) + expect(voteProvider.getAdjustedScore(testPostUri, serverScore), 11); + }); + + test('should adjust score when removing upvote', () async { + // Set initial state with upvote + voteProvider.setInitialVoteState( + postUri: testPostUri, + voteDirection: 'up', + voteUri: 'at://did:plc:test/social.coves.feed.vote/456', + ); + + when( + mockVoteService.createVote( + postUri: anyNamed('postUri'), + postCid: anyNamed('postCid'), + direction: anyNamed('direction'), + existingVoteRkey: anyNamed('existingVoteRkey'), + existingVoteDirection: anyNamed('existingVoteDirection'), + ), + ).thenAnswer( + (_) async => const VoteResponse(deleted: true), + ); + + const serverScore = 10; + + // Before removing, adjustment should be 0 (server knows about upvote) + expect(voteProvider.getAdjustedScore(testPostUri, serverScore), 10); + + // Remove upvote + await voteProvider.toggleVote( + postUri: testPostUri, + postCid: testPostCid, + ); + + // Should have -1 adjustment (upvote removed) + expect(voteProvider.getAdjustedScore(testPostUri, serverScore), 9); + }); + + test('should adjust score when creating downvote', () async { + when( + mockVoteService.createVote( + postUri: anyNamed('postUri'), + postCid: anyNamed('postCid'), + direction: anyNamed('direction'), + existingVoteRkey: anyNamed('existingVoteRkey'), + existingVoteDirection: anyNamed('existingVoteDirection'), + ), + ).thenAnswer( + (_) async => const VoteResponse( + uri: 'at://did:plc:test/social.coves.feed.vote/456', + cid: 'bafy123', + rkey: '456', + deleted: false, + ), + ); + + const serverScore = 10; + + // Create downvote + await voteProvider.toggleVote( + postUri: testPostUri, + postCid: testPostCid, + direction: 'down', + ); + + // Should have -1 adjustment (downvote added) + expect(voteProvider.getAdjustedScore(testPostUri, serverScore), 9); + }); + + test('should adjust score when switching from upvote to downvote', + () async { + // Set initial state with upvote + voteProvider.setInitialVoteState( + postUri: testPostUri, + voteDirection: 'up', + voteUri: 'at://did:plc:test/social.coves.feed.vote/456', + ); + + when( + mockVoteService.createVote( + postUri: anyNamed('postUri'), + postCid: anyNamed('postCid'), + direction: anyNamed('direction'), + existingVoteRkey: anyNamed('existingVoteRkey'), + existingVoteDirection: anyNamed('existingVoteDirection'), + ), + ).thenAnswer( + (_) async => const VoteResponse( + uri: 'at://did:plc:test/social.coves.feed.vote/789', + cid: 'bafy789', + rkey: '789', + deleted: false, + ), + ); + + const serverScore = 10; + + // Switch to downvote + await voteProvider.toggleVote( + postUri: testPostUri, + postCid: testPostCid, + direction: 'down', + ); + + // Should have -2 adjustment (remove +1, add -1) + expect(voteProvider.getAdjustedScore(testPostUri, serverScore), 8); + }); + + test('should adjust score when switching from downvote to upvote', + () async { + // Set initial state with downvote + voteProvider.setInitialVoteState( + postUri: testPostUri, + voteDirection: 'down', + voteUri: 'at://did:plc:test/social.coves.feed.vote/456', + ); + + when( + mockVoteService.createVote( + postUri: anyNamed('postUri'), + postCid: anyNamed('postCid'), + direction: anyNamed('direction'), + existingVoteRkey: anyNamed('existingVoteRkey'), + existingVoteDirection: anyNamed('existingVoteDirection'), + ), + ).thenAnswer( + (_) async => const VoteResponse( + uri: 'at://did:plc:test/social.coves.feed.vote/789', + cid: 'bafy789', + rkey: '789', + deleted: false, + ), + ); + + const serverScore = 10; + + // Switch to upvote + await voteProvider.toggleVote( + postUri: testPostUri, + postCid: testPostCid, + direction: 'up', + ); + + // Should have +2 adjustment (remove -1, add +1) + expect(voteProvider.getAdjustedScore(testPostUri, serverScore), 12); + }); + + test('should rollback score adjustment on error', () async { + const serverScore = 10; + + when( + mockVoteService.createVote( + postUri: anyNamed('postUri'), + postCid: anyNamed('postCid'), + direction: anyNamed('direction'), + existingVoteRkey: anyNamed('existingVoteRkey'), + existingVoteDirection: anyNamed('existingVoteDirection'), + ), + ).thenThrow( + ApiException('Network error', statusCode: 500), + ); + + // Try to vote (will fail) + expect( + () => voteProvider.toggleVote( + postUri: testPostUri, + postCid: testPostCid, + ), + throwsA(isA()), + ); + + await Future.delayed(Duration.zero); + + // Adjustment should be rolled back to 0 + expect(voteProvider.getAdjustedScore(testPostUri, serverScore), 10); + }); + + test('should clear score adjustments when clearing all state', () { + const testPostUri1 = 'at://did:plc:test/social.coves.post.record/1'; + const testPostUri2 = 'at://did:plc:test/social.coves.post.record/2'; + + // Manually set some adjustments (simulating votes) + voteProvider.setInitialVoteState( + postUri: testPostUri1, + voteDirection: 'up', + voteUri: 'at://did:plc:test/social.coves.feed.vote/1', + ); + + // Clear all + voteProvider.clear(); + + // Adjustments should be cleared (back to 0) + expect(voteProvider.getAdjustedScore(testPostUri1, 10), 10); + expect(voteProvider.getAdjustedScore(testPostUri2, 5), 5); + }); + }); + group('Auth state listener', () { test('should clear votes when user signs out', () { const testPostUri = 'at://did:plc:test/social.coves.post.record/123'; @@ -427,7 +682,7 @@ void main() { voteProvider.setInitialVoteState( postUri: testPostUri, voteDirection: 'up', - voteUri: 'at://did:plc:test/social.coves.interaction.vote/456', + voteUri: 'at://did:plc:test/social.coves.feed.vote/456', ); expect(voteProvider.isLiked(testPostUri), true); @@ -451,7 +706,7 @@ void main() { voteProvider.setInitialVoteState( postUri: testPostUri, voteDirection: 'up', - voteUri: 'at://did:plc:test/social.coves.interaction.vote/456', + voteUri: 'at://did:plc:test/social.coves.feed.vote/456', ); expect(voteProvider.isLiked(testPostUri), true); diff --git a/test/providers/vote_provider_test.mocks.dart b/test/providers/vote_provider_test.mocks.dart index 4d16fff..8b6e8ff 100644 --- a/test/providers/vote_provider_test.mocks.dart +++ b/test/providers/vote_provider_test.mocks.dart @@ -38,17 +38,31 @@ class MockVoteService extends _i1.Mock implements _i2.VoteService { _i1.throwOnMissingStub(this); } + @override + _i3.Future> getUserVotes() => + (super.noSuchMethod( + Invocation.method(#getUserVotes, []), + returnValue: _i3.Future>.value( + {}, + ), + ) + as _i3.Future>); + @override _i3.Future<_i2.VoteResponse> createVote({ required String? postUri, required String? postCid, String? direction = 'up', + String? existingVoteRkey, + String? existingVoteDirection, }) => (super.noSuchMethod( Invocation.method(#createVote, [], { #postUri: postUri, #postCid: postCid, #direction: direction, + #existingVoteRkey: existingVoteRkey, + #existingVoteDirection: existingVoteDirection, }), returnValue: _i3.Future<_i2.VoteResponse>.value( _FakeVoteResponse_0( @@ -57,6 +71,8 @@ class MockVoteService extends _i1.Mock implements _i2.VoteService { #postUri: postUri, #postCid: postCid, #direction: direction, + #existingVoteRkey: existingVoteRkey, + #existingVoteDirection: existingVoteDirection, }), ), ), diff --git a/test/services/vote_service_test.dart b/test/services/vote_service_test.dart index f5730cf..7708247 100644 --- a/test/services/vote_service_test.dart +++ b/test/services/vote_service_test.dart @@ -29,7 +29,7 @@ void main() { jsonEncode({ 'records': [ { - 'uri': 'at://did:plc:test/social.coves.interaction.vote/abc123', + 'uri': 'at://did:plc:test/social.coves.feed.vote/abc123', 'value': { 'subject': { 'uri': 'at://did:plc:author/social.coves.post.record/post1', @@ -95,7 +95,7 @@ void main() { jsonEncode({ 'records': [ { - 'uri': 'at://did:plc:test/social.coves.interaction.vote/abc1', + 'uri': 'at://did:plc:test/social.coves.feed.vote/abc1', 'value': { 'subject': { 'uri': 'at://did:plc:author/social.coves.post.record/other1', @@ -115,7 +115,7 @@ void main() { jsonEncode({ 'records': [ { - 'uri': 'at://did:plc:test/social.coves.interaction.vote/abc123', + 'uri': 'at://did:plc:test/social.coves.feed.vote/abc123', 'value': { 'subject': { 'uri': 'at://did:plc:author/social.coves.post.record/target', @@ -197,7 +197,7 @@ void main() { jsonEncode({ 'records': [ { - 'uri': 'at://did:plc:test/social.coves.interaction.vote/abc1', + 'uri': 'at://did:plc:test/social.coves.feed.vote/abc1', 'value': { 'subject': { 'uri': 'at://did:plc:author/social.coves.post.record/other', @@ -230,7 +230,7 @@ void main() { ).thenAnswer( (_) async => http.Response( jsonEncode({ - 'uri': 'at://did:plc:test/social.coves.interaction.vote/new123', + 'uri': 'at://did:plc:test/social.coves.feed.vote/new123', 'cid': 'bafy456', }), 200, @@ -268,13 +268,13 @@ void main() { // We'll use a minimal test to verify the VoteResponse parsing logic const response = VoteResponse( - uri: 'at://did:plc:test/social.coves.interaction.vote/456', + uri: 'at://did:plc:test/social.coves.feed.vote/456', cid: 'bafy123', rkey: '456', deleted: false, ); - expect(response.uri, 'at://did:plc:test/social.coves.interaction.vote/456'); + expect(response.uri, 'at://did:plc:test/social.coves.feed.vote/456'); expect(response.cid, 'bafy123'); expect(response.rkey, '456'); expect(response.deleted, false); -- 2.51.2