an atproto based link aggregator
at main 216 lines 5.6 kB view raw
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});