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}