WIP: A simple cli for daily tangled use cases and AI integration. This is for my personal use right now, but happy if others get mileage from it! :)
1import { beforeEach, describe, expect, it, vi } from 'vitest';
2import type { TangledApiClient } from '../../src/lib/api-client.js';
3import { buildRepoAtUri, parseAtUri, resolveHandleToDid } from '../../src/utils/at-uri.js';
4
5// Mock API client
6const createMockClient = (): TangledApiClient => {
7 return {
8 getAgent: vi.fn(() => ({
9 com: {
10 atproto: {
11 identity: {
12 resolveHandle: vi.fn(),
13 },
14 },
15 },
16 })),
17 } as unknown as TangledApiClient;
18};
19
20describe('parseAtUri', () => {
21 it('should parse AT-URI with rkey', () => {
22 const uri = 'at://did:plc:abc123/sh.tangled.repo.issue/xyz789';
23 const result = parseAtUri(uri);
24
25 expect(result).toEqual({
26 did: 'did:plc:abc123',
27 collection: 'sh.tangled.repo.issue',
28 rkey: 'xyz789',
29 });
30 });
31
32 it('should parse AT-URI without rkey', () => {
33 const uri = 'at://did:plc:abc123/sh.tangled.repo';
34 const result = parseAtUri(uri);
35
36 expect(result).toEqual({
37 did: 'did:plc:abc123',
38 collection: 'sh.tangled.repo',
39 });
40 });
41
42 it('should parse AT-URI with nested collection', () => {
43 const uri = 'at://did:plc:abc123/sh.tangled.repo.issue.state/xyz';
44 const result = parseAtUri(uri);
45
46 expect(result).toEqual({
47 did: 'did:plc:abc123',
48 collection: 'sh.tangled.repo.issue.state',
49 rkey: 'xyz',
50 });
51 });
52
53 it('should return null for invalid URI', () => {
54 expect(parseAtUri('not-a-uri')).toBeNull();
55 expect(parseAtUri('http://example.com')).toBeNull();
56 expect(parseAtUri('at://invalid-did/collection')).toBeNull();
57 expect(parseAtUri('')).toBeNull();
58 });
59
60 it('should handle DIDs with various characters', () => {
61 const uri = 'at://did:web:example.com/collection/rkey';
62 const result = parseAtUri(uri);
63
64 expect(result).toEqual({
65 did: 'did:web:example.com',
66 collection: 'collection',
67 rkey: 'rkey',
68 });
69 });
70});
71
72describe('resolveHandleToDid', () => {
73 let mockClient: TangledApiClient;
74
75 beforeEach(() => {
76 mockClient = createMockClient();
77 });
78
79 it('should resolve handle to DID', async () => {
80 const mockResolve = vi.fn().mockResolvedValue({
81 data: { did: 'did:plc:abc123' },
82 });
83
84 vi.mocked(mockClient.getAgent).mockReturnValue({
85 com: {
86 atproto: {
87 identity: {
88 resolveHandle: mockResolve,
89 },
90 },
91 },
92 } as never);
93
94 const result = await resolveHandleToDid('mark.bsky.social', mockClient);
95
96 expect(result).toBe('did:plc:abc123');
97 expect(mockResolve).toHaveBeenCalledWith({ handle: 'mark.bsky.social' });
98 });
99
100 it('should strip leading @ from handle', async () => {
101 const mockResolve = vi.fn().mockResolvedValue({
102 data: { did: 'did:plc:abc123' },
103 });
104
105 vi.mocked(mockClient.getAgent).mockReturnValue({
106 com: {
107 atproto: {
108 identity: {
109 resolveHandle: mockResolve,
110 },
111 },
112 },
113 } as never);
114
115 await resolveHandleToDid('@mark.bsky.social', mockClient);
116
117 expect(mockResolve).toHaveBeenCalledWith({ handle: 'mark.bsky.social' });
118 });
119
120 it('should throw error when handle not found', async () => {
121 const mockResolve = vi.fn().mockResolvedValue({
122 data: { did: null },
123 });
124
125 vi.mocked(mockClient.getAgent).mockReturnValue({
126 com: {
127 atproto: {
128 identity: {
129 resolveHandle: mockResolve,
130 },
131 },
132 },
133 } as never);
134
135 await expect(resolveHandleToDid('nonexistent.bsky.social', mockClient)).rejects.toThrow(
136 'No DID found for handle: nonexistent.bsky.social'
137 );
138 });
139
140 it('should throw error on network failure', async () => {
141 const mockResolve = vi.fn().mockRejectedValue(new Error('Network error'));
142
143 vi.mocked(mockClient.getAgent).mockReturnValue({
144 com: {
145 atproto: {
146 identity: {
147 resolveHandle: mockResolve,
148 },
149 },
150 },
151 } as never);
152
153 await expect(resolveHandleToDid('mark.bsky.social', mockClient)).rejects.toThrow(
154 "Failed to resolve handle 'mark.bsky.social': Network error"
155 );
156 });
157});
158
159describe('buildRepoAtUri', () => {
160 let mockClient: TangledApiClient;
161
162 beforeEach(() => {
163 mockClient = createMockClient();
164 });
165
166 it('should query PDS and use repo record rkey', async () => {
167 const mockListRecords = vi.fn().mockResolvedValue({
168 data: {
169 records: [
170 {
171 uri: 'at://did:plc:abc123/sh.tangled.repo/3mef23waqwq22',
172 value: { name: 'my-repo', description: 'Test repo' },
173 },
174 ],
175 },
176 });
177
178 vi.mocked(mockClient.getAgent).mockReturnValue({
179 com: {
180 atproto: {
181 repo: {
182 listRecords: mockListRecords,
183 },
184 },
185 },
186 } as never);
187
188 const result = await buildRepoAtUri('did:plc:abc123', 'my-repo', mockClient);
189
190 expect(result).toBe('at://did:plc:abc123/sh.tangled.repo/3mef23waqwq22');
191 expect(mockListRecords).toHaveBeenCalledWith({
192 repo: 'did:plc:abc123',
193 collection: 'sh.tangled.repo',
194 limit: 100,
195 });
196 });
197
198 it('should resolve handle then query for repo record', async () => {
199 const mockResolve = vi.fn().mockResolvedValue({
200 data: { did: 'did:plc:abc123' },
201 });
202
203 const mockListRecords = vi.fn().mockResolvedValue({
204 data: {
205 records: [
206 {
207 uri: 'at://did:plc:abc123/sh.tangled.repo/xyz789',
208 value: { name: 'my-repo' },
209 },
210 ],
211 },
212 });
213
214 vi.mocked(mockClient.getAgent).mockReturnValue({
215 com: {
216 atproto: {
217 identity: {
218 resolveHandle: mockResolve,
219 },
220 repo: {
221 listRecords: mockListRecords,
222 },
223 },
224 },
225 } as never);
226
227 const result = await buildRepoAtUri('mark.bsky.social', 'my-repo', mockClient);
228
229 expect(result).toBe('at://did:plc:abc123/sh.tangled.repo/xyz789');
230 expect(mockResolve).toHaveBeenCalledWith({ handle: 'mark.bsky.social' });
231 expect(mockListRecords).toHaveBeenCalledWith({
232 repo: 'did:plc:abc123',
233 collection: 'sh.tangled.repo',
234 limit: 100,
235 });
236 });
237
238 it('should find correct repo among multiple records', async () => {
239 const mockListRecords = vi.fn().mockResolvedValue({
240 data: {
241 records: [
242 {
243 uri: 'at://did:plc:abc123/sh.tangled.repo/aaa111',
244 value: { name: 'other-repo' },
245 },
246 {
247 uri: 'at://did:plc:abc123/sh.tangled.repo/bbb222',
248 value: { name: 'target-repo' },
249 },
250 {
251 uri: 'at://did:plc:abc123/sh.tangled.repo/ccc333',
252 value: { name: 'another-repo' },
253 },
254 ],
255 },
256 });
257
258 vi.mocked(mockClient.getAgent).mockReturnValue({
259 com: {
260 atproto: {
261 repo: {
262 listRecords: mockListRecords,
263 },
264 },
265 },
266 } as never);
267
268 const result = await buildRepoAtUri('did:plc:abc123', 'target-repo', mockClient);
269
270 expect(result).toBe('at://did:plc:abc123/sh.tangled.repo/bbb222');
271 });
272
273 it('should throw error when repository not found', async () => {
274 const mockListRecords = vi.fn().mockResolvedValue({
275 data: {
276 records: [
277 {
278 uri: 'at://did:plc:abc123/sh.tangled.repo/xyz789',
279 value: { name: 'different-repo' },
280 },
281 ],
282 },
283 });
284
285 vi.mocked(mockClient.getAgent).mockReturnValue({
286 com: {
287 atproto: {
288 repo: {
289 listRecords: mockListRecords,
290 },
291 },
292 },
293 } as never);
294
295 await expect(buildRepoAtUri('did:plc:abc123', 'nonexistent-repo', mockClient)).rejects.toThrow(
296 "Repository 'nonexistent-repo' not found for did:plc:abc123"
297 );
298 });
299
300 it('should throw error when handle resolution fails', async () => {
301 const mockResolve = vi.fn().mockRejectedValue(new Error('Resolution failed'));
302
303 vi.mocked(mockClient.getAgent).mockReturnValue({
304 com: {
305 atproto: {
306 identity: {
307 resolveHandle: mockResolve,
308 },
309 },
310 },
311 } as never);
312
313 await expect(buildRepoAtUri('mark.bsky.social', 'my-repo', mockClient)).rejects.toThrow(
314 "Failed to resolve handle 'mark.bsky.social': Resolution failed"
315 );
316 });
317
318 it('should throw error when listRecords fails', async () => {
319 const mockListRecords = vi.fn().mockRejectedValue(new Error('API error'));
320
321 vi.mocked(mockClient.getAgent).mockReturnValue({
322 com: {
323 atproto: {
324 repo: {
325 listRecords: mockListRecords,
326 },
327 },
328 },
329 } as never);
330
331 await expect(buildRepoAtUri('did:plc:abc123', 'my-repo', mockClient)).rejects.toThrow(
332 'Failed to resolve repository AT-URI: API error'
333 );
334 });
335});