diff --git a/jest.config.js b/jest.config.js index 4430c2f44..69ea173c2 100644 --- a/jest.config.js +++ b/jest.config.js @@ -3,5 +3,5 @@ module.exports = { transform: { '^.+\\.tsx?$': 'ts-jest', }, - testMatch: ['**/?(*.)+(spec|test).ts'], + testMatch: ['**/?(*.)+(spec|test|integ).ts'], }; diff --git a/package.json b/package.json index 5c049f8a6..fd08248cc 100644 --- a/package.json +++ b/package.json @@ -17,8 +17,9 @@ "format": "prettier --log-level warn --write .", "format:check": "prettier --check .", "prebuild": "rimraf dist", - "test": "jest .", - "test:coverage": "jest . --coverage", + "test": "jest --testPathIgnorePatterns=\\.integ\\.", + "test:integration": "jest --testPathIgnorePatterns=\\.test\\.", + "test:coverage": "jest . --coverage ", "release:check": "changeset status --verbose --since=origin/main", "release:publish": "yarn install && yarn build && changeset publish", "release:version": "changeset version && yarn install --immutable" diff --git a/src/core/getFrameAccountAddress.test.ts b/src/core/getFrameAccountAddress.test.ts index 01863e98a..df462ae40 100644 --- a/src/core/getFrameAccountAddress.test.ts +++ b/src/core/getFrameAccountAddress.test.ts @@ -1,5 +1,12 @@ import { getFrameAccountAddress } from './getFrameAccountAddress'; import { mockNeynarResponse } from './mock'; +import { neynarBulkUserLookup } from '../utils/neynar/user/neynarUserFunctions'; + +jest.mock('../utils/neynar/user/neynarUserFunctions', () => { + return { + neynarBulkUserLookup: jest.fn(), + }; +}); jest.mock('@farcaster/hub-nodejs', () => { return { @@ -23,7 +30,7 @@ describe('getFrameAccountAddress', () => { it('should return the first verification for valid input', async () => { const fid = 1234; const addresses = ['0xaddr1']; - mockNeynarResponse(fid, addresses); + mockNeynarResponse(fid, addresses, neynarBulkUserLookup as jest.Mock); const response = await getFrameAccountAddress(fakeFrameData, fakeApiKey); expect(response).toEqual(addresses[0]); @@ -32,7 +39,7 @@ describe('getFrameAccountAddress', () => { it('when the call from farcaster fails we should return undefined', async () => { const fid = 1234; const addresses = ['0xaddr1']; - const { validateMock } = mockNeynarResponse(fid, addresses); + const { validateMock } = mockNeynarResponse(fid, addresses, neynarBulkUserLookup as jest.Mock); validateMock.mockClear(); validateMock.mockResolvedValue({ isOk: () => { diff --git a/src/core/getFrameAccountAddress.ts b/src/core/getFrameAccountAddress.ts index e77d10be2..e1fb6d602 100644 --- a/src/core/getFrameAccountAddress.ts +++ b/src/core/getFrameAccountAddress.ts @@ -1,4 +1,5 @@ import { getFrameValidatedMessage } from './getFrameValidatedMessage'; +import { neynarBulkUserLookup } from '../utils/neynar/user/neynarUserFunctions'; type FidResponse = { verifications: string[]; @@ -26,16 +27,9 @@ async function getFrameAccountAddress( // Get the Farcaster ID from the message const farcasterID = validatedMessage?.data?.fid ?? 0; // Get the user verifications from the Farcaster Indexer - const options = { - method: 'GET', - url: `https://api.neynar.com/v2/farcaster/user/bulk?fids=${farcasterID}`, - headers: { accept: 'application/json', api_key: NEYNAR_API_KEY }, - }; - const resp = await fetch(options.url, { headers: options.headers }); - const responseBody = await resp.json(); - // Get the user verifications from the response - if (responseBody.users) { - const userVerifications = responseBody.users[0] as FidResponse; + const bulkUserLookupResponse = await neynarBulkUserLookup([farcasterID]); + if (bulkUserLookupResponse?.users) { + const userVerifications = bulkUserLookupResponse?.users[0] as FidResponse; if (userVerifications.verifications) { return userVerifications.verifications[0]; } diff --git a/src/core/getFrameValidatedMessage.test.ts b/src/core/getFrameValidatedMessage.test.ts index 93f17d51d..158f83a8a 100644 --- a/src/core/getFrameValidatedMessage.test.ts +++ b/src/core/getFrameValidatedMessage.test.ts @@ -1,5 +1,12 @@ import { mockNeynarResponse } from './mock'; import { getFrameValidatedMessage } from './getFrameValidatedMessage'; +import { neynarBulkUserLookup } from '../utils/neynar/user/neynarUserFunctions'; + +jest.mock('../utils/neynar/user/neynarUserFunctions', () => { + return { + neynarBulkUserLookup: jest.fn(), + }; +}); jest.mock('@farcaster/hub-nodejs', () => { return { @@ -16,7 +23,7 @@ describe('getFrameValidatedMessage', () => { it('should return undefined if the message is invalid', async () => { const fid = 1234; const addresses = ['0xaddr1']; - const { validateMock } = mockNeynarResponse(fid, addresses); + const { validateMock } = mockNeynarResponse(fid, addresses, neynarBulkUserLookup as jest.Mock); validateMock.mockClear(); validateMock.mockResolvedValue({ isOk: () => { @@ -32,7 +39,7 @@ describe('getFrameValidatedMessage', () => { it('should return the message if the message is valid', async () => { const fid = 1234; const addresses = ['0xaddr1']; - mockNeynarResponse(fid, addresses); + mockNeynarResponse(fid, addresses, neynarBulkUserLookup as jest.Mock); const fakeFrameData = { trustedData: {}, }; diff --git a/src/core/mock.ts b/src/core/mock.ts index f419c2f93..9b5e6835f 100644 --- a/src/core/mock.ts +++ b/src/core/mock.ts @@ -14,7 +14,7 @@ export function buildFarcasterResponse(fid: number) { }; } -export function mockNeynarResponse(fid: number, addresses: string[]) { +export function mockNeynarResponse(fid: number, addresses: string[], lookupMock: jest.Mock) { const neynarResponse = { users: [ { @@ -22,6 +22,7 @@ export function mockNeynarResponse(fid: number, addresses: string[]) { }, ], }; + lookupMock.mockResolvedValue(neynarResponse); const getSSLHubRpcClientMock = require('@farcaster/hub-nodejs').getSSLHubRpcClient; const validateMock = getSSLHubRpcClientMock().validateMessage as jest.Mock; @@ -33,12 +34,7 @@ export function mockNeynarResponse(fid: number, addresses: string[]) { }); validateMock.mockResolvedValue(buildFarcasterResponse(fid)); - // Mock the response from Neynar - global.fetch = jest.fn(() => - Promise.resolve({ - json: () => Promise.resolve(neynarResponse), - }), - ) as jest.Mock; + return { validateMock, }; diff --git a/src/utils/neynar/exceptions/FetchError.ts b/src/utils/neynar/exceptions/FetchError.ts new file mode 100644 index 000000000..3a3af7f9e --- /dev/null +++ b/src/utils/neynar/exceptions/FetchError.ts @@ -0,0 +1,6 @@ +export class FetchError extends Error { + constructor(message: string) { + super(message); + this.name = 'FetchError'; + } +} diff --git a/src/utils/neynar/neynar.integ.ts b/src/utils/neynar/neynar.integ.ts new file mode 100644 index 000000000..17d671439 --- /dev/null +++ b/src/utils/neynar/neynar.integ.ts @@ -0,0 +1,12 @@ +import { neynarBulkUserLookup } from './user/neynarUserFunctions'; + +describe('integration tests', () => { + it('bulk data lookup should find all users', async () => { + const fidsToLookup = [3, 194519]; // dwr and polak.eth fids + const response = await neynarBulkUserLookup(fidsToLookup); + expect(response?.users.length).toEqual(2); + for (const user of response?.users!) { + expect(fidsToLookup).toContain(user.fid); + } + }); +}); diff --git a/src/utils/neynar/user/neynarUserFunctions.test.ts b/src/utils/neynar/user/neynarUserFunctions.test.ts new file mode 100644 index 000000000..c48152d66 --- /dev/null +++ b/src/utils/neynar/user/neynarUserFunctions.test.ts @@ -0,0 +1,32 @@ +import { FetchError } from '../exceptions/FetchError'; +import { neynarBulkUserLookup } from './neynarUserFunctions'; + +describe('neynar user functions', () => { + let fetchMock = jest.fn(); + let status = 200; + + beforeEach(() => { + status = 200; + global.fetch = jest.fn(() => + Promise.resolve({ + status, + json: fetchMock, + }), + ) as jest.Mock; + }); + + it('should return fetch response correctly', async () => { + fetchMock.mockResolvedValue({ + users: [{ fid: 1 }], + }); + + const resp = await neynarBulkUserLookup([1]); + expect(resp?.users[0]?.fid).toEqual(1); + }); + + it('fails on a non-200', async () => { + status = 401; + const resp = neynarBulkUserLookup([1]); + await expect(resp).rejects.toThrow(FetchError); + }); +}); diff --git a/src/utils/neynar/user/neynarUserFunctions.ts b/src/utils/neynar/user/neynarUserFunctions.ts new file mode 100644 index 000000000..a16b3b52e --- /dev/null +++ b/src/utils/neynar/user/neynarUserFunctions.ts @@ -0,0 +1,76 @@ +import { FetchError } from '../exceptions/FetchError'; + +export const NEYNAR_DEFAULT_API_KEY = 'NEYNAR_API_DOCS'; +export interface NeynarUserModel { + fid: number; + custody_address: string; + username: string; + display_name: string; + pfp_url: string; + profile: { + bio: { + text: string; + }; + }; + follower_count: number; + verifications: string[]; +} +export interface NeynarBulkUserLookupModel { + users: NeynarUserModel[]; +} + +export async function neynarBulkUserLookup( + farcasterIDs: number[], + apiKey: string = NEYNAR_DEFAULT_API_KEY, +): Promise { + const options = { + method: 'GET', + url: `https://api.neynar.com/v2/farcaster/user/bulk?fids=${farcasterIDs.join(',')}`, + headers: { accept: 'application/json', api_key: apiKey }, + }; + const resp = await fetch(options.url, { headers: options.headers }); + if (resp.status !== 200) { + throw new FetchError(`non-200 status returned from neynar : ${resp.status}`); + } + const responseBody = await resp.json(); + return convertToNeynarResponseModel(responseBody); +} + +function convertToNeynarResponseModel(data: any): NeynarBulkUserLookupModel | undefined { + if (!data) { + return; + } + + const response: NeynarBulkUserLookupModel = { + users: [], + }; + + for (const user of data.users) { + const formattedUser = convertToNeynarUserModel(user); + if (formattedUser) { + response.users.push(formattedUser); + } + } + return response; +} + +function convertToNeynarUserModel(data: any): NeynarUserModel | undefined { + if (!data) { + return; + } + + return { + fid: data.fid ?? 0, + custody_address: data.custody_address ?? '', + username: data.username ?? '', + display_name: data.display_name ?? '', + pfp_url: data.pfp_url ?? '', + profile: { + bio: { + text: data.profile?.bio?.text ?? '', + }, + }, + follower_count: data.follower_count ?? 0, + verifications: Array.isArray(data.verifications) ? data.verifications : [], + }; +}