···11+/**
22+ * Test Fixtures
33+ *
44+ * Centralized exports for all test fixtures and utilities
55+ */
66+77+// Test user definitions
88+export {
99+ TEST_USERS,
1010+ TestUserId,
1111+ TestUser,
1212+ getTestUser,
1313+ isTestUserDid,
1414+ ALL_TEST_USER_DIDS,
1515+} from './testUsers';
1616+1717+// Session management
1818+export {
1919+ createTestSession,
2020+ createCustomTestSession,
2121+ createExpiredTestSession,
2222+ getOrCreateTestSession,
2323+ deleteTestSession,
2424+ cleanupAllTestSessions,
2525+ cleanupAllTestData,
2626+ countTestSessions,
2727+ CreateSessionOptions,
2828+} from './sessions';
+234
packages/api/__tests__/fixtures/sessions.ts
···11+/**
22+ * Test Session Management
33+ *
44+ * Provides functions to create, manage, and clean up test sessions.
55+ * Sessions are seeded directly into the database, bypassing OAuth for testing.
66+ */
77+88+import * as crypto from 'crypto';
99+import { db } from '../../src/db/client';
1010+import { TEST_USERS, TestUserId, isTestUserDid, ALL_TEST_USER_DIDS } from './testUsers';
1111+1212+const SESSION_EXPIRY_MS = 7 * 24 * 60 * 60 * 1000; // 7 days
1313+1414+/**
1515+ * Active test sessions - maps user ID to session ID
1616+ * Used to reuse sessions within a test run and for cleanup
1717+ */
1818+const activeTestSessions = new Map<string, string>();
1919+2020+export interface CreateSessionOptions {
2121+ /** Override the default expiry (7 days from now) */
2222+ expiresAt?: Date;
2323+ /** Custom fingerprint data */
2424+ fingerprint?: Record<string, unknown>;
2525+}
2626+2727+/**
2828+ * Create a test session for a predefined test user
2929+ *
3030+ * @param userId - The test user ID (e.g., 'standard', 'power', 'new')
3131+ * @param options - Optional session configuration
3232+ * @returns The session ID
3333+ *
3434+ * @example
3535+ * ```ts
3636+ * const sessionId = await createTestSession('standard');
3737+ * // Use in requests: Cookie: atlast_session_dev=${sessionId}
3838+ * ```
3939+ */
4040+export async function createTestSession(
4141+ userId: TestUserId = 'standard',
4242+ options: CreateSessionOptions = {},
4343+): Promise<string> {
4444+ const user = TEST_USERS[userId];
4545+ const sessionId = crypto.randomUUID();
4646+4747+ const expiresAt = options.expiresAt ?? new Date(Date.now() + SESSION_EXPIRY_MS);
4848+ const fingerprint = JSON.stringify(options.fingerprint ?? { test: true, userId });
4949+5050+ await db
5151+ .insertInto('user_sessions')
5252+ .values({
5353+ session_id: sessionId,
5454+ did: user.did,
5555+ fingerprint,
5656+ expires_at: expiresAt,
5757+ })
5858+ .execute();
5959+6060+ // Track active session for cleanup
6161+ activeTestSessions.set(userId, sessionId);
6262+6363+ return sessionId;
6464+}
6565+6666+/**
6767+ * Create a test session with a custom DID
6868+ * Use this when you need a DID not in the predefined test users
6969+ *
7070+ * @param did - The DID to create a session for (must start with 'did:plc:test-')
7171+ * @param options - Optional session configuration
7272+ * @returns The session ID
7373+ */
7474+export async function createCustomTestSession(
7575+ did: string,
7676+ options: CreateSessionOptions = {},
7777+): Promise<string> {
7878+ // Safety check: only allow test DIDs
7979+ if (!isTestUserDid(did)) {
8080+ throw new Error(
8181+ `Safety check failed: DID must start with 'did:plc:test-' for test sessions. Got: ${did}`,
8282+ );
8383+ }
8484+8585+ const sessionId = crypto.randomUUID();
8686+ const expiresAt = options.expiresAt ?? new Date(Date.now() + SESSION_EXPIRY_MS);
8787+ const fingerprint = JSON.stringify(options.fingerprint ?? { test: true, customDid: true });
8888+8989+ await db
9090+ .insertInto('user_sessions')
9191+ .values({
9292+ session_id: sessionId,
9393+ did,
9494+ fingerprint,
9595+ expires_at: expiresAt,
9696+ })
9797+ .execute();
9898+9999+ activeTestSessions.set(did, sessionId);
100100+101101+ return sessionId;
102102+}
103103+104104+/**
105105+ * Create an expired session for testing session expiry handling
106106+ *
107107+ * @param userId - The test user ID
108108+ * @returns The expired session ID
109109+ */
110110+export async function createExpiredTestSession(
111111+ userId: TestUserId = 'expired',
112112+): Promise<string> {
113113+ // Expired 1 hour ago
114114+ const expiresAt = new Date(Date.now() - 60 * 60 * 1000);
115115+116116+ return createTestSession(userId, { expiresAt });
117117+}
118118+119119+/**
120120+ * Get an existing test session or create one
121121+ * Useful for reusing sessions across tests in the same run
122122+ *
123123+ * @param userId - The test user ID
124124+ * @returns The session ID
125125+ */
126126+export async function getOrCreateTestSession(userId: TestUserId = 'standard'): Promise<string> {
127127+ const existing = activeTestSessions.get(userId);
128128+129129+ if (existing) {
130130+ // Verify session still exists in DB
131131+ const session = await db
132132+ .selectFrom('user_sessions')
133133+ .select('session_id')
134134+ .where('session_id', '=', existing)
135135+ .where('expires_at', '>', new Date())
136136+ .executeTakeFirst();
137137+138138+ if (session) {
139139+ return existing;
140140+ }
141141+ }
142142+143143+ // Create new session
144144+ return createTestSession(userId);
145145+}
146146+147147+/**
148148+ * Delete a specific test session
149149+ *
150150+ * @param sessionId - The session ID to delete
151151+ */
152152+export async function deleteTestSession(sessionId: string): Promise<void> {
153153+ await db.deleteFrom('user_sessions').where('session_id', '=', sessionId).execute();
154154+155155+ // Remove from active sessions map
156156+ for (const [key, value] of activeTestSessions.entries()) {
157157+ if (value === sessionId) {
158158+ activeTestSessions.delete(key);
159159+ break;
160160+ }
161161+ }
162162+}
163163+164164+/**
165165+ * Clean up all test sessions from the database
166166+ * Call this in afterAll() or after test runs
167167+ */
168168+export async function cleanupAllTestSessions(): Promise<void> {
169169+ // Delete sessions for all known test user DIDs
170170+ await db
171171+ .deleteFrom('user_sessions')
172172+ .where('did', 'in', ALL_TEST_USER_DIDS)
173173+ .execute();
174174+175175+ // Also delete any sessions with 'did:plc:test-' prefix (custom test sessions)
176176+ await db
177177+ .deleteFrom('user_sessions')
178178+ .where('did', 'like', 'did:plc:test-%')
179179+ .execute();
180180+181181+ // Clear the active sessions map
182182+ activeTestSessions.clear();
183183+}
184184+185185+/**
186186+ * Clean up all test data (sessions, uploads, matches, etc.)
187187+ * Use with caution - this removes ALL data for test users
188188+ */
189189+export async function cleanupAllTestData(): Promise<void> {
190190+ const testDidPattern = 'did:plc:test-%';
191191+192192+ // Delete in order respecting foreign keys
193193+ // 1. User match status (references atproto_matches)
194194+ await db
195195+ .deleteFrom('user_match_status')
196196+ .where('user_did', 'like', testDidPattern)
197197+ .execute();
198198+199199+ // 2. Notification queue (references user_did and match_id)
200200+ await db
201201+ .deleteFrom('notification_queue')
202202+ .where('user_did', 'like', testDidPattern)
203203+ .execute();
204204+205205+ // 3. User source follows (join table)
206206+ await db
207207+ .deleteFrom('user_source_follows')
208208+ .where('user_did', 'like', testDidPattern)
209209+ .execute();
210210+211211+ // 4. User uploads
212212+ await db
213213+ .deleteFrom('user_uploads')
214214+ .where('user_did', 'like', testDidPattern)
215215+ .execute();
216216+217217+ // 5. User sessions
218218+ await cleanupAllTestSessions();
219219+220220+ console.log('🧹 Cleaned up all test data');
221221+}
222222+223223+/**
224224+ * Get the count of active test sessions (for debugging)
225225+ */
226226+export async function countTestSessions(): Promise<number> {
227227+ const result = await db
228228+ .selectFrom('user_sessions')
229229+ .select((eb) => eb.fn.count('session_id').as('count'))
230230+ .where('did', 'like', 'did:plc:test-%')
231231+ .executeTakeFirst();
232232+233233+ return Number(result?.count ?? 0);
234234+}
+92
packages/api/__tests__/fixtures/testUsers.ts
···11+/**
22+ * Test User Fixtures
33+ *
44+ * Defines test users with different "roles" for testing various scenarios.
55+ * These are synthetic DIDs that follow the AT Protocol DID format but are
66+ * clearly marked as test accounts.
77+ *
88+ * Note: ATlast doesn't have a permission system yet, but this structure
99+ * allows for easy extension when/if permissions are added.
1010+ */
1111+1212+export interface TestUser {
1313+ /** Unique identifier for this test user */
1414+ id: string;
1515+ /** AT Protocol DID - uses 'test' prefix to avoid collision with real DIDs */
1616+ did: string;
1717+ /** Human-readable description */
1818+ description: string;
1919+ /** Optional: simulated handle for display purposes */
2020+ handle?: string;
2121+}
2222+2323+/**
2424+ * Predefined test users for different testing scenarios
2525+ */
2626+export const TEST_USERS = {
2727+ /**
2828+ * Standard user - typical user with normal access
2929+ * Use for most integration tests
3030+ */
3131+ standard: {
3232+ id: 'standard',
3333+ did: 'did:plc:test-standard-user-001',
3434+ description: 'Standard test user for typical operations',
3535+ handle: 'testuser.bsky.social',
3636+ },
3737+3838+ /**
3939+ * Power user - user with lots of data (uploads, matches)
4040+ * Use for pagination and performance tests
4141+ */
4242+ power: {
4343+ id: 'power',
4444+ did: 'did:plc:test-power-user-002',
4545+ description: 'Power user with extensive data for stress testing',
4646+ handle: 'poweruser.bsky.social',
4747+ },
4848+4949+ /**
5050+ * New user - freshly created account with no data
5151+ * Use for testing empty states and onboarding flows
5252+ */
5353+ new: {
5454+ id: 'new',
5555+ did: 'did:plc:test-new-user-003',
5656+ description: 'New user with no uploads or matches',
5757+ handle: 'newuser.bsky.social',
5858+ },
5959+6060+ /**
6161+ * Expired user - for testing session expiry handling
6262+ * Sessions for this user are created with past expiry dates
6363+ */
6464+ expired: {
6565+ id: 'expired',
6666+ did: 'did:plc:test-expired-user-004',
6767+ description: 'User for testing expired session handling',
6868+ handle: 'expired.bsky.social',
6969+ },
7070+} as const satisfies Record<string, TestUser>;
7171+7272+export type TestUserId = keyof typeof TEST_USERS;
7373+7474+/**
7575+ * Get a test user by ID
7676+ */
7777+export function getTestUser(id: TestUserId): TestUser {
7878+ return TEST_USERS[id];
7979+}
8080+8181+/**
8282+ * Check if a DID is a test user DID
8383+ * Useful for cleanup and safety checks
8484+ */
8585+export function isTestUserDid(did: string): boolean {
8686+ return did.startsWith('did:plc:test-');
8787+}
8888+8989+/**
9090+ * All test user DIDs for cleanup queries
9191+ */
9292+export const ALL_TEST_USER_DIDS = Object.values(TEST_USERS).map((u) => u.did);
+210
packages/api/__tests__/helpers.ts
···11+/**
22+ * Test Helpers
33+ * Utilities for API integration testing
44+ */
55+66+import app from '../src/server';
77+import {
88+ TEST_USERS,
99+ TestUserId,
1010+ getOrCreateTestSession,
1111+ createTestSession,
1212+} from './fixtures';
1313+1414+// ============================================================================
1515+// Session Management
1616+// ============================================================================
1717+1818+/**
1919+ * Environment-based session for real user testing
2020+ * Set TEST_SESSION env var to use a real Bluesky session
2121+ */
2222+export const REAL_TEST_SESSION = process.env.TEST_SESSION || '';
2323+2424+/**
2525+ * Check if a real test session is available (for manual testing with real account)
2626+ */
2727+export function hasRealTestSession(): boolean {
2828+ return !!REAL_TEST_SESSION;
2929+}
3030+3131+/**
3232+ * Session store for seeded test sessions
3333+ * Maps test user IDs to their active session IDs
3434+ */
3535+const testSessionCache = new Map<TestUserId, string>();
3636+3737+/**
3838+ * Get or create a session for a test user
3939+ * Sessions are cached for reuse within the test run
4040+ */
4141+export async function getTestSession(userId: TestUserId = 'standard'): Promise<string> {
4242+ let sessionId = testSessionCache.get(userId);
4343+4444+ if (!sessionId) {
4545+ sessionId = await getOrCreateTestSession(userId);
4646+ testSessionCache.set(userId, sessionId);
4747+ }
4848+4949+ return sessionId;
5050+}
5151+5252+/**
5353+ * Create a fresh session (not cached)
5454+ * Use when you need a new session for each test
5555+ */
5656+export async function createFreshTestSession(userId: TestUserId = 'standard'): Promise<string> {
5757+ return createTestSession(userId);
5858+}
5959+6060+// ============================================================================
6161+// Request Helpers
6262+// ============================================================================
6363+6464+/**
6565+ * Make an authenticated request using a seeded test session
6666+ *
6767+ * @param path - API path (e.g., '/api/results/uploads')
6868+ * @param options - Fetch options
6969+ * @param userId - Test user to authenticate as (default: 'standard')
7070+ */
7171+export async function authRequest(
7272+ path: string,
7373+ options: RequestInit = {},
7474+ userId: TestUserId = 'standard',
7575+): Promise<Response> {
7676+ const sessionId = await getTestSession(userId);
7777+ const headers = new Headers(options.headers);
7878+7979+ headers.set('Cookie', `atlast_session_dev=${sessionId}`);
8080+8181+ if (options.body && !headers.has('Content-Type')) {
8282+ headers.set('Content-Type', 'application/json');
8383+ }
8484+8585+ return app.request(path, {
8686+ ...options,
8787+ headers,
8888+ });
8989+}
9090+9191+/**
9292+ * Make an authenticated request using a real session from TEST_SESSION env var
9393+ * Use this for testing with a real Bluesky account
9494+ */
9595+export async function realAuthRequest(
9696+ path: string,
9797+ options: RequestInit = {},
9898+): Promise<Response> {
9999+ if (!REAL_TEST_SESSION) {
100100+ throw new Error(
101101+ 'REAL_TEST_SESSION requires TEST_SESSION environment variable. ' +
102102+ 'Run: pnpm test:login to get a session ID.',
103103+ );
104104+ }
105105+106106+ const headers = new Headers(options.headers);
107107+ headers.set('Cookie', `atlast_session_dev=${REAL_TEST_SESSION}`);
108108+109109+ if (options.body && !headers.has('Content-Type')) {
110110+ headers.set('Content-Type', 'application/json');
111111+ }
112112+113113+ return app.request(path, {
114114+ ...options,
115115+ headers,
116116+ });
117117+}
118118+119119+/**
120120+ * Make an unauthenticated request
121121+ */
122122+export async function request(
123123+ path: string,
124124+ options: RequestInit = {},
125125+): Promise<Response> {
126126+ const headers = new Headers(options.headers);
127127+128128+ if (options.body && !headers.has('Content-Type')) {
129129+ headers.set('Content-Type', 'application/json');
130130+ }
131131+132132+ return app.request(path, {
133133+ ...options,
134134+ headers,
135135+ });
136136+}
137137+138138+/**
139139+ * Make a request with a specific session ID
140140+ * Use when testing with custom or expired sessions
141141+ */
142142+export async function requestWithSession(
143143+ path: string,
144144+ sessionId: string,
145145+ options: RequestInit = {},
146146+): Promise<Response> {
147147+ const headers = new Headers(options.headers);
148148+ headers.set('Cookie', `atlast_session_dev=${sessionId}`);
149149+150150+ if (options.body && !headers.has('Content-Type')) {
151151+ headers.set('Content-Type', 'application/json');
152152+ }
153153+154154+ return app.request(path, {
155155+ ...options,
156156+ headers,
157157+ });
158158+}
159159+160160+// ============================================================================
161161+// Response Helpers
162162+// ============================================================================
163163+164164+/**
165165+ * Parse JSON response with type safety
166166+ */
167167+export async function parseResponse<T = Record<string, unknown>>(res: Response): Promise<T> {
168168+ return res.json() as Promise<T>;
169169+}
170170+171171+// ============================================================================
172172+// Test Data Helpers
173173+// ============================================================================
174174+175175+/**
176176+ * Generate unique test IDs with timestamp and random suffix
177177+ */
178178+export function testId(prefix: string = 'test'): string {
179179+ return `${prefix}-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
180180+}
181181+182182+/**
183183+ * Get test user info
184184+ */
185185+export function getTestUserInfo(userId: TestUserId) {
186186+ return TEST_USERS[userId];
187187+}
188188+189189+// ============================================================================
190190+// Backward Compatibility
191191+// ============================================================================
192192+193193+/**
194194+ * @deprecated Use hasRealTestSession() instead
195195+ */
196196+export function hasTestSession(): boolean {
197197+ return hasRealTestSession();
198198+}
199199+200200+/**
201201+ * @deprecated Use REAL_TEST_SESSION instead
202202+ */
203203+export const TEST_SESSION = REAL_TEST_SESSION;
204204+205205+/**
206206+ * @deprecated No longer needed - sessions are seeded automatically
207207+ */
208208+export function skipIfNoSession(): boolean {
209209+ return false; // Sessions are now always available via seeding
210210+}
+510
packages/api/__tests__/routes/results.test.ts
···11+/**
22+ * Results API Integration Tests
33+ *
44+ * Two test modes:
55+ * 1. Seeded sessions (default) - Uses synthetic test users, no real auth needed
66+ * 2. Real auth sessions - Uses your actual Bluesky account
77+ *
88+ * Run seeded tests only:
99+ * pnpm test
1010+ *
1111+ * Run with real auth (includes real user tests):
1212+ * 1. pnpm test:login (get a session)
1313+ * 2. set TEST_SESSION=<session-id> && pnpm test
1414+ */
1515+1616+import { describe, it, expect, beforeAll, afterAll } from 'vitest';
1717+import {
1818+ authRequest,
1919+ realAuthRequest,
2020+ hasRealTestSession,
2121+ REAL_TEST_SESSION,
2222+ request,
2323+ requestWithSession,
2424+ parseResponse,
2525+ testId,
2626+} from '../helpers';
2727+import {
2828+ createTestSession,
2929+ createExpiredTestSession,
3030+ cleanupAllTestData,
3131+ TEST_USERS,
3232+} from '../fixtures';
3333+3434+describe('Results API', () => {
3535+ // Session IDs created for this test suite
3636+ let standardSession: string;
3737+3838+ beforeAll(async () => {
3939+ // Create a fresh session for these tests
4040+ standardSession = await createTestSession('standard');
4141+ });
4242+4343+ afterAll(async () => {
4444+ // Clean up test data created during tests
4545+ await cleanupAllTestData();
4646+ });
4747+4848+ describe('GET /api/results/uploads', () => {
4949+ it('returns 401 without authentication', async () => {
5050+ const res = await request('/api/results/uploads');
5151+ expect(res.status).toBe(401);
5252+5353+ const body = await parseResponse(res);
5454+ expect(body.success).toBe(false);
5555+ });
5656+5757+ it('returns uploads list with valid session', async () => {
5858+ const res = await authRequest('/api/results/uploads');
5959+ expect(res.status).toBe(200);
6060+6161+ const body = await parseResponse(res);
6262+ expect(body.success).toBe(true);
6363+ expect(body.data).toHaveProperty('uploads');
6464+ expect(Array.isArray(body.data.uploads)).toBe(true);
6565+6666+ // Each upload should have expected fields
6767+ if (body.data.uploads.length > 0) {
6868+ const upload = body.data.uploads[0];
6969+ expect(upload).toHaveProperty('uploadId');
7070+ expect(upload).toHaveProperty('sourcePlatform');
7171+ expect(upload).toHaveProperty('createdAt');
7272+ expect(upload).toHaveProperty('totalUsers');
7373+ expect(upload).toHaveProperty('matchedUsers');
7474+ expect(upload).toHaveProperty('unmatchedUsers');
7575+ }
7676+ });
7777+7878+ it('returns 401 with expired session', async () => {
7979+ const expiredSession = await createExpiredTestSession();
8080+ const res = await requestWithSession('/api/results/uploads', expiredSession);
8181+ expect(res.status).toBe(401);
8282+ });
8383+ });
8484+8585+ describe('POST /api/results/save', () => {
8686+ it('returns 401 without authentication', async () => {
8787+ const res = await request('/api/results/save', {
8888+ method: 'POST',
8989+ body: JSON.stringify({
9090+ uploadId: 'test',
9191+ sourcePlatform: 'instagram',
9292+ results: [],
9393+ }),
9494+ });
9595+ expect(res.status).toBe(401);
9696+ });
9797+9898+ it('saves results with matches', async () => {
9999+ const uploadId = testId('upload');
100100+101101+ const res = await authRequest('/api/results/save', {
102102+ method: 'POST',
103103+ body: JSON.stringify({
104104+ uploadId,
105105+ sourcePlatform: 'instagram',
106106+ saveData: true,
107107+ results: [
108108+ {
109109+ sourceUser: {
110110+ username: 'testuser1',
111111+ date: '2024-01-01',
112112+ },
113113+ atprotoMatches: [
114114+ {
115115+ did: 'did:plc:test123',
116116+ handle: 'testuser.bsky.social',
117117+ displayName: 'Test User',
118118+ matchScore: 100,
119119+ postCount: 50,
120120+ followerCount: 200,
121121+ },
122122+ ],
123123+ },
124124+ {
125125+ sourceUser: {
126126+ username: 'testuser2',
127127+ date: '2024-01-02',
128128+ },
129129+ atprotoMatches: [],
130130+ },
131131+ ],
132132+ }),
133133+ });
134134+135135+ expect(res.status).toBe(200);
136136+137137+ const body = await parseResponse(res);
138138+ expect(body.success).toBe(true);
139139+ expect(body.uploadId).toBe(uploadId);
140140+ expect(body.totalUsers).toBe(2);
141141+ expect(body.matchedUsers).toBe(1);
142142+ expect(body.unmatchedUsers).toBe(1);
143143+ });
144144+145145+ it('respects saveData=false flag', async () => {
146146+ const uploadId = testId('nosave');
147147+148148+ const res = await authRequest('/api/results/save', {
149149+ method: 'POST',
150150+ body: JSON.stringify({
151151+ uploadId,
152152+ sourcePlatform: 'twitter',
153153+ saveData: false,
154154+ results: [
155155+ {
156156+ sourceUser: { username: 'tempuser', date: '' },
157157+ atprotoMatches: [],
158158+ },
159159+ ],
160160+ }),
161161+ });
162162+163163+ expect(res.status).toBe(200);
164164+165165+ const body = await parseResponse(res);
166166+ expect(body.success).toBe(true);
167167+ expect(body.message).toContain('Data storage disabled');
168168+169169+ // Verify it wasn't actually saved
170170+ const uploadsRes = await authRequest('/api/results/uploads');
171171+ const uploadsBody = await parseResponse(uploadsRes);
172172+ const found = uploadsBody.data.uploads.find(
173173+ (u: { uploadId: string }) => u.uploadId === uploadId,
174174+ );
175175+ expect(found).toBeUndefined();
176176+ });
177177+178178+ it('handles empty results array', async () => {
179179+ const uploadId = testId('empty');
180180+181181+ const res = await authRequest('/api/results/save', {
182182+ method: 'POST',
183183+ body: JSON.stringify({
184184+ uploadId,
185185+ sourcePlatform: 'tiktok',
186186+ results: [],
187187+ }),
188188+ });
189189+190190+ expect(res.status).toBe(200);
191191+192192+ const body = await parseResponse(res);
193193+ expect(body.success).toBe(true);
194194+ expect(body.totalUsers).toBe(0);
195195+ });
196196+197197+ it('isolates data between test users', async () => {
198198+ // Create upload as standard user
199199+ const uploadId = testId('isolation');
200200+ await authRequest('/api/results/save', {
201201+ method: 'POST',
202202+ body: JSON.stringify({
203203+ uploadId,
204204+ sourcePlatform: 'instagram',
205205+ saveData: true,
206206+ results: [{ sourceUser: { username: 'isolationtest', date: '' }, atprotoMatches: [] }],
207207+ }),
208208+ });
209209+210210+ // Verify standard user can see it
211211+ const standardRes = await authRequest('/api/results/uploads');
212212+ const standardBody = await parseResponse(standardRes);
213213+ const foundByStandard = standardBody.data.uploads.some(
214214+ (u: { uploadId: string }) => u.uploadId === uploadId,
215215+ );
216216+ expect(foundByStandard).toBe(true);
217217+218218+ // Verify new user cannot see it
219219+ const newUserRes = await authRequest('/api/results/uploads', {}, 'new');
220220+ const newUserBody = await parseResponse(newUserRes);
221221+ const foundByNewUser = newUserBody.data.uploads.some(
222222+ (u: { uploadId: string }) => u.uploadId === uploadId,
223223+ );
224224+ expect(foundByNewUser).toBe(false);
225225+ });
226226+ });
227227+228228+ describe('GET /api/results/upload-details', () => {
229229+ it('returns 401 without authentication', async () => {
230230+ const res = await request('/api/results/upload-details?uploadId=test');
231231+ expect(res.status).toBe(401);
232232+ });
233233+234234+ it('returns 404 for non-existent upload', async () => {
235235+ const res = await authRequest(
236236+ '/api/results/upload-details?uploadId=non-existent-id&page=1',
237237+ );
238238+ expect(res.status).toBe(404);
239239+240240+ const body = await parseResponse(res);
241241+ expect(body.success).toBe(false);
242242+ });
243243+244244+ it('returns paginated results for valid upload', async () => {
245245+ // First, create an upload with data
246246+ const uploadId = testId('details');
247247+248248+ await authRequest('/api/results/save', {
249249+ method: 'POST',
250250+ body: JSON.stringify({
251251+ uploadId,
252252+ sourcePlatform: 'instagram',
253253+ results: [
254254+ {
255255+ sourceUser: { username: 'detailsuser1', date: '2024-01-01' },
256256+ atprotoMatches: [
257257+ {
258258+ did: 'did:plc:details1',
259259+ handle: 'details1.bsky.social',
260260+ displayName: 'Details User 1',
261261+ matchScore: 95,
262262+ postCount: 100,
263263+ followerCount: 500,
264264+ },
265265+ ],
266266+ },
267267+ {
268268+ sourceUser: { username: 'detailsuser2', date: '2024-01-02' },
269269+ atprotoMatches: [],
270270+ },
271271+ ],
272272+ }),
273273+ });
274274+275275+ // Now fetch the details
276276+ const res = await authRequest(
277277+ `/api/results/upload-details?uploadId=${uploadId}&page=1&pageSize=50`,
278278+ );
279279+ expect(res.status).toBe(200);
280280+281281+ const body = await parseResponse(res);
282282+ expect(body.success).toBe(true);
283283+ expect(body.data).toHaveProperty('results');
284284+ expect(body.data).toHaveProperty('pagination');
285285+286286+ // Check pagination structure
287287+ const { pagination } = body.data;
288288+ expect(pagination).toHaveProperty('page', 1);
289289+ expect(pagination).toHaveProperty('pageSize', 50);
290290+ expect(pagination).toHaveProperty('totalPages');
291291+ expect(pagination).toHaveProperty('totalUsers', 2);
292292+ expect(pagination).toHaveProperty('hasNextPage');
293293+ expect(pagination).toHaveProperty('hasPrevPage', false);
294294+295295+ // Check results structure
296296+ expect(body.data.results.length).toBeGreaterThan(0);
297297+ const result = body.data.results[0];
298298+ expect(result).toHaveProperty('sourceUser');
299299+ expect(result.sourceUser).toHaveProperty('username');
300300+ expect(result).toHaveProperty('atprotoMatches');
301301+ });
302302+303303+ it('validates page parameters', async () => {
304304+ // Test with invalid page
305305+ const res = await authRequest(
306306+ '/api/results/upload-details?uploadId=test&page=0',
307307+ );
308308+ expect(res.status).toBe(400);
309309+ });
310310+ });
311311+});
312312+313313+describe('Health Check', () => {
314314+ it('returns healthy status', async () => {
315315+ const res = await request('/api/health');
316316+ expect(res.status).toBe(200);
317317+318318+ const body = await parseResponse(res);
319319+ expect(body.success).toBe(true);
320320+ expect(body.data.status).toBe('ok');
321321+ expect(body.data.database.status).toBe('connected');
322322+ });
323323+});
324324+325325+describe('Session Edge Cases', () => {
326326+ it('handles requests with expired session', async () => {
327327+ const expiredSession = await createExpiredTestSession();
328328+ const res = await requestWithSession('/api/results/uploads', expiredSession);
329329+ expect(res.status).toBe(401);
330330+331331+ const body = await parseResponse(res);
332332+ expect(body.success).toBe(false);
333333+ });
334334+335335+ it('handles requests with invalid session format', async () => {
336336+ const res = await requestWithSession('/api/results/uploads', 'not-a-valid-uuid');
337337+ expect(res.status).toBe(401);
338338+ });
339339+});
340340+341341+/**
342342+ * Real User Tests
343343+ *
344344+ * These tests use your actual Bluesky session to verify the API works
345345+ * with real authentication. They are SKIPPED unless TEST_SESSION is set.
346346+ *
347347+ * To run these tests:
348348+ * 1. pnpm test:login
349349+ * 2. set TEST_SESSION=<session-id> && pnpm test
350350+ */
351351+describe('Real User Tests', () => {
352352+ // Skip all tests in this block if no real session
353353+ const itReal = hasRealTestSession() ? it : it.skip;
354354+355355+ // Log which mode we're in
356356+ beforeAll(() => {
357357+ if (hasRealTestSession()) {
358358+ console.log(`\n🔐 Running real user tests with session: ${REAL_TEST_SESSION.slice(0, 8)}...`);
359359+ } else {
360360+ console.log('\n⏭️ Skipping real user tests (TEST_SESSION not set)');
361361+ }
362362+ });
363363+364364+ describe('Authentication', () => {
365365+ itReal('validates real session via /api/auth/session', async () => {
366366+ const res = await request(`/api/auth/session?session=${REAL_TEST_SESSION}`);
367367+ expect(res.status).toBe(200);
368368+369369+ const body = await parseResponse(res);
370370+ expect(body.success).toBe(true);
371371+ expect(body.data).toHaveProperty('did');
372372+ expect(body.data.did).toMatch(/^did:/); // Real DID format
373373+374374+ console.log(` ✅ Authenticated as: ${body.data.did}`);
375375+ });
376376+ });
377377+378378+ describe('GET /api/results/uploads (Real User)', () => {
379379+ itReal('fetches real user uploads', async () => {
380380+ const res = await realAuthRequest('/api/results/uploads');
381381+ expect(res.status).toBe(200);
382382+383383+ const body = await parseResponse(res);
384384+ expect(body.success).toBe(true);
385385+ expect(body.data).toHaveProperty('uploads');
386386+ expect(Array.isArray(body.data.uploads)).toBe(true);
387387+388388+ console.log(` 📦 Found ${body.data.uploads.length} uploads for real user`);
389389+390390+ // Log summary of uploads if any exist
391391+ if (body.data.uploads.length > 0) {
392392+ body.data.uploads.forEach((upload: {
393393+ uploadId: string;
394394+ sourcePlatform: string;
395395+ totalUsers: number;
396396+ matchedUsers: number;
397397+ }) => {
398398+ console.log(` - ${upload.sourcePlatform}: ${upload.totalUsers} users, ${upload.matchedUsers} matched`);
399399+ });
400400+ }
401401+ });
402402+ });
403403+404404+ describe('POST /api/results/save (Real User)', () => {
405405+ itReal('can save and retrieve test data with real session', async () => {
406406+ // Use a unique ID to avoid conflicts
407407+ const uploadId = `real-test-${Date.now()}`;
408408+409409+ // Save some test data
410410+ const saveRes = await realAuthRequest('/api/results/save', {
411411+ method: 'POST',
412412+ body: JSON.stringify({
413413+ uploadId,
414414+ sourcePlatform: 'test',
415415+ saveData: true,
416416+ results: [
417417+ {
418418+ sourceUser: { username: 'realtest_user', date: '2024-01-01' },
419419+ atprotoMatches: [],
420420+ },
421421+ ],
422422+ }),
423423+ });
424424+425425+ expect(saveRes.status).toBe(200);
426426+427427+ const saveBody = await parseResponse(saveRes);
428428+ expect(saveBody.success).toBe(true);
429429+ expect(saveBody.uploadId).toBe(uploadId);
430430+431431+ console.log(` 💾 Saved test upload: ${uploadId}`);
432432+433433+ // Verify it appears in uploads list
434434+ const listRes = await realAuthRequest('/api/results/uploads');
435435+ const listBody = await parseResponse(listRes);
436436+437437+ const found = listBody.data.uploads.find(
438438+ (u: { uploadId: string }) => u.uploadId === uploadId
439439+ );
440440+ expect(found).toBeDefined();
441441+ expect(found.sourcePlatform).toBe('test');
442442+443443+ console.log(` ✅ Verified upload appears in list`);
444444+ });
445445+446446+ itReal('respects saveData=false with real session', async () => {
447447+ const uploadId = `real-nosave-${Date.now()}`;
448448+449449+ const res = await realAuthRequest('/api/results/save', {
450450+ method: 'POST',
451451+ body: JSON.stringify({
452452+ uploadId,
453453+ sourcePlatform: 'test',
454454+ saveData: false,
455455+ results: [
456456+ {
457457+ sourceUser: { username: 'nosave_user', date: '' },
458458+ atprotoMatches: [],
459459+ },
460460+ ],
461461+ }),
462462+ });
463463+464464+ expect(res.status).toBe(200);
465465+466466+ const body = await parseResponse(res);
467467+ expect(body.success).toBe(true);
468468+ expect(body.message).toContain('Data storage disabled');
469469+470470+ // Verify it was NOT saved
471471+ const listRes = await realAuthRequest('/api/results/uploads');
472472+ const listBody = await parseResponse(listRes);
473473+474474+ const found = listBody.data.uploads.find(
475475+ (u: { uploadId: string }) => u.uploadId === uploadId
476476+ );
477477+ expect(found).toBeUndefined();
478478+479479+ console.log(` ✅ Verified saveData=false works`);
480480+ });
481481+ });
482482+483483+ describe('GET /api/results/upload-details (Real User)', () => {
484484+ itReal('fetches details for a real upload', async () => {
485485+ // First check if user has any uploads
486486+ const listRes = await realAuthRequest('/api/results/uploads');
487487+ const listBody = await parseResponse(listRes);
488488+489489+ if (listBody.data.uploads.length === 0) {
490490+ console.log(' ⏭️ No uploads found, skipping details test');
491491+ return;
492492+ }
493493+494494+ // Get details for the first upload
495495+ const firstUpload = listBody.data.uploads[0];
496496+ const res = await realAuthRequest(
497497+ `/api/results/upload-details?uploadId=${firstUpload.uploadId}&page=1&pageSize=10`
498498+ );
499499+500500+ expect(res.status).toBe(200);
501501+502502+ const body = await parseResponse(res);
503503+ expect(body.success).toBe(true);
504504+ expect(body.data).toHaveProperty('results');
505505+ expect(body.data).toHaveProperty('pagination');
506506+507507+ console.log(` 📋 Upload "${firstUpload.uploadId}" has ${body.data.pagination.totalUsers} users`);
508508+ });
509509+ });
510510+});
+57
packages/api/__tests__/setup.ts
···11+/**
22+ * Test Setup
33+ * Configures environment and lifecycle hooks for integration tests
44+ */
55+66+import 'dotenv/config';
77+import { afterAll, beforeAll } from 'vitest';
88+import { cleanupAllTestSessions, cleanupAllTestData } from './fixtures';
99+1010+// ============================================================================
1111+// Environment Validation
1212+// ============================================================================
1313+1414+if (!process.env.DATABASE_URL) {
1515+ console.error('❌ DATABASE_URL not set - tests will fail');
1616+ console.error(' Set it in packages/api/.env or as an environment variable');
1717+ process.exit(1);
1818+}
1919+2020+// ============================================================================
2121+// Optional: Real User Testing
2222+// ============================================================================
2323+2424+if (process.env.TEST_SESSION) {
2525+ console.log('✅ TEST_SESSION set - real user tests available');
2626+ console.log(' Use realAuthRequest() to test with your real Bluesky session');
2727+} else {
2828+ console.log('ℹ️ TEST_SESSION not set - using seeded test sessions');
2929+ console.log(' Run: pnpm test:login to get a real session for testing');
3030+}
3131+3232+// ============================================================================
3333+// Token Encryption (optional in dev)
3434+// ============================================================================
3535+3636+if (!process.env.TOKEN_ENCRYPTION_KEY) {
3737+ // This is expected in development - suppress the warning in test output
3838+ // by setting a test-only key (won't be used since we bypass OAuth)
3939+ process.env.TOKEN_ENCRYPTION_KEY_BYPASS = 'test';
4040+}
4141+4242+// ============================================================================
4343+// Global Test Lifecycle
4444+// ============================================================================
4545+4646+beforeAll(async () => {
4747+ // Clean slate - remove any leftover test data from previous runs
4848+ await cleanupAllTestSessions();
4949+ console.log('🧹 Cleaned up previous test sessions');
5050+});
5151+5252+afterAll(async () => {
5353+ // Clean up all test data after the test run
5454+ // Use cleanupAllTestData() if you want to clean uploads/matches too
5555+ await cleanupAllTestSessions();
5656+ console.log('🧹 Cleaned up test sessions');
5757+});