Main coves client
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}