Main coves client
1/// Unit tests for the identity resolution layer.
2///
3/// Note: These are basic validation tests. Real integration tests would
4/// require network calls to live services.
5
6import 'package:flutter_test/flutter_test.dart';
7import 'package:atproto_oauth_flutter/src/identity/identity.dart';
8
9void main() {
10 group('DID Validation', () {
11 test('isDidPlc validates did:plc correctly', () {
12 // did:plc must be exactly 32 chars total (8 prefix + 24 base32 [a-z2-7])
13 expect(isDidPlc('did:plc:z72i7hdynmk6r22z27h6abc2'), isTrue);
14 expect(isDidPlc('did:plc:2222222222222222222222ab'), isTrue);
15 expect(isDidPlc('did:plc:abcdefgabcdefgabcdefgabc'), isTrue);
16
17 // Wrong length
18 expect(isDidPlc('did:plc:short'), isFalse);
19 expect(isDidPlc('did:plc:toolonggggggggggggggggggggg'), isFalse);
20
21 // Wrong prefix
22 expect(isDidPlc('did:web:example.com'), isFalse);
23
24 // Invalid characters (not base32)
25 expect(isDidPlc('did:plc:0000000000000000000000'), isFalse); // has 0
26 expect(isDidPlc('did:plc:1111111111111111111111'), isFalse); // has 1
27 });
28
29 test('isDidWeb validates did:web correctly', () {
30 expect(isDidWeb('did:web:example.com'), isTrue);
31 expect(isDidWeb('did:web:example.com:user:alice'), isTrue);
32 expect(isDidWeb('did:web:localhost%3A3000'), isTrue);
33
34 // Wrong prefix
35 expect(isDidWeb('did:plc:abc123xyz789abc123xyz789'), isFalse);
36
37 // Can't start with colon after prefix
38 expect(isDidWeb('did:web::example.com'), isFalse);
39 });
40
41 test('isDid validates general DIDs', () {
42 expect(isDid('did:plc:abc123xyz789abc123xyz789'), isTrue);
43 expect(isDid('did:web:example.com'), isTrue);
44 expect(
45 isDid('did:key:z6MkhaXgBZDvotDkL5257faiztiGiC2QtKLGpbnnEGta2doK'),
46 isTrue,
47 );
48
49 // Invalid
50 expect(isDid('not-a-did'), isFalse);
51 expect(isDid('did:'), isFalse);
52 expect(isDid('did:method'), isFalse);
53 expect(isDid(''), isFalse);
54 });
55
56 test('extractDidMethod extracts method name', () {
57 expect(extractDidMethod('did:plc:abc123'), equals('plc'));
58 expect(extractDidMethod('did:web:example.com'), equals('web'));
59 expect(extractDidMethod('did:key:z6Mk...'), equals('key'));
60 });
61
62 test('didWebToUrl converts did:web to URL', () {
63 final url1 = didWebToUrl('did:web:example.com');
64 expect(url1.toString(), equals('https://example.com'));
65
66 final url2 = didWebToUrl('did:web:example.com:user:alice');
67 expect(url2.toString(), equals('https://example.com/user/alice'));
68
69 final url3 = didWebToUrl('did:web:localhost%3A3000');
70 expect(url3.toString(), equals('http://localhost:3000'));
71 });
72
73 test('urlToDidWeb converts URL to did:web', () {
74 final did1 = urlToDidWeb(Uri.parse('https://example.com'));
75 expect(did1, equals('did:web:example.com'));
76
77 final did2 = urlToDidWeb(Uri.parse('https://example.com/user/alice'));
78 expect(did2, equals('did:web:example.com:user:alice'));
79 });
80 });
81
82 group('Handle Validation', () {
83 test('isValidHandle validates handles', () {
84 expect(isValidHandle('alice.example.com'), isTrue);
85 expect(isValidHandle('user.bsky.social'), isTrue);
86 expect(isValidHandle('sub.domain.example.com'), isTrue);
87 expect(isValidHandle('a.b'), isTrue);
88
89 // Invalid
90 expect(isValidHandle(''), isFalse);
91 expect(isValidHandle('no-tld'), isFalse);
92 expect(isValidHandle('.starts-with-dot.com'), isFalse);
93 expect(isValidHandle('ends-with-dot.com.'), isFalse);
94 expect(isValidHandle('has..double-dot.com'), isFalse);
95 expect(isValidHandle('has spaces.com'), isFalse);
96
97 // Too long (254+ chars)
98 final longHandle = '${'a' * 250}.com';
99 expect(isValidHandle(longHandle), isFalse);
100 });
101
102 test('normalizeHandle converts to lowercase', () {
103 expect(normalizeHandle('Alice.Example.Com'), equals('alice.example.com'));
104 expect(normalizeHandle('USER.BSKY.SOCIAL'), equals('user.bsky.social'));
105 });
106
107 test('asNormalizedHandle validates and normalizes', () {
108 expect(
109 asNormalizedHandle('Alice.Example.Com'),
110 equals('alice.example.com'),
111 );
112 expect(asNormalizedHandle('invalid'), isNull);
113 expect(asNormalizedHandle(''), isNull);
114 });
115 });
116
117 group('DID Document', () {
118 test('DidDocument parses from JSON', () {
119 final json = {
120 'id': 'did:plc:abc123xyz789abc123xyz789',
121 'alsoKnownAs': ['at://alice.bsky.social'],
122 'service': [
123 {
124 'id': '#atproto_pds',
125 'type': 'AtprotoPersonalDataServer',
126 'serviceEndpoint': 'https://pds.example.com',
127 },
128 ],
129 };
130
131 final doc = DidDocument.fromJson(json);
132
133 expect(doc.id, equals('did:plc:abc123xyz789abc123xyz789'));
134 expect(doc.alsoKnownAs, contains('at://alice.bsky.social'));
135 expect(doc.service?.length, equals(1));
136 expect(doc.service?[0].type, equals('AtprotoPersonalDataServer'));
137 });
138
139 test('DidDocument extracts PDS URL', () {
140 final doc = DidDocument(
141 id: 'did:plc:test',
142 service: [
143 DidService(
144 id: '#atproto_pds',
145 type: 'AtprotoPersonalDataServer',
146 serviceEndpoint: 'https://pds.example.com',
147 ),
148 ],
149 );
150
151 expect(doc.extractPdsUrl(), equals('https://pds.example.com'));
152 });
153
154 test('DidDocument extracts handle', () {
155 final doc = DidDocument(
156 id: 'did:plc:test',
157 alsoKnownAs: ['at://alice.bsky.social', 'https://example.com'],
158 );
159
160 expect(doc.extractAtprotoHandle(), equals('alice.bsky.social'));
161 expect(doc.extractNormalizedHandle(), equals('alice.bsky.social'));
162 });
163
164 test('DidDocument returns null for missing PDS', () {
165 final doc = DidDocument(id: 'did:plc:test');
166 expect(doc.extractPdsUrl(), isNull);
167 });
168
169 test('DidDocument returns null for missing handle', () {
170 final doc = DidDocument(id: 'did:plc:test');
171 expect(doc.extractAtprotoHandle(), isNull);
172 expect(doc.extractNormalizedHandle(), isNull);
173 });
174 });
175
176 group('Cache', () {
177 test('InMemoryDidCache stores and retrieves', () async {
178 final cache = InMemoryDidCache(ttl: Duration(seconds: 1));
179 final doc = DidDocument(id: 'did:plc:test');
180
181 await cache.set('did:plc:test', doc);
182 final retrieved = await cache.get('did:plc:test');
183
184 expect(retrieved?.id, equals('did:plc:test'));
185 });
186
187 test('InMemoryDidCache expires entries', () async {
188 final cache = InMemoryDidCache(ttl: Duration(milliseconds: 100));
189 final doc = DidDocument(id: 'did:plc:test');
190
191 await cache.set('did:plc:test', doc);
192
193 // Should exist immediately
194 expect(await cache.get('did:plc:test'), isNotNull);
195
196 // Wait for expiration
197 await Future.delayed(Duration(milliseconds: 150));
198
199 // Should be expired
200 expect(await cache.get('did:plc:test'), isNull);
201 });
202
203 test('InMemoryHandleCache stores and retrieves', () async {
204 final cache = InMemoryHandleCache(ttl: Duration(seconds: 1));
205
206 await cache.set('alice.bsky.social', 'did:plc:test');
207 final retrieved = await cache.get('alice.bsky.social');
208
209 expect(retrieved, equals('did:plc:test'));
210 });
211
212 test('Cache clears all entries', () async {
213 final cache = InMemoryDidCache();
214 final doc = DidDocument(id: 'did:plc:test');
215
216 await cache.set('did:plc:test', doc);
217 expect(await cache.get('did:plc:test'), isNotNull);
218
219 await cache.clear();
220 expect(await cache.get('did:plc:test'), isNull);
221 });
222 });
223
224 group('Error Types', () {
225 test('IdentityResolverError has message', () {
226 final error = IdentityResolverError('Test error');
227 expect(error.message, equals('Test error'));
228 expect(error.toString(), contains('Test error'));
229 });
230
231 test('InvalidDidError includes DID', () {
232 final error = InvalidDidError('not:valid', 'Invalid format');
233 expect(error.did, equals('not:valid'));
234 expect(error.toString(), contains('not:valid'));
235 expect(error.toString(), contains('Invalid format'));
236 });
237
238 test('InvalidHandleError includes handle', () {
239 final error = InvalidHandleError('invalid', 'Invalid format');
240 expect(error.handle, equals('invalid'));
241 expect(error.toString(), contains('invalid'));
242 expect(error.toString(), contains('Invalid format'));
243 });
244 });
245}