Sifa professional network API (Fastify, AT Protocol, Jetstream) sifa.id/
at main 267 lines 8.3 kB view raw
1import { describe, expect, it, vi, beforeEach } from 'vitest'; 2import { 3 fetchKeytraceClaims, 4 matchClaimsToAccounts, 5 mergeExternalAccounts, 6 type KeytraceClaim, 7 type SifaExternalAccount, 8} from '../../src/services/keytrace.js'; 9 10const { mockListRecords } = vi.hoisted(() => { 11 const mockListRecords = vi.fn(); 12 return { mockListRecords }; 13}); 14 15vi.mock('@atproto/api', () => ({ 16 Agent: class MockAgent { 17 com = { 18 atproto: { 19 repo: { 20 listRecords: mockListRecords, 21 }, 22 }, 23 }; 24 }, 25})); 26 27function makeClaim(overrides: Partial<KeytraceClaim> = {}): KeytraceClaim { 28 return { 29 rkey: 'claim1', 30 platform: 'github', 31 url: 'https://github.com/gxjansen', 32 claimedAt: '2026-03-15T00:00:00Z', 33 ...overrides, 34 }; 35} 36 37function makeSifaAccount(overrides: Partial<SifaExternalAccount> = {}): SifaExternalAccount { 38 return { 39 rkey: 'acc1', 40 platform: 'github', 41 url: 'https://github.com/gxjansen', 42 label: null, 43 feedUrl: null, 44 isPrimary: false, 45 verified: false, 46 verifiedVia: null, 47 verifiable: true, 48 ...overrides, 49 }; 50} 51 52describe('keytrace', () => { 53 describe('fetchKeytraceClaims', () => { 54 beforeEach(() => { 55 vi.clearAllMocks(); 56 }); 57 58 it('returns cached claims when available', async () => { 59 const mockValkey = { 60 get: vi.fn().mockResolvedValue(JSON.stringify([makeClaim()])), 61 setex: vi.fn(), 62 }; 63 64 const claims = await fetchKeytraceClaims( 65 'did:plc:test', 66 'pds.example.com', 67 mockValkey as never, 68 ); 69 expect(claims).toHaveLength(1); 70 expect(claims[0]?.platform).toBe('github'); 71 expect(mockValkey.get).toHaveBeenCalledWith('keytrace:claims:did:plc:test'); 72 }); 73 74 it('returns empty array when PDS fetch fails', async () => { 75 mockListRecords.mockRejectedValueOnce(new Error('PDS down')); 76 77 const mockValkey = { 78 get: vi.fn().mockResolvedValue(null), 79 setex: vi.fn(), 80 }; 81 82 const claims = await fetchKeytraceClaims( 83 'did:plc:test', 84 'pds.example.com', 85 mockValkey as never, 86 ); 87 expect(claims).toEqual([]); 88 }); 89 90 it('returns empty array when valkey is null', async () => { 91 mockListRecords.mockResolvedValueOnce({ 92 data: { 93 records: [ 94 { 95 uri: 'at://did:plc:test/dev.keytrace.claim/abc123', 96 value: { 97 url: 'https://github.com/gxjansen', 98 platform: 'github', 99 createdAt: '2026-03-15T00:00:00Z', 100 }, 101 }, 102 ], 103 }, 104 }); 105 106 const claims = await fetchKeytraceClaims('did:plc:test', 'pds.example.com', null); 107 expect(claims).toHaveLength(1); 108 expect(claims[0]?.platform).toBe('github'); 109 }); 110 111 it('caches fetched claims in valkey with 2h TTL', async () => { 112 mockListRecords.mockResolvedValueOnce({ 113 data: { 114 records: [ 115 { 116 uri: 'at://did:plc:test/dev.keytrace.claim/abc123', 117 value: { 118 url: 'https://github.com/gxjansen', 119 platform: 'github', 120 createdAt: '2026-03-15T00:00:00Z', 121 }, 122 }, 123 ], 124 }, 125 }); 126 127 const mockValkey = { 128 get: vi.fn().mockResolvedValue(null), 129 setex: vi.fn(), 130 }; 131 132 await fetchKeytraceClaims('did:plc:test', 'pds.example.com', mockValkey as never); 133 expect(mockValkey.setex).toHaveBeenCalledWith( 134 'keytrace:claims:did:plc:test', 135 7200, 136 expect.any(String), 137 ); 138 }); 139 140 it('skips malformed records', async () => { 141 mockListRecords.mockResolvedValueOnce({ 142 data: { 143 records: [ 144 { 145 uri: 'at://did:plc:test/dev.keytrace.claim/good', 146 value: { 147 url: 'https://github.com/gxjansen', 148 platform: 'github', 149 createdAt: '2026-03-15T00:00:00Z', 150 }, 151 }, 152 { 153 uri: 'at://did:plc:test/dev.keytrace.claim/bad', 154 value: { 155 // missing url and platform 156 createdAt: '2026-03-15T00:00:00Z', 157 }, 158 }, 159 ], 160 }, 161 }); 162 163 const claims = await fetchKeytraceClaims('did:plc:test', 'pds.example.com', null); 164 expect(claims).toHaveLength(1); 165 }); 166 }); 167 168 describe('matchClaimsToAccounts', () => { 169 it('matches by exact normalized URL', () => { 170 const claims = [makeClaim({ url: 'https://github.com/gxjansen/' })]; 171 const accounts = [makeSifaAccount({ url: 'https://github.com/gxjansen' })]; 172 173 const result = matchClaimsToAccounts(claims, accounts); 174 expect(result.matched.size).toBe(1); 175 expect(result.matched.get('acc1')?.rkey).toBe('claim1'); 176 expect(result.unmatched).toHaveLength(0); 177 }); 178 179 it('matches by platform + username fallback', () => { 180 const claims = [makeClaim({ url: 'https://github.com/gxjansen' })]; 181 const accounts = [ 182 makeSifaAccount({ url: 'https://www.github.com/gxjansen/repos', rkey: 'acc1' }), 183 ]; 184 185 const result = matchClaimsToAccounts(claims, accounts); 186 expect(result.matched.size).toBe(1); 187 }); 188 189 it('returns unmatched claims when no Sifa account matches', () => { 190 const claims = [makeClaim({ platform: 'twitter', url: 'https://x.com/guido' })]; 191 const accounts = [makeSifaAccount({ platform: 'github', url: 'https://github.com/other' })]; 192 193 const result = matchClaimsToAccounts(claims, accounts); 194 expect(result.matched.size).toBe(0); 195 expect(result.unmatched).toHaveLength(1); 196 }); 197 198 it('handles empty claims array', () => { 199 const result = matchClaimsToAccounts([], [makeSifaAccount()]); 200 expect(result.matched.size).toBe(0); 201 expect(result.unmatched).toHaveLength(0); 202 }); 203 204 it('handles empty accounts array', () => { 205 const claims = [makeClaim()]; 206 const result = matchClaimsToAccounts(claims, []); 207 expect(result.matched.size).toBe(0); 208 expect(result.unmatched).toHaveLength(1); 209 }); 210 }); 211 212 describe('mergeExternalAccounts', () => { 213 it('marks matched Sifa accounts as keytrace-verified', () => { 214 const accounts = [makeSifaAccount()]; 215 const claims = [makeClaim()]; 216 217 const result = mergeExternalAccounts(accounts, claims); 218 expect(result).toHaveLength(1); 219 expect(result[0]?.source).toBe('sifa'); 220 expect(result[0]?.keytraceVerified).toBe(true); 221 expect(result[0]?.keytraceClaim).toBeDefined(); 222 expect(result[0]?.keytraceClaim?.rkey).toBe('claim1'); 223 }); 224 225 it('leaves unmatched Sifa accounts unchanged', () => { 226 const accounts = [ 227 makeSifaAccount({ platform: 'linkedin', url: 'https://linkedin.com/in/gxjansen' }), 228 ]; 229 const claims = [makeClaim({ platform: 'github', url: 'https://github.com/gxjansen' })]; 230 231 const result = mergeExternalAccounts(accounts, claims); 232 const sifaEntry = result.find((r) => r.source === 'sifa'); 233 const keytraceEntry = result.find((r) => r.source === 'keytrace'); 234 235 expect(sifaEntry?.keytraceVerified).toBe(false); 236 expect(keytraceEntry?.keytraceVerified).toBe(true); 237 }); 238 239 it('appends unmatched Keytrace claims as keytrace-source entries', () => { 240 const accounts: SifaExternalAccount[] = []; 241 const claims = [makeClaim()]; 242 243 const result = mergeExternalAccounts(accounts, claims); 244 expect(result).toHaveLength(1); 245 expect(result[0]?.source).toBe('keytrace'); 246 expect(result[0]?.keytraceVerified).toBe(true); 247 expect(result[0]?.platform).toBe('github'); 248 expect(result[0]?.isPrimary).toBe(false); 249 expect(result[0]?.verified).toBe(false); 250 expect(result[0]?.verifiedVia).toBeNull(); 251 }); 252 253 it('returns only Sifa accounts when claims is empty', () => { 254 const accounts = [makeSifaAccount()]; 255 256 const result = mergeExternalAccounts(accounts, []); 257 expect(result).toHaveLength(1); 258 expect(result[0]?.source).toBe('sifa'); 259 expect(result[0]?.keytraceVerified).toBe(false); 260 }); 261 262 it('returns empty array when both are empty', () => { 263 const result = mergeExternalAccounts([], []); 264 expect(result).toEqual([]); 265 }); 266 }); 267});