ATlast — you'll never need to find your favorites on another platform again. Find your favs in the ATmosphere.
atproto
16
fork

Configure Feed

Select the types of activity you want to include in your feed.

test(api): add mock atproto agent

byarielm.fyi 0cbcef3f 1709e880

verified
+345
+18
packages/api/__tests__/fixtures/index.ts
··· 13 13 } from './testUsers'; 14 14 export type { TestUserId, TestUser } from './testUsers'; 15 15 16 + // Mock AT Protocol agent 17 + export { 18 + createMockAgent, 19 + createTimeoutAgent, 20 + createRateLimitAgent, 21 + createServiceUnavailableAgent, 22 + createPartialFailureSearchAgent, 23 + createSuccessfulSearchAgent, 24 + createFollowAgent, 25 + createMalformedResponseAgent, 26 + } from './mockAgent'; 27 + export type { 28 + MockActor, 29 + MockProfile, 30 + MockFollowRecord, 31 + MockAgentOptions, 32 + } from './mockAgent'; 33 + 16 34 // Session management 17 35 export { 18 36 createTestSession,
+327
packages/api/__tests__/fixtures/mockAgent.ts
··· 1 + /** 2 + * Mock AT Protocol Agent 3 + * 4 + * Provides a configurable mock agent for deterministic testing 5 + * of search and follow route error scenarios without real AT Protocol calls. 6 + */ 7 + 8 + import { vi } from 'vitest'; 9 + import type { Agent } from '@atproto/api'; 10 + 11 + // ============================================================================ 12 + // Types 13 + // ============================================================================ 14 + 15 + /** Shape of actor returned by searchActors */ 16 + export interface MockActor { 17 + did: string; 18 + handle: string; 19 + displayName?: string; 20 + avatar?: string; 21 + description?: string; 22 + } 23 + 24 + /** Shape of profile returned by getProfiles */ 25 + export interface MockProfile { 26 + did: string; 27 + handle: string; 28 + displayName?: string; 29 + postsCount?: number; 30 + followersCount?: number; 31 + } 32 + 33 + /** Shape of record returned by listRecords */ 34 + export interface MockFollowRecord { 35 + uri: string; 36 + cid: string; 37 + value: { 38 + subject: string; 39 + createdAt: string; 40 + }; 41 + } 42 + 43 + /** Configuration for mock agent behavior */ 44 + export interface MockAgentOptions { 45 + /** Mock searchActors response or error */ 46 + searchActors?: (params: { q: string; limit: number }) => Promise<{ 47 + data: { actors: MockActor[] }; 48 + }>; 49 + /** Mock getProfiles response or error */ 50 + getProfiles?: (params: { actors: string[] }) => Promise<{ 51 + data: { profiles: MockProfile[] }; 52 + }>; 53 + /** Mock createRecord response or error */ 54 + createRecord?: (params: { 55 + repo: string; 56 + collection: string; 57 + record: Record<string, unknown>; 58 + }) => Promise<{ uri: string; cid: string }>; 59 + /** Mock listRecords response or error */ 60 + listRecords?: (params: { 61 + repo: string; 62 + collection: string; 63 + limit: number; 64 + cursor?: string; 65 + }) => Promise<{ 66 + data: { records: MockFollowRecord[]; cursor?: string }; 67 + }>; 68 + } 69 + 70 + // ============================================================================ 71 + // Mock Agent Factory 72 + // ============================================================================ 73 + 74 + /** 75 + * Creates a mock AT Protocol agent with configurable behavior. 76 + * 77 + * Default behavior returns empty/successful responses. 78 + * Override individual methods to simulate errors, rate limits, etc. 79 + */ 80 + export function createMockAgent(options: MockAgentOptions = {}): Agent { 81 + const searchActorsFn = 82 + options.searchActors ?? 83 + (async () => ({ data: { actors: [] as MockActor[] } })); 84 + 85 + const getProfilesFn = 86 + options.getProfiles ?? 87 + (async () => ({ data: { profiles: [] as MockProfile[] } })); 88 + 89 + const createRecordFn = 90 + options.createRecord ?? 91 + (async () => ({ uri: 'at://did:plc:mock/app.bsky.graph.follow/mock', cid: 'mock-cid' })); 92 + 93 + const listRecordsFn = 94 + options.listRecords ?? 95 + (async () => ({ data: { records: [] as MockFollowRecord[], cursor: undefined } })); 96 + 97 + // Build the mock agent with the nested structure matching @atproto/api Agent 98 + const mockAgent = { 99 + app: { 100 + bsky: { 101 + actor: { 102 + searchActors: vi.fn().mockImplementation(searchActorsFn), 103 + getProfiles: vi.fn().mockImplementation(getProfilesFn), 104 + }, 105 + }, 106 + }, 107 + api: { 108 + com: { 109 + atproto: { 110 + repo: { 111 + createRecord: vi.fn().mockImplementation(createRecordFn), 112 + listRecords: vi.fn().mockImplementation(listRecordsFn), 113 + }, 114 + }, 115 + }, 116 + }, 117 + } as unknown as Agent; 118 + 119 + return mockAgent; 120 + } 121 + 122 + // ============================================================================ 123 + // Pre-built Error Scenarios 124 + // ============================================================================ 125 + 126 + /** Creates a mock agent that throws network timeout errors on search */ 127 + export function createTimeoutAgent(): Agent { 128 + return createMockAgent({ 129 + searchActors: async () => { 130 + const error = new Error('request to https://bsky.social timed out'); 131 + error.name = 'FetchError'; 132 + throw error; 133 + }, 134 + getProfiles: async () => { 135 + const error = new Error('request to https://bsky.social timed out'); 136 + error.name = 'FetchError'; 137 + throw error; 138 + }, 139 + createRecord: async () => { 140 + const error = new Error('request to https://bsky.social timed out'); 141 + error.name = 'FetchError'; 142 + throw error; 143 + }, 144 + listRecords: async () => { 145 + const error = new Error('request to https://bsky.social timed out'); 146 + error.name = 'FetchError'; 147 + throw error; 148 + }, 149 + }); 150 + } 151 + 152 + /** Creates a mock agent that returns 429 rate limit errors */ 153 + export function createRateLimitAgent(): Agent { 154 + return createMockAgent({ 155 + searchActors: async () => { 156 + const error = new Error('Rate Limit Exceeded'); 157 + (error as NodeJS.ErrnoException).cause = { status: 429 }; 158 + throw error; 159 + }, 160 + createRecord: async () => { 161 + const error = new Error('rate limit exceeded - 429'); 162 + throw error; 163 + }, 164 + listRecords: async () => { 165 + const error = new Error('Rate Limit Exceeded'); 166 + throw error; 167 + }, 168 + }); 169 + } 170 + 171 + /** Creates a mock agent that returns 503 Service Unavailable */ 172 + export function createServiceUnavailableAgent(): Agent { 173 + return createMockAgent({ 174 + searchActors: async () => { 175 + const error = new Error('Service Unavailable'); 176 + (error as NodeJS.ErrnoException).cause = { status: 503 }; 177 + throw error; 178 + }, 179 + createRecord: async () => { 180 + const error = new Error('Service Unavailable'); 181 + throw error; 182 + }, 183 + listRecords: async () => { 184 + const error = new Error('Service Unavailable'); 185 + throw error; 186 + }, 187 + }); 188 + } 189 + 190 + /** 191 + * Creates a mock agent where searchActors partially fails. 192 + * Succeeds for usernames starting with 'good', fails for others. 193 + */ 194 + export function createPartialFailureSearchAgent(): Agent { 195 + return createMockAgent({ 196 + searchActors: async ({ q }: { q: string; limit: number }) => { 197 + if (!q.startsWith('good')) { 198 + throw new Error(`Search failed for "${q}"`); 199 + } 200 + return { 201 + data: { 202 + actors: [ 203 + { 204 + did: `did:plc:${q}`, 205 + handle: `${q}.bsky.social`, 206 + displayName: q, 207 + }, 208 + ], 209 + }, 210 + }; 211 + }, 212 + getProfiles: async ({ actors }: { actors: string[] }) => ({ 213 + data: { 214 + profiles: actors.map((did) => ({ 215 + did, 216 + handle: `${did.split(':').pop()}.bsky.social`, 217 + postsCount: 10, 218 + followersCount: 100, 219 + })), 220 + }, 221 + }), 222 + }); 223 + } 224 + 225 + /** 226 + * Creates a mock agent that returns actors for search 227 + * and succeeds on profiles and follow status checks. 228 + */ 229 + export function createSuccessfulSearchAgent( 230 + actorMap: Record<string, MockActor[]> = {}, 231 + ): Agent { 232 + return createMockAgent({ 233 + searchActors: async ({ q }: { q: string; limit: number }) => ({ 234 + data: { 235 + actors: actorMap[q] ?? [ 236 + { 237 + did: `did:plc:${q}`, 238 + handle: `${q}.bsky.social`, 239 + displayName: q, 240 + }, 241 + ], 242 + }, 243 + }), 244 + getProfiles: async ({ actors }: { actors: string[] }) => ({ 245 + data: { 246 + profiles: actors.map((did) => ({ 247 + did, 248 + handle: `${did.split(':').pop()}.bsky.social`, 249 + postsCount: 42, 250 + followersCount: 100, 251 + })), 252 + }, 253 + }), 254 + listRecords: async () => ({ 255 + data: { records: [], cursor: undefined }, 256 + }), 257 + }); 258 + } 259 + 260 + /** 261 + * Creates a mock agent for follow operations. 262 + * Configurable: which DIDs are already followed, which fail. 263 + */ 264 + export function createFollowAgent(opts: { 265 + alreadyFollowing?: string[]; 266 + failDids?: string[]; 267 + } = {}): Agent { 268 + const { alreadyFollowing = [], failDids = [] } = opts; 269 + const alreadyFollowingSet = new Set(alreadyFollowing); 270 + const failDidsSet = new Set(failDids); 271 + 272 + return createMockAgent({ 273 + listRecords: async () => ({ 274 + data: { 275 + records: alreadyFollowing.map((did) => ({ 276 + uri: `at://did:plc:mock/app.bsky.graph.follow/${did}`, 277 + cid: 'mock-cid', 278 + value: { subject: did, createdAt: new Date().toISOString() }, 279 + })), 280 + cursor: undefined, 281 + }, 282 + }), 283 + createRecord: async (params) => { 284 + const targetDid = (params.record as { subject?: string }).subject ?? ''; 285 + 286 + if (alreadyFollowingSet.has(targetDid)) { 287 + throw new Error('Record already exists'); 288 + } 289 + if (failDidsSet.has(targetDid)) { 290 + throw new Error('Follow failed for test'); 291 + } 292 + return { 293 + uri: `at://did:plc:mock/app.bsky.graph.follow/${Date.now()}`, 294 + cid: 'mock-cid', 295 + }; 296 + }, 297 + }); 298 + } 299 + 300 + /** 301 + * Creates a mock agent that returns malformed/unexpected response structures. 302 + */ 303 + export function createMalformedResponseAgent(): Agent { 304 + return createMockAgent({ 305 + searchActors: async () => ({ 306 + data: { 307 + // Actors with empty/missing fields - should not crash the ranking code 308 + actors: [ 309 + { did: '', handle: '' } as MockActor, 310 + { did: 'did:plc:empty', handle: '', displayName: '' } as MockActor, 311 + ], 312 + }, 313 + }), 314 + getProfiles: async () => ({ 315 + data: { 316 + profiles: [ 317 + { 318 + did: '', 319 + handle: '', 320 + postsCount: 0, 321 + followersCount: 0, 322 + } as unknown as MockProfile, 323 + ], 324 + }, 325 + }), 326 + }); 327 + }