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 contract tests for all API endpoints

byarielm.fyi 7f391497 6004d388

verified
+1058
+223
packages/api/__tests__/contracts/auth.contract.test.ts
··· 1 + /** 2 + * Auth API Contract Tests 3 + * 4 + * Validates that auth endpoint responses match expected Zod schemas. 5 + * - GET /api/auth/session 6 + * - POST /api/auth/logout 7 + * - GET /api/auth/client-metadata.json 8 + * - GET /api/auth/jwks 9 + * 10 + * Note: oauth-start and oauth-callback involve redirects / external OAuth 11 + * providers and are tested separately in integration tests. 12 + */ 13 + 14 + import { describe, it, expect } from 'vitest'; 15 + import { authRequest, parseResponse, request, requestWithSession } from '../helpers'; 16 + import { 17 + SessionSuccessSchema, 18 + SessionErrorSchema, 19 + LogoutSuccessSchema, 20 + ClientMetadataLoopbackSchema, 21 + JwksResponseSchema, 22 + JwkSchema, 23 + } from '../../src/types/schemas'; 24 + 25 + describe('Auth API Contract', () => { 26 + // ======================================================================== 27 + // GET /api/auth/session 28 + // ======================================================================== 29 + 30 + describe('GET /api/auth/session', () => { 31 + it('authenticated session matches SessionSuccessSchema', async () => { 32 + const res = await authRequest('/api/auth/session'); 33 + 34 + expect(res.status).toBe(200); 35 + const body = await parseResponse(res); 36 + const result = SessionSuccessSchema.safeParse(body); 37 + 38 + if (!result.success) { 39 + console.error('Schema validation errors:', result.error.issues); 40 + } 41 + expect(result.success).toBe(true); 42 + }); 43 + 44 + it('session data contains valid DID format', async () => { 45 + const res = await authRequest('/api/auth/session'); 46 + const body = await parseResponse<{ 47 + data: { did: string; sessionId: string }; 48 + }>(res); 49 + 50 + expect(body.data.did).toMatch(/^did:/); 51 + expect(body.data.sessionId).toBeTruthy(); 52 + }); 53 + 54 + it('success response has no unexpected top-level keys', async () => { 55 + const res = await authRequest('/api/auth/session'); 56 + const body = await parseResponse<Record<string, unknown>>(res); 57 + 58 + const expectedKeys = ['success', 'data']; 59 + const unexpectedKeys = Object.keys(body).filter( 60 + (k) => !expectedKeys.includes(k), 61 + ); 62 + expect(unexpectedKeys).toEqual([]); 63 + }); 64 + 65 + it('missing session returns error matching SessionErrorSchema', async () => { 66 + const res = await request('/api/auth/session'); 67 + 68 + expect(res.status).toBe(401); 69 + const body = await parseResponse(res); 70 + const result = SessionErrorSchema.safeParse(body); 71 + 72 + expect(result.success).toBe(true); 73 + }); 74 + 75 + it('invalid session returns error matching SessionErrorSchema', async () => { 76 + const res = await requestWithSession( 77 + '/api/auth/session', 78 + 'nonexistent-session-id-12345', 79 + ); 80 + 81 + expect(res.status).toBe(401); 82 + const body = await parseResponse(res); 83 + const result = SessionErrorSchema.safeParse(body); 84 + 85 + expect(result.success).toBe(true); 86 + }); 87 + }); 88 + 89 + // ======================================================================== 90 + // POST /api/auth/logout 91 + // ======================================================================== 92 + 93 + describe('POST /api/auth/logout', () => { 94 + it('logout response matches LogoutSuccessSchema', async () => { 95 + const res = await request('/api/auth/logout', { method: 'POST' }); 96 + 97 + expect(res.status).toBe(200); 98 + const body = await parseResponse(res); 99 + const result = LogoutSuccessSchema.safeParse(body); 100 + 101 + if (!result.success) { 102 + console.error('Schema validation errors:', result.error.issues); 103 + } 104 + expect(result.success).toBe(true); 105 + }); 106 + 107 + it('logout response has no unexpected keys', async () => { 108 + const res = await request('/api/auth/logout', { method: 'POST' }); 109 + const body = await parseResponse<Record<string, unknown>>(res); 110 + 111 + const expectedKeys = ['success']; 112 + const unexpectedKeys = Object.keys(body).filter( 113 + (k) => !expectedKeys.includes(k), 114 + ); 115 + expect(unexpectedKeys).toEqual([]); 116 + }); 117 + }); 118 + 119 + // ======================================================================== 120 + // GET /api/auth/client-metadata.json 121 + // ======================================================================== 122 + 123 + describe('GET /api/auth/client-metadata.json', () => { 124 + it('loopback metadata matches ClientMetadataLoopbackSchema', async () => { 125 + const res = await request('/api/auth/client-metadata.json', { 126 + headers: { Host: '127.0.0.1:8888' }, 127 + }); 128 + 129 + expect(res.status).toBe(200); 130 + const body = await parseResponse(res); 131 + const result = ClientMetadataLoopbackSchema.safeParse(body); 132 + 133 + if (!result.success) { 134 + console.error('Schema validation errors:', result.error.issues); 135 + } 136 + expect(result.success).toBe(true); 137 + }); 138 + 139 + it('loopback metadata has required OAuth fields', async () => { 140 + const res = await request('/api/auth/client-metadata.json', { 141 + headers: { Host: '127.0.0.1:8888' }, 142 + }); 143 + 144 + const body = await parseResponse<Record<string, unknown>>(res); 145 + 146 + expect(body).toHaveProperty('client_id'); 147 + expect(body).toHaveProperty('redirect_uris'); 148 + expect(body).toHaveProperty('grant_types'); 149 + expect(body).toHaveProperty('response_types'); 150 + expect(body).toHaveProperty('dpop_bound_access_tokens', true); 151 + }); 152 + 153 + it('loopback redirect_uris contains callback path', async () => { 154 + const res = await request('/api/auth/client-metadata.json', { 155 + headers: { Host: '127.0.0.1:8888' }, 156 + }); 157 + 158 + const body = await parseResponse<{ redirect_uris: string[] }>(res); 159 + 160 + expect(body.redirect_uris).toHaveLength(1); 161 + expect(body.redirect_uris[0]).toContain('/api/auth/oauth-callback'); 162 + }); 163 + }); 164 + 165 + // ======================================================================== 166 + // GET /api/auth/jwks 167 + // ======================================================================== 168 + 169 + describe('GET /api/auth/jwks', () => { 170 + it('jwks response matches JwksResponseSchema', async () => { 171 + const res = await request('/api/auth/jwks'); 172 + 173 + expect(res.status).toBe(200); 174 + const body = await parseResponse(res); 175 + const result = JwksResponseSchema.safeParse(body); 176 + 177 + if (!result.success) { 178 + console.error('Schema validation errors:', result.error.issues); 179 + } 180 + expect(result.success).toBe(true); 181 + }); 182 + 183 + it('each key matches JwkSchema', async () => { 184 + const res = await request('/api/auth/jwks'); 185 + const body = await parseResponse<{ 186 + keys: Array<Record<string, unknown>>; 187 + }>(res); 188 + 189 + expect(body.keys.length).toBeGreaterThan(0); 190 + 191 + for (const key of body.keys) { 192 + const result = JwkSchema.safeParse(key); 193 + if (!result.success) { 194 + console.error('JWK validation errors:', result.error.issues); 195 + } 196 + expect(result.success).toBe(true); 197 + } 198 + }); 199 + 200 + it('jwks response has no unexpected top-level keys', async () => { 201 + const res = await request('/api/auth/jwks'); 202 + const body = await parseResponse<Record<string, unknown>>(res); 203 + 204 + const expectedKeys = ['keys']; 205 + const unexpectedKeys = Object.keys(body).filter( 206 + (k) => !expectedKeys.includes(k), 207 + ); 208 + expect(unexpectedKeys).toEqual([]); 209 + }); 210 + 211 + it('jwk contains EC key parameters', async () => { 212 + const res = await request('/api/auth/jwks'); 213 + const body = await parseResponse<{ 214 + keys: Array<{ kty: string; crv: string; alg: string }>; 215 + }>(res); 216 + 217 + const key = body.keys[0]; 218 + expect(key.kty).toBe('EC'); 219 + expect(key.crv).toBe('P-256'); 220 + expect(key.alg).toBe('ES256'); 221 + }); 222 + }); 223 + });
+126
packages/api/__tests__/contracts/extension.contract.test.ts
··· 1 + /** 2 + * Extension API Contract Tests 3 + * 4 + * Validates that POST /api/extension/import responses 5 + * match the expected Zod schemas at runtime. 6 + */ 7 + 8 + import { describe, it, expect } from 'vitest'; 9 + import { authRequest, parseResponse, request } from '../helpers'; 10 + import { 11 + ExtensionImportSuccessSchema, 12 + ExtensionImportDataSchema, 13 + } from '../../src/types/schemas'; 14 + 15 + describe('Extension API Contract', () => { 16 + const validImportBody = { 17 + platform: 'twitter', 18 + usernames: ['user1', 'user2', 'user3'], 19 + metadata: { 20 + extensionVersion: '1.0.0', 21 + scrapedAt: new Date().toISOString(), 22 + pageType: 'following', 23 + sourceUrl: 'https://twitter.com/testuser/following', 24 + }, 25 + }; 26 + 27 + // ======================================================================== 28 + // POST /api/extension/import — success 29 + // ======================================================================== 30 + 31 + it('success response matches ExtensionImportSuccessSchema', async () => { 32 + const res = await authRequest('/api/extension/import', { 33 + method: 'POST', 34 + body: JSON.stringify(validImportBody), 35 + }); 36 + 37 + expect(res.status).toBe(200); 38 + const body = await parseResponse(res); 39 + const result = ExtensionImportSuccessSchema.safeParse(body); 40 + 41 + if (!result.success) { 42 + console.error('Schema validation errors:', result.error.issues); 43 + } 44 + expect(result.success).toBe(true); 45 + }); 46 + 47 + it('import data matches ExtensionImportDataSchema', async () => { 48 + const res = await authRequest('/api/extension/import', { 49 + method: 'POST', 50 + body: JSON.stringify(validImportBody), 51 + }); 52 + 53 + const body = await parseResponse<{ 54 + data: Record<string, unknown>; 55 + }>(res); 56 + const result = ExtensionImportDataSchema.safeParse(body.data); 57 + 58 + if (!result.success) { 59 + console.error('Data validation errors:', result.error.issues); 60 + } 61 + expect(result.success).toBe(true); 62 + }); 63 + 64 + it('usernameCount matches input array length', async () => { 65 + const res = await authRequest('/api/extension/import', { 66 + method: 'POST', 67 + body: JSON.stringify(validImportBody), 68 + }); 69 + 70 + const body = await parseResponse<{ 71 + data: { usernameCount: number }; 72 + }>(res); 73 + 74 + expect(body.data.usernameCount).toBe(validImportBody.usernames.length); 75 + }); 76 + 77 + it('redirectUrl contains the importId', async () => { 78 + const res = await authRequest('/api/extension/import', { 79 + method: 'POST', 80 + body: JSON.stringify(validImportBody), 81 + }); 82 + 83 + const body = await parseResponse<{ 84 + data: { importId: string; redirectUrl: string }; 85 + }>(res); 86 + 87 + expect(body.data.redirectUrl).toContain(body.data.importId); 88 + }); 89 + 90 + it('success response has no unexpected top-level keys', async () => { 91 + const res = await authRequest('/api/extension/import', { 92 + method: 'POST', 93 + body: JSON.stringify(validImportBody), 94 + }); 95 + 96 + const body = await parseResponse<Record<string, unknown>>(res); 97 + const expectedKeys = ['success', 'data']; 98 + const unexpectedKeys = Object.keys(body).filter( 99 + (k) => !expectedKeys.includes(k), 100 + ); 101 + expect(unexpectedKeys).toEqual([]); 102 + }); 103 + 104 + // ======================================================================== 105 + // Error responses 106 + // ======================================================================== 107 + 108 + it('unauthenticated request returns 401', async () => { 109 + const res = await request('/api/extension/import', { 110 + method: 'POST', 111 + body: JSON.stringify(validImportBody), 112 + }); 113 + expect(res.status).toBe(401); 114 + }); 115 + 116 + it('invalid body returns validation error', async () => { 117 + const res = await authRequest('/api/extension/import', { 118 + method: 'POST', 119 + body: JSON.stringify({ platform: 'twitter', usernames: [] }), 120 + }); 121 + 122 + // Zod min(1) rejects empty array 123 + expect(res.status).toBeGreaterThanOrEqual(400); 124 + expect(res.status).toBeLessThan(500); 125 + }); 126 + });
+216
packages/api/__tests__/contracts/follow.contract.test.ts
··· 1 + /** 2 + * Follow API Contract Tests 3 + * 4 + * Validates that follow endpoint responses match expected Zod schemas. 5 + * - POST /api/follow/batch-follow-users 6 + * - POST /api/follow/check-status 7 + */ 8 + 9 + import { describe, it, expect, beforeAll, afterAll } from 'vitest'; 10 + import { authRequest, parseResponse, request } from '../helpers'; 11 + import { 12 + BatchFollowSuccessSchema, 13 + CheckStatusSuccessSchema, 14 + FollowErrorSchema, 15 + FollowResultEntrySchema, 16 + } from '../../src/types/schemas'; 17 + import { SessionService } from '../../src/services/SessionService'; 18 + import { createFollowAgent } from '../fixtures/mockAgent'; 19 + import type { Agent } from '@atproto/api'; 20 + 21 + describe('Follow API Contract', () => { 22 + const originalGetAgent = SessionService.getAgentForSession; 23 + 24 + beforeAll(() => { 25 + const agent: Agent = createFollowAgent({ 26 + alreadyFollowing: ['did:plc:already1'], 27 + failDids: ['did:plc:failme'], 28 + }); 29 + 30 + SessionService.getAgentForSession = async () => ({ 31 + agent, 32 + did: 'did:plc:test-contract-follow', 33 + }); 34 + }); 35 + 36 + afterAll(() => { 37 + SessionService.getAgentForSession = originalGetAgent; 38 + }); 39 + 40 + // ======================================================================== 41 + // batch-follow-users success contract 42 + // ======================================================================== 43 + 44 + describe('POST /api/follow/batch-follow-users', () => { 45 + it('success response matches BatchFollowSuccessSchema', async () => { 46 + const res = await authRequest('/api/follow/batch-follow-users', { 47 + method: 'POST', 48 + body: JSON.stringify({ 49 + dids: ['did:plc:new1', 'did:plc:already1', 'did:plc:failme'], 50 + }), 51 + }); 52 + 53 + expect(res.status).toBe(200); 54 + const body = await parseResponse(res); 55 + const result = BatchFollowSuccessSchema.safeParse(body); 56 + 57 + if (!result.success) { 58 + console.error('Schema validation errors:', result.error.issues); 59 + } 60 + expect(result.success).toBe(true); 61 + }); 62 + 63 + it('individual follow results match FollowResultEntrySchema', async () => { 64 + const res = await authRequest('/api/follow/batch-follow-users', { 65 + method: 'POST', 66 + body: JSON.stringify({ dids: ['did:plc:new1', 'did:plc:already1'] }), 67 + }); 68 + 69 + const body = await parseResponse<{ 70 + data: { results: Array<Record<string, unknown>> }; 71 + }>(res); 72 + 73 + for (const entry of body.data.results) { 74 + const result = FollowResultEntrySchema.safeParse(entry); 75 + if (!result.success) { 76 + console.error( 77 + `Entry "${entry.did}" failed validation:`, 78 + result.error.issues, 79 + ); 80 + } 81 + expect(result.success).toBe(true); 82 + } 83 + }); 84 + 85 + it('data counts are consistent', async () => { 86 + const dids = ['did:plc:new1', 'did:plc:already1', 'did:plc:failme']; 87 + const res = await authRequest('/api/follow/batch-follow-users', { 88 + method: 'POST', 89 + body: JSON.stringify({ dids }), 90 + }); 91 + 92 + const body = await parseResponse<{ 93 + data: { 94 + total: number; 95 + succeeded: number; 96 + failed: number; 97 + results: Array<{ did: string }>; 98 + }; 99 + }>(res); 100 + 101 + expect(body.data.total).toBe(dids.length); 102 + expect(body.data.succeeded + body.data.failed).toBe(dids.length); 103 + expect(body.data.results).toHaveLength(dids.length); 104 + }); 105 + 106 + it('success response has no unexpected top-level keys', async () => { 107 + const res = await authRequest('/api/follow/batch-follow-users', { 108 + method: 'POST', 109 + body: JSON.stringify({ dids: ['did:plc:new1'] }), 110 + }); 111 + 112 + const body = await parseResponse<Record<string, unknown>>(res); 113 + const expectedKeys = ['success', 'data']; 114 + const unexpectedKeys = Object.keys(body).filter( 115 + (k) => !expectedKeys.includes(k), 116 + ); 117 + expect(unexpectedKeys).toEqual([]); 118 + }); 119 + 120 + it('validation error matches ErrorResponseSchema', async () => { 121 + const res = await authRequest('/api/follow/batch-follow-users', { 122 + method: 'POST', 123 + body: JSON.stringify({ dids: [] }), 124 + }); 125 + 126 + expect(res.status).toBe(400); 127 + const body = await parseResponse(res); 128 + const result = FollowErrorSchema.safeParse(body); 129 + expect(result.success).toBe(true); 130 + }); 131 + 132 + it('non-did strings rejected with error response', async () => { 133 + const res = await authRequest('/api/follow/batch-follow-users', { 134 + method: 'POST', 135 + body: JSON.stringify({ dids: ['notadid'] }), 136 + }); 137 + 138 + expect(res.status).toBe(400); 139 + const body = await parseResponse(res); 140 + const result = FollowErrorSchema.safeParse(body); 141 + expect(result.success).toBe(true); 142 + }); 143 + 144 + it('unauthenticated request returns 401', async () => { 145 + const res = await request('/api/follow/batch-follow-users', { 146 + method: 'POST', 147 + body: JSON.stringify({ dids: ['did:plc:test'] }), 148 + }); 149 + expect(res.status).toBe(401); 150 + }); 151 + }); 152 + 153 + // ======================================================================== 154 + // check-status success contract 155 + // ======================================================================== 156 + 157 + describe('POST /api/follow/check-status', () => { 158 + it('success response matches CheckStatusSuccessSchema', async () => { 159 + const res = await authRequest('/api/follow/check-status', { 160 + method: 'POST', 161 + body: JSON.stringify({ dids: ['did:plc:already1', 'did:plc:new1'] }), 162 + }); 163 + 164 + expect(res.status).toBe(200); 165 + const body = await parseResponse(res); 166 + const result = CheckStatusSuccessSchema.safeParse(body); 167 + 168 + if (!result.success) { 169 + console.error('Schema validation errors:', result.error.issues); 170 + } 171 + expect(result.success).toBe(true); 172 + }); 173 + 174 + it('followStatus keys are DID strings with boolean values', async () => { 175 + const res = await authRequest('/api/follow/check-status', { 176 + method: 'POST', 177 + body: JSON.stringify({ dids: ['did:plc:already1', 'did:plc:new1'] }), 178 + }); 179 + 180 + const body = await parseResponse<{ 181 + data: { followStatus: Record<string, boolean> }; 182 + }>(res); 183 + 184 + for (const [key, value] of Object.entries(body.data.followStatus)) { 185 + expect(key).toMatch(/^did:/); 186 + expect(typeof value).toBe('boolean'); 187 + } 188 + }); 189 + 190 + it('success response has no unexpected top-level keys', async () => { 191 + const res = await authRequest('/api/follow/check-status', { 192 + method: 'POST', 193 + body: JSON.stringify({ dids: ['did:plc:test1'] }), 194 + }); 195 + 196 + const body = await parseResponse<Record<string, unknown>>(res); 197 + const expectedKeys = ['success', 'data']; 198 + const unexpectedKeys = Object.keys(body).filter( 199 + (k) => !expectedKeys.includes(k), 200 + ); 201 + expect(unexpectedKeys).toEqual([]); 202 + }); 203 + 204 + it('validation error matches ErrorResponseSchema', async () => { 205 + const res = await authRequest('/api/follow/check-status', { 206 + method: 'POST', 207 + body: JSON.stringify({ dids: [] }), 208 + }); 209 + 210 + expect(res.status).toBe(400); 211 + const body = await parseResponse(res); 212 + const result = FollowErrorSchema.safeParse(body); 213 + expect(result.success).toBe(true); 214 + }); 215 + }); 216 + });
+300
packages/api/__tests__/contracts/results.contract.test.ts
··· 1 + /** 2 + * Results API Contract Tests 3 + * 4 + * Validates that results endpoint responses match expected Zod schemas. 5 + * - POST /api/results/save 6 + * - GET /api/results/uploads 7 + * - GET /api/results/upload-details 8 + */ 9 + 10 + import { describe, it, expect, beforeAll, afterAll } from 'vitest'; 11 + import { authRequest, parseResponse, testId, request } from '../helpers'; 12 + import { 13 + SaveResultsSuccessSchema, 14 + UploadsListSuccessSchema, 15 + UploadEntrySchema, 16 + UploadDetailsSuccessSchema, 17 + PaginationSchema, 18 + } from '../../src/types/schemas'; 19 + 20 + describe('Results API Contract', () => { 21 + let savedUploadId: string; 22 + 23 + // ======================================================================== 24 + // POST /api/results/save 25 + // ======================================================================== 26 + 27 + describe('POST /api/results/save', () => { 28 + it('success response matches SaveResultsSuccessSchema', async () => { 29 + savedUploadId = testId('contract'); 30 + const res = await authRequest('/api/results/save', { 31 + method: 'POST', 32 + body: JSON.stringify({ 33 + uploadId: savedUploadId, 34 + sourcePlatform: 'instagram', 35 + results: [ 36 + { 37 + sourceUser: { username: 'contractuser1' }, 38 + atprotoMatches: [ 39 + { 40 + did: 'did:plc:contract1', 41 + handle: 'contractuser1.bsky.social', 42 + displayName: 'Contract User', 43 + matchScore: 100, 44 + postCount: 10, 45 + followerCount: 50, 46 + }, 47 + ], 48 + }, 49 + { 50 + sourceUser: { username: 'contractuser2' }, 51 + atprotoMatches: [], 52 + }, 53 + ], 54 + }), 55 + }); 56 + 57 + expect(res.status).toBe(200); 58 + const body = await parseResponse(res); 59 + const result = SaveResultsSuccessSchema.safeParse(body); 60 + 61 + if (!result.success) { 62 + console.error('Schema validation errors:', result.error.issues); 63 + } 64 + expect(result.success).toBe(true); 65 + }); 66 + 67 + it('save response counts are consistent', async () => { 68 + const uploadId = testId('contract-counts'); 69 + const results = [ 70 + { 71 + sourceUser: { username: 'user1' }, 72 + atprotoMatches: [ 73 + { 74 + did: 'did:plc:c1', 75 + handle: 'u1.bsky.social', 76 + matchScore: 80, 77 + postCount: 5, 78 + followerCount: 20, 79 + }, 80 + ], 81 + }, 82 + { 83 + sourceUser: { username: 'user2' }, 84 + atprotoMatches: [], 85 + }, 86 + { 87 + sourceUser: { username: 'user3' }, 88 + atprotoMatches: [], 89 + }, 90 + ]; 91 + 92 + const res = await authRequest('/api/results/save', { 93 + method: 'POST', 94 + body: JSON.stringify({ 95 + uploadId, 96 + sourcePlatform: 'tiktok', 97 + results, 98 + }), 99 + }); 100 + 101 + const body = await parseResponse<{ 102 + totalUsers: number; 103 + matchedUsers: number; 104 + unmatchedUsers: number; 105 + }>(res); 106 + 107 + expect(body.totalUsers).toBe(3); 108 + expect(body.matchedUsers).toBe(1); 109 + expect(body.unmatchedUsers).toBe(2); 110 + expect(body.matchedUsers + body.unmatchedUsers).toBe(body.totalUsers); 111 + }); 112 + 113 + it('save with storage disabled includes message field', async () => { 114 + const res = await authRequest('/api/results/save', { 115 + method: 'POST', 116 + body: JSON.stringify({ 117 + uploadId: testId('no-save'), 118 + sourcePlatform: 'twitter', 119 + results: [ 120 + { 121 + sourceUser: { username: 'nosave' }, 122 + atprotoMatches: [], 123 + }, 124 + ], 125 + saveData: false, 126 + }), 127 + }); 128 + 129 + expect(res.status).toBe(200); 130 + const body = await parseResponse(res); 131 + const result = SaveResultsSuccessSchema.safeParse(body); 132 + expect(result.success).toBe(true); 133 + if (result.success) { 134 + expect(result.data.message).toBeDefined(); 135 + } 136 + }); 137 + 138 + it('success response has no unexpected top-level keys', async () => { 139 + const res = await authRequest('/api/results/save', { 140 + method: 'POST', 141 + body: JSON.stringify({ 142 + uploadId: testId('keys-check'), 143 + sourcePlatform: 'instagram', 144 + results: [], 145 + }), 146 + }); 147 + 148 + const body = await parseResponse<Record<string, unknown>>(res); 149 + const expectedKeys = [ 150 + 'success', 151 + 'uploadId', 152 + 'totalUsers', 153 + 'matchedUsers', 154 + 'unmatchedUsers', 155 + 'message', 156 + ]; 157 + const unexpectedKeys = Object.keys(body).filter( 158 + (k) => !expectedKeys.includes(k), 159 + ); 160 + expect(unexpectedKeys).toEqual([]); 161 + }); 162 + 163 + it('unauthenticated save returns 401', async () => { 164 + const res = await request('/api/results/save', { 165 + method: 'POST', 166 + body: JSON.stringify({ 167 + uploadId: 'x', 168 + sourcePlatform: 'x', 169 + results: [], 170 + }), 171 + }); 172 + expect(res.status).toBe(401); 173 + }); 174 + }); 175 + 176 + // ======================================================================== 177 + // GET /api/results/uploads 178 + // ======================================================================== 179 + 180 + describe('GET /api/results/uploads', () => { 181 + it('success response matches UploadsListSuccessSchema', async () => { 182 + const res = await authRequest('/api/results/uploads'); 183 + 184 + expect(res.status).toBe(200); 185 + const body = await parseResponse(res); 186 + const result = UploadsListSuccessSchema.safeParse(body); 187 + 188 + if (!result.success) { 189 + console.error('Schema validation errors:', result.error.issues); 190 + } 191 + expect(result.success).toBe(true); 192 + }); 193 + 194 + it('each upload entry matches UploadEntrySchema', async () => { 195 + const res = await authRequest('/api/results/uploads'); 196 + const body = await parseResponse<{ 197 + data: { uploads: Array<Record<string, unknown>> }; 198 + }>(res); 199 + 200 + for (const upload of body.data.uploads) { 201 + const result = UploadEntrySchema.safeParse(upload); 202 + if (!result.success) { 203 + console.error( 204 + `Upload "${upload.uploadId}" failed validation:`, 205 + result.error.issues, 206 + ); 207 + } 208 + expect(result.success).toBe(true); 209 + } 210 + }); 211 + 212 + it('success response has no unexpected top-level keys', async () => { 213 + const res = await authRequest('/api/results/uploads'); 214 + const body = await parseResponse<Record<string, unknown>>(res); 215 + 216 + const expectedKeys = ['success', 'data']; 217 + const unexpectedKeys = Object.keys(body).filter( 218 + (k) => !expectedKeys.includes(k), 219 + ); 220 + expect(unexpectedKeys).toEqual([]); 221 + }); 222 + 223 + it('unauthenticated request returns 401', async () => { 224 + const res = await request('/api/results/uploads'); 225 + expect(res.status).toBe(401); 226 + }); 227 + }); 228 + 229 + // ======================================================================== 230 + // GET /api/results/upload-details 231 + // ======================================================================== 232 + 233 + describe('GET /api/results/upload-details', () => { 234 + it('success response matches UploadDetailsSuccessSchema', async () => { 235 + // Use the upload we created earlier 236 + const res = await authRequest( 237 + `/api/results/upload-details?uploadId=${savedUploadId}&page=1&pageSize=50`, 238 + ); 239 + 240 + expect(res.status).toBe(200); 241 + const body = await parseResponse(res); 242 + const result = UploadDetailsSuccessSchema.safeParse(body); 243 + 244 + if (!result.success) { 245 + console.error('Schema validation errors:', result.error.issues); 246 + } 247 + expect(result.success).toBe(true); 248 + }); 249 + 250 + it('pagination matches PaginationSchema', async () => { 251 + const res = await authRequest( 252 + `/api/results/upload-details?uploadId=${savedUploadId}&page=1&pageSize=10`, 253 + ); 254 + 255 + const body = await parseResponse<{ 256 + data: { pagination: Record<string, unknown> }; 257 + }>(res); 258 + const result = PaginationSchema.safeParse(body.data.pagination); 259 + 260 + if (!result.success) { 261 + console.error('Pagination validation errors:', result.error.issues); 262 + } 263 + expect(result.success).toBe(true); 264 + }); 265 + 266 + it('pagination values are self-consistent', async () => { 267 + const res = await authRequest( 268 + `/api/results/upload-details?uploadId=${savedUploadId}&page=1&pageSize=10`, 269 + ); 270 + 271 + const body = await parseResponse<{ 272 + data: { 273 + pagination: { 274 + page: number; 275 + totalPages: number; 276 + hasNextPage: boolean; 277 + hasPrevPage: boolean; 278 + }; 279 + }; 280 + }>(res); 281 + 282 + const p = body.data.pagination; 283 + expect(p.hasPrevPage).toBe(p.page > 1); 284 + expect(p.hasNextPage).toBe(p.page < p.totalPages); 285 + }); 286 + 287 + it('success response has no unexpected top-level keys', async () => { 288 + const res = await authRequest( 289 + `/api/results/upload-details?uploadId=${savedUploadId}&page=1`, 290 + ); 291 + 292 + const body = await parseResponse<Record<string, unknown>>(res); 293 + const expectedKeys = ['success', 'data']; 294 + const unexpectedKeys = Object.keys(body).filter( 295 + (k) => !expectedKeys.includes(k), 296 + ); 297 + expect(unexpectedKeys).toEqual([]); 298 + }); 299 + }); 300 + });
+193
packages/api/__tests__/contracts/search.contract.test.ts
··· 1 + /** 2 + * Search API Contract Tests 3 + * 4 + * Validates that POST /api/search/batch-search-actors responses 5 + * match the expected Zod schemas at runtime. 6 + */ 7 + 8 + import { describe, it, expect, beforeAll, afterAll } from 'vitest'; 9 + import { authRequest, parseResponse, request } from '../helpers'; 10 + import { 11 + BatchSearchSuccessSchema, 12 + BatchSearchErrorSchema, 13 + EnrichedActorSchema, 14 + SearchResultEntrySchema, 15 + } from '../../src/types/schemas'; 16 + import { SessionService } from '../../src/services/SessionService'; 17 + import { createSuccessfulSearchAgent } from '../fixtures/mockAgent'; 18 + import type { Agent } from '@atproto/api'; 19 + 20 + describe('Search API Contract', () => { 21 + const originalGetAgent = SessionService.getAgentForSession; 22 + let mockAgent: Agent; 23 + 24 + beforeAll(() => { 25 + // Create a rich mock agent that returns actors with all fields populated 26 + mockAgent = createSuccessfulSearchAgent({ 27 + testuser: [ 28 + { 29 + did: 'did:plc:testuser123', 30 + handle: 'testuser.bsky.social', 31 + displayName: 'Test User', 32 + avatar: 'https://cdn.bsky.app/avatar.jpg', 33 + description: 'A test user profile', 34 + }, 35 + { 36 + did: 'did:plc:testuser456', 37 + handle: 'testuser2.bsky.social', 38 + displayName: 'Another Testuser', 39 + }, 40 + ], 41 + }); 42 + 43 + SessionService.getAgentForSession = async () => ({ 44 + agent: mockAgent, 45 + did: 'did:plc:test-contract', 46 + }); 47 + }); 48 + 49 + afterAll(() => { 50 + SessionService.getAgentForSession = originalGetAgent; 51 + }); 52 + 53 + // ======================================================================== 54 + // Success response contract 55 + // ======================================================================== 56 + 57 + it('success response matches BatchSearchSuccessSchema', async () => { 58 + const res = await authRequest('/api/search/batch-search-actors', { 59 + method: 'POST', 60 + body: JSON.stringify({ usernames: ['testuser'] }), 61 + }); 62 + 63 + expect(res.status).toBe(200); 64 + const body = await parseResponse(res); 65 + const result = BatchSearchSuccessSchema.safeParse(body); 66 + 67 + if (!result.success) { 68 + console.error('Schema validation errors:', result.error.issues); 69 + } 70 + expect(result.success).toBe(true); 71 + }); 72 + 73 + it('result entries match SearchResultEntrySchema', async () => { 74 + const res = await authRequest('/api/search/batch-search-actors', { 75 + method: 'POST', 76 + body: JSON.stringify({ usernames: ['testuser'] }), 77 + }); 78 + 79 + const body = await parseResponse<{ 80 + data: { results: Array<Record<string, unknown>> }; 81 + }>(res); 82 + 83 + for (const entry of body.data.results) { 84 + const result = SearchResultEntrySchema.safeParse(entry); 85 + if (!result.success) { 86 + console.error( 87 + `Entry "${entry.username}" failed validation:`, 88 + result.error.issues, 89 + ); 90 + } 91 + expect(result.success).toBe(true); 92 + } 93 + }); 94 + 95 + it('enriched actors match EnrichedActorSchema', async () => { 96 + const res = await authRequest('/api/search/batch-search-actors', { 97 + method: 'POST', 98 + body: JSON.stringify({ usernames: ['testuser'] }), 99 + }); 100 + 101 + const body = await parseResponse<{ 102 + data: { results: Array<{ actors: Array<Record<string, unknown>> }> }; 103 + }>(res); 104 + 105 + const actors = body.data.results.flatMap((r) => r.actors); 106 + expect(actors.length).toBeGreaterThan(0); 107 + 108 + for (const actor of actors) { 109 + const result = EnrichedActorSchema.safeParse(actor); 110 + if (!result.success) { 111 + console.error( 112 + `Actor "${actor.handle}" failed validation:`, 113 + result.error.issues, 114 + ); 115 + } 116 + expect(result.success).toBe(true); 117 + } 118 + }); 119 + 120 + it('success response has no unexpected top-level keys', async () => { 121 + const res = await authRequest('/api/search/batch-search-actors', { 122 + method: 'POST', 123 + body: JSON.stringify({ usernames: ['testuser'] }), 124 + }); 125 + 126 + const body = await parseResponse<Record<string, unknown>>(res); 127 + const expectedKeys = ['success', 'data']; 128 + const unexpectedKeys = Object.keys(body).filter( 129 + (k) => !expectedKeys.includes(k), 130 + ); 131 + 132 + expect(unexpectedKeys).toEqual([]); 133 + }); 134 + 135 + // ======================================================================== 136 + // Multiple usernames 137 + // ======================================================================== 138 + 139 + it('multi-username response preserves schema for all entries', async () => { 140 + const res = await authRequest('/api/search/batch-search-actors', { 141 + method: 'POST', 142 + body: JSON.stringify({ usernames: ['testuser', 'another', 'missing'] }), 143 + }); 144 + 145 + expect(res.status).toBe(200); 146 + const body = await parseResponse(res); 147 + const result = BatchSearchSuccessSchema.safeParse(body); 148 + 149 + expect(result.success).toBe(true); 150 + if (result.success) { 151 + expect(result.data.data.results).toHaveLength(3); 152 + } 153 + }); 154 + 155 + // ======================================================================== 156 + // Error response contract 157 + // ======================================================================== 158 + 159 + it('validation error matches ErrorResponseSchema', async () => { 160 + const res = await authRequest('/api/search/batch-search-actors', { 161 + method: 'POST', 162 + body: JSON.stringify({ usernames: [] }), 163 + }); 164 + 165 + expect(res.status).toBe(400); 166 + const body = await parseResponse(res); 167 + const result = BatchSearchErrorSchema.safeParse(body); 168 + 169 + expect(result.success).toBe(true); 170 + }); 171 + 172 + it('missing body returns error matching ErrorResponseSchema', async () => { 173 + const res = await authRequest('/api/search/batch-search-actors', { 174 + method: 'POST', 175 + body: JSON.stringify({}), 176 + }); 177 + 178 + expect(res.status).toBe(400); 179 + const body = await parseResponse(res); 180 + const result = BatchSearchErrorSchema.safeParse(body); 181 + 182 + expect(result.success).toBe(true); 183 + }); 184 + 185 + it('unauthenticated request returns error response', async () => { 186 + const res = await request('/api/search/batch-search-actors', { 187 + method: 'POST', 188 + body: JSON.stringify({ usernames: ['test'] }), 189 + }); 190 + 191 + expect(res.status).toBe(401); 192 + }); 193 + });