Main coves client
1import 'dart:convert';
2
3import 'package:atproto_oauth_flutter/atproto_oauth_flutter.dart';
4import 'package:coves_flutter/services/api_exceptions.dart';
5import 'package:coves_flutter/services/vote_service.dart';
6import 'package:dio/dio.dart';
7import 'package:flutter_test/flutter_test.dart';
8import 'package:http/http.dart' as http;
9import 'package:mockito/annotations.dart';
10import 'package:mockito/mockito.dart';
11
12import 'vote_service_test.mocks.dart';
13
14// Generate mocks for OAuthSession
15@GenerateMocks([OAuthSession])
16void main() {
17 group('VoteService', () {
18 group('_findExistingVote pagination', () {
19 test('should find vote in first page', () async {
20 final mockSession = MockOAuthSession();
21 final service = VoteService(
22 sessionGetter: () async => mockSession,
23 didGetter: () => 'did:plc:test',
24 pdsUrlGetter: () => 'https://test.pds',
25 );
26
27 // Mock first page response with matching vote
28 final firstPageResponse = http.Response(
29 jsonEncode({
30 'records': [
31 {
32 'uri': 'at://did:plc:test/social.coves.feed.vote/abc123',
33 'value': {
34 'subject': {
35 'uri': 'at://did:plc:author/social.coves.post.record/post1',
36 'cid': 'bafy123',
37 },
38 'direction': 'up',
39 'createdAt': '2024-01-01T00:00:00Z',
40 },
41 },
42 ],
43 'cursor': null,
44 }),
45 200,
46 );
47
48 when(
49 mockSession.fetchHandler(
50 argThat(contains('listRecords')),
51 ),
52 ).thenAnswer((_) async => firstPageResponse);
53
54 // Mock deleteRecord for when existing vote is found
55 when(
56 mockSession.fetchHandler(
57 argThat(contains('deleteRecord')),
58 method: 'POST',
59 headers: anyNamed('headers'),
60 body: anyNamed('body'),
61 ),
62 ).thenAnswer((_) async => http.Response(jsonEncode({}), 200));
63
64 // Test that vote is found via reflection (private method)
65 // This is verified indirectly through createVote behavior
66 final response = await service.createVote(
67 postUri: 'at://did:plc:author/social.coves.post.record/post1',
68 postCid: 'bafy123',
69 );
70
71 // Should return deleted=true because existing vote with same direction
72 expect(response.deleted, true);
73 verify(
74 mockSession.fetchHandler(
75 argThat(contains('listRecords')),
76 ),
77 ).called(1);
78 });
79
80 test('should paginate through multiple pages to find vote', () async {
81 final mockSession = MockOAuthSession();
82 final service = VoteService(
83 sessionGetter: () async => mockSession,
84 didGetter: () => 'did:plc:test',
85 pdsUrlGetter: () => 'https://test.pds',
86 );
87
88 // Mock first page without matching vote but with cursor
89 final firstPageResponse = http.Response(
90 jsonEncode({
91 'records': [
92 {
93 'uri': 'at://did:plc:test/social.coves.feed.vote/abc1',
94 'value': {
95 'subject': {
96 'uri':
97 'at://did:plc:author/social.coves.post.record/other1',
98 'cid': 'bafy001',
99 },
100 'direction': 'up',
101 },
102 },
103 ],
104 'cursor': 'cursor123',
105 }),
106 200,
107 );
108
109 // Mock second page with matching vote
110 final secondPageResponse = http.Response(
111 jsonEncode({
112 'records': [
113 {
114 'uri': 'at://did:plc:test/social.coves.feed.vote/abc123',
115 'value': {
116 'subject': {
117 'uri':
118 'at://did:plc:author/social.coves.post.record/target',
119 'cid': 'bafy123',
120 },
121 'direction': 'up',
122 'createdAt': '2024-01-01T00:00:00Z',
123 },
124 },
125 ],
126 'cursor': null,
127 }),
128 200,
129 );
130
131 // Setup mock responses based on URL
132 when(
133 mockSession.fetchHandler(
134 argThat(allOf(contains('listRecords'), isNot(contains('cursor')))),
135 ),
136 ).thenAnswer((_) async => firstPageResponse);
137
138 when(
139 mockSession.fetchHandler(
140 argThat(
141 allOf(contains('listRecords'), contains('cursor=cursor123')),
142 ),
143 ),
144 ).thenAnswer((_) async => secondPageResponse);
145
146 // Mock deleteRecord for when existing vote is found
147 when(
148 mockSession.fetchHandler(
149 argThat(contains('deleteRecord')),
150 method: 'POST',
151 headers: anyNamed('headers'),
152 body: anyNamed('body'),
153 ),
154 ).thenAnswer((_) async => http.Response(jsonEncode({}), 200));
155
156 // Test that pagination works by creating vote that exists on page 2
157 final response = await service.createVote(
158 postUri: 'at://did:plc:author/social.coves.post.record/target',
159 postCid: 'bafy123',
160 );
161
162 // Should return deleted=true because existing vote was found on page 2
163 expect(response.deleted, true);
164
165 // Verify both pages were fetched
166 verify(
167 mockSession.fetchHandler(
168 argThat(allOf(contains('listRecords'), isNot(contains('cursor')))),
169 ),
170 ).called(1);
171
172 verify(
173 mockSession.fetchHandler(
174 argThat(
175 allOf(contains('listRecords'), contains('cursor=cursor123')),
176 ),
177 ),
178 ).called(1);
179 });
180
181 test('should handle vote not found after pagination', () async {
182 final mockSession = MockOAuthSession();
183 final service = VoteService(
184 sessionGetter: () async => mockSession,
185 didGetter: () => 'did:plc:test',
186 pdsUrlGetter: () => 'https://test.pds',
187 );
188
189 // Mock response with no matching votes
190 final response = http.Response(
191 jsonEncode({
192 'records': [
193 {
194 'uri': 'at://did:plc:test/social.coves.feed.vote/abc1',
195 'value': {
196 'subject': {
197 'uri': 'at://did:plc:author/social.coves.post.record/other',
198 'cid': 'bafy001',
199 },
200 'direction': 'up',
201 },
202 },
203 ],
204 'cursor': null,
205 }),
206 200,
207 );
208
209 when(
210 mockSession.fetchHandler(
211 argThat(contains('listRecords')),
212 ),
213 ).thenAnswer((_) async => response);
214
215 // Mock createRecord for new vote
216 when(
217 mockSession.fetchHandler(
218 argThat(contains('createRecord')),
219 method: 'POST',
220 headers: anyNamed('headers'),
221 body: anyNamed('body'),
222 ),
223 ).thenAnswer(
224 (_) async => http.Response(
225 jsonEncode({
226 'uri': 'at://did:plc:test/social.coves.feed.vote/new123',
227 'cid': 'bafy456',
228 }),
229 200,
230 ),
231 );
232
233 // Test creating vote for post not in vote history
234 final voteResponse = await service.createVote(
235 postUri: 'at://did:plc:author/social.coves.post.record/newpost',
236 postCid: 'bafy123',
237 );
238
239 // Should create new vote
240 expect(voteResponse.deleted, false);
241 expect(voteResponse.uri, isNotNull);
242 expect(voteResponse.cid, 'bafy456');
243
244 // Verify createRecord was called
245 verify(
246 mockSession.fetchHandler(
247 argThat(contains('createRecord')),
248 method: 'POST',
249 headers: anyNamed('headers'),
250 body: anyNamed('body'),
251 ),
252 ).called(1);
253 });
254 });
255
256 group('createVote', () {
257 test('should create vote successfully', () async {
258 // Create a real VoteService instance that we can test with
259 // We'll use a minimal test to verify the VoteResponse parsing logic
260
261 const response = VoteResponse(
262 uri: 'at://did:plc:test/social.coves.feed.vote/456',
263 cid: 'bafy123',
264 rkey: '456',
265 deleted: false,
266 );
267
268 expect(response.uri, 'at://did:plc:test/social.coves.feed.vote/456');
269 expect(response.cid, 'bafy123');
270 expect(response.rkey, '456');
271 expect(response.deleted, false);
272 });
273
274 test('should return deleted response when vote is toggled off', () {
275 const response = VoteResponse(deleted: true);
276
277 expect(response.deleted, true);
278 expect(response.uri, null);
279 expect(response.cid, null);
280 });
281
282 test('should throw ApiException on Dio network error', () {
283 // Test ApiException.fromDioError for connection errors
284 final dioError = DioException(
285 requestOptions: RequestOptions(path: '/test'),
286 type: DioExceptionType.connectionError,
287 );
288
289 final exception = ApiException.fromDioError(dioError);
290
291 expect(exception, isA<NetworkException>());
292 expect(exception.message, contains('Connection failed'));
293 });
294
295 test('should throw ApiException on Dio timeout', () {
296 final dioError = DioException(
297 requestOptions: RequestOptions(path: '/test'),
298 type: DioExceptionType.connectionTimeout,
299 );
300
301 final exception = ApiException.fromDioError(dioError);
302
303 expect(exception, isA<NetworkException>());
304 expect(exception.message, contains('timeout'));
305 });
306
307 test('should throw AuthenticationException on 401 response', () {
308 final dioError = DioException(
309 requestOptions: RequestOptions(path: '/test'),
310 type: DioExceptionType.badResponse,
311 response: Response(
312 requestOptions: RequestOptions(path: '/test'),
313 statusCode: 401,
314 data: {'message': 'Unauthorized'},
315 ),
316 );
317
318 final exception = ApiException.fromDioError(dioError);
319
320 expect(exception, isA<AuthenticationException>());
321 expect(exception.statusCode, 401);
322 expect(exception.message, 'Unauthorized');
323 });
324
325 test('should throw NotFoundException on 404 response', () {
326 final dioError = DioException(
327 requestOptions: RequestOptions(path: '/test'),
328 type: DioExceptionType.badResponse,
329 response: Response(
330 requestOptions: RequestOptions(path: '/test'),
331 statusCode: 404,
332 data: {'message': 'Post not found'},
333 ),
334 );
335
336 final exception = ApiException.fromDioError(dioError);
337
338 expect(exception, isA<NotFoundException>());
339 expect(exception.statusCode, 404);
340 expect(exception.message, 'Post not found');
341 });
342
343 test('should throw ServerException on 500 response', () {
344 final dioError = DioException(
345 requestOptions: RequestOptions(path: '/test'),
346 type: DioExceptionType.badResponse,
347 response: Response(
348 requestOptions: RequestOptions(path: '/test'),
349 statusCode: 500,
350 data: {'error': 'Internal server error'},
351 ),
352 );
353
354 final exception = ApiException.fromDioError(dioError);
355
356 expect(exception, isA<ServerException>());
357 expect(exception.statusCode, 500);
358 expect(exception.message, 'Internal server error');
359 });
360
361 test('should extract error message from response data', () {
362 final dioError = DioException(
363 requestOptions: RequestOptions(path: '/test'),
364 type: DioExceptionType.badResponse,
365 response: Response(
366 requestOptions: RequestOptions(path: '/test'),
367 statusCode: 400,
368 data: {'message': 'Invalid post URI'},
369 ),
370 );
371
372 final exception = ApiException.fromDioError(dioError);
373
374 expect(exception.message, 'Invalid post URI');
375 expect(exception.statusCode, 400);
376 });
377
378 test('should use default message if no error message in response', () {
379 final dioError = DioException(
380 requestOptions: RequestOptions(path: '/test'),
381 type: DioExceptionType.badResponse,
382 response: Response(
383 requestOptions: RequestOptions(path: '/test'),
384 statusCode: 400,
385 data: {},
386 ),
387 );
388
389 final exception = ApiException.fromDioError(dioError);
390
391 expect(exception.message, 'Server error');
392 });
393
394 test('should handle cancelled requests', () {
395 final dioError = DioException(
396 requestOptions: RequestOptions(path: '/test'),
397 type: DioExceptionType.cancel,
398 );
399
400 final exception = ApiException.fromDioError(dioError);
401
402 expect(exception.message, contains('cancelled'));
403 });
404
405 test('should handle bad certificate errors', () {
406 final dioError = DioException(
407 requestOptions: RequestOptions(path: '/test'),
408 type: DioExceptionType.badCertificate,
409 );
410
411 final exception = ApiException.fromDioError(dioError);
412
413 expect(exception, isA<NetworkException>());
414 expect(exception.message, contains('certificate'));
415 });
416
417 test('should handle unknown errors', () {
418 final dioError = DioException(
419 requestOptions: RequestOptions(path: '/test'),
420 );
421
422 final exception = ApiException.fromDioError(dioError);
423
424 expect(exception, isA<NetworkException>());
425 expect(exception.message, contains('Network error'));
426 });
427 });
428
429 group('VoteResponse', () {
430 test('should create response with uri, cid, and rkey', () {
431 const response = VoteResponse(
432 uri: 'at://vote/123',
433 cid: 'bafy123',
434 rkey: '123',
435 deleted: false,
436 );
437
438 expect(response.uri, 'at://vote/123');
439 expect(response.cid, 'bafy123');
440 expect(response.rkey, '123');
441 expect(response.deleted, false);
442 });
443
444 test('should create response with rkey extracted from uri', () {
445 const response = VoteResponse(
446 uri: 'at://vote/456',
447 cid: 'bafy456',
448 rkey: '456',
449 deleted: false,
450 );
451
452 expect(response.uri, 'at://vote/456');
453 expect(response.cid, 'bafy456');
454 expect(response.rkey, '456');
455 expect(response.deleted, false);
456 });
457
458 test('should create deleted response', () {
459 const response = VoteResponse(deleted: true);
460
461 expect(response.deleted, true);
462 expect(response.uri, null);
463 expect(response.cid, null);
464 expect(response.rkey, null);
465 });
466 });
467 });
468}