1import 'package:coves_flutter/models/comment.dart'; 2import 'package:coves_flutter/services/api_exceptions.dart'; 3import 'package:coves_flutter/services/coves_api_service.dart'; 4import 'package:dio/dio.dart'; 5import 'package:flutter_test/flutter_test.dart'; 6import 'package:http_mock_adapter/http_mock_adapter.dart'; 7 8void main() { 9 TestWidgetsFlutterBinding.ensureInitialized(); 10 11 group('CovesApiService - getComments', () { 12 late Dio dio; 13 late DioAdapter dioAdapter; 14 late CovesApiService apiService; 15 16 setUp(() { 17 dio = Dio(BaseOptions(baseUrl: 'https://api.test.coves.social')); 18 dioAdapter = DioAdapter(dio: dio); 19 apiService = CovesApiService( 20 dio: dio, 21 tokenGetter: () async => 'test-token', 22 ); 23 }); 24 25 tearDown(() { 26 apiService.dispose(); 27 }); 28 29 test('should successfully fetch comments', () async { 30 const postUri = 'at://did:plc:test/social.coves.post.record/123'; 31 32 final mockResponse = { 33 'post': {'uri': postUri}, 34 'cursor': 'next-cursor', 35 'comments': [ 36 { 37 'comment': { 38 'uri': 'at://did:plc:test/comment/1', 39 'cid': 'cid1', 40 'content': 'Test comment 1', 41 'createdAt': '2025-01-01T12:00:00Z', 42 'indexedAt': '2025-01-01T12:00:00Z', 43 'author': { 44 'did': 'did:plc:author1', 45 'handle': 'user1.test', 46 'displayName': 'User One', 47 }, 48 'post': {'uri': postUri, 'cid': 'post-cid'}, 49 'stats': {'upvotes': 10, 'downvotes': 2, 'score': 8}, 50 }, 51 'hasMore': false, 52 }, 53 { 54 'comment': { 55 'uri': 'at://did:plc:test/comment/2', 56 'cid': 'cid2', 57 'content': 'Test comment 2', 58 'createdAt': '2025-01-01T13:00:00Z', 59 'indexedAt': '2025-01-01T13:00:00Z', 60 'author': {'did': 'did:plc:author2', 'handle': 'user2.test'}, 61 'post': {'uri': postUri, 'cid': 'post-cid'}, 62 'stats': {'upvotes': 5, 'downvotes': 1, 'score': 4}, 63 }, 64 'hasMore': false, 65 }, 66 ], 67 }; 68 69 dioAdapter.onGet( 70 '/xrpc/social.coves.community.comment.getComments', 71 (server) => server.reply(200, mockResponse), 72 queryParameters: { 73 'post': postUri, 74 'sort': 'hot', 75 'depth': 10, 76 'limit': 50, 77 }, 78 ); 79 80 final response = await apiService.getComments(postUri: postUri); 81 82 expect(response, isA<CommentsResponse>()); 83 expect(response.comments.length, 2); 84 expect(response.cursor, 'next-cursor'); 85 expect(response.comments[0].comment.uri, 'at://did:plc:test/comment/1'); 86 expect(response.comments[0].comment.content, 'Test comment 1'); 87 expect(response.comments[1].comment.uri, 'at://did:plc:test/comment/2'); 88 }); 89 90 test('should handle empty comments response', () async { 91 const postUri = 'at://did:plc:test/social.coves.post.record/123'; 92 93 final mockResponse = { 94 'post': {'uri': postUri}, 95 'cursor': null, 96 'comments': [], 97 }; 98 99 dioAdapter.onGet( 100 '/xrpc/social.coves.community.comment.getComments', 101 (server) => server.reply(200, mockResponse), 102 queryParameters: { 103 'post': postUri, 104 'sort': 'hot', 105 'depth': 10, 106 'limit': 50, 107 }, 108 ); 109 110 final response = await apiService.getComments(postUri: postUri); 111 112 expect(response.comments, isEmpty); 113 expect(response.cursor, null); 114 }); 115 116 test('should handle null comments array', () async { 117 const postUri = 'at://did:plc:test/social.coves.post.record/123'; 118 119 final mockResponse = { 120 'post': {'uri': postUri}, 121 'cursor': null, 122 'comments': null, 123 }; 124 125 dioAdapter.onGet( 126 '/xrpc/social.coves.community.comment.getComments', 127 (server) => server.reply(200, mockResponse), 128 queryParameters: { 129 'post': postUri, 130 'sort': 'hot', 131 'depth': 10, 132 'limit': 50, 133 }, 134 ); 135 136 final response = await apiService.getComments(postUri: postUri); 137 138 expect(response.comments, isEmpty); 139 }); 140 141 test('should fetch comments with custom sort option', () async { 142 const postUri = 'at://did:plc:test/social.coves.post.record/123'; 143 144 final mockResponse = { 145 'post': {'uri': postUri}, 146 'cursor': null, 147 'comments': [ 148 { 149 'comment': { 150 'uri': 'at://did:plc:test/comment/1', 151 'cid': 'cid1', 152 'content': 'Newest comment', 153 'createdAt': '2025-01-01T15:00:00Z', 154 'indexedAt': '2025-01-01T15:00:00Z', 155 'author': {'did': 'did:plc:author', 'handle': 'user.test'}, 156 'post': {'uri': postUri, 'cid': 'post-cid'}, 157 'stats': {'upvotes': 1, 'downvotes': 0, 'score': 1}, 158 }, 159 'hasMore': false, 160 }, 161 ], 162 }; 163 164 dioAdapter.onGet( 165 '/xrpc/social.coves.community.comment.getComments', 166 (server) => server.reply(200, mockResponse), 167 queryParameters: { 168 'post': postUri, 169 'sort': 'new', 170 'depth': 10, 171 'limit': 50, 172 }, 173 ); 174 175 final response = await apiService.getComments( 176 postUri: postUri, 177 sort: 'new', 178 ); 179 180 expect(response.comments.length, 1); 181 expect(response.comments[0].comment.content, 'Newest comment'); 182 }); 183 184 test('should fetch comments with timeframe', () async { 185 const postUri = 'at://did:plc:test/social.coves.post.record/123'; 186 187 final mockResponse = { 188 'post': {'uri': postUri}, 189 'cursor': null, 190 'comments': [], 191 }; 192 193 dioAdapter.onGet( 194 '/xrpc/social.coves.community.comment.getComments', 195 (server) => server.reply(200, mockResponse), 196 queryParameters: { 197 'post': postUri, 198 'sort': 'top', 199 'timeframe': 'week', 200 'depth': 10, 201 'limit': 50, 202 }, 203 ); 204 205 final response = await apiService.getComments( 206 postUri: postUri, 207 sort: 'top', 208 timeframe: 'week', 209 ); 210 211 expect(response, isA<CommentsResponse>()); 212 }); 213 214 test('should fetch comments with cursor for pagination', () async { 215 const postUri = 'at://did:plc:test/social.coves.post.record/123'; 216 const cursor = 'pagination-cursor-123'; 217 218 final mockResponse = { 219 'post': {'uri': postUri}, 220 'cursor': 'next-cursor-456', 221 'comments': [ 222 { 223 'comment': { 224 'uri': 'at://did:plc:test/comment/10', 225 'cid': 'cid10', 226 'content': 'Paginated comment', 227 'createdAt': '2025-01-01T12:00:00Z', 228 'indexedAt': '2025-01-01T12:00:00Z', 229 'author': {'did': 'did:plc:author', 'handle': 'user.test'}, 230 'post': {'uri': postUri, 'cid': 'post-cid'}, 231 'stats': {'upvotes': 5, 'downvotes': 0, 'score': 5}, 232 }, 233 'hasMore': false, 234 }, 235 ], 236 }; 237 238 dioAdapter.onGet( 239 '/xrpc/social.coves.community.comment.getComments', 240 (server) => server.reply(200, mockResponse), 241 queryParameters: { 242 'post': postUri, 243 'sort': 'hot', 244 'depth': 10, 245 'limit': 50, 246 'cursor': cursor, 247 }, 248 ); 249 250 final response = await apiService.getComments( 251 postUri: postUri, 252 cursor: cursor, 253 ); 254 255 expect(response.comments.length, 1); 256 expect(response.cursor, 'next-cursor-456'); 257 }); 258 259 test('should fetch comments with custom depth and limit', () async { 260 const postUri = 'at://did:plc:test/social.coves.post.record/123'; 261 262 final mockResponse = { 263 'post': {'uri': postUri}, 264 'cursor': null, 265 'comments': [], 266 }; 267 268 dioAdapter.onGet( 269 '/xrpc/social.coves.community.comment.getComments', 270 (server) => server.reply(200, mockResponse), 271 queryParameters: { 272 'post': postUri, 273 'sort': 'hot', 274 'depth': 5, 275 'limit': 20, 276 }, 277 ); 278 279 final response = await apiService.getComments( 280 postUri: postUri, 281 depth: 5, 282 limit: 20, 283 ); 284 285 expect(response, isA<CommentsResponse>()); 286 }); 287 288 test('should handle 404 error', () async { 289 const postUri = 'at://did:plc:test/social.coves.post.record/nonexistent'; 290 291 dioAdapter.onGet( 292 '/xrpc/social.coves.community.comment.getComments', 293 (server) => server.reply(404, { 294 'error': 'NotFoundError', 295 'message': 'Post not found', 296 }), 297 queryParameters: { 298 'post': postUri, 299 'sort': 'hot', 300 'depth': 10, 301 'limit': 50, 302 }, 303 ); 304 305 expect( 306 () => apiService.getComments(postUri: postUri), 307 throwsA(isA<Exception>()), 308 ); 309 }); 310 311 test('should handle 500 internal server error', () async { 312 const postUri = 'at://did:plc:test/social.coves.post.record/123'; 313 314 dioAdapter.onGet( 315 '/xrpc/social.coves.community.comment.getComments', 316 (server) => server.reply(500, { 317 'error': 'InternalServerError', 318 'message': 'Database connection failed', 319 }), 320 queryParameters: { 321 'post': postUri, 322 'sort': 'hot', 323 'depth': 10, 324 'limit': 50, 325 }, 326 ); 327 328 expect( 329 () => apiService.getComments(postUri: postUri), 330 throwsA(isA<Exception>()), 331 ); 332 }); 333 334 test('should handle network timeout', () async { 335 const postUri = 'at://did:plc:test/social.coves.post.record/123'; 336 337 dioAdapter.onGet( 338 '/xrpc/social.coves.community.comment.getComments', 339 (server) => server.throws( 340 408, 341 DioException.connectionTimeout( 342 timeout: const Duration(seconds: 30), 343 requestOptions: RequestOptions(), 344 ), 345 ), 346 queryParameters: { 347 'post': postUri, 348 'sort': 'hot', 349 'depth': 10, 350 'limit': 50, 351 }, 352 ); 353 354 expect( 355 () => apiService.getComments(postUri: postUri), 356 throwsA(isA<NetworkException>()), 357 ); 358 }); 359 360 test('should handle network connection error', () async { 361 const postUri = 'at://did:plc:test/social.coves.post.record/123'; 362 363 dioAdapter.onGet( 364 '/xrpc/social.coves.community.comment.getComments', 365 (server) => server.throws( 366 503, 367 DioException.connectionError( 368 reason: 'Connection refused', 369 requestOptions: RequestOptions(), 370 ), 371 ), 372 queryParameters: { 373 'post': postUri, 374 'sort': 'hot', 375 'depth': 10, 376 'limit': 50, 377 }, 378 ); 379 380 expect( 381 () => apiService.getComments(postUri: postUri), 382 throwsA(isA<NetworkException>()), 383 ); 384 }); 385 386 test('should handle invalid JSON response', () async { 387 const postUri = 'at://did:plc:test/social.coves.post.record/123'; 388 389 dioAdapter.onGet( 390 '/xrpc/social.coves.community.comment.getComments', 391 (server) => server.reply(200, 'invalid json string'), 392 queryParameters: { 393 'post': postUri, 394 'sort': 'hot', 395 'depth': 10, 396 'limit': 50, 397 }, 398 ); 399 400 expect( 401 () => apiService.getComments(postUri: postUri), 402 throwsA(isA<ApiException>()), 403 ); 404 }); 405 406 test('should handle malformed JSON with missing required fields', () async { 407 const postUri = 'at://did:plc:test/social.coves.post.record/123'; 408 409 final mockResponse = { 410 'post': {'uri': postUri}, 411 'comments': [ 412 { 413 'comment': { 414 'uri': 'at://did:plc:test/comment/1', 415 // Missing required 'cid' field 416 'content': 'Test', 417 'createdAt': '2025-01-01T12:00:00Z', 418 'indexedAt': '2025-01-01T12:00:00Z', 419 'author': {'did': 'did:plc:author', 'handle': 'user.test'}, 420 'post': {'uri': postUri, 'cid': 'post-cid'}, 421 'stats': {'upvotes': 0, 'downvotes': 0, 'score': 0}, 422 }, 423 'hasMore': false, 424 }, 425 ], 426 }; 427 428 dioAdapter.onGet( 429 '/xrpc/social.coves.community.comment.getComments', 430 (server) => server.reply(200, mockResponse), 431 queryParameters: { 432 'post': postUri, 433 'sort': 'hot', 434 'depth': 10, 435 'limit': 50, 436 }, 437 ); 438 439 expect( 440 () => apiService.getComments(postUri: postUri), 441 throwsA(isA<ApiException>()), 442 ); 443 }); 444 445 test('should handle comments with nested replies', () async { 446 const postUri = 'at://did:plc:test/social.coves.post.record/123'; 447 448 final mockResponse = { 449 'post': {'uri': postUri}, 450 'cursor': null, 451 'comments': [ 452 { 453 'comment': { 454 'uri': 'at://did:plc:test/comment/1', 455 'cid': 'cid1', 456 'content': 'Parent comment', 457 'createdAt': '2025-01-01T12:00:00Z', 458 'indexedAt': '2025-01-01T12:00:00Z', 459 'author': {'did': 'did:plc:author1', 'handle': 'user1.test'}, 460 'post': {'uri': postUri, 'cid': 'post-cid'}, 461 'stats': {'upvotes': 10, 'downvotes': 2, 'score': 8}, 462 }, 463 'replies': [ 464 { 465 'comment': { 466 'uri': 'at://did:plc:test/comment/2', 467 'cid': 'cid2', 468 'content': 'Reply comment', 469 'createdAt': '2025-01-01T13:00:00Z', 470 'indexedAt': '2025-01-01T13:00:00Z', 471 'author': {'did': 'did:plc:author2', 'handle': 'user2.test'}, 472 'post': {'uri': postUri, 'cid': 'post-cid'}, 473 'parent': { 474 'uri': 'at://did:plc:test/comment/1', 475 'cid': 'cid1', 476 }, 477 'stats': {'upvotes': 5, 'downvotes': 0, 'score': 5}, 478 }, 479 'hasMore': false, 480 }, 481 ], 482 'hasMore': false, 483 }, 484 ], 485 }; 486 487 dioAdapter.onGet( 488 '/xrpc/social.coves.community.comment.getComments', 489 (server) => server.reply(200, mockResponse), 490 queryParameters: { 491 'post': postUri, 492 'sort': 'hot', 493 'depth': 10, 494 'limit': 50, 495 }, 496 ); 497 498 final response = await apiService.getComments(postUri: postUri); 499 500 expect(response.comments.length, 1); 501 expect(response.comments[0].comment.content, 'Parent comment'); 502 expect(response.comments[0].replies, isNotNull); 503 expect(response.comments[0].replies!.length, 1); 504 expect(response.comments[0].replies![0].comment.content, 'Reply comment'); 505 }); 506 507 test('should handle comments with viewer state', () async { 508 const postUri = 'at://did:plc:test/social.coves.post.record/123'; 509 510 final mockResponse = { 511 'post': {'uri': postUri}, 512 'cursor': null, 513 'comments': [ 514 { 515 'comment': { 516 'uri': 'at://did:plc:test/comment/1', 517 'cid': 'cid1', 518 'content': 'Voted comment', 519 'createdAt': '2025-01-01T12:00:00Z', 520 'indexedAt': '2025-01-01T12:00:00Z', 521 'author': {'did': 'did:plc:author', 'handle': 'user.test'}, 522 'post': {'uri': postUri, 'cid': 'post-cid'}, 523 'stats': {'upvotes': 10, 'downvotes': 0, 'score': 10}, 524 'viewer': {'vote': 'upvote'}, 525 }, 526 'hasMore': false, 527 }, 528 ], 529 }; 530 531 dioAdapter.onGet( 532 '/xrpc/social.coves.community.comment.getComments', 533 (server) => server.reply(200, mockResponse), 534 queryParameters: { 535 'post': postUri, 536 'sort': 'hot', 537 'depth': 10, 538 'limit': 50, 539 }, 540 ); 541 542 final response = await apiService.getComments(postUri: postUri); 543 544 expect(response.comments.length, 1); 545 expect(response.comments[0].comment.viewer, isNotNull); 546 expect(response.comments[0].comment.viewer!.vote, 'upvote'); 547 }); 548 }); 549}