From 0958107951e3d7cc2f5e8ad620a8f9d50287db22 Mon Sep 17 00:00:00 2001 From: Kevin Szuchet <31735779+kevinszuchet@users.noreply.github.com> Date: Thu, 23 Jan 2025 12:50:44 -0300 Subject: [PATCH] feat: Get friends and Get Mutual optimizations and improved responses (#37) * refactor: Change logs and return empty lists on get friends errors * feat: Add Catalyst Client adapter implementation with catalyst rotation and utils * feat: Get profiles from catalyst and map to friends * feat: GetMutualFriends also retrieves profiles and improves the response * fix: Wrong usage of table alias * feat: Get sent and received friendship requests optimizations (#38) * chore: seq diag improvements * feat: Use the profile image url to create the full url * feat: Get Sent and Received Friendship Requests respond with profile data * chore: Remove unused imports * refactor: Try with filter boolean but doesn't work * refactor: Pick specific props from entity * chore: Use Avatar type from schemas --- .env.default | 3 + README.md | 9 +- package.json | 4 +- src/adapters/catalyst-client.ts | 57 +++++++ src/adapters/db.ts | 62 ++++---- src/adapters/rpc-server/rpc-server.ts | 17 ++- .../rpc-server/services/get-friends.ts | 31 +++- .../services/get-friendship-status.ts | 4 +- .../rpc-server/services/get-mutual-friends.ts | 38 +++-- .../get-pending-friendship-requests.ts | 26 ++-- .../services/get-sent-friendship-requests.ts | 29 ++-- .../rpc-server/services/upsert-friendship.ts | 4 +- src/components.ts | 17 ++- src/controllers/handlers/ws-handler.ts | 5 +- src/logic/friends.ts | 19 +++ src/logic/friendships.ts | 40 ++++- src/logic/profiles.ts | 15 ++ src/types.ts | 12 ++ src/utils/array.ts | 7 + src/utils/retrier.ts | 19 +++ src/utils/timer.ts | 3 + test/mocks/components/catalyst-client.ts | 5 + test/mocks/components/index.ts | 1 + test/mocks/friend.ts | 16 ++ test/mocks/friendship-request.ts | 20 ++- test/mocks/profile.ts | 43 ++++++ test/unit/adapters/catalyst-client.spec.ts | 141 ++++++++++++++++++ test/unit/adapters/rpc-server.spec.ts | 4 +- .../rpc-server/services/get-friends.spec.ts | 75 ++++++---- .../services/get-mutual-friends.spec.ts | 80 +++++----- .../get-pending-friendship-requests.spec.ts | 31 ++-- .../get-sent-friendship-requests.spec.ts | 29 ++-- test/unit/logic/friends.spec.ts | 40 +++++ test/unit/logic/friendships.spec.ts | 36 +++++ test/unit/logic/profiles.spec.ts | 43 ++++++ test/unit/utils/array.spec.ts | 54 +++++++ test/unit/utils/retrier.spec.ts | 72 +++++++++ test/unit/utils/timer.spec.ts | 23 +++ yarn.lock | 65 +++++++- 39 files changed, 999 insertions(+), 200 deletions(-) create mode 100644 src/adapters/catalyst-client.ts create mode 100644 src/logic/friends.ts create mode 100644 src/logic/profiles.ts create mode 100644 src/utils/array.ts create mode 100644 src/utils/retrier.ts create mode 100644 src/utils/timer.ts create mode 100644 test/mocks/components/catalyst-client.ts create mode 100644 test/mocks/friend.ts create mode 100644 test/mocks/profile.ts create mode 100644 test/unit/adapters/catalyst-client.spec.ts create mode 100644 test/unit/logic/friends.spec.ts create mode 100644 test/unit/logic/profiles.spec.ts create mode 100644 test/unit/utils/array.spec.ts create mode 100644 test/unit/utils/retrier.spec.ts create mode 100644 test/unit/utils/timer.spec.ts diff --git a/.env.default b/.env.default index 19b70bf..d113483 100644 --- a/.env.default +++ b/.env.default @@ -14,3 +14,6 @@ HTTP_SERVER_HOST=0.0.0.0 WKC_METRICS_RESET_AT_NIGHT=false NATS_URL=localhost:4222 + +#CATALYST_CONTENT_URL_LOADBALANCER=https://peer.decentraland.org/ +#PROFILE_IMAGES_URL=https://profile-images.decentraland.org diff --git a/README.md b/README.md index dfb2e03..a849554 100644 --- a/README.md +++ b/README.md @@ -138,13 +138,12 @@ sequenceDiagram Note over RPC Server: (accept/cancel/reject/delete) Note over Client,DB: Friends Lifecycle - NATS-->>Redis: Peer Heartbeat - Redis-->>RPC Server: Friend Status Update - RPC Server->>Redis: Request Cached Peers + NATS-->>Redis: Publish Peer Connection Update Event + Redis-->>RPC Server: Broadcast Friend Status Update Event + RPC Server->>Redis: Get Cached Peers Redis-->>RPC Server: Cached Peers - RPC Server->>DB: Request Online Friends - DB-->>RPC Server: Online Friends RPC Server->>DB: Query Online Friends + DB-->>RPC Server: Online Friends RPC Server-->>Client: Stream Friend Status Updates Note over RPC Server: (online/offline) diff --git a/package.json b/package.json index 84996c4..2ec1f16 100644 --- a/package.json +++ b/package.json @@ -30,8 +30,9 @@ }, "dependencies": { "@dcl/platform-crypto-middleware": "^1.1.0", - "@dcl/protocol": "https://sdk-team-cdn.decentraland.org/@dcl/protocol/branch//dcl-protocol-1.0.0-12890706635.commit-a7e4210.tgz", + "@dcl/protocol": "https://sdk-team-cdn.decentraland.org/@dcl/protocol/branch//dcl-protocol-1.0.0-12916692077.commit-190ed21.tgz", "@dcl/rpc": "^1.1.2", + "@dcl/schemas": "^15.6.0", "@well-known-components/env-config-provider": "^1.2.0", "@well-known-components/fetch-component": "^2.0.2", "@well-known-components/http-server": "^2.1.0", @@ -41,6 +42,7 @@ "@well-known-components/nats-component": "^2.0.0", "@well-known-components/pg-component": "^0.2.2", "@well-known-components/uws-http-server": "^0.0.2", + "dcl-catalyst-client": "^21.7.0", "fp-future": "^1.0.1", "lru-cache": "^10.4.3", "mitt": "^3.0.1", diff --git a/src/adapters/catalyst-client.ts b/src/adapters/catalyst-client.ts new file mode 100644 index 0000000..1cf14b8 --- /dev/null +++ b/src/adapters/catalyst-client.ts @@ -0,0 +1,57 @@ +import { Entity } from '@dcl/schemas' +import { createContentClient, ContentClient } from 'dcl-catalyst-client' +import { getCatalystServersFromCache } from 'dcl-catalyst-client/dist/contracts-snapshots' +import { AppComponents, ICatalystClient, ICatalystClientRequestOptions } from '../types' +import { retry } from '../utils/retrier' +import { shuffleArray } from '../utils/array' + +const L1_MAINNET = 'mainnet' +const L1_TESTNET = 'sepolia' + +export async function createCatalystClient({ + fetcher, + config +}: Pick): Promise { + const loadBalancer = await config.requireString('CATALYST_CONTENT_URL_LOADBALANCER') + const contractNetwork = (await config.getString('ENV')) === 'prod' ? L1_MAINNET : L1_TESTNET + + function getContentClientOrDefault(contentServerUrl?: string): ContentClient { + return contentServerUrl + ? createContentClient({ fetcher, url: contentServerUrl }) + : createContentClient({ + fetcher, + url: loadBalancer + }) + } + + function rotateContentServerClient( + executeClientRequest: (client: ContentClient) => Promise, + contentServerUrl?: string + ) { + const catalystServers = shuffleArray(getCatalystServersFromCache(contractNetwork)).map((server) => server.address) + let contentClientToUse: ContentClient = getContentClientOrDefault(contentServerUrl) + + return (attempt: number): Promise => { + if (attempt > 1 && catalystServers.length > 0) { + const [catalystServerUrl] = catalystServers.splice(attempt % catalystServers.length, 1) + contentClientToUse = getContentClientOrDefault(catalystServerUrl) + } + + return executeClientRequest(contentClientToUse) + } + } + + async function getEntitiesByPointers( + pointers: string[], + options: ICatalystClientRequestOptions = {} + ): Promise { + const { retries = 3, waitTime = 300, contentServerUrl } = options + const executeClientRequest = rotateContentServerClient( + (contentClientToUse) => contentClientToUse.fetchEntitiesByPointers(pointers), + contentServerUrl + ) + return retry(executeClientRequest, retries, waitTime) + } + + return { getEntitiesByPointers } +} diff --git a/src/adapters/db.ts b/src/adapters/db.ts index b4da8a5..6722ee3 100644 --- a/src/adapters/db.ts +++ b/src/adapters/db.ts @@ -108,11 +108,9 @@ export function createDBComponent(components: Pick end as address FROM ( - SELECT - f_a.* - from - friendships f_a - where + SELECT f_a.* + FROM friendships f_a + WHERE ( f_a.address_requester = ${userAddress1} or f_a.address_requested = ${userAddress1} @@ -120,11 +118,11 @@ export function createDBComponent(components: Pick ) as friends_a ) SELECT - address + f_b.address FROM friendsA f_b WHERE - address IN ( + f_b.address IN ( SELECT CASE WHEN address_requester = ${userAddress2} then address_requested @@ -134,18 +132,18 @@ export function createDBComponent(components: Pick ( SELECT f_b.* - from + FROM friendships f_b - where + WHERE ( f_b.address_requester = ${userAddress2} or f_b.address_requested = ${userAddress2} ) and f_b.is_active = true ) as friends_b ) - ORDER BY f_a.address + ORDER BY f_b.address LIMIT ${limit} - OFFSET ${offset};` + OFFSET ${offset}` ) return result.rows @@ -155,20 +153,18 @@ export function createDBComponent(components: Pick SQL`WITH friendsA as ( SELECT CASE - WHEN address_requester = ${userAddress1} then address_requested - else address_requester - end as address + WHEN address_requester = ${userAddress1} THEN address_requested + ELSE address_requester + END as address FROM ( - SELECT - f_a.* - from - friendships f_a - where + SELECT f_a.* + FROM friendships f_a + WHERE ( f_a.address_requester = ${userAddress1} - or f_a.address_requested = ${userAddress1} - ) and f_a.is_active = true + OR f_a.address_requested = ${userAddress1} + ) AND f_a.is_active = true ) as friends_a ) SELECT @@ -179,22 +175,20 @@ export function createDBComponent(components: Pick address IN ( SELECT CASE - WHEN address_requester = ${userAddress2} then address_requested - else address_requester - end as address_a + WHEN address_requester = ${userAddress2} THEN address_requested + ELSE address_requester + END as address_a FROM ( - SELECT - f_b.* - from - friendships f_b - where + SELECT f_b.* + FROM friendships f_b + WHERE ( f_b.address_requester = ${userAddress2} - or f_b.address_requested = ${userAddress2} - ) and f_b.is_active = true + OR f_b.address_requested = ${userAddress2} + ) AND f_b.is_active = true ) as friends_b - );` + )` ) return result.rows[0].count @@ -321,8 +315,8 @@ export function createDBComponent(components: Pick const res = await cb(client) await client.query('COMMIT') return res - } catch (error) { - logger.error(error as any) + } catch (error: any) { + logger.error(`Error executing transaction: ${error.message}`) await client.query('ROLLBACK') client.release() throw error diff --git a/src/adapters/rpc-server/rpc-server.ts b/src/adapters/rpc-server/rpc-server.ts index 5cd7bc3..91b42f1 100644 --- a/src/adapters/rpc-server/rpc-server.ts +++ b/src/adapters/rpc-server/rpc-server.ts @@ -18,10 +18,11 @@ export async function createRpcServerComponent({ pubsub, config, server, - archipelagoStats + archipelagoStats, + catalystClient }: Pick< AppComponents, - 'logs' | 'db' | 'pubsub' | 'config' | 'server' | 'nats' | 'archipelagoStats' | 'redis' + 'logs' | 'db' | 'pubsub' | 'config' | 'server' | 'nats' | 'archipelagoStats' | 'redis' | 'catalystClient' >): Promise { // TODO: this should be a redis if we want to have more than one instance of the server const SHARED_CONTEXT: Pick = { @@ -36,10 +37,14 @@ export async function createRpcServerComponent({ const rpcServerPort = (await config.getNumber('RPC_SERVER_PORT')) || 8085 - const getFriends = getFriendsService({ components: { logs, db } }) - const getMutualFriends = getMutualFriendsService({ components: { logs, db } }) - const getPendingFriendshipRequests = getPendingFriendshipRequestsService({ components: { logs, db } }) - const getSentFriendshipRequests = getSentFriendshipRequestsService({ components: { logs, db } }) + const getFriends = await getFriendsService({ components: { logs, db, catalystClient, config } }) + const getMutualFriends = await getMutualFriendsService({ components: { logs, db, catalystClient, config } }) + const getPendingFriendshipRequests = await getPendingFriendshipRequestsService({ + components: { logs, db, catalystClient, config } + }) + const getSentFriendshipRequests = await getSentFriendshipRequestsService({ + components: { logs, db, catalystClient, config } + }) const upsertFriendship = upsertFriendshipService({ components: { logs, db, pubsub } }) const getFriendshipStatus = getFriendshipStatusService({ components: { logs, db } }) const subscribeToFriendshipUpdates = subscribeToFriendshipUpdatesService({ components: { logs } }) diff --git a/src/adapters/rpc-server/services/get-friends.ts b/src/adapters/rpc-server/services/get-friends.ts index f61654a..b973cdc 100644 --- a/src/adapters/rpc-server/services/get-friends.ts +++ b/src/adapters/rpc-server/services/get-friends.ts @@ -1,15 +1,22 @@ +import { parseProfilesToFriends } from '../../../logic/friends' import { RpcServerContext, RPCServiceContext } from '../../../types' import { getPage } from '../../../utils/pagination' -import { FRIENDSHIPS_PER_PAGE, INTERNAL_SERVER_ERROR } from '../constants' +import { FRIENDSHIPS_PER_PAGE } from '../constants' import { GetFriendsPayload, - PaginatedUsersResponse + PaginatedFriendsProfilesResponse } from '@dcl/protocol/out-js/decentraland/social_service/v2/social_service_v2.gen' -export function getFriendsService({ components: { logs, db } }: RPCServiceContext<'logs' | 'db'>) { +export async function getFriendsService({ + components: { logs, db, catalystClient, config } +}: RPCServiceContext<'logs' | 'db' | 'catalystClient' | 'config'>) { const logger = logs.getLogger('get-friends-service') + const profileImagesUrl = await config.requireString('PROFILE_IMAGES_URL') - return async function (request: GetFriendsPayload, context: RpcServerContext): Promise { + return async function ( + request: GetFriendsPayload, + context: RpcServerContext + ): Promise { const { pagination } = request const { address: loggedUserAddress } = context @@ -20,16 +27,24 @@ export function getFriendsService({ components: { logs, db } }: RPCServiceContex db.getFriendsCount(loggedUserAddress) ]) + const profiles = await catalystClient.getEntitiesByPointers(friends.map((friend) => friend.address)) + return { - users: friends, + friends: parseProfilesToFriends(profiles, profileImagesUrl), paginationData: { total, page: getPage(pagination?.limit || FRIENDSHIPS_PER_PAGE, pagination?.offset) } } - } catch (error) { - logger.error(error as any) - throw new Error(INTERNAL_SERVER_ERROR) + } catch (error: any) { + logger.error(`Error getting friends: ${error.message}`) + return { + friends: [], + paginationData: { + total: 0, + page: 1 + } + } } } } diff --git a/src/adapters/rpc-server/services/get-friendship-status.ts b/src/adapters/rpc-server/services/get-friendship-status.ts index 8d6c7e4..e73c637 100644 --- a/src/adapters/rpc-server/services/get-friendship-status.ts +++ b/src/adapters/rpc-server/services/get-friendship-status.ts @@ -46,8 +46,8 @@ export function getFriendshipStatusService({ components: { logs, db } }: RPCServ } } } - } catch (error) { - logger.error(error as any) + } catch (error: any) { + logger.error(`Error getting friendship status: ${error.message}`) return { response: { $case: 'internalServerError', diff --git a/src/adapters/rpc-server/services/get-mutual-friends.ts b/src/adapters/rpc-server/services/get-mutual-friends.ts index 6da62d5..9302eae 100644 --- a/src/adapters/rpc-server/services/get-mutual-friends.ts +++ b/src/adapters/rpc-server/services/get-mutual-friends.ts @@ -1,37 +1,53 @@ import { RpcServerContext, RPCServiceContext } from '../../../types' -import { INTERNAL_SERVER_ERROR, FRIENDSHIPS_PER_PAGE } from '../constants' +import { FRIENDSHIPS_PER_PAGE } from '../constants' import { GetMutualFriendsPayload, - PaginatedUsersResponse + PaginatedFriendsProfilesResponse } from '@dcl/protocol/out-js/decentraland/social_service/v2/social_service_v2.gen' import { normalizeAddress } from '../../../utils/address' import { getPage } from '../../../utils/pagination' +import { parseProfilesToFriends } from '../../../logic/friends' -export function getMutualFriendsService({ components: { logs, db } }: RPCServiceContext<'logs' | 'db'>) { +export async function getMutualFriendsService({ + components: { logs, db, catalystClient, config } +}: RPCServiceContext<'logs' | 'db' | 'catalystClient' | 'config'>) { const logger = logs.getLogger('get-mutual-friends-service') + const profileImagesUrl = await config.requireString('PROFILE_IMAGES_URL') + + return async function ( + request: GetMutualFriendsPayload, + context: RpcServerContext + ): Promise { + logger.debug(`Getting mutual friends ${context.address}<>${request.user!.address}`) - return async function (request: GetMutualFriendsPayload, context: RpcServerContext): Promise { - logger.debug(`getting mutual friends ${context.address}<>${request.user!.address}`) try { const { address: requester } = context const { pagination, user } = request const requested = normalizeAddress(user!.address) + const [mutualFriends, total] = await Promise.all([ db.getMutualFriends(requester, requested, pagination), db.getMutualFriendsCount(requester, requested) ]) + + const profiles = await catalystClient.getEntitiesByPointers(mutualFriends.map((friend) => friend.address)) + return { - users: mutualFriends, + friends: parseProfilesToFriends(profiles, profileImagesUrl), paginationData: { total, page: getPage(pagination?.limit || FRIENDSHIPS_PER_PAGE, pagination?.offset) } } - } catch (error) { - logger.error(error as any) - // throw an error bc there is no sense to create a generator to send an error - // as it's done in the previous Social Service - throw new Error(INTERNAL_SERVER_ERROR) + } catch (error: any) { + logger.error(`Error getting mutual friends: ${error.message}`) + return { + friends: [], + paginationData: { + total: 0, + page: 1 + } + } } } } diff --git a/src/adapters/rpc-server/services/get-pending-friendship-requests.ts b/src/adapters/rpc-server/services/get-pending-friendship-requests.ts index 7b241e3..78c3223 100644 --- a/src/adapters/rpc-server/services/get-pending-friendship-requests.ts +++ b/src/adapters/rpc-server/services/get-pending-friendship-requests.ts @@ -1,11 +1,15 @@ +import { parseFriendshipRequestsToFriendshipRequestResponses } from '../../../logic/friendships' import { RpcServerContext, RPCServiceContext } from '../../../types' import { PaginatedFriendshipRequestsResponse, GetFriendshipRequestsPayload } from '@dcl/protocol/out-js/decentraland/social_service/v2/social_service_v2.gen' -export function getPendingFriendshipRequestsService({ components: { logs, db } }: RPCServiceContext<'logs' | 'db'>) { +export async function getPendingFriendshipRequestsService({ + components: { logs, db, catalystClient, config } +}: RPCServiceContext<'logs' | 'db' | 'catalystClient' | 'config'>) { const logger = logs.getLogger('get-pending-friendship-requests-service') + const profileImagesUrl = await config.requireString('PROFILE_IMAGES_URL') return async function ( request: GetFriendshipRequestsPayload, @@ -13,23 +17,25 @@ export function getPendingFriendshipRequestsService({ components: { logs, db } } ): Promise { try { const pendingRequests = await db.getReceivedFriendshipRequests(context.address, request.pagination) - const mappedRequests = pendingRequests.map(({ id, address, timestamp, metadata }) => ({ - id, - user: { address }, - createdAt: new Date(timestamp).getTime(), - message: metadata?.message || '' - })) + const pendingRequestsAddresses = pendingRequests.map(({ address }) => address) + + const pendingRequesterProfiles = await catalystClient.getEntitiesByPointers(pendingRequestsAddresses) + const requests = parseFriendshipRequestsToFriendshipRequestResponses( + pendingRequests, + pendingRequesterProfiles, + profileImagesUrl + ) return { response: { $case: 'requests', requests: { - requests: mappedRequests + requests } } } - } catch (error) { - logger.error(error as any) + } catch (error: any) { + logger.error(`Error getting pending friendship requests: ${error.message}`) return { response: { $case: 'internalServerError', diff --git a/src/adapters/rpc-server/services/get-sent-friendship-requests.ts b/src/adapters/rpc-server/services/get-sent-friendship-requests.ts index 5b984bd..e3f7942 100644 --- a/src/adapters/rpc-server/services/get-sent-friendship-requests.ts +++ b/src/adapters/rpc-server/services/get-sent-friendship-requests.ts @@ -1,35 +1,42 @@ +import { parseFriendshipRequestsToFriendshipRequestResponses } from '../../../logic/friendships' import { RpcServerContext, RPCServiceContext } from '../../../types' import { PaginatedFriendshipRequestsResponse, GetFriendshipRequestsPayload } from '@dcl/protocol/out-js/decentraland/social_service/v2/social_service_v2.gen' -export function getSentFriendshipRequestsService({ components: { logs, db } }: RPCServiceContext<'logs' | 'db'>) { +export async function getSentFriendshipRequestsService({ + components: { logs, db, catalystClient, config } +}: RPCServiceContext<'logs' | 'db' | 'catalystClient' | 'config'>) { const logger = logs.getLogger('get-sent-friendship-requests-service') + const profileImagesUrl = await config.requireString('PROFILE_IMAGES_URL') return async function ( request: GetFriendshipRequestsPayload, context: RpcServerContext ): Promise { try { - const pendingRequests = await db.getSentFriendshipRequests(context.address, request.pagination) - const mappedRequests = pendingRequests.map(({ id, address, timestamp, metadata }) => ({ - id, - user: { address }, - createdAt: new Date(timestamp).getTime(), - message: metadata?.message || '' - })) + const sentRequests = await db.getSentFriendshipRequests(context.address, request.pagination) + const sentRequestsAddresses = sentRequests.map(({ address }) => address) + + const sentRequestedProfiles = await catalystClient.getEntitiesByPointers(sentRequestsAddresses) + + const requests = parseFriendshipRequestsToFriendshipRequestResponses( + sentRequests, + sentRequestedProfiles, + profileImagesUrl + ) return { response: { $case: 'requests', requests: { - requests: mappedRequests + requests } } } - } catch (error) { - logger.error(error as any) + } catch (error: any) { + logger.error(`Error getting sent friendship requests: ${error.message}`) return { response: { $case: 'internalServerError', diff --git a/src/adapters/rpc-server/services/upsert-friendship.ts b/src/adapters/rpc-server/services/upsert-friendship.ts index b392017..5edfc67 100644 --- a/src/adapters/rpc-server/services/upsert-friendship.ts +++ b/src/adapters/rpc-server/services/upsert-friendship.ts @@ -114,8 +114,8 @@ export function upsertFriendshipService({ } } } - } catch (error) { - logger.error(error as any) + } catch (error: any) { + logger.error(`Error upserting friendship: ${error.message}`) return { response: { $case: 'internalServerError', diff --git a/src/components.ts b/src/components.ts index feb4713..15a8147 100644 --- a/src/components.ts +++ b/src/components.ts @@ -15,6 +15,7 @@ import { createArchipelagoStatsComponent } from './adapters/archipelago-stats' import { createPeersSynchronizerComponent } from './adapters/peers-synchronizer' import { createNatsComponent } from '@well-known-components/nats-component' import { createPeerTrackingComponent } from './adapters/peer-tracking' +import { createCatalystClient } from './adapters/catalyst-client' // Initialize all the components of the app export async function initComponents(): Promise { @@ -55,7 +56,18 @@ export async function initComponents(): Promise { const pubsub = createPubSubComponent({ logs, redis }) const archipelagoStats = await createArchipelagoStatsComponent({ logs, config, fetcher, redis }) const nats = await createNatsComponent({ logs, config }) - const rpcServer = await createRpcServerComponent({ logs, db, pubsub, server, config, nats, archipelagoStats, redis }) + const catalystClient = await createCatalystClient({ config, fetcher }) + const rpcServer = await createRpcServerComponent({ + logs, + db, + pubsub, + server, + config, + nats, + archipelagoStats, + redis, + catalystClient + }) const peersSynchronizer = await createPeersSynchronizerComponent({ logs, archipelagoStats, redis, config }) const peerTracking = createPeerTrackingComponent({ logs, pubsub, nats }) @@ -73,6 +85,7 @@ export async function initComponents(): Promise { archipelagoStats, peersSynchronizer, nats, - peerTracking + peerTracking, + catalystClient } } diff --git a/src/controllers/handlers/ws-handler.ts b/src/controllers/handlers/ws-handler.ts index c3c049d..540f43f 100644 --- a/src/controllers/handlers/ws-handler.ts +++ b/src/controllers/handlers/ws-handler.ts @@ -80,9 +80,8 @@ export async function registerWsHandler( logger.error(err) } }) - } catch (error) { - console.log(error) - logger.error(error as any) + } catch (error: any) { + logger.error(`Error verifying auth chain: ${error.message}`) ws.close() } } diff --git a/src/logic/friends.ts b/src/logic/friends.ts new file mode 100644 index 0000000..46acdfe --- /dev/null +++ b/src/logic/friends.ts @@ -0,0 +1,19 @@ +import { FriendProfile } from '@dcl/protocol/out-js/decentraland/social_service/v2/social_service_v2.gen' +import { Entity } from '@dcl/schemas' +import { getProfileAvatar, getProfilePictureUrl } from './profiles' +import { normalizeAddress } from '../utils/address' + +export function parseProfileToFriend(profile: Entity, contentServerUrl: string): FriendProfile { + const { userId, name, hasClaimedName } = getProfileAvatar(profile) + + return { + address: normalizeAddress(userId), + name, + hasClaimedName, + profilePictureUrl: getProfilePictureUrl(contentServerUrl, profile) + } +} + +export function parseProfilesToFriends(profiles: Entity[], contentServerUrl: string): FriendProfile[] { + return profiles.map((profile) => parseProfileToFriend(profile, contentServerUrl)) +} diff --git a/src/logic/friendships.ts b/src/logic/friendships.ts index 9c4ec74..6d50108 100644 --- a/src/logic/friendships.ts +++ b/src/logic/friendships.ts @@ -2,16 +2,21 @@ import { FriendshipUpdate, UpsertFriendshipPayload, FriendshipStatus as FriendshipRequestStatus, - FriendUpdate + FriendUpdate, + FriendshipRequestResponse } from '@dcl/protocol/out-js/decentraland/social_service/v2/social_service_v2.gen' import { Action, FRIENDSHIP_ACTION_TRANSITIONS, FriendshipAction, + FriendshipRequest, FriendshipStatus, SubscriptionEventsEmitter } from '../types' import { normalizeAddress } from '../utils/address' +import { parseProfileToFriend } from './friends' +import { Entity } from '@dcl/schemas' +import { getProfileAvatar } from './profiles' const FRIENDSHIP_STATUS_BY_ACTION: Record< Action, @@ -207,3 +212,36 @@ export function getFriendshipRequestStatus( const statusResolver = FRIENDSHIP_STATUS_BY_ACTION[action] return statusResolver?.(acting_user, loggedUserAddress) ?? FriendshipRequestStatus.UNRECOGNIZED } + +export function parseFriendshipRequestToFriendshipRequestResponse( + request: FriendshipRequest, + profile: Entity, + profileImagesUrl: string +): FriendshipRequestResponse { + return { + id: request.id, + friend: parseProfileToFriend(profile, profileImagesUrl), + createdAt: new Date(request.timestamp).getTime(), + message: request.metadata?.message || '' + } +} + +export function parseFriendshipRequestsToFriendshipRequestResponses( + requests: FriendshipRequest[], + profiles: Entity[], + profileImagesUrl: string +): FriendshipRequestResponse[] { + const profilesMap = new Map(profiles.map((profile) => [getProfileAvatar(profile).userId, profile])) + + return requests + .map((request) => { + const profile = profilesMap.get(request.address) + + if (!profile) { + return null + } + + return parseFriendshipRequestToFriendshipRequestResponse(request, profile, profileImagesUrl) + }) + .filter((request) => !!request) +} diff --git a/src/logic/profiles.ts b/src/logic/profiles.ts new file mode 100644 index 0000000..9bebd11 --- /dev/null +++ b/src/logic/profiles.ts @@ -0,0 +1,15 @@ +import { Entity, Avatar } from '@dcl/schemas' + +export function getProfileAvatar(profile: Pick): Avatar { + const [avatar] = profile.metadata.avatars + + if (!avatar) throw new Error('Missing profile avatar') + + return avatar +} + +export function getProfilePictureUrl(baseUrl: string, { id }: Pick): string { + if (!baseUrl) throw new Error('Missing baseUrl for profile picture') + + return `${baseUrl}/entities/${id}/face.png` +} diff --git a/src/types.ts b/src/types.ts index 8151559..a5f7652 100644 --- a/src/types.ts +++ b/src/types.ts @@ -18,6 +18,7 @@ import { PoolClient } from 'pg' import { createClient, SetOptions } from 'redis' import { INatsComponent, Subscription } from '@well-known-components/nats-component/dist/types' import { ConnectivityStatus } from '@dcl/protocol/out-js/decentraland/social_service/v2/social_service_v2.gen' +import { Entity } from '@dcl/schemas' export type GlobalContext = { components: BaseComponents @@ -39,6 +40,7 @@ export type BaseComponents = { peersSynchronizer: IPeersSynchronizer nats: INatsComponent peerTracking: IPeerTrackingComponent + catalystClient: ICatalystClient } // components used in runtime @@ -118,6 +120,16 @@ export type IPeerTrackingComponent = IBaseComponent & { getSubscriptions(): Map } +export type ICatalystClientRequestOptions = { + retries?: number + waitTime?: number + contentServerUrl?: string +} + +export type ICatalystClient = { + getEntitiesByPointers(pointers: string[], options?: ICatalystClientRequestOptions): Promise +} + // this type simplifies the typings of http handlers export type HandlerContextWithPath< ComponentNames extends keyof AppComponents, diff --git a/src/utils/array.ts b/src/utils/array.ts new file mode 100644 index 0000000..d0af231 --- /dev/null +++ b/src/utils/array.ts @@ -0,0 +1,7 @@ +export function shuffleArray(array: T[]): T[] { + for (let i = array.length - 1; i > 0; i--) { + const j = Math.floor(Math.random() * (i + 1)) + ;[array[i], array[j]] = [array[j], array[i]] + } + return array +} diff --git a/src/utils/retrier.ts b/src/utils/retrier.ts new file mode 100644 index 0000000..01a4914 --- /dev/null +++ b/src/utils/retrier.ts @@ -0,0 +1,19 @@ +import { sleep } from './timer' + +export async function retry( + action: (attempt: number) => Promise, + retries: number = 3, + waitTime: number = 300 +): Promise { + for (let attempt = 1; attempt <= retries; attempt++) { + try { + return await action(attempt) + } catch (error: any) { + if (attempt === retries) { + throw new Error(`Failed after ${retries} attempts: ${error.message}`) + } + await sleep(waitTime) + } + } + throw new Error('Unexpected error: retry loop ended without throwing') +} diff --git a/src/utils/timer.ts b/src/utils/timer.ts new file mode 100644 index 0000000..b666e1a --- /dev/null +++ b/src/utils/timer.ts @@ -0,0 +1,3 @@ +export async function sleep(ms: number) { + return new Promise((ok) => setTimeout(ok, ms)) +} diff --git a/test/mocks/components/catalyst-client.ts b/test/mocks/components/catalyst-client.ts new file mode 100644 index 0000000..4a885a9 --- /dev/null +++ b/test/mocks/components/catalyst-client.ts @@ -0,0 +1,5 @@ +import { ICatalystClient } from '../../../src/types' + +export const mockCatalystClient: jest.Mocked = { + getEntitiesByPointers: jest.fn() +} diff --git a/test/mocks/components/index.ts b/test/mocks/components/index.ts index e5dfeda..74c2c10 100644 --- a/test/mocks/components/index.ts +++ b/test/mocks/components/index.ts @@ -8,3 +8,4 @@ export * from './redis' export * from './archipelago-stats' export * from './nats' export * from './fetcher' +export * from './catalyst-client' diff --git a/test/mocks/friend.ts b/test/mocks/friend.ts new file mode 100644 index 0000000..6ec9776 --- /dev/null +++ b/test/mocks/friend.ts @@ -0,0 +1,16 @@ +import { Entity } from '@dcl/schemas' +import { Friend } from '../../src/types' +import { getProfileAvatar } from '../../src/logic/profiles' + +export const createMockFriend = (address: string): Friend => ({ + address +}) + +export function parseExpectedFriends(profileImagesUrl: string) { + return (profile: Entity) => ({ + address: getProfileAvatar(profile).userId, + name: getProfileAvatar(profile).name, + hasClaimedName: true, + profilePictureUrl: `${profileImagesUrl}/entities/${profile.id}/face.png` + }) +} diff --git a/test/mocks/friendship-request.ts b/test/mocks/friendship-request.ts index 095cb95..55b4488 100644 --- a/test/mocks/friendship-request.ts +++ b/test/mocks/friendship-request.ts @@ -1,4 +1,7 @@ +import { Entity } from '@dcl/schemas' import { FriendshipRequest } from '../../src/types' +import { getProfileAvatar } from '../../src/logic/profiles' +import { FriendshipRequestResponse } from '@dcl/protocol/out-js/decentraland/social_service/v2/social_service_v2.gen' /** * Creates a mock friendship request from given parameters. @@ -21,11 +24,18 @@ export const createMockFriendshipRequest = ( export const createMockExpectedFriendshipRequest = ( id: string, address: string, + profile: Entity, createdAt?: string, - message?: string -) => ({ + message: string = '', + profileImagesUrl: string = 'https://profile-images.decentraland.org' +): FriendshipRequestResponse => ({ id, - user: { address }, - createdAt: createdAt ? new Date(createdAt).getTime() : new Date(createdAt).getTime(), - message: message || '' + friend: { + address, + name: getProfileAvatar(profile).name, + hasClaimedName: getProfileAvatar(profile).hasClaimedName, + profilePictureUrl: `${profileImagesUrl}/entities/${profile.id}/face.png` + }, + createdAt: createdAt ? new Date(createdAt).getTime() : new Date().getTime(), + message }) diff --git a/test/mocks/profile.ts b/test/mocks/profile.ts new file mode 100644 index 0000000..b17b10a --- /dev/null +++ b/test/mocks/profile.ts @@ -0,0 +1,43 @@ +import { Entity, EntityType } from '@dcl/schemas' + +export const mockProfile: Entity = { + version: '1', + id: 'profile-id', + type: EntityType.PROFILE, + metadata: { + avatars: [ + { + userId: '0x123', + name: 'TestUser', + hasClaimedName: true, + snapshots: { + face256: 'bafybeiasdfqwer' + } + } + ] + }, + pointers: ['0x123'], + timestamp: new Date().getTime(), + content: [ + { + file: 'face256', + hash: 'bafybeiasdfqwer' + } + ] +} + +export const createMockProfile = (address: string): Entity => ({ + ...mockProfile, + pointers: [address], + metadata: { + ...mockProfile.metadata, + avatars: [ + { + ...mockProfile.metadata.avatars[0], + userId: address, + name: `Profile name ${address}`, + hasClaimedName: true + } + ] + } +}) diff --git a/test/unit/adapters/catalyst-client.spec.ts b/test/unit/adapters/catalyst-client.spec.ts new file mode 100644 index 0000000..f799454 --- /dev/null +++ b/test/unit/adapters/catalyst-client.spec.ts @@ -0,0 +1,141 @@ +import { Entity } from '@dcl/schemas' +import { createCatalystClient } from '../../../src/adapters/catalyst-client' +import { ICatalystClient } from '../../../src/types' +import { ContentClient, createContentClient } from 'dcl-catalyst-client' +import { mockConfig, mockFetcher } from '../../mocks/components' + +jest.mock('dcl-catalyst-client', () => ({ + ...jest.requireActual('dcl-catalyst-client'), + createContentClient: jest.fn().mockReturnValue({ + fetchEntitiesByPointers: jest.fn() + }) +})) + +jest.mock('dcl-catalyst-client/dist/contracts-snapshots', () => ({ + getCatalystServersFromCache: jest + .fn() + .mockReturnValue([ + { address: 'http://catalyst-server-1.com' }, + { address: 'http://catalyst-server-2.com' }, + { address: 'http://catalyst-server-3.com' } + ]) +})) + +jest.mock('../../../src/utils/array', () => ({ + shuffleArray: jest.fn((array) => array) // for predictability +})) + +jest.mock('../../../src/utils/timer', () => ({ + sleep: jest.fn() +})) + +const LOAD_BALANCER_URL = 'http://catalyst-server.com' + +describe('Catalyst client', () => { + let catalystClient: ICatalystClient + let contentClientMock: ContentClient + + beforeEach(async () => { + mockConfig.requireString.mockResolvedValue(LOAD_BALANCER_URL) + + catalystClient = await createCatalystClient({ + fetcher: mockFetcher, + config: mockConfig + }) + contentClientMock = createContentClient({ fetcher: mockFetcher, url: LOAD_BALANCER_URL }) + }) + + afterEach(() => { + jest.clearAllMocks() + }) + + describe('getEntitiesByPointers', () => { + let pointers: string[] + let entities: Pick[] + let customContentServer: string + + beforeEach(() => { + pointers = ['pointer1', 'pointer2'] + entities = [{ id: 'entity1' }, { id: 'entity2' }] + customContentServer = 'http://custom-content-server.com' + }) + + it('should fetch entities by pointers with retries and default values', async () => { + contentClientMock.fetchEntitiesByPointers = jest + .fn() + .mockRejectedValueOnce(new Error('Failure on first attempt')) + .mockResolvedValueOnce(entities) + + const result = await catalystClient.getEntitiesByPointers(pointers) + + expect(contentClientMock.fetchEntitiesByPointers).toHaveBeenCalledTimes(2) + expect(result).toEqual(entities) + }) + + it('should fetch entities by pointers with custom retries and wait time', async () => { + contentClientMock.fetchEntitiesByPointers = jest + .fn() + .mockRejectedValueOnce(new Error('Failure')) + .mockResolvedValueOnce(entities) + + const result = await catalystClient.getEntitiesByPointers(pointers, { retries: 5, waitTime: 500 }) + + expect(contentClientMock.fetchEntitiesByPointers).toHaveBeenCalledTimes(2) + expect(result).toEqual(entities) + }) + + it('should fetch entities by pointers from custom content server on the first attempt', async () => { + contentClientMock.fetchEntitiesByPointers = jest.fn().mockResolvedValue(entities) + + const result = await catalystClient.getEntitiesByPointers(pointers, { contentServerUrl: customContentServer }) + + expectContentClientToHaveBeenCalledWithUrl(customContentServer) + expect(contentClientMock.fetchEntitiesByPointers).toHaveBeenCalledTimes(1) + + expect(result).toEqual(entities) + }) + + it('should rotate among catalyst server URLs on subsequent attempts', async () => { + contentClientMock.fetchEntitiesByPointers = jest + .fn() + .mockRejectedValueOnce(new Error('Failure on first attempt')) + .mockRejectedValueOnce(new Error('Failure on second attempt')) + .mockResolvedValueOnce(entities) + + await catalystClient.getEntitiesByPointers(pointers) + + expectContentClientToHaveBeenCalledWithUrl(LOAD_BALANCER_URL) + expectContentClientToHaveBeenCalledWithUrl('http://catalyst-server-3.com') + expectContentClientToHaveBeenCalledWithUrl('http://catalyst-server-2.com') + + expect(contentClientMock.fetchEntitiesByPointers).toHaveBeenCalledTimes(3) + }) + + it('should not reuse the same catalyst server URL on different attempts', async () => { + contentClientMock.fetchEntitiesByPointers = jest + .fn() + .mockRejectedValueOnce(new Error('Failure on first attempt')) + .mockRejectedValueOnce(new Error('Failure on second attempt')) + .mockRejectedValueOnce(new Error('Failure on third attempt')) + + await catalystClient.getEntitiesByPointers(pointers, { retries: 3 }).catch(() => {}) + + const createContentClientMock = createContentClient as jest.Mock + const currentCalls = createContentClientMock.mock.calls.slice(1) // Avoid the first call which is the one made in the beforeEach + + const urlsUsed = currentCalls.map((args) => args[0].url) + const uniqueUrls = new Set(urlsUsed) + + expect(uniqueUrls.size).toBe(urlsUsed.length) + }) + }) + + // Helpers + function expectContentClientToHaveBeenCalledWithUrl(url: string) { + expect(createContentClient).toHaveBeenCalledWith( + expect.objectContaining({ + url + }) + ) + } +}) diff --git a/test/unit/adapters/rpc-server.spec.ts b/test/unit/adapters/rpc-server.spec.ts index 028fa6b..27d881f 100644 --- a/test/unit/adapters/rpc-server.spec.ts +++ b/test/unit/adapters/rpc-server.spec.ts @@ -3,6 +3,7 @@ import { IRPCServerComponent, RpcServerContext } from '../../../src/types' import { RpcServer, Transport, createRpcServer } from '@dcl/rpc' import { mockArchipelagoStats, + mockCatalystClient, mockConfig, mockDb, mockLogs, @@ -42,7 +43,8 @@ describe('createRpcServerComponent', () => { server: mockUWs, nats: mockNats, archipelagoStats: mockArchipelagoStats, - redis: mockRedis + redis: mockRedis, + catalystClient: mockCatalystClient }) }) diff --git a/test/unit/adapters/rpc-server/services/get-friends.spec.ts b/test/unit/adapters/rpc-server/services/get-friends.spec.ts index f296fa3..db3128a 100644 --- a/test/unit/adapters/rpc-server/services/get-friends.spec.ts +++ b/test/unit/adapters/rpc-server/services/get-friends.spec.ts @@ -1,33 +1,41 @@ -import { mockDb, mockLogs } from '../../../../mocks/components' +import { mockCatalystClient, mockConfig, mockDb, mockLogs } from '../../../../mocks/components' import { getFriendsService } from '../../../../../src/adapters/rpc-server/services/get-friends' -import { FRIENDSHIPS_PER_PAGE, INTERNAL_SERVER_ERROR } from '../../../../../src/adapters/rpc-server/constants' -import { RpcServerContext, AppComponents, Friend } from '../../../../../src/types' +import { RpcServerContext } from '../../../../../src/types' +import { createMockProfile } from '../../../../mocks/profile' +import { createMockFriend, parseExpectedFriends } from '../../../../mocks/friend' describe('getFriendsService', () => { - let components: jest.Mocked> - let getFriends: ReturnType + let getFriends: Awaited> + + const profileImagesUrl = 'https://profile-images.decentraland.org' const rpcContext: RpcServerContext = { address: '0x123', subscribers: undefined } - beforeEach(() => { - components = { db: mockDb, logs: mockLogs } - getFriends = getFriendsService({ components }) + beforeEach(async () => { + mockConfig.requireString.mockResolvedValueOnce(profileImagesUrl) + + getFriends = await getFriendsService({ + components: { db: mockDb, logs: mockLogs, catalystClient: mockCatalystClient, config: mockConfig } + }) }) it('should return the correct list of friends with pagination data', async () => { - const mockFriends = [createMockFriend('0x456'), createMockFriend('0x789'), createMockFriend('0x987')] + const addresses = ['0x456', '0x789', '0x987'] + const mockFriends = addresses.map(createMockFriend) + const mockProfiles = addresses.map(createMockProfile) const totalFriends = 2 mockDb.getFriends.mockResolvedValueOnce(mockFriends) mockDb.getFriendsCount.mockResolvedValueOnce(totalFriends) + mockCatalystClient.getEntitiesByPointers.mockResolvedValueOnce(mockProfiles) const response = await getFriends({ pagination: { limit: 10, offset: 0 } }, rpcContext) expect(response).toEqual({ - users: [{ address: '0x456' }, { address: '0x789' }, { address: '0x987' }], + friends: mockProfiles.map(parseExpectedFriends(profileImagesUrl)), paginationData: { total: totalFriends, page: 1 @@ -35,22 +43,6 @@ describe('getFriendsService', () => { }) }) - it('should respect the pagination limit', async () => { - const mockFriends = Array.from({ length: FRIENDSHIPS_PER_PAGE }, (_, i) => createMockFriend(`0x${i + 1}`)) - const totalFriends = FRIENDSHIPS_PER_PAGE + 5 - - mockDb.getFriends.mockResolvedValueOnce(mockFriends) - mockDb.getFriendsCount.mockResolvedValueOnce(totalFriends) - - const response = await getFriends({ pagination: { limit: FRIENDSHIPS_PER_PAGE, offset: 0 } }, rpcContext) - - expect(response.users).toHaveLength(FRIENDSHIPS_PER_PAGE) - expect(response.paginationData).toEqual({ - total: totalFriends, - page: 1 - }) - }) - it('should return an empty list if no friends are found', async () => { mockDb.getFriends.mockResolvedValueOnce([]) mockDb.getFriendsCount.mockResolvedValueOnce(0) @@ -58,7 +50,7 @@ describe('getFriendsService', () => { const response = await getFriends({ pagination: { limit: 10, offset: 0 } }, rpcContext) expect(response).toEqual({ - users: [], + friends: [], paginationData: { total: 0, page: 1 @@ -71,13 +63,30 @@ describe('getFriendsService', () => { throw new Error('Database error') }) - await expect(getFriends({ pagination: { limit: 10, offset: 0 } }, rpcContext)).rejects.toThrow( - INTERNAL_SERVER_ERROR - ) + const response = await getFriends({ pagination: { limit: 10, offset: 0 } }, rpcContext) + + expect(response).toEqual({ + friends: [], + paginationData: { + total: 0, + page: 1 + } + }) }) - // Helper to create a mock friendship object - const createMockFriend = (address): Friend => ({ - address + it('should handle errors from the catalyst gracefully', async () => { + mockCatalystClient.getEntitiesByPointers.mockImplementationOnce(() => { + throw new Error('Catalyst error') + }) + + const response = await getFriends({ pagination: { limit: 10, offset: 0 } }, rpcContext) + + expect(response).toEqual({ + friends: [], + paginationData: { + total: 0, + page: 1 + } + }) }) }) diff --git a/test/unit/adapters/rpc-server/services/get-mutual-friends.spec.ts b/test/unit/adapters/rpc-server/services/get-mutual-friends.spec.ts index 9dcf4cc..e799e81 100644 --- a/test/unit/adapters/rpc-server/services/get-mutual-friends.spec.ts +++ b/test/unit/adapters/rpc-server/services/get-mutual-friends.spec.ts @@ -1,12 +1,14 @@ -import { mockDb, mockLogs } from '../../../../mocks/components' +import { mockCatalystClient, mockConfig, mockDb, mockLogs } from '../../../../mocks/components' import { getMutualFriendsService } from '../../../../../src/adapters/rpc-server/services/get-mutual-friends' -import { INTERNAL_SERVER_ERROR, FRIENDSHIPS_PER_PAGE } from '../../../../../src/adapters/rpc-server/constants' import { GetMutualFriendsPayload } from '@dcl/protocol/out-js/decentraland/social_service/v2/social_service_v2.gen' -import { RpcServerContext, AppComponents } from '../../../../../src/types' +import { RpcServerContext } from '../../../../../src/types' +import { createMockProfile } from '../../../../mocks/profile' +import { createMockFriend, parseExpectedFriends } from '../../../../mocks/friend' describe('getMutualFriendsService', () => { - let components: jest.Mocked> - let getMutualFriends: ReturnType + let getMutualFriends: Awaited> + + const profileImagesUrl = 'https://profile-images.decentraland.org' const rpcContext: RpcServerContext = { address: '0x123', @@ -18,50 +20,34 @@ describe('getMutualFriendsService', () => { pagination: { limit: 10, offset: 0 } } - beforeEach(() => { - components = { db: mockDb, logs: mockLogs } - getMutualFriends = getMutualFriendsService({ components }) + beforeEach(async () => { + mockConfig.requireString.mockResolvedValueOnce(profileImagesUrl) + getMutualFriends = await getMutualFriendsService({ + components: { db: mockDb, logs: mockLogs, catalystClient: mockCatalystClient, config: mockConfig } + }) }) it('should return the correct list of mutual friends with pagination data', async () => { - const mockMutualFriends = [{ address: '0x789' }, { address: '0xabc' }] + const addresses = ['0x789', '0xabc'] + const mockMutualFriends = addresses.map(createMockFriend) + const mockMutualFriendsProfiles = addresses.map(createMockProfile) const totalMutualFriends = 2 mockDb.getMutualFriends.mockResolvedValueOnce(mockMutualFriends) mockDb.getMutualFriendsCount.mockResolvedValueOnce(totalMutualFriends) + mockCatalystClient.getEntitiesByPointers.mockResolvedValueOnce(mockMutualFriendsProfiles) const response = await getMutualFriends(mutualFriendsRequest, rpcContext) expect(response).toEqual({ - users: mockMutualFriends, + friends: mockMutualFriendsProfiles.map(parseExpectedFriends(profileImagesUrl)), paginationData: { total: totalMutualFriends, - page: 1 // First page is 1 + page: 1 } }) }) - it('should respect the pagination limit', async () => { - const mockMutualFriends = Array.from({ length: FRIENDSHIPS_PER_PAGE }, (_, i) => ({ - address: `0x${i + 1}` - })) - const totalMutualFriends = FRIENDSHIPS_PER_PAGE + 5 - - mockDb.getMutualFriends.mockResolvedValueOnce(mockMutualFriends) - mockDb.getMutualFriendsCount.mockResolvedValueOnce(totalMutualFriends) - - const response = await getMutualFriends( - { ...mutualFriendsRequest, pagination: { limit: FRIENDSHIPS_PER_PAGE, offset: 0 } }, - rpcContext - ) - - expect(response.users).toHaveLength(FRIENDSHIPS_PER_PAGE) - expect(response.paginationData).toEqual({ - total: totalMutualFriends, - page: 1 // First page is 1 - }) - }) - it('should return an empty list if no mutual friends are found', async () => { mockDb.getMutualFriends.mockResolvedValueOnce([]) mockDb.getMutualFriendsCount.mockResolvedValueOnce(0) @@ -72,10 +58,10 @@ describe('getMutualFriendsService', () => { ) expect(response).toEqual({ - users: [], + friends: [], paginationData: { total: 0, - page: 1 // First page is 1, even when no results + page: 1 } }) }) @@ -85,6 +71,30 @@ describe('getMutualFriendsService', () => { throw new Error('Database error') }) - await expect(getMutualFriends(mutualFriendsRequest, rpcContext)).rejects.toThrow(INTERNAL_SERVER_ERROR) + const response = await getMutualFriends(mutualFriendsRequest, rpcContext) + + expect(response).toEqual({ + friends: [], + paginationData: { + total: 0, + page: 1 + } + }) + }) + + it('should handle errors from the catalyst gracefully', async () => { + mockCatalystClient.getEntitiesByPointers.mockImplementationOnce(() => { + throw new Error('Catalyst error') + }) + + const response = await getMutualFriends(mutualFriendsRequest, rpcContext) + + expect(response).toEqual({ + friends: [], + paginationData: { + total: 0, + page: 1 + } + }) }) }) diff --git a/test/unit/adapters/rpc-server/services/get-pending-friendship-requests.spec.ts b/test/unit/adapters/rpc-server/services/get-pending-friendship-requests.spec.ts index 243f1d3..04eac48 100644 --- a/test/unit/adapters/rpc-server/services/get-pending-friendship-requests.spec.ts +++ b/test/unit/adapters/rpc-server/services/get-pending-friendship-requests.spec.ts @@ -1,22 +1,26 @@ -import { mockDb, mockLogs } from '../../../../mocks/components' +import { mockCatalystClient, mockConfig, mockDb, mockLogs } from '../../../../mocks/components' import { getPendingFriendshipRequestsService } from '../../../../../src/adapters/rpc-server/services/get-pending-friendship-requests' -import { RpcServerContext, AppComponents } from '../../../../../src/types' +import { RpcServerContext } from '../../../../../src/types' import { PaginatedFriendshipRequestsResponse } from '@dcl/protocol/out-js/decentraland/social_service/v2/social_service_v2.gen' import { emptyRequest } from '../../../../mocks/empty-request' import { createMockFriendshipRequest, createMockExpectedFriendshipRequest } from '../../../../mocks/friendship-request' +import { createMockProfile } from '../../../../mocks/profile' describe('getPendingFriendshipRequestsService', () => { - let components: jest.Mocked> - let getPendingRequests: ReturnType + let getPendingRequests: Awaited> const rpcContext: RpcServerContext = { address: '0x123', subscribers: undefined } - beforeEach(() => { - components = { db: mockDb, logs: mockLogs } - getPendingRequests = getPendingFriendshipRequestsService({ components }) + const profileImagesUrl = 'https://profile-images.decentraland.org' + + beforeEach(async () => { + mockConfig.requireString.mockResolvedValueOnce(profileImagesUrl) + getPendingRequests = await getPendingFriendshipRequestsService({ + components: { db: mockDb, logs: mockLogs, config: mockConfig, catalystClient: mockCatalystClient } + }) }) it('should return the correct list of pending friendship requests', async () => { @@ -24,9 +28,10 @@ describe('getPendingFriendshipRequestsService', () => { createMockFriendshipRequest('id1', '0x456', '2025-01-01T00:00:00Z', 'Hi!'), createMockFriendshipRequest('id2', '0x789', '2025-01-02T00:00:00Z') ] + const mockProfiles = mockPendingRequests.map(({ address }) => createMockProfile(address)) mockDb.getReceivedFriendshipRequests.mockResolvedValueOnce(mockPendingRequests) - + mockCatalystClient.getEntitiesByPointers.mockResolvedValueOnce(mockProfiles) const result: PaginatedFriendshipRequestsResponse = await getPendingRequests(emptyRequest, rpcContext) expect(result).toEqual({ @@ -34,8 +39,8 @@ describe('getPendingFriendshipRequestsService', () => { $case: 'requests', requests: { requests: [ - createMockExpectedFriendshipRequest('id1', '0x456', '2025-01-01T00:00:00Z', 'Hi!'), - createMockExpectedFriendshipRequest('id2', '0x789', '2025-01-02T00:00:00Z') + createMockExpectedFriendshipRequest('id1', '0x456', mockProfiles[0], '2025-01-01T00:00:00Z', 'Hi!'), + createMockExpectedFriendshipRequest('id2', '0x789', mockProfiles[1], '2025-01-02T00:00:00Z') ] } } @@ -58,9 +63,11 @@ describe('getPendingFriendshipRequestsService', () => { }) it('should map metadata.message to an empty string if undefined', async () => { - const mockPendingRequests = [createMockFriendshipRequest('id1', '0x456', '2025-01-01T00:00:00Z', 'Hi!')] + const mockPendingRequests = [createMockFriendshipRequest('id1', '0x456', '2025-01-01T00:00:00Z')] + const mockProfiles = mockPendingRequests.map(({ address }) => createMockProfile(address)) mockDb.getReceivedFriendshipRequests.mockResolvedValueOnce(mockPendingRequests) + mockCatalystClient.getEntitiesByPointers.mockResolvedValueOnce(mockProfiles) const result: PaginatedFriendshipRequestsResponse = await getPendingRequests(emptyRequest, rpcContext) @@ -68,7 +75,7 @@ describe('getPendingFriendshipRequestsService', () => { response: { $case: 'requests', requests: { - requests: [createMockExpectedFriendshipRequest('id1', '0x456', '2025-01-01T00:00:00Z', 'Hi!')] + requests: [createMockExpectedFriendshipRequest('id1', '0x456', mockProfiles[0], '2025-01-01T00:00:00Z', '')] } } }) diff --git a/test/unit/adapters/rpc-server/services/get-sent-friendship-requests.spec.ts b/test/unit/adapters/rpc-server/services/get-sent-friendship-requests.spec.ts index a5d2d3e..29a826c 100644 --- a/test/unit/adapters/rpc-server/services/get-sent-friendship-requests.spec.ts +++ b/test/unit/adapters/rpc-server/services/get-sent-friendship-requests.spec.ts @@ -1,22 +1,26 @@ -import { mockDb, mockLogs } from '../../../../mocks/components' +import { mockCatalystClient, mockConfig, mockDb, mockLogs } from '../../../../mocks/components' import { getSentFriendshipRequestsService } from '../../../../../src/adapters/rpc-server/services/get-sent-friendship-requests' -import { RpcServerContext, AppComponents } from '../../../../../src/types' +import { RpcServerContext } from '../../../../../src/types' import { emptyRequest } from '../../../../mocks/empty-request' import { createMockFriendshipRequest, createMockExpectedFriendshipRequest } from '../../../../mocks/friendship-request' import { PaginatedFriendshipRequestsResponse } from '@dcl/protocol/out-js/decentraland/social_service/v2/social_service_v2.gen' +import { createMockProfile } from '../../../../mocks/profile' describe('getSentFriendshipRequestsService', () => { - let components: jest.Mocked> - let getSentRequests: ReturnType + let getSentRequests: Awaited> const rpcContext: RpcServerContext = { address: '0x123', subscribers: undefined } - beforeEach(() => { - components = { db: mockDb, logs: mockLogs } - getSentRequests = getSentFriendshipRequestsService({ components }) + const profileImagesUrl = 'https://profile-images.decentraland.org' + + beforeEach(async () => { + mockConfig.requireString.mockResolvedValueOnce(profileImagesUrl) + getSentRequests = await getSentFriendshipRequestsService({ + components: { db: mockDb, logs: mockLogs, config: mockConfig, catalystClient: mockCatalystClient } + }) }) it('should return the correct list of sent friendship requests', async () => { @@ -24,8 +28,10 @@ describe('getSentFriendshipRequestsService', () => { createMockFriendshipRequest('id1', '0x456', '2025-01-01T00:00:00Z', 'Hello!'), createMockFriendshipRequest('id2', '0x789', '2025-01-02T00:00:00Z') ] + const mockProfiles = mockSentRequests.map(({ address }) => createMockProfile(address)) mockDb.getSentFriendshipRequests.mockResolvedValueOnce(mockSentRequests) + mockCatalystClient.getEntitiesByPointers.mockResolvedValueOnce(mockProfiles) const result: PaginatedFriendshipRequestsResponse = await getSentRequests(emptyRequest, rpcContext) @@ -34,8 +40,8 @@ describe('getSentFriendshipRequestsService', () => { $case: 'requests', requests: { requests: [ - createMockExpectedFriendshipRequest('id1', '0x456', '2025-01-01T00:00:00Z', 'Hello!'), - createMockExpectedFriendshipRequest('id2', '0x789', '2025-01-02T00:00:00Z') + createMockExpectedFriendshipRequest('id1', '0x456', mockProfiles[0], '2025-01-01T00:00:00Z', 'Hello!'), + createMockExpectedFriendshipRequest('id2', '0x789', mockProfiles[1], '2025-01-02T00:00:00Z') ] } } @@ -56,10 +62,13 @@ describe('getSentFriendshipRequestsService', () => { } }) }) + it('should map metadata.message to an empty string if undefined', async () => { const mockSentRequests = [createMockFriendshipRequest('id1', '0x456', '2025-01-01T00:00:00Z')] + const mockProfiles = mockSentRequests.map(({ address }) => createMockProfile(address)) mockDb.getSentFriendshipRequests.mockResolvedValueOnce(mockSentRequests) + mockCatalystClient.getEntitiesByPointers.mockResolvedValueOnce(mockProfiles) const result: PaginatedFriendshipRequestsResponse = await getSentRequests(emptyRequest, rpcContext) @@ -67,7 +76,7 @@ describe('getSentFriendshipRequestsService', () => { response: { $case: 'requests', requests: { - requests: [createMockExpectedFriendshipRequest('id1', '0x456', '2025-01-01T00:00:00Z')] + requests: [createMockExpectedFriendshipRequest('id1', '0x456', mockProfiles[0], '2025-01-01T00:00:00Z')] } } }) diff --git a/test/unit/logic/friends.spec.ts b/test/unit/logic/friends.spec.ts new file mode 100644 index 0000000..96e4292 --- /dev/null +++ b/test/unit/logic/friends.spec.ts @@ -0,0 +1,40 @@ +import { parseProfilesToFriends } from '../../../src/logic/friends' +import { mockProfile } from '../../mocks/profile' + +describe('parseProfilesToFriends', () => { + it('should convert profile entities to friend users', () => { + const profileImagesUrl = 'https://profile-images.decentraland.org' + const anotherProfile = { + ...mockProfile, + metadata: { + ...mockProfile.metadata, + avatars: [ + { + ...mockProfile.metadata.avatars[0], + userId: '0x123aBcDE', + name: 'TestUser2', + hasClaimedName: false + } + ] + } + } + const profiles = [mockProfile, anotherProfile] + + const result = parseProfilesToFriends(profiles, profileImagesUrl) + + expect(result).toEqual([ + { + address: '0x123', + name: 'TestUser', + hasClaimedName: true, + profilePictureUrl: `${profileImagesUrl}/entities/${mockProfile.id}/face.png` + }, + { + address: '0x123abcde', + name: 'TestUser2', + hasClaimedName: false, + profilePictureUrl: `${profileImagesUrl}/entities/${anotherProfile.id}/face.png` + } + ]) + }) +}) diff --git a/test/unit/logic/friendships.spec.ts b/test/unit/logic/friendships.spec.ts index c2cc519..793414c 100644 --- a/test/unit/logic/friendships.spec.ts +++ b/test/unit/logic/friendships.spec.ts @@ -5,6 +5,8 @@ import { isUserActionValid, parseEmittedUpdateToFriendshipUpdate, parseEmittedUpdateToFriendStatusUpdate, + parseFriendshipRequestsToFriendshipRequestResponses, + parseFriendshipRequestToFriendshipRequestResponse, parseUpsertFriendshipRequest, validateNewFriendshipAction } from '../../../src/logic/friendships' @@ -13,6 +15,8 @@ import { ConnectivityStatus, FriendshipStatus as FriendshipRequestStatus } from '@dcl/protocol/out-js/decentraland/social_service/v2/social_service_v2.gen' +import { createMockExpectedFriendshipRequest, createMockFriendshipRequest } from '../../mocks/friendship-request' +import { createMockProfile } from '../../mocks/profile' describe('isFriendshipActionValid()', () => { test('it should be valid if from is null and to is REQUEST ', () => { @@ -564,3 +568,35 @@ describe('parseEmittedUpdateToFriendStatusUpdate()', () => { }) }) }) + +describe('parseFriendshipRequestToFriendshipRequestResponse()', () => { + test('it should parse friendship request to friendship request response', () => { + const request = createMockFriendshipRequest('id1', '0x456', '2025-01-01T00:00:00Z') + const profile = createMockProfile('0x456') + const profileImagesUrl = 'https://profile-images.decentraland.org' + + expect(parseFriendshipRequestToFriendshipRequestResponse(request, profile, profileImagesUrl)).toEqual( + createMockExpectedFriendshipRequest('id1', '0x456', profile, '2025-01-01T00:00:00Z', '') + ) + }) +}) + +describe('parseFriendshipRequestsToFriendshipRequestResponses()', () => { + test('it should parse friendship requests to friendship request responses', () => { + const requests = [createMockFriendshipRequest('id1', '0x456', '2025-01-01T00:00:00Z')] + const profiles = [createMockProfile('0x456')] + const profileImagesUrl = 'https://profile-images.decentraland.org' + + expect(parseFriendshipRequestsToFriendshipRequestResponses(requests, profiles, profileImagesUrl)).toEqual([ + createMockExpectedFriendshipRequest('id1', '0x456', profiles[0], '2025-01-01T00:00:00Z', '') + ]) + }) + + test('it should return an empty array if the requester/requested address is not found in the profiles', () => { + const requests = [createMockFriendshipRequest('id1', '0x456', '2025-01-01T00:00:00Z')] + const profiles = [createMockProfile('0x789')] + const profileImagesUrl = 'https://profile-images.decentraland.org' + + expect(parseFriendshipRequestsToFriendshipRequestResponses(requests, profiles, profileImagesUrl)).toEqual([]) + }) +}) diff --git a/test/unit/logic/profiles.spec.ts b/test/unit/logic/profiles.spec.ts new file mode 100644 index 0000000..f74d8bc --- /dev/null +++ b/test/unit/logic/profiles.spec.ts @@ -0,0 +1,43 @@ +import { Entity } from '@dcl/schemas' +import { getProfileAvatar, getProfilePictureUrl } from '../../../src/logic/profiles' +import { mockProfile } from '../../mocks/profile' + +describe('getProfileAvatar', () => { + it('should extract avatar information from profile entity', () => { + const avatar = getProfileAvatar(mockProfile) + + expect(avatar).toEqual({ + userId: '0x123', + name: 'TestUser', + hasClaimedName: true, + snapshots: { + face256: 'bafybeiasdfqwer' + } + }) + }) + + it('should handle profile without avatars gracefully', () => { + const emptyProfile: Entity = { + ...mockProfile, + metadata: { + avatars: [] + } + } + + expect(() => getProfileAvatar(emptyProfile)).toThrow('Missing profile avatar') + }) +}) + +describe('getProfilePictureUrl', () => { + const baseUrl = 'https://profile-images.decentraland.org' + + it('should construct correct profile picture URL', () => { + const url = getProfilePictureUrl(baseUrl, mockProfile) + + expect(url).toBe(`${baseUrl}/entities/${mockProfile.id}/face.png`) + }) + + it('should throw on empty baseUrl', () => { + expect(() => getProfilePictureUrl('', mockProfile)).toThrow('Missing baseUrl') + }) +}) diff --git a/test/unit/utils/array.spec.ts b/test/unit/utils/array.spec.ts new file mode 100644 index 0000000..62f306a --- /dev/null +++ b/test/unit/utils/array.spec.ts @@ -0,0 +1,54 @@ +import { shuffleArray } from '../../../src/utils/array' + +describe('shuffleArray', () => { + it('should return an array with the same elements in a different order', () => { + const originalArray = [1, 2, 3, 4, 5] + const shuffledArray = shuffleArray([...originalArray]) + + expect(shuffledArray).toHaveLength(originalArray.length) + expect(shuffledArray).not.toEqual(originalArray) + expect(shuffledArray.sort()).toEqual(originalArray.sort()) + }) + + it('should shuffle elements randomly (mocked Math.random)', () => { + const originalArray = [1, 2, 3, 4, 5] + + // to control the shuffle + jest + .spyOn(global.Math, 'random') + .mockReturnValueOnce(0.1) + .mockReturnValueOnce(0.3) + .mockReturnValueOnce(0.7) + .mockReturnValueOnce(0.9) + .mockReturnValueOnce(0.5) + + const shuffledArray = shuffleArray([...originalArray]) + + expect(shuffledArray).not.toEqual(originalArray) + + jest.restoreAllMocks() + }) + + it('should not modify the original array', () => { + const originalArray = [1, 2, 3, 4, 5] + const arrayCopy = [...originalArray] + + shuffleArray(arrayCopy) + + expect(originalArray).toEqual([1, 2, 3, 4, 5]) + }) + + it('should handle an empty array gracefully', () => { + const emptyArray: number[] = [] + const shuffledArray = shuffleArray(emptyArray) + + expect(shuffledArray).toEqual([]) + }) + + it('should handle an array with one element gracefully', () => { + const singleElementArray = [1] + const shuffledArray = shuffleArray(singleElementArray) + + expect(shuffledArray).toEqual([1]) + }) +}) diff --git a/test/unit/utils/retrier.spec.ts b/test/unit/utils/retrier.spec.ts new file mode 100644 index 0000000..b5b0597 --- /dev/null +++ b/test/unit/utils/retrier.spec.ts @@ -0,0 +1,72 @@ +import { retry } from '../../../src/utils/retrier' +import { sleep } from '../../../src/utils/timer' + +jest.mock('../../../src/utils/timer', () => ({ + sleep: jest.fn() +})) + +describe('retry', () => { + const mockAction = jest.fn() + const mockSleep = sleep as jest.MockedFunction + + beforeEach(() => { + jest.clearAllMocks() + }) + + it('should return result on the first attempt without retrying', async () => { + mockAction.mockResolvedValue('success') + + const result = await retry(mockAction) + + expect(result).toBe('success') + expect(mockAction).toHaveBeenCalledTimes(1) + expect(mockSleep).not.toHaveBeenCalled() + }) + + it('should retry the action and succeed after a few attempts', async () => { + mockAction + .mockRejectedValueOnce(new Error('Failure on first try')) + .mockRejectedValueOnce(new Error('Failure on second try')) + .mockResolvedValueOnce('success on third try') + + const result = await retry(mockAction, 3, 100) + + expect(result).toBe('success on third try') + expect(mockAction).toHaveBeenCalledTimes(3) + expect(mockSleep).toHaveBeenCalledTimes(2) + expect(mockSleep).toHaveBeenCalledWith(100) + }) + + it('should throw an error after exhausting all retries', async () => { + mockAction.mockRejectedValue(new Error('Fail on every attempt')) + + await expect(retry(mockAction, 3, 100)).rejects.toThrowError('Failed after 3 attempts') + + expect(mockAction).toHaveBeenCalledTimes(3) + expect(mockSleep).toHaveBeenCalledTimes(2) + }) + + it('should call sleep between retries', async () => { + mockAction.mockRejectedValueOnce(new Error('Fail')).mockResolvedValueOnce('success after sleep') + + const result = await retry(mockAction, 2, 200) + + expect(result).toBe('success after sleep') + expect(mockAction).toHaveBeenCalledTimes(2) + expect(mockSleep).toHaveBeenCalledWith(200) + }) + + it('should retry with custom retry count and wait time', async () => { + mockAction + .mockRejectedValueOnce(new Error('Fail')) + .mockRejectedValueOnce(new Error('Fail again')) + .mockResolvedValueOnce('success finally') + + const result = await retry(mockAction, 5, 500) + + expect(result).toBe('success finally') + expect(mockAction).toHaveBeenCalledTimes(3) + expect(mockSleep).toHaveBeenCalledTimes(2) + expect(mockSleep).toHaveBeenCalledWith(500) + }) +}) diff --git a/test/unit/utils/timer.spec.ts b/test/unit/utils/timer.spec.ts new file mode 100644 index 0000000..6344bad --- /dev/null +++ b/test/unit/utils/timer.spec.ts @@ -0,0 +1,23 @@ +import { sleep } from '../../../src/utils/timer' + +describe('sleep', () => { + beforeAll(() => { + jest.useFakeTimers() + jest.spyOn(global, 'setTimeout') + }) + + afterAll(() => { + jest.restoreAllMocks() + jest.useRealTimers() + }) + + it('should call setTimeout with the correct delay and resolve', async () => { + const sleepPromise = sleep(1000) + + jest.runAllTimers() + + expect(setTimeout).toHaveBeenCalledWith(expect.any(Function), 1000) + + await expect(sleepPromise).resolves.toBeUndefined() + }) +}) diff --git a/yarn.lock b/yarn.lock index df38fb6..af9232a 100644 --- a/yarn.lock +++ b/yarn.lock @@ -309,7 +309,12 @@ dependencies: "@jridgewell/trace-mapping" "0.3.9" -"@dcl/crypto@^3.4.3": +"@dcl/catalyst-contracts@^4.4.0": + version "4.4.2" + resolved "https://registry.yarnpkg.com/@dcl/catalyst-contracts/-/catalyst-contracts-4.4.2.tgz#e7a329d7aee25c90d7ee07dbe82200a048f17ff9" + integrity sha512-gJZo3IB8U+jhBJWR0DgoLS+zaUDIb/u79e7Rp+MEM78uH3bvC19S3Dd8lxWEbPXNKlCB0saUptfK/Buw0c1y2Q== + +"@dcl/crypto@^3.4.0", "@dcl/crypto@^3.4.3": version "3.4.5" resolved "https://registry.yarnpkg.com/@dcl/crypto/-/crypto-3.4.5.tgz#95ca2beab46fa3494b4d38f009bf0d49c87ba235" integrity sha512-uneyjOAOx7pi5kabZsLmPm9kSLkCk4Cok8FUsvT+6k8RquqkjKqocvkGVOMaoWsfU6S3mkLOyaeqEKmOy4ErxA== @@ -335,6 +340,11 @@ eslint-plugin-prettier "^5.1.3" prettier "^3.3.2" +"@dcl/hashing@^3.0.0": + version "3.0.4" + resolved "https://registry.yarnpkg.com/@dcl/hashing/-/hashing-3.0.4.tgz#4df2a4cb3a8114765aed34cb57b91c93bf33bfb3" + integrity sha512-Cg+MoIOn+BYmQV2q8zSFnNYY+GldlnUazwBnfgrq3i66ZxOaZ65h01btd8OUtSAlfWG4VTNIOHDjtKqmuwJNBg== + "@dcl/platform-crypto-middleware@^1.1.0": version "1.1.0" resolved "https://registry.yarnpkg.com/@dcl/platform-crypto-middleware/-/platform-crypto-middleware-1.1.0.tgz#11434eb4fc8d461799ab71d28ce869ab330bdb23" @@ -344,15 +354,15 @@ "@well-known-components/fetch-component" "^2.0.2" "@well-known-components/interfaces" "^1.4.2" -"@dcl/protocol@https://sdk-team-cdn.decentraland.org/@dcl/protocol/branch//dcl-protocol-1.0.0-12875508193.commit-808a662.tgz": - version "1.0.0-12875508193.commit-808a662" - resolved "https://sdk-team-cdn.decentraland.org/@dcl/protocol/branch//dcl-protocol-1.0.0-12875508193.commit-808a662.tgz#53592864b3d9e7ea382363ce225ce20e6792321a" +"@dcl/protocol@https://sdk-team-cdn.decentraland.org/@dcl/protocol/branch//dcl-protocol-1.0.0-12916006744.commit-ba473a8.tgz": + version "1.0.0-12916006744.commit-ba473a8" + resolved "https://sdk-team-cdn.decentraland.org/@dcl/protocol/branch//dcl-protocol-1.0.0-12916006744.commit-ba473a8.tgz#23f50b793eee33ee1c43d433b5f5d63d90f1d4c5" dependencies: "@dcl/ts-proto" "1.154.0" -"@dcl/protocol@https://sdk-team-cdn.decentraland.org/@dcl/protocol/branch//dcl-protocol-1.0.0-12890706635.commit-a7e4210.tgz": - version "1.0.0-12890706635.commit-a7e4210" - resolved "https://sdk-team-cdn.decentraland.org/@dcl/protocol/branch//dcl-protocol-1.0.0-12890706635.commit-a7e4210.tgz#26bac792c70a98cd33346847e4298540eacd1b27" +"@dcl/protocol@https://sdk-team-cdn.decentraland.org/@dcl/protocol/branch//dcl-protocol-1.0.0-12916692077.commit-190ed21.tgz": + version "1.0.0-12916692077.commit-190ed21" + resolved "https://sdk-team-cdn.decentraland.org/@dcl/protocol/branch//dcl-protocol-1.0.0-12916692077.commit-190ed21.tgz#32741f275d43b610db3ffcc702381672cfa9acbc" dependencies: "@dcl/ts-proto" "1.154.0" @@ -364,6 +374,26 @@ mitt "^3.0.0" ts-proto "^1.146.0" +"@dcl/schemas@^11.5.0": + version "11.12.0" + resolved "https://registry.yarnpkg.com/@dcl/schemas/-/schemas-11.12.0.tgz#f52493b615ebdea8e02819bc2ed4a456239f3747" + integrity sha512-L04KTucvxSnrHDAl3/rnkzhjfZ785dSSPeKarBVfzyuw41uyQ0Mh4HVFWjX9hC+f/nMpM5Adg5udlT5efmepcA== + dependencies: + ajv "^8.11.0" + ajv-errors "^3.0.0" + ajv-keywords "^5.1.0" + mitt "^3.0.1" + +"@dcl/schemas@^15.6.0": + version "15.6.0" + resolved "https://registry.yarnpkg.com/@dcl/schemas/-/schemas-15.6.0.tgz#0b5859ce3b313a42965350aa7426a10d72866338" + integrity sha512-YBX9pqANci2bNmEpa+T48KYHg0dLz7gaNolwECjqXPWrwQZbNMlcz2vvU4+ZlOyMY2RwJxlz8NJSCyZfS23x1A== + dependencies: + ajv "^8.11.0" + ajv-errors "^3.0.0" + ajv-keywords "^5.1.0" + mitt "^3.0.1" + "@dcl/schemas@^9.2.0": version "9.15.0" resolved "https://registry.yarnpkg.com/@dcl/schemas/-/schemas-9.15.0.tgz#81337faa396d21a2d1e704e5ec3cfd7b7b14343e" @@ -1159,7 +1189,7 @@ dependencies: dotenv "^16.0.1" -"@well-known-components/fetch-component@^2.0.2": +"@well-known-components/fetch-component@^2.0.0", "@well-known-components/fetch-component@^2.0.2": version "2.0.2" resolved "https://registry.yarnpkg.com/@well-known-components/fetch-component/-/fetch-component-2.0.2.tgz#02260611f9d05551efe825c4358cbc628a71c4c0" integrity sha512-LdY+6n9kuyACg3fcU4qMrNhLZuG7eqPxLSqzDgQyoHKeNjlzggoUqTVJKtIyi6vjPs8pSQ/Fx1xdLuBhOKCgww== @@ -1640,6 +1670,11 @@ convert-source-map@^2.0.0: resolved "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz" integrity sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg== +cookie@^0.5.0: + version "0.5.0" + resolved "https://registry.yarnpkg.com/cookie/-/cookie-0.5.0.tgz#d1f5d71adec6558c58f389987c366aa47e994f8b" + integrity sha512-YZ3GUyn/o8gfKJlnlX7g7xq4gyO6OSuhGPKaaGssGB2qgDUS0gPgtTvoyZLTt9Ab6dC4hfc9dV5arkvc/OCmrw== + create-jest@^29.7.0: version "29.7.0" resolved "https://registry.npmjs.org/create-jest/-/create-jest-29.7.0.tgz" @@ -1679,6 +1714,20 @@ dataloader@^1.4.0: resolved "https://registry.yarnpkg.com/dataloader/-/dataloader-1.4.0.tgz#bca11d867f5d3f1b9ed9f737bd15970c65dff5c8" integrity sha512-68s5jYdlvasItOJnCuI2Q9s4q98g0pCyL3HrcKJu8KNugUl8ahgmZYg38ysLTgQjjXX3H8CJLkAvWrclWfcalw== +dcl-catalyst-client@^21.7.0: + version "21.7.0" + resolved "https://registry.yarnpkg.com/dcl-catalyst-client/-/dcl-catalyst-client-21.7.0.tgz#dbf33dff0bcf382c8383f359dbfaf7f85011af25" + integrity sha512-10NyeYrKSh7yM5y7suLfoDeVAM9xknlvlxDBof1lJiuPYPzj5Aee8kaEDfXzVWTpfI7ssvSXhBlGVRvyd0RcJA== + dependencies: + "@dcl/catalyst-contracts" "^4.4.0" + "@dcl/crypto" "^3.4.0" + "@dcl/hashing" "^3.0.0" + "@dcl/schemas" "^11.5.0" + "@well-known-components/fetch-component" "^2.0.0" + cookie "^0.5.0" + cross-fetch "^3.1.5" + form-data "^4.0.0" + debug@^3.2.7: version "3.2.7" resolved "https://registry.yarnpkg.com/debug/-/debug-3.2.7.tgz#72580b7e9145fb39b6676f9c5e5fb100b934179a"