1import 'package:coves_flutter/services/coves_auth_service.dart'; 2import 'package:dio/dio.dart'; 3import 'package:flutter_secure_storage/flutter_secure_storage.dart'; 4import 'package:flutter_test/flutter_test.dart'; 5import 'package:mockito/annotations.dart'; 6 7import 'coves_auth_service_test.mocks.dart'; 8 9@GenerateMocks([Dio, FlutterSecureStorage]) 10void main() { 11 late MockDio mockDio; 12 late MockFlutterSecureStorage mockStorage; 13 late CovesAuthService authService; 14 15 setUp(() { 16 CovesAuthService.resetInstance(); 17 mockDio = MockDio(); 18 mockStorage = MockFlutterSecureStorage(); 19 authService = CovesAuthService.createTestInstance( 20 dio: mockDio, 21 storage: mockStorage, 22 ); 23 }); 24 25 tearDown(() { 26 CovesAuthService.resetInstance(); 27 }); 28 29 group('Handle Validation', () { 30 group('Valid inputs', () { 31 test('should accept standard handle format', () { 32 final result = authService.validateAndNormalizeHandle( 33 'alice.bsky.social', 34 ); 35 expect(result, 'alice.bsky.social'); 36 }); 37 38 test('should accept handle with @ prefix and strip it', () { 39 final result = authService.validateAndNormalizeHandle( 40 '@alice.bsky.social', 41 ); 42 expect(result, 'alice.bsky.social'); 43 }); 44 45 test( 46 'should accept handle with leading/trailing whitespace and trim', 47 () { 48 final result = authService.validateAndNormalizeHandle( 49 ' alice.bsky.social ', 50 ); 51 expect(result, 'alice.bsky.social'); 52 }, 53 ); 54 55 test('should accept handle with hyphen in segment', () { 56 final result = authService.validateAndNormalizeHandle( 57 'alice-bob.bsky.social', 58 ); 59 expect(result, 'alice-bob.bsky.social'); 60 }); 61 62 test('should accept handle with multiple hyphens', () { 63 final result = authService.validateAndNormalizeHandle( 64 'alice-bob-charlie.bsky-app.social', 65 ); 66 expect(result, 'alice-bob-charlie.bsky-app.social'); 67 }); 68 69 test('should accept handle with multiple subdomains', () { 70 final result = authService.validateAndNormalizeHandle( 71 'alice.subdomain.example.com', 72 ); 73 expect(result, 'alice.subdomain.example.com'); 74 }); 75 76 test('should accept handle with numbers', () { 77 final result = authService.validateAndNormalizeHandle( 78 'user123.bsky.social', 79 ); 80 expect(result, 'user123.bsky.social'); 81 }); 82 83 test('should convert handle to lowercase', () { 84 final result = authService.validateAndNormalizeHandle( 85 'Alice.Bsky.Social', 86 ); 87 expect(result, 'alice.bsky.social'); 88 }); 89 90 test( 91 'should extract and validate handle from Bluesky profile URL (HTTP)', 92 () { 93 final result = authService.validateAndNormalizeHandle( 94 'http://bsky.app/profile/alice.bsky.social', 95 ); 96 expect(result, 'alice.bsky.social'); 97 }, 98 ); 99 100 test( 101 'should extract and validate handle from Bluesky profile URL (HTTPS)', 102 () { 103 final result = authService.validateAndNormalizeHandle( 104 'https://bsky.app/profile/alice.bsky.social', 105 ); 106 expect(result, 'alice.bsky.social'); 107 }, 108 ); 109 110 test( 111 'should extract and validate handle from Bluesky profile URL with www', 112 () { 113 final result = authService.validateAndNormalizeHandle( 114 'https://www.bsky.app/profile/alice.bsky.social', 115 ); 116 expect(result, 'alice.bsky.social'); 117 }, 118 ); 119 120 test('should accept DID with plc method', () { 121 final result = authService.validateAndNormalizeHandle( 122 'did:plc:abc123def456', 123 ); 124 expect(result, 'did:plc:abc123def456'); 125 }); 126 127 test('should accept DID with web method', () { 128 final result = authService.validateAndNormalizeHandle( 129 'did:web:example.com', 130 ); 131 expect(result, 'did:web:example.com'); 132 }); 133 134 test('should accept DID with complex identifier', () { 135 final result = authService.validateAndNormalizeHandle( 136 'did:plc:z72i7hdynmk6r22z27h6tvur', 137 ); 138 expect(result, 'did:plc:z72i7hdynmk6r22z27h6tvur'); 139 }); 140 141 test('should accept DID with periods and colons in identifier', () { 142 final result = authService.validateAndNormalizeHandle( 143 'did:web:example.com:user:alice', 144 ); 145 expect(result, 'did:web:example.com:user:alice'); 146 }); 147 148 test('should accept short handle', () { 149 final result = authService.validateAndNormalizeHandle('a.b'); 150 expect(result, 'a.b'); 151 }); 152 153 test('should normalize handle with @ prefix and whitespace', () { 154 final result = authService.validateAndNormalizeHandle( 155 ' @Alice.Bsky.Social ', 156 ); 157 expect(result, 'alice.bsky.social'); 158 }); 159 160 test('should accept handle with numeric first segment', () { 161 final result = authService.validateAndNormalizeHandle( 162 '123.bsky.social', 163 ); 164 expect(result, '123.bsky.social'); 165 }); 166 167 test('should accept handle with numeric middle segment', () { 168 final result = authService.validateAndNormalizeHandle( 169 'alice.456.social', 170 ); 171 expect(result, 'alice.456.social'); 172 }); 173 174 test('should accept handle with multiple numeric segments', () { 175 final result = authService.validateAndNormalizeHandle('42.example.com'); 176 expect(result, '42.example.com'); 177 }); 178 179 test('should accept handle similar to 4chan.org', () { 180 final result = authService.validateAndNormalizeHandle('4chan.org'); 181 expect(result, '4chan.org'); 182 }); 183 184 test('should accept handle with numeric and alpha mixed', () { 185 final result = authService.validateAndNormalizeHandle('8.cn'); 186 expect(result, '8.cn'); 187 }); 188 189 test('should accept handle like IP but with valid TLD', () { 190 final result = authService.validateAndNormalizeHandle('120.0.0.1.com'); 191 expect(result, '120.0.0.1.com'); 192 }); 193 }); 194 195 group('Invalid inputs', () { 196 test('should throw ArgumentError when handle is empty', () { 197 expect( 198 () => authService.validateAndNormalizeHandle(''), 199 throwsA(isA<ArgumentError>()), 200 ); 201 }); 202 203 test('should throw ArgumentError when handle is whitespace-only', () { 204 expect( 205 () => authService.validateAndNormalizeHandle(' '), 206 throwsA(isA<ArgumentError>()), 207 ); 208 }); 209 210 test('should throw ArgumentError for handle without period', () { 211 expect( 212 () => authService.validateAndNormalizeHandle('alice'), 213 throwsA( 214 predicate( 215 (e) => 216 e is ArgumentError && 217 e.message.toString().contains('domain format'), 218 ), 219 ), 220 ); 221 }); 222 223 test('should throw ArgumentError for handle starting with hyphen', () { 224 expect( 225 () => authService.validateAndNormalizeHandle('-alice.bsky.social'), 226 throwsA( 227 predicate( 228 (e) => 229 e is ArgumentError && 230 e.message.toString().contains('Invalid handle format'), 231 ), 232 ), 233 ); 234 }); 235 236 test('should throw ArgumentError for handle ending with hyphen', () { 237 expect( 238 () => authService.validateAndNormalizeHandle('alice-.bsky.social'), 239 throwsA( 240 predicate( 241 (e) => 242 e is ArgumentError && 243 e.message.toString().contains('Invalid handle format'), 244 ), 245 ), 246 ); 247 }); 248 249 test('should throw ArgumentError for segment with hyphen at end', () { 250 expect( 251 () => authService.validateAndNormalizeHandle('alice.bsky-.social'), 252 throwsA( 253 predicate( 254 (e) => 255 e is ArgumentError && 256 e.message.toString().contains('Invalid handle format'), 257 ), 258 ), 259 ); 260 }); 261 262 test('should throw ArgumentError for handle starting with period', () { 263 expect( 264 () => authService.validateAndNormalizeHandle('.alice.bsky.social'), 265 throwsA( 266 predicate( 267 (e) => 268 e is ArgumentError && 269 e.message.toString().contains('Invalid handle format'), 270 ), 271 ), 272 ); 273 }); 274 275 test('should throw ArgumentError for handle ending with period', () { 276 expect( 277 () => authService.validateAndNormalizeHandle('alice.bsky.social.'), 278 throwsA( 279 predicate( 280 (e) => 281 e is ArgumentError && 282 e.message.toString().contains('Invalid handle format'), 283 ), 284 ), 285 ); 286 }); 287 288 test( 289 'should throw ArgumentError for handle with consecutive periods', 290 () { 291 expect( 292 () => authService.validateAndNormalizeHandle('alice..bsky.social'), 293 throwsA( 294 predicate( 295 (e) => 296 e is ArgumentError && 297 (e.message.toString().contains('empty segments') || 298 e.message.toString().contains('Invalid handle format')), 299 ), 300 ), 301 ); 302 }, 303 ); 304 305 test('should throw ArgumentError for handle with spaces', () { 306 expect( 307 () => authService.validateAndNormalizeHandle('alice bsky.social'), 308 throwsA( 309 predicate( 310 (e) => 311 e is ArgumentError && 312 e.message.toString().contains('Invalid handle format'), 313 ), 314 ), 315 ); 316 }); 317 318 test('should throw ArgumentError for handle with @ in middle', () { 319 expect( 320 () => authService.validateAndNormalizeHandle('alice@bsky.social'), 321 throwsA( 322 predicate( 323 (e) => 324 e is ArgumentError && 325 e.message.toString().contains('Invalid handle format'), 326 ), 327 ), 328 ); 329 }); 330 331 test('should throw ArgumentError for handle with underscore', () { 332 expect( 333 () => authService.validateAndNormalizeHandle('alice_bob.bsky.social'), 334 throwsA( 335 predicate( 336 (e) => 337 e is ArgumentError && 338 e.message.toString().contains('Invalid handle format'), 339 ), 340 ), 341 ); 342 }); 343 344 test('should throw ArgumentError for handle with exclamation mark', () { 345 expect( 346 () => authService.validateAndNormalizeHandle('alice!.bsky.social'), 347 throwsA( 348 predicate( 349 (e) => 350 e is ArgumentError && 351 e.message.toString().contains('Invalid handle format'), 352 ), 353 ), 354 ); 355 }); 356 357 test('should throw ArgumentError for handle with slash', () { 358 expect( 359 () => authService.validateAndNormalizeHandle('alice/bob.bsky.social'), 360 throwsA( 361 predicate( 362 (e) => 363 e is ArgumentError && 364 e.message.toString().contains('Invalid handle format'), 365 ), 366 ), 367 ); 368 }); 369 370 test( 371 'should throw ArgumentError for handle exceeding 253 characters', 372 () { 373 // Create a handle that's 254 characters long 374 final longHandle = '${'a' * 240}.bsky.social'; 375 expect( 376 () => authService.validateAndNormalizeHandle(longHandle), 377 throwsA( 378 predicate( 379 (e) => 380 e is ArgumentError && 381 e.message.toString().contains('too long'), 382 ), 383 ), 384 ); 385 }, 386 ); 387 388 test( 389 'should throw ArgumentError for segment exceeding 63 characters', 390 () { 391 // DNS label limit is 63 characters per segment 392 final longSegment = '${'a' * 64}.bsky.social'; 393 expect( 394 () => authService.validateAndNormalizeHandle(longSegment), 395 throwsA( 396 predicate( 397 (e) => 398 e is ArgumentError && 399 e.message.toString().contains('too long'), 400 ), 401 ), 402 ); 403 }, 404 ); 405 406 test('should throw ArgumentError for TLD starting with digit', () { 407 expect( 408 () => authService.validateAndNormalizeHandle('alice.bsky.123'), 409 throwsA( 410 predicate( 411 (e) => 412 e is ArgumentError && 413 e.message.toString().contains('TLD') && 414 e.message.toString().contains('cannot start with a digit'), 415 ), 416 ), 417 ); 418 }); 419 420 test('should throw ArgumentError for all-numeric TLD', () { 421 expect( 422 () => authService.validateAndNormalizeHandle('123.456.789'), 423 throwsA( 424 predicate( 425 (e) => 426 e is ArgumentError && 427 e.message.toString().contains('TLD') && 428 e.message.toString().contains('cannot start with a digit'), 429 ), 430 ), 431 ); 432 }); 433 434 test( 435 'should throw ArgumentError for IPv4 address (TLD starts with digit)', 436 () { 437 expect( 438 () => authService.validateAndNormalizeHandle('127.0.0.1'), 439 throwsA( 440 predicate( 441 (e) => 442 e is ArgumentError && 443 e.message.toString().contains('TLD') && 444 e.message.toString().contains('cannot start with a digit'), 445 ), 446 ), 447 ); 448 }, 449 ); 450 451 test('should throw ArgumentError for IPv4 address variant', () { 452 expect( 453 () => authService.validateAndNormalizeHandle('192.168.0.142'), 454 throwsA( 455 predicate( 456 (e) => 457 e is ArgumentError && 458 e.message.toString().contains('TLD') && 459 e.message.toString().contains('cannot start with a digit'), 460 ), 461 ), 462 ); 463 }); 464 }); 465 466 group('DID Validation', () { 467 test('should accept valid plc DID', () { 468 final result = authService.validateAndNormalizeHandle('did:plc:abc123'); 469 expect(result, 'did:plc:abc123'); 470 }); 471 472 test('should accept valid web DID', () { 473 final result = authService.validateAndNormalizeHandle( 474 'did:web:example.com', 475 ); 476 expect(result, 'did:web:example.com'); 477 }); 478 479 test('should accept DID with underscores in identifier', () { 480 // Underscores are allowed in the DID pattern (part of [a-zA-Z0-9._:%-]+) 481 final result = authService.validateAndNormalizeHandle( 482 'did:plc:abc_123', 483 ); 484 expect(result, 'did:plc:abc_123'); 485 }); 486 487 test( 488 'should throw ArgumentError for invalid DID with @ special chars', 489 () { 490 expect( 491 () => authService.validateAndNormalizeHandle('did:plc:abc@123'), 492 throwsA( 493 predicate( 494 (e) => 495 e is ArgumentError && 496 e.message.toString().contains('Invalid DID format'), 497 ), 498 ), 499 ); 500 }, 501 ); 502 503 test('should throw ArgumentError for DID with uppercase method', () { 504 expect( 505 () => authService.validateAndNormalizeHandle('did:PLC:abc123'), 506 throwsA( 507 predicate( 508 (e) => 509 e is ArgumentError && 510 e.message.toString().contains('Invalid DID format'), 511 ), 512 ), 513 ); 514 }); 515 516 test('should throw ArgumentError for DID with spaces', () { 517 expect( 518 () => authService.validateAndNormalizeHandle('did:plc:abc 123'), 519 throwsA( 520 predicate( 521 (e) => 522 e is ArgumentError && 523 e.message.toString().contains('Invalid DID format'), 524 ), 525 ), 526 ); 527 }); 528 529 test( 530 'should throw ArgumentError for malformed DID (missing identifier)', 531 () { 532 expect( 533 () => authService.validateAndNormalizeHandle('did:plc'), 534 throwsA( 535 predicate( 536 (e) => 537 e is ArgumentError && 538 e.message.toString().contains('Invalid DID format'), 539 ), 540 ), 541 ); 542 }, 543 ); 544 545 test('should throw ArgumentError for malformed DID (missing method)', () { 546 expect( 547 () => authService.validateAndNormalizeHandle('did::abc123'), 548 throwsA( 549 predicate( 550 (e) => 551 e is ArgumentError && 552 e.message.toString().contains('Invalid DID format'), 553 ), 554 ), 555 ); 556 }); 557 558 test('should throw ArgumentError for DID without prefix', () { 559 expect( 560 () => authService.validateAndNormalizeHandle('plc:abc123'), 561 throwsA( 562 predicate( 563 (e) => 564 e is ArgumentError && 565 e.message.toString().contains('domain format'), 566 ), 567 ), 568 ); 569 }); 570 571 test('should throw ArgumentError for DID with invalid method chars', () { 572 expect( 573 () => authService.validateAndNormalizeHandle('did:pl-c:abc123'), 574 throwsA( 575 predicate( 576 (e) => 577 e is ArgumentError && 578 e.message.toString().contains('Invalid DID format'), 579 ), 580 ), 581 ); 582 }); 583 }); 584 }); 585}