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 search performance test

byarielm.fyi d0a66cc7 c14bc88e

verified
+367
+367
packages/api/__tests__/performance/search.perf.test.ts
··· 1 + /** 2 + * Search Performance Tests 3 + * 4 + * Validates search endpoint performance under load using mock AT Protocol agents. 5 + * These tests measure the processing overhead of our code (ranking, normalization, 6 + * profile enrichment, follow status checking) without real network calls. 7 + */ 8 + 9 + import { describe, it, expect, beforeAll, afterAll, vi } from 'vitest'; 10 + import { 11 + authRequest, 12 + requestWithSession, 13 + parseResponse, 14 + createFreshTestSession, 15 + } from '../helpers'; 16 + import { 17 + createTestSession, 18 + cleanupAllTestSessions, 19 + createMockAgent, 20 + } from '../fixtures'; 21 + import type { MockActor, MockProfile } from '../fixtures'; 22 + import { SessionService } from '../../src/services/SessionService'; 23 + 24 + describe('Search Performance Tests', () => { 25 + let validSession: string; 26 + 27 + beforeAll(async () => { 28 + validSession = await createTestSession('power'); 29 + }); 30 + 31 + afterAll(async () => { 32 + await cleanupAllTestSessions(); 33 + }); 34 + 35 + describe('Batch Search Throughput', () => { 36 + it('batch search handles 50 usernames under 5s', async () => { 37 + // Create a mock agent that returns multiple actors per search 38 + // to exercise the full ranking pipeline 39 + const mockAgent = createMockAgent({ 40 + searchActors: async ({ q }: { q: string; limit: number }) => ({ 41 + data: { 42 + actors: Array.from({ length: 10 }, (_, i): MockActor => ({ 43 + did: `did:plc:perf-search-${q}-${i}`, 44 + handle: i === 0 45 + ? `${q}.bsky.social` 46 + : `${q}variant${i}.bsky.social`, 47 + displayName: i === 0 ? q : `${q} Similar ${i}`, 48 + avatar: `https://example.com/avatar/${q}/${i}.jpg`, 49 + description: `Test profile for performance testing - ${q}`, 50 + })), 51 + }, 52 + }), 53 + getProfiles: async ({ actors }: { actors: string[] }) => ({ 54 + data: { 55 + profiles: actors.map((did): MockProfile => ({ 56 + did, 57 + handle: `${did.split(':').pop()}.bsky.social`, 58 + postsCount: Math.floor(Math.random() * 1000), 59 + followersCount: Math.floor(Math.random() * 5000), 60 + })), 61 + }, 62 + }), 63 + listRecords: async () => ({ 64 + data: { records: [], cursor: undefined }, 65 + }), 66 + }); 67 + 68 + const originalMethod = SessionService.getAgentForSession; 69 + SessionService.getAgentForSession = vi.fn().mockResolvedValue({ 70 + agent: mockAgent, 71 + did: 'did:plc:test-power-user-002', 72 + client: {}, 73 + }); 74 + 75 + try { 76 + const usernames = Array.from({ length: 50 }, (_, i) => `perfuser${i}`); 77 + 78 + const start = performance.now(); 79 + const res = await requestWithSession( 80 + '/api/search/batch-search-actors', 81 + validSession, 82 + { 83 + method: 'POST', 84 + body: JSON.stringify({ usernames }), 85 + }, 86 + ); 87 + const duration = performance.now() - start; 88 + 89 + expect(res.status).toBe(200); 90 + 91 + const body = await parseResponse<{ 92 + success: boolean; 93 + data: { 94 + results: Array<{ 95 + username: string; 96 + actors: Array<{ did: string; matchScore: number }>; 97 + error: string | null; 98 + }>; 99 + }; 100 + }>(res); 101 + 102 + expect(body.success).toBe(true); 103 + expect(body.data.results).toHaveLength(50); 104 + 105 + // Verify all results have actors (mock agent returns actors for every query) 106 + for (const result of body.data.results) { 107 + expect(result.error).toBeNull(); 108 + expect(result.actors.length).toBeGreaterThan(0); 109 + // Actors should be sorted by matchScore descending 110 + for (let i = 1; i < result.actors.length; i++) { 111 + expect(result.actors[i - 1].matchScore).toBeGreaterThanOrEqual( 112 + result.actors[i].matchScore, 113 + ); 114 + } 115 + } 116 + 117 + // Performance assertion: 50 searches should complete in under 5 seconds 118 + expect(duration).toBeLessThan(5000); 119 + console.log( 120 + `[Perf] 50-username batch search completed in ${duration.toFixed(0)}ms`, 121 + ); 122 + } finally { 123 + SessionService.getAgentForSession = originalMethod; 124 + } 125 + }); 126 + 127 + it('handles maximum batch size (50 usernames) with enrichment', async () => { 128 + // Test that profile enrichment and follow status don't add excessive overhead 129 + const mockAgent = createMockAgent({ 130 + searchActors: async ({ q }: { q: string; limit: number }) => ({ 131 + data: { 132 + actors: Array.from({ length: 5 }, (_, i): MockActor => ({ 133 + did: `did:plc:enrich-${q}-${i}`, 134 + handle: `${q}match${i}.bsky.social`, 135 + displayName: `${q} Match ${i}`, 136 + })), 137 + }, 138 + }), 139 + getProfiles: async ({ actors }: { actors: string[] }) => ({ 140 + data: { 141 + profiles: actors.map((did): MockProfile => ({ 142 + did, 143 + handle: `${did.split(':').pop()}.bsky.social`, 144 + postsCount: 42, 145 + followersCount: 100, 146 + })), 147 + }, 148 + }), 149 + listRecords: async () => ({ 150 + data: { records: [], cursor: undefined }, 151 + }), 152 + }); 153 + 154 + const originalMethod = SessionService.getAgentForSession; 155 + SessionService.getAgentForSession = vi.fn().mockResolvedValue({ 156 + agent: mockAgent, 157 + did: 'did:plc:test-power-user-002', 158 + client: {}, 159 + }); 160 + 161 + try { 162 + const usernames = Array.from({ length: 50 }, (_, i) => `enrichuser${i}`); 163 + 164 + const start = performance.now(); 165 + const res = await requestWithSession( 166 + '/api/search/batch-search-actors', 167 + validSession, 168 + { 169 + method: 'POST', 170 + body: JSON.stringify({ usernames }), 171 + }, 172 + ); 173 + const duration = performance.now() - start; 174 + 175 + expect(res.status).toBe(200); 176 + 177 + const body = await parseResponse<{ 178 + success: boolean; 179 + data: { 180 + results: Array<{ 181 + username: string; 182 + actors: Array<{ 183 + postCount: number; 184 + followerCount: number; 185 + followStatus: Record<string, boolean>; 186 + }>; 187 + error: string | null; 188 + }>; 189 + }; 190 + }>(res); 191 + 192 + // Verify enrichment was applied 193 + for (const result of body.data.results) { 194 + for (const actor of result.actors) { 195 + expect(actor).toHaveProperty('postCount'); 196 + expect(actor).toHaveProperty('followerCount'); 197 + expect(actor).toHaveProperty('followStatus'); 198 + } 199 + } 200 + 201 + // Enriched search should still complete within 5s 202 + expect(duration).toBeLessThan(5000); 203 + console.log( 204 + `[Perf] 50-username enriched search completed in ${duration.toFixed(0)}ms`, 205 + ); 206 + } finally { 207 + SessionService.getAgentForSession = originalMethod; 208 + } 209 + }); 210 + }); 211 + 212 + describe('Concurrent Sessions', () => { 213 + it('handles 10 concurrent search requests without degradation', async () => { 214 + const mockAgent = createMockAgent({ 215 + searchActors: async ({ q }: { q: string; limit: number }) => ({ 216 + data: { 217 + actors: [ 218 + { 219 + did: `did:plc:concurrent-${q}`, 220 + handle: `${q}.bsky.social`, 221 + displayName: q, 222 + }, 223 + ], 224 + }, 225 + }), 226 + getProfiles: async ({ actors }: { actors: string[] }) => ({ 227 + data: { 228 + profiles: actors.map((did): MockProfile => ({ 229 + did, 230 + handle: `${did.split(':').pop()}.bsky.social`, 231 + postsCount: 10, 232 + followersCount: 50, 233 + })), 234 + }, 235 + }), 236 + listRecords: async () => ({ 237 + data: { records: [], cursor: undefined }, 238 + }), 239 + }); 240 + 241 + const originalMethod = SessionService.getAgentForSession; 242 + SessionService.getAgentForSession = vi.fn().mockResolvedValue({ 243 + agent: mockAgent, 244 + did: 'did:plc:test-power-user-002', 245 + client: {}, 246 + }); 247 + 248 + try { 249 + // Create 10 concurrent search requests 250 + const requests = Array.from({ length: 10 }, (_, i) => 251 + requestWithSession( 252 + '/api/search/batch-search-actors', 253 + validSession, 254 + { 255 + method: 'POST', 256 + body: JSON.stringify({ 257 + usernames: [`concurrent_user_${i}_a`, `concurrent_user_${i}_b`], 258 + }), 259 + }, 260 + ), 261 + ); 262 + 263 + const start = performance.now(); 264 + const results = await Promise.all(requests); 265 + const duration = performance.now() - start; 266 + 267 + // All requests should succeed 268 + for (const res of results) { 269 + expect(res.status).toBe(200); 270 + } 271 + 272 + // Verify each response has correct data 273 + for (const res of results) { 274 + const body = await parseResponse<{ 275 + success: boolean; 276 + data: { results: Array<{ username: string }> }; 277 + }>(res); 278 + expect(body.success).toBe(true); 279 + expect(body.data.results).toHaveLength(2); 280 + } 281 + 282 + // 10 concurrent requests should complete within 10s 283 + expect(duration).toBeLessThan(10000); 284 + console.log( 285 + `[Perf] 10 concurrent search requests completed in ${duration.toFixed(0)}ms`, 286 + ); 287 + } finally { 288 + SessionService.getAgentForSession = originalMethod; 289 + } 290 + }); 291 + 292 + it('handles concurrent requests from different sessions', async () => { 293 + const mockAgent = createMockAgent({ 294 + searchActors: async ({ q }: { q: string; limit: number }) => ({ 295 + data: { 296 + actors: [ 297 + { 298 + did: `did:plc:multi-session-${q}`, 299 + handle: `${q}.bsky.social`, 300 + displayName: q, 301 + }, 302 + ], 303 + }, 304 + }), 305 + getProfiles: async ({ actors }: { actors: string[] }) => ({ 306 + data: { 307 + profiles: actors.map((did): MockProfile => ({ 308 + did, 309 + handle: `${did.split(':').pop()}.bsky.social`, 310 + postsCount: 5, 311 + followersCount: 25, 312 + })), 313 + }, 314 + }), 315 + listRecords: async () => ({ 316 + data: { records: [], cursor: undefined }, 317 + }), 318 + }); 319 + 320 + const originalMethod = SessionService.getAgentForSession; 321 + SessionService.getAgentForSession = vi.fn().mockResolvedValue({ 322 + agent: mockAgent, 323 + did: 'did:plc:test-power-user-002', 324 + client: {}, 325 + }); 326 + 327 + try { 328 + // Create multiple sessions (reuse same user, different session IDs) 329 + const sessions = await Promise.all([ 330 + createFreshTestSession('power'), 331 + createFreshTestSession('standard'), 332 + createFreshTestSession('new'), 333 + ]); 334 + 335 + // Fire concurrent requests from different sessions 336 + const requests = sessions.map((session, i) => 337 + requestWithSession( 338 + '/api/search/batch-search-actors', 339 + session, 340 + { 341 + method: 'POST', 342 + body: JSON.stringify({ 343 + usernames: [`session${i}_user1`, `session${i}_user2`], 344 + }), 345 + }, 346 + ), 347 + ); 348 + 349 + const start = performance.now(); 350 + const results = await Promise.all(requests); 351 + const duration = performance.now() - start; 352 + 353 + // All should succeed 354 + for (const res of results) { 355 + expect(res.status).toBe(200); 356 + } 357 + 358 + expect(duration).toBeLessThan(5000); 359 + console.log( 360 + `[Perf] 3 multi-session concurrent requests completed in ${duration.toFixed(0)}ms`, 361 + ); 362 + } finally { 363 + SessionService.getAgentForSession = originalMethod; 364 + } 365 + }); 366 + }); 367 + });