feat: update vote lexicon and add optimistic vote updates

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 <noreply@anthropic.com>

+7 -7
DEVELOPMENT_SUMMARY.md
···
### 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"
···
**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;
```
···
## 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
{
···
"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"
···
"viewer": {
"vote": {
"direction": "up",
-
"uri": "at://did:plc:user/social.coves.interaction.vote/3kby..."
+
"uri": "at://did:plc:user/social.coves.feed.vote/3kby..."
}
}
}
+16 -1
lib/main.dart
···
MultiProvider(
providers: [
ChangeNotifierProvider.value(value: authProvider),
-
ChangeNotifierProvider(create: (_) => FeedProvider(authProvider)),
ChangeNotifierProvider(
create: (_) => VoteProvider(
voteService: voteService,
authProvider: authProvider,
),
+
),
+
ChangeNotifierProxyProvider2<AuthProvider, VoteProvider, FeedProvider>(
+
create: (context) => FeedProvider(
+
authProvider,
+
voteProvider: context.read<VoteProvider>(),
+
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(),
+27 -1
lib/providers/feed_provider.dart
···
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
///
···
/// 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 =
···
final AuthProvider _authProvider;
late final CovesApiService _apiService;
+
final VoteProvider? _voteProvider;
+
final VoteService? _voteService;
// Track previous auth state to detect transitions
bool _wasAuthenticated = false;
···
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();
+104 -4
lib/providers/vote_provider.dart
···
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
···
// Map of post URI -> in-flight request flag
final Map<String, bool> _pendingRequests = {};
+
// Map of post URI -> score adjustment (for optimistic UI updates)
+
// Tracks the local delta from the server's score
+
final Map<String, int> _scoreAdjustments = {};
+
/// Get vote state for a post
VoteState? getVoteState(String postUri) => _votes[postUri];
···
/// 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:
···
// 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)) {
···
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
···
} else {
_votes.remove(postUri);
}
+
+
// Rollback score adjustment
+
if (previousAdjustment != 0) {
+
_scoreAdjustments[postUri] = previousAdjustment;
+
} else {
+
_scoreAdjustments.remove(postUri);
+
}
+
notifyListeners();
rethrow;
···
}) {
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('/');
···
// 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<String, VoteInfo> 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();
}
}
···
/// 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
+134 -9
lib/services/vote_service.dart
···
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<String, VoteInfo> where key is the post URI
+
/// - Empty map if not authenticated or no votes found
+
Future<Map<String, VoteInfo>> 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, VoteInfo>{};
+
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<String, dynamic>;
+
final records = data['records'] as List<dynamic>?;
+
+
if (records != null) {
+
for (final record in records) {
+
final recordMap = record as Map<String, dynamic>;
+
final value = recordMap['value'] as Map<String, dynamic>?;
+
final uri = recordMap['uri'] as String?;
+
+
if (value == null || uri == null) {
+
continue;
+
}
+
+
final subject = value['subject'] as Map<String, dynamic>?;
+
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
···
/// "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
···
required String postUri,
required String postCid,
String direction = 'up',
+
String? existingVoteRkey,
+
String? existingVoteDirection,
}) async {
try {
// Get user's DID and PDS URL
···
}
// 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) {
···
}
return response;
-
} catch (e) {
+
} on Exception catch (e) {
throw ApiException('Failed to create vote: $e');
}
}
···
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);
···
// Vote not found after searching all pages
return null;
-
} catch (e) {
+
} on Exception catch (e) {
if (kDebugMode) {
debugPrint('⚠️ Failed to list votes: $e');
}
···
/// 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;
+
}
+5 -1
lib/widgets/post_card.dart
···
Consumer<VoteProvider>(
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 {
···
),
const SizedBox(width: 5),
Text(
-
DateTimeUtils.formatCount(post.post.stats.score),
+
DateTimeUtils.formatCount(adjustedScore),
style: TextStyle(
color: AppColors.textPrimary
.withValues(alpha: 0.6),
+287 -32
test/providers/vote_provider_test.dart
···
// 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,
···
// 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);
});
···
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);
···
// 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),
···
// 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),
···
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);
···
// 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'),
···
// 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,
···
// 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);
});
···
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,
···
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);
});
···
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);
···
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)
···
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);
···
// 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,
···
});
});
+
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<ApiException>()),
+
);
+
+
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';
···
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);
···
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);
+16
test/providers/vote_provider_test.mocks.dart
···
}
@override
+
_i3.Future<Map<String, _i2.VoteInfo>> getUserVotes() =>
+
(super.noSuchMethod(
+
Invocation.method(#getUserVotes, []),
+
returnValue: _i3.Future<Map<String, _i2.VoteInfo>>.value(
+
<String, _i2.VoteInfo>{},
+
),
+
)
+
as _i3.Future<Map<String, _i2.VoteInfo>>);
+
+
@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(
···
#postUri: postUri,
#postCid: postCid,
#direction: direction,
+
#existingVoteRkey: existingVoteRkey,
+
#existingVoteDirection: existingVoteDirection,
}),
),
),
+7 -7
test/services/vote_service_test.dart
···
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',
···
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',
···
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',
···
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',
···
).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,
···
// 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);