Sifa professional network API (Fastify, AT Protocol, Jetstream)
sifa.id/
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});