an atproto based link aggregator
1import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest';
2import { fetchProfile, fetchProfiles, getProfileOrFallback, clearProfileCache } from './profiles';
3import type { AuthorProfile } from '$lib/types';
4
5// Mock fetch
6const mockFetch = vi.fn();
7vi.stubGlobal('fetch', mockFetch);
8
9describe('fetchProfile', () => {
10 beforeEach(() => {
11 mockFetch.mockReset();
12 clearProfileCache();
13 });
14
15 it('should fetch and return a profile', async () => {
16 mockFetch.mockResolvedValueOnce({
17 ok: true,
18 json: () =>
19 Promise.resolve({
20 did: 'did:plc:test123',
21 handle: 'test.bsky.social',
22 avatar: 'https://example.com/avatar.jpg'
23 })
24 });
25
26 const profile = await fetchProfile('did:plc:test123');
27
28 expect(profile).toEqual({
29 did: 'did:plc:test123',
30 handle: 'test.bsky.social',
31 avatar: 'https://example.com/avatar.jpg'
32 });
33 expect(mockFetch).toHaveBeenCalledWith(expect.stringContaining('did%3Aplc%3Atest123'));
34 });
35
36 it('should return null on HTTP error', async () => {
37 mockFetch.mockResolvedValueOnce({
38 ok: false,
39 status: 404
40 });
41
42 const profile = await fetchProfile('did:plc:notfound');
43
44 expect(profile).toBeNull();
45 });
46
47 it('should return null on network error', async () => {
48 mockFetch.mockRejectedValueOnce(new Error('Network error'));
49
50 const profile = await fetchProfile('did:plc:test123');
51
52 expect(profile).toBeNull();
53 });
54
55 it('should handle profiles without avatars', async () => {
56 mockFetch.mockResolvedValueOnce({
57 ok: true,
58 json: () =>
59 Promise.resolve({
60 did: 'did:plc:noavatar',
61 handle: 'noavatar.bsky.social'
62 // No avatar field
63 })
64 });
65
66 const profile = await fetchProfile('did:plc:noavatar');
67
68 expect(profile).toEqual({
69 did: 'did:plc:noavatar',
70 handle: 'noavatar.bsky.social',
71 avatar: undefined
72 });
73 });
74});
75
76describe('fetchProfiles', () => {
77 beforeEach(() => {
78 mockFetch.mockReset();
79 clearProfileCache();
80 });
81
82 it('should fetch multiple profiles in parallel', async () => {
83 mockFetch
84 .mockResolvedValueOnce({
85 ok: true,
86 json: () =>
87 Promise.resolve({
88 did: 'did:plc:user1',
89 handle: 'user1.bsky.social'
90 })
91 })
92 .mockResolvedValueOnce({
93 ok: true,
94 json: () =>
95 Promise.resolve({
96 did: 'did:plc:user2',
97 handle: 'user2.bsky.social'
98 })
99 });
100
101 const profiles = await fetchProfiles(['did:plc:user1', 'did:plc:user2']);
102
103 expect(profiles.size).toBe(2);
104 expect(profiles.get('did:plc:user1')?.handle).toBe('user1.bsky.social');
105 expect(profiles.get('did:plc:user2')?.handle).toBe('user2.bsky.social');
106 });
107
108 it('should deduplicate DIDs', async () => {
109 mockFetch.mockResolvedValue({
110 ok: true,
111 json: () =>
112 Promise.resolve({
113 did: 'did:plc:duplicate',
114 handle: 'duplicate.bsky.social'
115 })
116 });
117
118 const profiles = await fetchProfiles([
119 'did:plc:duplicate',
120 'did:plc:duplicate',
121 'did:plc:duplicate'
122 ]);
123
124 // Should only make one fetch call
125 expect(mockFetch).toHaveBeenCalledTimes(1);
126 expect(profiles.size).toBe(1);
127 });
128
129 it('should handle partial failures', async () => {
130 mockFetch
131 .mockResolvedValueOnce({
132 ok: true,
133 json: () =>
134 Promise.resolve({
135 did: 'did:plc:success',
136 handle: 'success.bsky.social'
137 })
138 })
139 .mockResolvedValueOnce({
140 ok: false,
141 status: 404
142 })
143 .mockResolvedValueOnce({
144 ok: true,
145 json: () =>
146 Promise.resolve({
147 did: 'did:plc:success2',
148 handle: 'success2.bsky.social'
149 })
150 });
151
152 const profiles = await fetchProfiles([
153 'did:plc:success',
154 'did:plc:notfound',
155 'did:plc:success2'
156 ]);
157
158 // Should have 2 profiles, failed one excluded
159 expect(profiles.size).toBe(2);
160 expect(profiles.has('did:plc:success')).toBe(true);
161 expect(profiles.has('did:plc:notfound')).toBe(false);
162 expect(profiles.has('did:plc:success2')).toBe(true);
163 });
164
165 it('should return empty map for empty input', async () => {
166 const profiles = await fetchProfiles([]);
167
168 expect(profiles.size).toBe(0);
169 expect(mockFetch).not.toHaveBeenCalled();
170 });
171
172 it('should handle all failures gracefully', async () => {
173 mockFetch.mockRejectedValue(new Error('Network error'));
174
175 const profiles = await fetchProfiles(['did:plc:fail1', 'did:plc:fail2']);
176
177 // Should return empty map, not throw
178 expect(profiles.size).toBe(0);
179 });
180});
181
182describe('getProfileOrFallback', () => {
183 it('should return profile from map when found', () => {
184 const profiles = new Map<string, AuthorProfile>();
185 profiles.set('did:plc:found', {
186 did: 'did:plc:found' as `did:${string}:${string}`,
187 handle: 'found.bsky.social' as `${string}.${string}`,
188 avatar: 'https://example.com/avatar.jpg'
189 });
190
191 const result = getProfileOrFallback(profiles, 'did:plc:found');
192
193 expect(result.handle).toBe('found.bsky.social');
194 expect(result.avatar).toBe('https://example.com/avatar.jpg');
195 });
196
197 it('should return fallback when profile not found', () => {
198 const profiles = new Map<string, AuthorProfile>();
199
200 const result = getProfileOrFallback(profiles, 'did:plc:notfound123');
201
202 expect(result.did).toBe('did:plc:notfound123');
203 expect(result.handle).toBe('did:plc:notfound123...');
204 });
205
206 it('should truncate long DIDs in fallback', () => {
207 const profiles = new Map<string, AuthorProfile>();
208 const longDid = 'did:plc:verylongidentifierthatexceedstwentycharacters';
209
210 const result = getProfileOrFallback(profiles, longDid);
211
212 // slice(0, 20) gives first 20 chars, then '...'
213 expect(result.handle).toBe('did:plc:verylongiden...');
214 expect(result.handle.length).toBe(23); // 20 chars + '...'
215 });
216});