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 vitest infrastructure w session seeding

- seeded sessions: standard, power, new, expired users
- add helpers for authRequest vs realAuthRequest

byarielm.fyi d2083b54 e90e34b7

verified
+1142
+28
packages/api/__tests__/fixtures/index.ts
··· 1 + /** 2 + * Test Fixtures 3 + * 4 + * Centralized exports for all test fixtures and utilities 5 + */ 6 + 7 + // Test user definitions 8 + export { 9 + TEST_USERS, 10 + TestUserId, 11 + TestUser, 12 + getTestUser, 13 + isTestUserDid, 14 + ALL_TEST_USER_DIDS, 15 + } from './testUsers'; 16 + 17 + // Session management 18 + export { 19 + createTestSession, 20 + createCustomTestSession, 21 + createExpiredTestSession, 22 + getOrCreateTestSession, 23 + deleteTestSession, 24 + cleanupAllTestSessions, 25 + cleanupAllTestData, 26 + countTestSessions, 27 + CreateSessionOptions, 28 + } from './sessions';
+234
packages/api/__tests__/fixtures/sessions.ts
··· 1 + /** 2 + * Test Session Management 3 + * 4 + * Provides functions to create, manage, and clean up test sessions. 5 + * Sessions are seeded directly into the database, bypassing OAuth for testing. 6 + */ 7 + 8 + import * as crypto from 'crypto'; 9 + import { db } from '../../src/db/client'; 10 + import { TEST_USERS, TestUserId, isTestUserDid, ALL_TEST_USER_DIDS } from './testUsers'; 11 + 12 + const SESSION_EXPIRY_MS = 7 * 24 * 60 * 60 * 1000; // 7 days 13 + 14 + /** 15 + * Active test sessions - maps user ID to session ID 16 + * Used to reuse sessions within a test run and for cleanup 17 + */ 18 + const activeTestSessions = new Map<string, string>(); 19 + 20 + export interface CreateSessionOptions { 21 + /** Override the default expiry (7 days from now) */ 22 + expiresAt?: Date; 23 + /** Custom fingerprint data */ 24 + fingerprint?: Record<string, unknown>; 25 + } 26 + 27 + /** 28 + * Create a test session for a predefined test user 29 + * 30 + * @param userId - The test user ID (e.g., 'standard', 'power', 'new') 31 + * @param options - Optional session configuration 32 + * @returns The session ID 33 + * 34 + * @example 35 + * ```ts 36 + * const sessionId = await createTestSession('standard'); 37 + * // Use in requests: Cookie: atlast_session_dev=${sessionId} 38 + * ``` 39 + */ 40 + export async function createTestSession( 41 + userId: TestUserId = 'standard', 42 + options: CreateSessionOptions = {}, 43 + ): Promise<string> { 44 + const user = TEST_USERS[userId]; 45 + const sessionId = crypto.randomUUID(); 46 + 47 + const expiresAt = options.expiresAt ?? new Date(Date.now() + SESSION_EXPIRY_MS); 48 + const fingerprint = JSON.stringify(options.fingerprint ?? { test: true, userId }); 49 + 50 + await db 51 + .insertInto('user_sessions') 52 + .values({ 53 + session_id: sessionId, 54 + did: user.did, 55 + fingerprint, 56 + expires_at: expiresAt, 57 + }) 58 + .execute(); 59 + 60 + // Track active session for cleanup 61 + activeTestSessions.set(userId, sessionId); 62 + 63 + return sessionId; 64 + } 65 + 66 + /** 67 + * Create a test session with a custom DID 68 + * Use this when you need a DID not in the predefined test users 69 + * 70 + * @param did - The DID to create a session for (must start with 'did:plc:test-') 71 + * @param options - Optional session configuration 72 + * @returns The session ID 73 + */ 74 + export async function createCustomTestSession( 75 + did: string, 76 + options: CreateSessionOptions = {}, 77 + ): Promise<string> { 78 + // Safety check: only allow test DIDs 79 + if (!isTestUserDid(did)) { 80 + throw new Error( 81 + `Safety check failed: DID must start with 'did:plc:test-' for test sessions. Got: ${did}`, 82 + ); 83 + } 84 + 85 + const sessionId = crypto.randomUUID(); 86 + const expiresAt = options.expiresAt ?? new Date(Date.now() + SESSION_EXPIRY_MS); 87 + const fingerprint = JSON.stringify(options.fingerprint ?? { test: true, customDid: true }); 88 + 89 + await db 90 + .insertInto('user_sessions') 91 + .values({ 92 + session_id: sessionId, 93 + did, 94 + fingerprint, 95 + expires_at: expiresAt, 96 + }) 97 + .execute(); 98 + 99 + activeTestSessions.set(did, sessionId); 100 + 101 + return sessionId; 102 + } 103 + 104 + /** 105 + * Create an expired session for testing session expiry handling 106 + * 107 + * @param userId - The test user ID 108 + * @returns The expired session ID 109 + */ 110 + export async function createExpiredTestSession( 111 + userId: TestUserId = 'expired', 112 + ): Promise<string> { 113 + // Expired 1 hour ago 114 + const expiresAt = new Date(Date.now() - 60 * 60 * 1000); 115 + 116 + return createTestSession(userId, { expiresAt }); 117 + } 118 + 119 + /** 120 + * Get an existing test session or create one 121 + * Useful for reusing sessions across tests in the same run 122 + * 123 + * @param userId - The test user ID 124 + * @returns The session ID 125 + */ 126 + export async function getOrCreateTestSession(userId: TestUserId = 'standard'): Promise<string> { 127 + const existing = activeTestSessions.get(userId); 128 + 129 + if (existing) { 130 + // Verify session still exists in DB 131 + const session = await db 132 + .selectFrom('user_sessions') 133 + .select('session_id') 134 + .where('session_id', '=', existing) 135 + .where('expires_at', '>', new Date()) 136 + .executeTakeFirst(); 137 + 138 + if (session) { 139 + return existing; 140 + } 141 + } 142 + 143 + // Create new session 144 + return createTestSession(userId); 145 + } 146 + 147 + /** 148 + * Delete a specific test session 149 + * 150 + * @param sessionId - The session ID to delete 151 + */ 152 + export async function deleteTestSession(sessionId: string): Promise<void> { 153 + await db.deleteFrom('user_sessions').where('session_id', '=', sessionId).execute(); 154 + 155 + // Remove from active sessions map 156 + for (const [key, value] of activeTestSessions.entries()) { 157 + if (value === sessionId) { 158 + activeTestSessions.delete(key); 159 + break; 160 + } 161 + } 162 + } 163 + 164 + /** 165 + * Clean up all test sessions from the database 166 + * Call this in afterAll() or after test runs 167 + */ 168 + export async function cleanupAllTestSessions(): Promise<void> { 169 + // Delete sessions for all known test user DIDs 170 + await db 171 + .deleteFrom('user_sessions') 172 + .where('did', 'in', ALL_TEST_USER_DIDS) 173 + .execute(); 174 + 175 + // Also delete any sessions with 'did:plc:test-' prefix (custom test sessions) 176 + await db 177 + .deleteFrom('user_sessions') 178 + .where('did', 'like', 'did:plc:test-%') 179 + .execute(); 180 + 181 + // Clear the active sessions map 182 + activeTestSessions.clear(); 183 + } 184 + 185 + /** 186 + * Clean up all test data (sessions, uploads, matches, etc.) 187 + * Use with caution - this removes ALL data for test users 188 + */ 189 + export async function cleanupAllTestData(): Promise<void> { 190 + const testDidPattern = 'did:plc:test-%'; 191 + 192 + // Delete in order respecting foreign keys 193 + // 1. User match status (references atproto_matches) 194 + await db 195 + .deleteFrom('user_match_status') 196 + .where('user_did', 'like', testDidPattern) 197 + .execute(); 198 + 199 + // 2. Notification queue (references user_did and match_id) 200 + await db 201 + .deleteFrom('notification_queue') 202 + .where('user_did', 'like', testDidPattern) 203 + .execute(); 204 + 205 + // 3. User source follows (join table) 206 + await db 207 + .deleteFrom('user_source_follows') 208 + .where('user_did', 'like', testDidPattern) 209 + .execute(); 210 + 211 + // 4. User uploads 212 + await db 213 + .deleteFrom('user_uploads') 214 + .where('user_did', 'like', testDidPattern) 215 + .execute(); 216 + 217 + // 5. User sessions 218 + await cleanupAllTestSessions(); 219 + 220 + console.log('🧹 Cleaned up all test data'); 221 + } 222 + 223 + /** 224 + * Get the count of active test sessions (for debugging) 225 + */ 226 + export async function countTestSessions(): Promise<number> { 227 + const result = await db 228 + .selectFrom('user_sessions') 229 + .select((eb) => eb.fn.count('session_id').as('count')) 230 + .where('did', 'like', 'did:plc:test-%') 231 + .executeTakeFirst(); 232 + 233 + return Number(result?.count ?? 0); 234 + }
+92
packages/api/__tests__/fixtures/testUsers.ts
··· 1 + /** 2 + * Test User Fixtures 3 + * 4 + * Defines test users with different "roles" for testing various scenarios. 5 + * These are synthetic DIDs that follow the AT Protocol DID format but are 6 + * clearly marked as test accounts. 7 + * 8 + * Note: ATlast doesn't have a permission system yet, but this structure 9 + * allows for easy extension when/if permissions are added. 10 + */ 11 + 12 + export interface TestUser { 13 + /** Unique identifier for this test user */ 14 + id: string; 15 + /** AT Protocol DID - uses 'test' prefix to avoid collision with real DIDs */ 16 + did: string; 17 + /** Human-readable description */ 18 + description: string; 19 + /** Optional: simulated handle for display purposes */ 20 + handle?: string; 21 + } 22 + 23 + /** 24 + * Predefined test users for different testing scenarios 25 + */ 26 + export const TEST_USERS = { 27 + /** 28 + * Standard user - typical user with normal access 29 + * Use for most integration tests 30 + */ 31 + standard: { 32 + id: 'standard', 33 + did: 'did:plc:test-standard-user-001', 34 + description: 'Standard test user for typical operations', 35 + handle: 'testuser.bsky.social', 36 + }, 37 + 38 + /** 39 + * Power user - user with lots of data (uploads, matches) 40 + * Use for pagination and performance tests 41 + */ 42 + power: { 43 + id: 'power', 44 + did: 'did:plc:test-power-user-002', 45 + description: 'Power user with extensive data for stress testing', 46 + handle: 'poweruser.bsky.social', 47 + }, 48 + 49 + /** 50 + * New user - freshly created account with no data 51 + * Use for testing empty states and onboarding flows 52 + */ 53 + new: { 54 + id: 'new', 55 + did: 'did:plc:test-new-user-003', 56 + description: 'New user with no uploads or matches', 57 + handle: 'newuser.bsky.social', 58 + }, 59 + 60 + /** 61 + * Expired user - for testing session expiry handling 62 + * Sessions for this user are created with past expiry dates 63 + */ 64 + expired: { 65 + id: 'expired', 66 + did: 'did:plc:test-expired-user-004', 67 + description: 'User for testing expired session handling', 68 + handle: 'expired.bsky.social', 69 + }, 70 + } as const satisfies Record<string, TestUser>; 71 + 72 + export type TestUserId = keyof typeof TEST_USERS; 73 + 74 + /** 75 + * Get a test user by ID 76 + */ 77 + export function getTestUser(id: TestUserId): TestUser { 78 + return TEST_USERS[id]; 79 + } 80 + 81 + /** 82 + * Check if a DID is a test user DID 83 + * Useful for cleanup and safety checks 84 + */ 85 + export function isTestUserDid(did: string): boolean { 86 + return did.startsWith('did:plc:test-'); 87 + } 88 + 89 + /** 90 + * All test user DIDs for cleanup queries 91 + */ 92 + export const ALL_TEST_USER_DIDS = Object.values(TEST_USERS).map((u) => u.did);
+210
packages/api/__tests__/helpers.ts
··· 1 + /** 2 + * Test Helpers 3 + * Utilities for API integration testing 4 + */ 5 + 6 + import app from '../src/server'; 7 + import { 8 + TEST_USERS, 9 + TestUserId, 10 + getOrCreateTestSession, 11 + createTestSession, 12 + } from './fixtures'; 13 + 14 + // ============================================================================ 15 + // Session Management 16 + // ============================================================================ 17 + 18 + /** 19 + * Environment-based session for real user testing 20 + * Set TEST_SESSION env var to use a real Bluesky session 21 + */ 22 + export const REAL_TEST_SESSION = process.env.TEST_SESSION || ''; 23 + 24 + /** 25 + * Check if a real test session is available (for manual testing with real account) 26 + */ 27 + export function hasRealTestSession(): boolean { 28 + return !!REAL_TEST_SESSION; 29 + } 30 + 31 + /** 32 + * Session store for seeded test sessions 33 + * Maps test user IDs to their active session IDs 34 + */ 35 + const testSessionCache = new Map<TestUserId, string>(); 36 + 37 + /** 38 + * Get or create a session for a test user 39 + * Sessions are cached for reuse within the test run 40 + */ 41 + export async function getTestSession(userId: TestUserId = 'standard'): Promise<string> { 42 + let sessionId = testSessionCache.get(userId); 43 + 44 + if (!sessionId) { 45 + sessionId = await getOrCreateTestSession(userId); 46 + testSessionCache.set(userId, sessionId); 47 + } 48 + 49 + return sessionId; 50 + } 51 + 52 + /** 53 + * Create a fresh session (not cached) 54 + * Use when you need a new session for each test 55 + */ 56 + export async function createFreshTestSession(userId: TestUserId = 'standard'): Promise<string> { 57 + return createTestSession(userId); 58 + } 59 + 60 + // ============================================================================ 61 + // Request Helpers 62 + // ============================================================================ 63 + 64 + /** 65 + * Make an authenticated request using a seeded test session 66 + * 67 + * @param path - API path (e.g., '/api/results/uploads') 68 + * @param options - Fetch options 69 + * @param userId - Test user to authenticate as (default: 'standard') 70 + */ 71 + export async function authRequest( 72 + path: string, 73 + options: RequestInit = {}, 74 + userId: TestUserId = 'standard', 75 + ): Promise<Response> { 76 + const sessionId = await getTestSession(userId); 77 + const headers = new Headers(options.headers); 78 + 79 + headers.set('Cookie', `atlast_session_dev=${sessionId}`); 80 + 81 + if (options.body && !headers.has('Content-Type')) { 82 + headers.set('Content-Type', 'application/json'); 83 + } 84 + 85 + return app.request(path, { 86 + ...options, 87 + headers, 88 + }); 89 + } 90 + 91 + /** 92 + * Make an authenticated request using a real session from TEST_SESSION env var 93 + * Use this for testing with a real Bluesky account 94 + */ 95 + export async function realAuthRequest( 96 + path: string, 97 + options: RequestInit = {}, 98 + ): Promise<Response> { 99 + if (!REAL_TEST_SESSION) { 100 + throw new Error( 101 + 'REAL_TEST_SESSION requires TEST_SESSION environment variable. ' + 102 + 'Run: pnpm test:login to get a session ID.', 103 + ); 104 + } 105 + 106 + const headers = new Headers(options.headers); 107 + headers.set('Cookie', `atlast_session_dev=${REAL_TEST_SESSION}`); 108 + 109 + if (options.body && !headers.has('Content-Type')) { 110 + headers.set('Content-Type', 'application/json'); 111 + } 112 + 113 + return app.request(path, { 114 + ...options, 115 + headers, 116 + }); 117 + } 118 + 119 + /** 120 + * Make an unauthenticated request 121 + */ 122 + export async function request( 123 + path: string, 124 + options: RequestInit = {}, 125 + ): Promise<Response> { 126 + const headers = new Headers(options.headers); 127 + 128 + if (options.body && !headers.has('Content-Type')) { 129 + headers.set('Content-Type', 'application/json'); 130 + } 131 + 132 + return app.request(path, { 133 + ...options, 134 + headers, 135 + }); 136 + } 137 + 138 + /** 139 + * Make a request with a specific session ID 140 + * Use when testing with custom or expired sessions 141 + */ 142 + export async function requestWithSession( 143 + path: string, 144 + sessionId: string, 145 + options: RequestInit = {}, 146 + ): Promise<Response> { 147 + const headers = new Headers(options.headers); 148 + headers.set('Cookie', `atlast_session_dev=${sessionId}`); 149 + 150 + if (options.body && !headers.has('Content-Type')) { 151 + headers.set('Content-Type', 'application/json'); 152 + } 153 + 154 + return app.request(path, { 155 + ...options, 156 + headers, 157 + }); 158 + } 159 + 160 + // ============================================================================ 161 + // Response Helpers 162 + // ============================================================================ 163 + 164 + /** 165 + * Parse JSON response with type safety 166 + */ 167 + export async function parseResponse<T = Record<string, unknown>>(res: Response): Promise<T> { 168 + return res.json() as Promise<T>; 169 + } 170 + 171 + // ============================================================================ 172 + // Test Data Helpers 173 + // ============================================================================ 174 + 175 + /** 176 + * Generate unique test IDs with timestamp and random suffix 177 + */ 178 + export function testId(prefix: string = 'test'): string { 179 + return `${prefix}-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; 180 + } 181 + 182 + /** 183 + * Get test user info 184 + */ 185 + export function getTestUserInfo(userId: TestUserId) { 186 + return TEST_USERS[userId]; 187 + } 188 + 189 + // ============================================================================ 190 + // Backward Compatibility 191 + // ============================================================================ 192 + 193 + /** 194 + * @deprecated Use hasRealTestSession() instead 195 + */ 196 + export function hasTestSession(): boolean { 197 + return hasRealTestSession(); 198 + } 199 + 200 + /** 201 + * @deprecated Use REAL_TEST_SESSION instead 202 + */ 203 + export const TEST_SESSION = REAL_TEST_SESSION; 204 + 205 + /** 206 + * @deprecated No longer needed - sessions are seeded automatically 207 + */ 208 + export function skipIfNoSession(): boolean { 209 + return false; // Sessions are now always available via seeding 210 + }
+510
packages/api/__tests__/routes/results.test.ts
··· 1 + /** 2 + * Results API Integration Tests 3 + * 4 + * Two test modes: 5 + * 1. Seeded sessions (default) - Uses synthetic test users, no real auth needed 6 + * 2. Real auth sessions - Uses your actual Bluesky account 7 + * 8 + * Run seeded tests only: 9 + * pnpm test 10 + * 11 + * Run with real auth (includes real user tests): 12 + * 1. pnpm test:login (get a session) 13 + * 2. set TEST_SESSION=<session-id> && pnpm test 14 + */ 15 + 16 + import { describe, it, expect, beforeAll, afterAll } from 'vitest'; 17 + import { 18 + authRequest, 19 + realAuthRequest, 20 + hasRealTestSession, 21 + REAL_TEST_SESSION, 22 + request, 23 + requestWithSession, 24 + parseResponse, 25 + testId, 26 + } from '../helpers'; 27 + import { 28 + createTestSession, 29 + createExpiredTestSession, 30 + cleanupAllTestData, 31 + TEST_USERS, 32 + } from '../fixtures'; 33 + 34 + describe('Results API', () => { 35 + // Session IDs created for this test suite 36 + let standardSession: string; 37 + 38 + beforeAll(async () => { 39 + // Create a fresh session for these tests 40 + standardSession = await createTestSession('standard'); 41 + }); 42 + 43 + afterAll(async () => { 44 + // Clean up test data created during tests 45 + await cleanupAllTestData(); 46 + }); 47 + 48 + describe('GET /api/results/uploads', () => { 49 + it('returns 401 without authentication', async () => { 50 + const res = await request('/api/results/uploads'); 51 + expect(res.status).toBe(401); 52 + 53 + const body = await parseResponse(res); 54 + expect(body.success).toBe(false); 55 + }); 56 + 57 + it('returns uploads list with valid session', async () => { 58 + const res = await authRequest('/api/results/uploads'); 59 + expect(res.status).toBe(200); 60 + 61 + const body = await parseResponse(res); 62 + expect(body.success).toBe(true); 63 + expect(body.data).toHaveProperty('uploads'); 64 + expect(Array.isArray(body.data.uploads)).toBe(true); 65 + 66 + // Each upload should have expected fields 67 + if (body.data.uploads.length > 0) { 68 + const upload = body.data.uploads[0]; 69 + expect(upload).toHaveProperty('uploadId'); 70 + expect(upload).toHaveProperty('sourcePlatform'); 71 + expect(upload).toHaveProperty('createdAt'); 72 + expect(upload).toHaveProperty('totalUsers'); 73 + expect(upload).toHaveProperty('matchedUsers'); 74 + expect(upload).toHaveProperty('unmatchedUsers'); 75 + } 76 + }); 77 + 78 + it('returns 401 with expired session', async () => { 79 + const expiredSession = await createExpiredTestSession(); 80 + const res = await requestWithSession('/api/results/uploads', expiredSession); 81 + expect(res.status).toBe(401); 82 + }); 83 + }); 84 + 85 + describe('POST /api/results/save', () => { 86 + it('returns 401 without authentication', async () => { 87 + const res = await request('/api/results/save', { 88 + method: 'POST', 89 + body: JSON.stringify({ 90 + uploadId: 'test', 91 + sourcePlatform: 'instagram', 92 + results: [], 93 + }), 94 + }); 95 + expect(res.status).toBe(401); 96 + }); 97 + 98 + it('saves results with matches', async () => { 99 + const uploadId = testId('upload'); 100 + 101 + const res = await authRequest('/api/results/save', { 102 + method: 'POST', 103 + body: JSON.stringify({ 104 + uploadId, 105 + sourcePlatform: 'instagram', 106 + saveData: true, 107 + results: [ 108 + { 109 + sourceUser: { 110 + username: 'testuser1', 111 + date: '2024-01-01', 112 + }, 113 + atprotoMatches: [ 114 + { 115 + did: 'did:plc:test123', 116 + handle: 'testuser.bsky.social', 117 + displayName: 'Test User', 118 + matchScore: 100, 119 + postCount: 50, 120 + followerCount: 200, 121 + }, 122 + ], 123 + }, 124 + { 125 + sourceUser: { 126 + username: 'testuser2', 127 + date: '2024-01-02', 128 + }, 129 + atprotoMatches: [], 130 + }, 131 + ], 132 + }), 133 + }); 134 + 135 + expect(res.status).toBe(200); 136 + 137 + const body = await parseResponse(res); 138 + expect(body.success).toBe(true); 139 + expect(body.uploadId).toBe(uploadId); 140 + expect(body.totalUsers).toBe(2); 141 + expect(body.matchedUsers).toBe(1); 142 + expect(body.unmatchedUsers).toBe(1); 143 + }); 144 + 145 + it('respects saveData=false flag', async () => { 146 + const uploadId = testId('nosave'); 147 + 148 + const res = await authRequest('/api/results/save', { 149 + method: 'POST', 150 + body: JSON.stringify({ 151 + uploadId, 152 + sourcePlatform: 'twitter', 153 + saveData: false, 154 + results: [ 155 + { 156 + sourceUser: { username: 'tempuser', date: '' }, 157 + atprotoMatches: [], 158 + }, 159 + ], 160 + }), 161 + }); 162 + 163 + expect(res.status).toBe(200); 164 + 165 + const body = await parseResponse(res); 166 + expect(body.success).toBe(true); 167 + expect(body.message).toContain('Data storage disabled'); 168 + 169 + // Verify it wasn't actually saved 170 + const uploadsRes = await authRequest('/api/results/uploads'); 171 + const uploadsBody = await parseResponse(uploadsRes); 172 + const found = uploadsBody.data.uploads.find( 173 + (u: { uploadId: string }) => u.uploadId === uploadId, 174 + ); 175 + expect(found).toBeUndefined(); 176 + }); 177 + 178 + it('handles empty results array', async () => { 179 + const uploadId = testId('empty'); 180 + 181 + const res = await authRequest('/api/results/save', { 182 + method: 'POST', 183 + body: JSON.stringify({ 184 + uploadId, 185 + sourcePlatform: 'tiktok', 186 + results: [], 187 + }), 188 + }); 189 + 190 + expect(res.status).toBe(200); 191 + 192 + const body = await parseResponse(res); 193 + expect(body.success).toBe(true); 194 + expect(body.totalUsers).toBe(0); 195 + }); 196 + 197 + it('isolates data between test users', async () => { 198 + // Create upload as standard user 199 + const uploadId = testId('isolation'); 200 + await authRequest('/api/results/save', { 201 + method: 'POST', 202 + body: JSON.stringify({ 203 + uploadId, 204 + sourcePlatform: 'instagram', 205 + saveData: true, 206 + results: [{ sourceUser: { username: 'isolationtest', date: '' }, atprotoMatches: [] }], 207 + }), 208 + }); 209 + 210 + // Verify standard user can see it 211 + const standardRes = await authRequest('/api/results/uploads'); 212 + const standardBody = await parseResponse(standardRes); 213 + const foundByStandard = standardBody.data.uploads.some( 214 + (u: { uploadId: string }) => u.uploadId === uploadId, 215 + ); 216 + expect(foundByStandard).toBe(true); 217 + 218 + // Verify new user cannot see it 219 + const newUserRes = await authRequest('/api/results/uploads', {}, 'new'); 220 + const newUserBody = await parseResponse(newUserRes); 221 + const foundByNewUser = newUserBody.data.uploads.some( 222 + (u: { uploadId: string }) => u.uploadId === uploadId, 223 + ); 224 + expect(foundByNewUser).toBe(false); 225 + }); 226 + }); 227 + 228 + describe('GET /api/results/upload-details', () => { 229 + it('returns 401 without authentication', async () => { 230 + const res = await request('/api/results/upload-details?uploadId=test'); 231 + expect(res.status).toBe(401); 232 + }); 233 + 234 + it('returns 404 for non-existent upload', async () => { 235 + const res = await authRequest( 236 + '/api/results/upload-details?uploadId=non-existent-id&page=1', 237 + ); 238 + expect(res.status).toBe(404); 239 + 240 + const body = await parseResponse(res); 241 + expect(body.success).toBe(false); 242 + }); 243 + 244 + it('returns paginated results for valid upload', async () => { 245 + // First, create an upload with data 246 + const uploadId = testId('details'); 247 + 248 + await authRequest('/api/results/save', { 249 + method: 'POST', 250 + body: JSON.stringify({ 251 + uploadId, 252 + sourcePlatform: 'instagram', 253 + results: [ 254 + { 255 + sourceUser: { username: 'detailsuser1', date: '2024-01-01' }, 256 + atprotoMatches: [ 257 + { 258 + did: 'did:plc:details1', 259 + handle: 'details1.bsky.social', 260 + displayName: 'Details User 1', 261 + matchScore: 95, 262 + postCount: 100, 263 + followerCount: 500, 264 + }, 265 + ], 266 + }, 267 + { 268 + sourceUser: { username: 'detailsuser2', date: '2024-01-02' }, 269 + atprotoMatches: [], 270 + }, 271 + ], 272 + }), 273 + }); 274 + 275 + // Now fetch the details 276 + const res = await authRequest( 277 + `/api/results/upload-details?uploadId=${uploadId}&page=1&pageSize=50`, 278 + ); 279 + expect(res.status).toBe(200); 280 + 281 + const body = await parseResponse(res); 282 + expect(body.success).toBe(true); 283 + expect(body.data).toHaveProperty('results'); 284 + expect(body.data).toHaveProperty('pagination'); 285 + 286 + // Check pagination structure 287 + const { pagination } = body.data; 288 + expect(pagination).toHaveProperty('page', 1); 289 + expect(pagination).toHaveProperty('pageSize', 50); 290 + expect(pagination).toHaveProperty('totalPages'); 291 + expect(pagination).toHaveProperty('totalUsers', 2); 292 + expect(pagination).toHaveProperty('hasNextPage'); 293 + expect(pagination).toHaveProperty('hasPrevPage', false); 294 + 295 + // Check results structure 296 + expect(body.data.results.length).toBeGreaterThan(0); 297 + const result = body.data.results[0]; 298 + expect(result).toHaveProperty('sourceUser'); 299 + expect(result.sourceUser).toHaveProperty('username'); 300 + expect(result).toHaveProperty('atprotoMatches'); 301 + }); 302 + 303 + it('validates page parameters', async () => { 304 + // Test with invalid page 305 + const res = await authRequest( 306 + '/api/results/upload-details?uploadId=test&page=0', 307 + ); 308 + expect(res.status).toBe(400); 309 + }); 310 + }); 311 + }); 312 + 313 + describe('Health Check', () => { 314 + it('returns healthy status', async () => { 315 + const res = await request('/api/health'); 316 + expect(res.status).toBe(200); 317 + 318 + const body = await parseResponse(res); 319 + expect(body.success).toBe(true); 320 + expect(body.data.status).toBe('ok'); 321 + expect(body.data.database.status).toBe('connected'); 322 + }); 323 + }); 324 + 325 + describe('Session Edge Cases', () => { 326 + it('handles requests with expired session', async () => { 327 + const expiredSession = await createExpiredTestSession(); 328 + const res = await requestWithSession('/api/results/uploads', expiredSession); 329 + expect(res.status).toBe(401); 330 + 331 + const body = await parseResponse(res); 332 + expect(body.success).toBe(false); 333 + }); 334 + 335 + it('handles requests with invalid session format', async () => { 336 + const res = await requestWithSession('/api/results/uploads', 'not-a-valid-uuid'); 337 + expect(res.status).toBe(401); 338 + }); 339 + }); 340 + 341 + /** 342 + * Real User Tests 343 + * 344 + * These tests use your actual Bluesky session to verify the API works 345 + * with real authentication. They are SKIPPED unless TEST_SESSION is set. 346 + * 347 + * To run these tests: 348 + * 1. pnpm test:login 349 + * 2. set TEST_SESSION=<session-id> && pnpm test 350 + */ 351 + describe('Real User Tests', () => { 352 + // Skip all tests in this block if no real session 353 + const itReal = hasRealTestSession() ? it : it.skip; 354 + 355 + // Log which mode we're in 356 + beforeAll(() => { 357 + if (hasRealTestSession()) { 358 + console.log(`\n🔐 Running real user tests with session: ${REAL_TEST_SESSION.slice(0, 8)}...`); 359 + } else { 360 + console.log('\n⏭️ Skipping real user tests (TEST_SESSION not set)'); 361 + } 362 + }); 363 + 364 + describe('Authentication', () => { 365 + itReal('validates real session via /api/auth/session', async () => { 366 + const res = await request(`/api/auth/session?session=${REAL_TEST_SESSION}`); 367 + expect(res.status).toBe(200); 368 + 369 + const body = await parseResponse(res); 370 + expect(body.success).toBe(true); 371 + expect(body.data).toHaveProperty('did'); 372 + expect(body.data.did).toMatch(/^did:/); // Real DID format 373 + 374 + console.log(` ✅ Authenticated as: ${body.data.did}`); 375 + }); 376 + }); 377 + 378 + describe('GET /api/results/uploads (Real User)', () => { 379 + itReal('fetches real user uploads', async () => { 380 + const res = await realAuthRequest('/api/results/uploads'); 381 + expect(res.status).toBe(200); 382 + 383 + const body = await parseResponse(res); 384 + expect(body.success).toBe(true); 385 + expect(body.data).toHaveProperty('uploads'); 386 + expect(Array.isArray(body.data.uploads)).toBe(true); 387 + 388 + console.log(` 📦 Found ${body.data.uploads.length} uploads for real user`); 389 + 390 + // Log summary of uploads if any exist 391 + if (body.data.uploads.length > 0) { 392 + body.data.uploads.forEach((upload: { 393 + uploadId: string; 394 + sourcePlatform: string; 395 + totalUsers: number; 396 + matchedUsers: number; 397 + }) => { 398 + console.log(` - ${upload.sourcePlatform}: ${upload.totalUsers} users, ${upload.matchedUsers} matched`); 399 + }); 400 + } 401 + }); 402 + }); 403 + 404 + describe('POST /api/results/save (Real User)', () => { 405 + itReal('can save and retrieve test data with real session', async () => { 406 + // Use a unique ID to avoid conflicts 407 + const uploadId = `real-test-${Date.now()}`; 408 + 409 + // Save some test data 410 + const saveRes = await realAuthRequest('/api/results/save', { 411 + method: 'POST', 412 + body: JSON.stringify({ 413 + uploadId, 414 + sourcePlatform: 'test', 415 + saveData: true, 416 + results: [ 417 + { 418 + sourceUser: { username: 'realtest_user', date: '2024-01-01' }, 419 + atprotoMatches: [], 420 + }, 421 + ], 422 + }), 423 + }); 424 + 425 + expect(saveRes.status).toBe(200); 426 + 427 + const saveBody = await parseResponse(saveRes); 428 + expect(saveBody.success).toBe(true); 429 + expect(saveBody.uploadId).toBe(uploadId); 430 + 431 + console.log(` 💾 Saved test upload: ${uploadId}`); 432 + 433 + // Verify it appears in uploads list 434 + const listRes = await realAuthRequest('/api/results/uploads'); 435 + const listBody = await parseResponse(listRes); 436 + 437 + const found = listBody.data.uploads.find( 438 + (u: { uploadId: string }) => u.uploadId === uploadId 439 + ); 440 + expect(found).toBeDefined(); 441 + expect(found.sourcePlatform).toBe('test'); 442 + 443 + console.log(` ✅ Verified upload appears in list`); 444 + }); 445 + 446 + itReal('respects saveData=false with real session', async () => { 447 + const uploadId = `real-nosave-${Date.now()}`; 448 + 449 + const res = await realAuthRequest('/api/results/save', { 450 + method: 'POST', 451 + body: JSON.stringify({ 452 + uploadId, 453 + sourcePlatform: 'test', 454 + saveData: false, 455 + results: [ 456 + { 457 + sourceUser: { username: 'nosave_user', date: '' }, 458 + atprotoMatches: [], 459 + }, 460 + ], 461 + }), 462 + }); 463 + 464 + expect(res.status).toBe(200); 465 + 466 + const body = await parseResponse(res); 467 + expect(body.success).toBe(true); 468 + expect(body.message).toContain('Data storage disabled'); 469 + 470 + // Verify it was NOT saved 471 + const listRes = await realAuthRequest('/api/results/uploads'); 472 + const listBody = await parseResponse(listRes); 473 + 474 + const found = listBody.data.uploads.find( 475 + (u: { uploadId: string }) => u.uploadId === uploadId 476 + ); 477 + expect(found).toBeUndefined(); 478 + 479 + console.log(` ✅ Verified saveData=false works`); 480 + }); 481 + }); 482 + 483 + describe('GET /api/results/upload-details (Real User)', () => { 484 + itReal('fetches details for a real upload', async () => { 485 + // First check if user has any uploads 486 + const listRes = await realAuthRequest('/api/results/uploads'); 487 + const listBody = await parseResponse(listRes); 488 + 489 + if (listBody.data.uploads.length === 0) { 490 + console.log(' ⏭️ No uploads found, skipping details test'); 491 + return; 492 + } 493 + 494 + // Get details for the first upload 495 + const firstUpload = listBody.data.uploads[0]; 496 + const res = await realAuthRequest( 497 + `/api/results/upload-details?uploadId=${firstUpload.uploadId}&page=1&pageSize=10` 498 + ); 499 + 500 + expect(res.status).toBe(200); 501 + 502 + const body = await parseResponse(res); 503 + expect(body.success).toBe(true); 504 + expect(body.data).toHaveProperty('results'); 505 + expect(body.data).toHaveProperty('pagination'); 506 + 507 + console.log(` 📋 Upload "${firstUpload.uploadId}" has ${body.data.pagination.totalUsers} users`); 508 + }); 509 + }); 510 + });
+57
packages/api/__tests__/setup.ts
··· 1 + /** 2 + * Test Setup 3 + * Configures environment and lifecycle hooks for integration tests 4 + */ 5 + 6 + import 'dotenv/config'; 7 + import { afterAll, beforeAll } from 'vitest'; 8 + import { cleanupAllTestSessions, cleanupAllTestData } from './fixtures'; 9 + 10 + // ============================================================================ 11 + // Environment Validation 12 + // ============================================================================ 13 + 14 + if (!process.env.DATABASE_URL) { 15 + console.error('❌ DATABASE_URL not set - tests will fail'); 16 + console.error(' Set it in packages/api/.env or as an environment variable'); 17 + process.exit(1); 18 + } 19 + 20 + // ============================================================================ 21 + // Optional: Real User Testing 22 + // ============================================================================ 23 + 24 + if (process.env.TEST_SESSION) { 25 + console.log('✅ TEST_SESSION set - real user tests available'); 26 + console.log(' Use realAuthRequest() to test with your real Bluesky session'); 27 + } else { 28 + console.log('ℹ️ TEST_SESSION not set - using seeded test sessions'); 29 + console.log(' Run: pnpm test:login to get a real session for testing'); 30 + } 31 + 32 + // ============================================================================ 33 + // Token Encryption (optional in dev) 34 + // ============================================================================ 35 + 36 + if (!process.env.TOKEN_ENCRYPTION_KEY) { 37 + // This is expected in development - suppress the warning in test output 38 + // by setting a test-only key (won't be used since we bypass OAuth) 39 + process.env.TOKEN_ENCRYPTION_KEY_BYPASS = 'test'; 40 + } 41 + 42 + // ============================================================================ 43 + // Global Test Lifecycle 44 + // ============================================================================ 45 + 46 + beforeAll(async () => { 47 + // Clean slate - remove any leftover test data from previous runs 48 + await cleanupAllTestSessions(); 49 + console.log('🧹 Cleaned up previous test sessions'); 50 + }); 51 + 52 + afterAll(async () => { 53 + // Clean up all test data after the test run 54 + // Use cleanupAllTestData() if you want to clean uploads/matches too 55 + await cleanupAllTestSessions(); 56 + console.log('🧹 Cleaned up test sessions'); 57 + });
+11
packages/api/vitest.config.ts
··· 1 + import { defineConfig } from "vitest/config"; 2 + 3 + export default defineConfig({ 4 + test: { 5 + globals: true, 6 + environment: "node", 7 + include: ["__tests__/**/*.test.ts"], 8 + setupFiles: ["__tests__/setup.ts"], 9 + testTimeout: 30000, // 30s for API calls 10 + }, 11 + });