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