fix: implement cursor-based pagination for vote lookup

Fixes critical bug where users could not unlike posts after voting on
more than 100 posts, leading to duplicate vote records accumulating on
their PDS.

## Problem
The _findExistingVote method only checked the first 100 vote records
(limit=100). Once a user voted on >100 posts, older votes fell off the
first page, causing:
- _findExistingVote to return null for older votes
- Creation of duplicate vote records instead of deleting originals
- Users unable to unlike older posts

## Solution
Implemented cursor-based pagination to search through ALL vote records:
- Added do-while loop to fetch pages until cursor is null
- Constructs URL with cursor parameter for subsequent pages
- Searches each page for matching vote before moving to next
- Early exit when vote is found for efficiency

## Changes
- lib/services/vote_service.dart:
- _findExistingVote now uses cursor-based pagination (lines 198-273)
- Added pageSize constant (100 records per page)
- Loop continues while cursor != null
- Updated doc comment to explain pagination

- test/services/vote_service_test.dart:
- Added 3 comprehensive pagination tests:
1. Vote found in first page
2. Vote found on second page (validates cursor following)
3. Vote not found after exhausting all pages
- Uses mockito to mock OAuthSession.fetchHandler
- Verifies correct number of listRecords calls

## Testing
All 112 tests pass (up from 109, +3 new pagination tests).

## Impact
✅ Users can now unlike posts regardless of vote count
✅ No duplicate vote records created
✅ Proper atProto cursor-based pagination pattern
✅ Early exit optimization when vote found

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>

Changed files
+463 -36
lib
test
+49 -36
lib/services/vote_service.dart
···
/// Find existing vote for a post
///
/// Queries the user's PDS to check if they've already voted on this post.
///
/// Returns ExistingVote with direction and rkey if found, null otherwise.
Future<ExistingVote?> _findExistingVote({
···
return null;
}
-
// Query listRecords to find votes using session's fetchHandler
-
final response = await session.fetchHandler(
-
'/xrpc/com.atproto.repo.listRecords?repo=$userDid&collection=$voteCollection&limit=100&reverse=true',
-
method: 'GET',
-
);
-
if (response.statusCode != 200) {
-
if (kDebugMode) {
-
debugPrint('⚠️ Failed to list votes: ${response.statusCode}');
}
-
return null;
-
}
-
final data = jsonDecode(response.body) as Map<String, dynamic>;
-
final records = data['records'] as List<dynamic>?;
-
if (records == null || records.isEmpty) {
-
return null;
-
}
-
// Find vote for this specific post
-
for (final record in records) {
-
final recordMap = record as Map<String, dynamic>;
-
final value = recordMap['value'] as Map<String, dynamic>?;
-
if (value == null) {
-
continue;
-
}
-
final subject = value['subject'] as Map<String, dynamic>?;
-
if (subject == null) {
-
continue;
-
}
-
final subjectUri = subject['uri'] as String?;
-
if (subjectUri == postUri) {
-
// Found existing vote!
-
final direction = value['direction'] as String;
-
final uri = recordMap['uri'] as String;
-
// Extract rkey from URI
-
// Format: at://did:plc:xyz/social.coves.interaction.vote/3kby...
-
final rkey = uri.split('/').last;
-
return ExistingVote(direction: direction, rkey: rkey);
}
-
}
return null;
} catch (e) {
if (kDebugMode) {
···
/// Find existing vote for a post
///
/// Queries the user's PDS to check if they've already voted on this post.
+
/// Uses cursor-based pagination to search through all vote records, not just
+
/// the first 100. This prevents duplicate votes when users have voted on
+
/// more than 100 posts.
///
/// Returns ExistingVote with direction and rkey if found, null otherwise.
Future<ExistingVote?> _findExistingVote({
···
return null;
}
+
// Paginate through all vote records using cursor
+
String? cursor;
+
const pageSize = 100;
+
do {
+
// Build URL with cursor if available
+
final url = cursor == null
+
? '/xrpc/com.atproto.repo.listRecords?repo=$userDid&collection=$voteCollection&limit=$pageSize&reverse=true'
+
: '/xrpc/com.atproto.repo.listRecords?repo=$userDid&collection=$voteCollection&limit=$pageSize&reverse=true&cursor=$cursor';
+
+
final response = await session.fetchHandler(url, method: 'GET');
+
+
if (response.statusCode != 200) {
+
if (kDebugMode) {
+
debugPrint('⚠️ Failed to list votes: ${response.statusCode}');
+
}
+
return null;
}
+
final data = jsonDecode(response.body) as Map<String, dynamic>;
+
final records = data['records'] as List<dynamic>?;
+
// Search current page for matching vote
+
if (records != null) {
+
for (final record in records) {
+
final recordMap = record as Map<String, dynamic>;
+
final value = recordMap['value'] as Map<String, dynamic>?;
+
if (value == null) {
+
continue;
+
}
+
final subject = value['subject'] as Map<String, dynamic>?;
+
if (subject == null) {
+
continue;
+
}
+
final subjectUri = subject['uri'] as String?;
+
if (subjectUri == postUri) {
+
// Found existing vote!
+
final direction = value['direction'] as String;
+
final uri = recordMap['uri'] as String;
+
// Extract rkey from URI
+
// Format: at://did:plc:xyz/social.coves.interaction.vote/3kby...
+
final rkey = uri.split('/').last;
+
return ExistingVote(direction: direction, rkey: rkey);
+
}
+
}
}
+
// Get cursor for next page
+
cursor = data['cursor'] as String?;
+
} while (cursor != null);
+
+
// Vote not found after searching all pages
return null;
} catch (e) {
if (kDebugMode) {
+256
test/services/vote_service_test.dart
···
import 'package:coves_flutter/services/api_exceptions.dart';
import 'package:coves_flutter/services/vote_service.dart';
import 'package:dio/dio.dart';
import 'package:flutter_test/flutter_test.dart';
void main() {
group('VoteService', () {
group('createVote', () {
test('should create vote successfully', () async {
···
+
import 'dart:convert';
+
+
import 'package:atproto_oauth_flutter/atproto_oauth_flutter.dart';
import 'package:coves_flutter/services/api_exceptions.dart';
import 'package:coves_flutter/services/vote_service.dart';
import 'package:dio/dio.dart';
import 'package:flutter_test/flutter_test.dart';
+
import 'package:http/http.dart' as http;
+
import 'package:mockito/annotations.dart';
+
import 'package:mockito/mockito.dart';
+
+
import 'vote_service_test.mocks.dart';
+
// Generate mocks for OAuthSession
+
@GenerateMocks([OAuthSession])
void main() {
group('VoteService', () {
+
group('_findExistingVote pagination', () {
+
test('should find vote in first page', () async {
+
final mockSession = MockOAuthSession();
+
final service = VoteService(
+
sessionGetter: () async => mockSession,
+
didGetter: () => 'did:plc:test',
+
pdsUrlGetter: () => 'https://test.pds',
+
);
+
+
// Mock first page response with matching vote
+
final firstPageResponse = http.Response(
+
jsonEncode({
+
'records': [
+
{
+
'uri': 'at://did:plc:test/social.coves.interaction.vote/abc123',
+
'value': {
+
'subject': {
+
'uri': 'at://did:plc:author/social.coves.post.record/post1',
+
'cid': 'bafy123',
+
},
+
'direction': 'up',
+
'createdAt': '2024-01-01T00:00:00Z',
+
},
+
},
+
],
+
'cursor': null,
+
}),
+
200,
+
);
+
+
when(
+
mockSession.fetchHandler(
+
argThat(contains('listRecords')),
+
method: 'GET',
+
),
+
).thenAnswer((_) async => firstPageResponse);
+
+
// Mock deleteRecord for when existing vote is found
+
when(
+
mockSession.fetchHandler(
+
argThat(contains('deleteRecord')),
+
method: 'POST',
+
headers: anyNamed('headers'),
+
body: anyNamed('body'),
+
),
+
).thenAnswer(
+
(_) async => http.Response(jsonEncode({}), 200),
+
);
+
+
// Test that vote is found via reflection (private method)
+
// This is verified indirectly through createVote behavior
+
final response = await service.createVote(
+
postUri: 'at://did:plc:author/social.coves.post.record/post1',
+
postCid: 'bafy123',
+
direction: 'up',
+
);
+
+
// Should return deleted=true because existing vote with same direction
+
expect(response.deleted, true);
+
verify(
+
mockSession.fetchHandler(
+
argThat(contains('listRecords')),
+
method: 'GET',
+
),
+
).called(1);
+
});
+
+
test('should paginate through multiple pages to find vote', () async {
+
final mockSession = MockOAuthSession();
+
final service = VoteService(
+
sessionGetter: () async => mockSession,
+
didGetter: () => 'did:plc:test',
+
pdsUrlGetter: () => 'https://test.pds',
+
);
+
+
// Mock first page without matching vote but with cursor
+
final firstPageResponse = http.Response(
+
jsonEncode({
+
'records': [
+
{
+
'uri': 'at://did:plc:test/social.coves.interaction.vote/abc1',
+
'value': {
+
'subject': {
+
'uri': 'at://did:plc:author/social.coves.post.record/other1',
+
'cid': 'bafy001',
+
},
+
'direction': 'up',
+
},
+
},
+
],
+
'cursor': 'cursor123',
+
}),
+
200,
+
);
+
+
// Mock second page with matching vote
+
final secondPageResponse = http.Response(
+
jsonEncode({
+
'records': [
+
{
+
'uri': 'at://did:plc:test/social.coves.interaction.vote/abc123',
+
'value': {
+
'subject': {
+
'uri': 'at://did:plc:author/social.coves.post.record/target',
+
'cid': 'bafy123',
+
},
+
'direction': 'up',
+
'createdAt': '2024-01-01T00:00:00Z',
+
},
+
},
+
],
+
'cursor': null,
+
}),
+
200,
+
);
+
+
// Setup mock responses based on URL
+
when(
+
mockSession.fetchHandler(
+
argThat(allOf(contains('listRecords'), isNot(contains('cursor')))),
+
method: 'GET',
+
),
+
).thenAnswer((_) async => firstPageResponse);
+
+
when(
+
mockSession.fetchHandler(
+
argThat(allOf(contains('listRecords'), contains('cursor=cursor123'))),
+
method: 'GET',
+
),
+
).thenAnswer((_) async => secondPageResponse);
+
+
// Mock deleteRecord for when existing vote is found
+
when(
+
mockSession.fetchHandler(
+
argThat(contains('deleteRecord')),
+
method: 'POST',
+
headers: anyNamed('headers'),
+
body: anyNamed('body'),
+
),
+
).thenAnswer(
+
(_) async => http.Response(jsonEncode({}), 200),
+
);
+
+
// Test that pagination works by creating vote that exists on page 2
+
final response = await service.createVote(
+
postUri: 'at://did:plc:author/social.coves.post.record/target',
+
postCid: 'bafy123',
+
direction: 'up',
+
);
+
+
// Should return deleted=true because existing vote was found on page 2
+
expect(response.deleted, true);
+
+
// Verify both pages were fetched
+
verify(
+
mockSession.fetchHandler(
+
argThat(allOf(contains('listRecords'), isNot(contains('cursor')))),
+
method: 'GET',
+
),
+
).called(1);
+
+
verify(
+
mockSession.fetchHandler(
+
argThat(allOf(contains('listRecords'), contains('cursor=cursor123'))),
+
method: 'GET',
+
),
+
).called(1);
+
});
+
+
test('should handle vote not found after pagination', () async {
+
final mockSession = MockOAuthSession();
+
final service = VoteService(
+
sessionGetter: () async => mockSession,
+
didGetter: () => 'did:plc:test',
+
pdsUrlGetter: () => 'https://test.pds',
+
);
+
+
// Mock response with no matching votes
+
final response = http.Response(
+
jsonEncode({
+
'records': [
+
{
+
'uri': 'at://did:plc:test/social.coves.interaction.vote/abc1',
+
'value': {
+
'subject': {
+
'uri': 'at://did:plc:author/social.coves.post.record/other',
+
'cid': 'bafy001',
+
},
+
'direction': 'up',
+
},
+
},
+
],
+
'cursor': null,
+
}),
+
200,
+
);
+
+
when(
+
mockSession.fetchHandler(
+
argThat(contains('listRecords')),
+
method: 'GET',
+
),
+
).thenAnswer((_) async => response);
+
+
// Mock createRecord for new vote
+
when(
+
mockSession.fetchHandler(
+
argThat(contains('createRecord')),
+
method: 'POST',
+
headers: anyNamed('headers'),
+
body: anyNamed('body'),
+
),
+
).thenAnswer(
+
(_) async => http.Response(
+
jsonEncode({
+
'uri': 'at://did:plc:test/social.coves.interaction.vote/new123',
+
'cid': 'bafy456',
+
}),
+
200,
+
),
+
);
+
+
// Test creating vote for post not in vote history
+
final voteResponse = await service.createVote(
+
postUri: 'at://did:plc:author/social.coves.post.record/newpost',
+
postCid: 'bafy123',
+
direction: 'up',
+
);
+
+
// Should create new vote
+
expect(voteResponse.deleted, false);
+
expect(voteResponse.uri, isNotNull);
+
expect(voteResponse.cid, 'bafy456');
+
+
// Verify createRecord was called
+
verify(
+
mockSession.fetchHandler(
+
argThat(contains('createRecord')),
+
method: 'POST',
+
headers: anyNamed('headers'),
+
body: anyNamed('body'),
+
),
+
).called(1);
+
});
+
});
+
group('createVote', () {
test('should create vote successfully', () async {
+158
test/services/vote_service_test.mocks.dart
···
···
+
// Mocks generated by Mockito 5.4.6 from annotations
+
// in coves_flutter/test/services/vote_service_test.dart.
+
// Do not manually edit this file.
+
+
// ignore_for_file: no_leading_underscores_for_library_prefixes
+
import 'dart:async' as _i6;
+
+
import 'package:atproto_oauth_flutter/src/oauth/oauth_server_agent.dart' as _i2;
+
import 'package:atproto_oauth_flutter/src/session/oauth_session.dart' as _i3;
+
import 'package:http/http.dart' as _i4;
+
import 'package:mockito/mockito.dart' as _i1;
+
import 'package:mockito/src/dummies.dart' as _i5;
+
+
// ignore_for_file: type=lint
+
// ignore_for_file: avoid_redundant_argument_values
+
// ignore_for_file: avoid_setters_without_getters
+
// ignore_for_file: comment_references
+
// ignore_for_file: deprecated_member_use
+
// ignore_for_file: deprecated_member_use_from_same_package
+
// ignore_for_file: implementation_imports
+
// ignore_for_file: invalid_use_of_visible_for_testing_member
+
// ignore_for_file: must_be_immutable
+
// ignore_for_file: prefer_const_constructors
+
// ignore_for_file: unnecessary_parenthesis
+
// ignore_for_file: camel_case_types
+
// ignore_for_file: subtype_of_sealed_class
+
// ignore_for_file: invalid_use_of_internal_member
+
+
class _FakeOAuthServerAgent_0 extends _i1.SmartFake
+
implements _i2.OAuthServerAgent {
+
_FakeOAuthServerAgent_0(Object parent, Invocation parentInvocation)
+
: super(parent, parentInvocation);
+
}
+
+
class _FakeSessionGetterInterface_1 extends _i1.SmartFake
+
implements _i3.SessionGetterInterface {
+
_FakeSessionGetterInterface_1(Object parent, Invocation parentInvocation)
+
: super(parent, parentInvocation);
+
}
+
+
class _FakeTokenInfo_2 extends _i1.SmartFake implements _i3.TokenInfo {
+
_FakeTokenInfo_2(Object parent, Invocation parentInvocation)
+
: super(parent, parentInvocation);
+
}
+
+
class _FakeResponse_3 extends _i1.SmartFake implements _i4.Response {
+
_FakeResponse_3(Object parent, Invocation parentInvocation)
+
: super(parent, parentInvocation);
+
}
+
+
/// A class which mocks [OAuthSession].
+
///
+
/// See the documentation for Mockito's code generation for more information.
+
class MockOAuthSession extends _i1.Mock implements _i3.OAuthSession {
+
MockOAuthSession() {
+
_i1.throwOnMissingStub(this);
+
}
+
+
@override
+
_i2.OAuthServerAgent get server =>
+
(super.noSuchMethod(
+
Invocation.getter(#server),
+
returnValue: _FakeOAuthServerAgent_0(
+
this,
+
Invocation.getter(#server),
+
),
+
)
+
as _i2.OAuthServerAgent);
+
+
@override
+
String get sub =>
+
(super.noSuchMethod(
+
Invocation.getter(#sub),
+
returnValue: _i5.dummyValue<String>(this, Invocation.getter(#sub)),
+
)
+
as String);
+
+
@override
+
_i3.SessionGetterInterface get sessionGetter =>
+
(super.noSuchMethod(
+
Invocation.getter(#sessionGetter),
+
returnValue: _FakeSessionGetterInterface_1(
+
this,
+
Invocation.getter(#sessionGetter),
+
),
+
)
+
as _i3.SessionGetterInterface);
+
+
@override
+
String get did =>
+
(super.noSuchMethod(
+
Invocation.getter(#did),
+
returnValue: _i5.dummyValue<String>(this, Invocation.getter(#did)),
+
)
+
as String);
+
+
@override
+
Map<String, dynamic> get serverMetadata =>
+
(super.noSuchMethod(
+
Invocation.getter(#serverMetadata),
+
returnValue: <String, dynamic>{},
+
)
+
as Map<String, dynamic>);
+
+
@override
+
_i6.Future<_i3.TokenInfo> getTokenInfo([dynamic refresh = 'auto']) =>
+
(super.noSuchMethod(
+
Invocation.method(#getTokenInfo, [refresh]),
+
returnValue: _i6.Future<_i3.TokenInfo>.value(
+
_FakeTokenInfo_2(
+
this,
+
Invocation.method(#getTokenInfo, [refresh]),
+
),
+
),
+
)
+
as _i6.Future<_i3.TokenInfo>);
+
+
@override
+
_i6.Future<void> signOut() =>
+
(super.noSuchMethod(
+
Invocation.method(#signOut, []),
+
returnValue: _i6.Future<void>.value(),
+
returnValueForMissingStub: _i6.Future<void>.value(),
+
)
+
as _i6.Future<void>);
+
+
@override
+
_i6.Future<_i4.Response> fetchHandler(
+
String? pathname, {
+
String? method = 'GET',
+
Map<String, String>? headers,
+
dynamic body,
+
}) =>
+
(super.noSuchMethod(
+
Invocation.method(
+
#fetchHandler,
+
[pathname],
+
{#method: method, #headers: headers, #body: body},
+
),
+
returnValue: _i6.Future<_i4.Response>.value(
+
_FakeResponse_3(
+
this,
+
Invocation.method(
+
#fetchHandler,
+
[pathname],
+
{#method: method, #headers: headers, #body: body},
+
),
+
),
+
),
+
)
+
as _i6.Future<_i4.Response>);
+
+
@override
+
void dispose() => super.noSuchMethod(
+
Invocation.method(#dispose, []),
+
returnValueForMissingStub: null,
+
);
+
}