From 96d5e27d4a9584a37cc02653306f51e17b7a4119 Mon Sep 17 00:00:00 2001 From: pedrotambo Date: Thu, 16 Mar 2023 16:17:14 -0300 Subject: [PATCH] break: redesign (#125) * wip * wip * fix * wip * wip * wip * fix * fix * names * test: add tests for GET /users/:address/wearables * add tests for definitions * add tests for error cases on GET /users/:address/wearables * add cache tests for wearables endpoint * chore: emotes (#122) * chore: add emotes just duplicated all logic from wearables * chore: abstraction in definitions-fetcher * chore: abstract wearablesFetcher and emotesFetcher in itemsFetcher * chore: fix tests * fix: tests * chore: item-fetcher queries record * test: add emotes integration tests (#124) * chore: create test components with local fetch and the graph mock to avoid external calls * test: add emote integration tests * chore: add types to test function * chore: add integration tests for names * chore: change ropsten to goerli * feat: add LANDs endpoint * test: add lands integration tests * chore: remove old definitions logic * chore: delete nfts.ts unused code * chore: remove commented code * chore: readd old endpoints (#127) * chore: re add old endpoints * chore: small stuff * chore: remove non used functions and dont export functions * chore: stuff * chore: stuff --------- Co-authored-by: Hugo Arregui Co-authored-by: Alejo Thomas Ortega --- package.json | 3 +- src/adapters/definitions-fetcher.ts | 52 + src/adapters/definitions.ts | 1 + src/adapters/items-fetcher.ts | 160 ++++ src/adapters/lands-fetcher.ts | 181 ++++ src/adapters/names-fetcher.ts | 131 +++ src/adapters/nfts.ts | 133 --- src/adapters/third-party-wearables-fetcher.ts | 261 +++++ src/components.ts | 45 +- src/controllers/handlers/emotes-handler.ts | 92 +- src/controllers/handlers/lands-handler.ts | 57 +- src/controllers/handlers/names-handler.ts | 57 +- .../handlers/old-wearables-handler.ts | 718 ++++++++++++++ src/controllers/handlers/wearables-handler.ts | 271 +++++- src/controllers/routes.ts | 67 +- src/logic/definitions.ts | 58 -- src/logic/emotes.ts | 197 ---- src/logic/lands.ts | 157 --- src/logic/names.ts | 129 --- src/logic/ownership.ts | 1 + src/logic/profiles.ts | 46 +- src/logic/third-party-wearables.ts | 92 +- src/logic/utils.ts | 37 + src/logic/wearables.ts | 290 ------ src/ports/content.ts | 14 +- .../wearables-ownership-checker.ts | 3 +- src/ports/the-graph.ts | 30 +- src/ports/wearables-caches.ts | 37 - src/types.ts | 136 +-- test/components.ts | 47 +- test/data/emotes.ts | 42 + test/data/lands.ts | 41 + test/data/names.ts | 18 + test/data/wearables.ts | 42 + test/integration/emotes-handler.spec.ts | 248 +++++ test/integration/lands-handler.spec.ts | 153 +++ test/integration/names-handler.spec.ts | 145 +++ test/integration/profiles-controller.spec.ts | 895 +++++++++++------- test/integration/wearables-handler.spec.ts | 368 +++++++ test/mocks/content-mock.ts | 11 + test/mocks/the-graph-mock.ts | 28 + test/unit/ownership.spec.ts | 32 +- test/unit/pagination.spec.ts | 28 + yarn.lock | 351 ++++++- 44 files changed, 4228 insertions(+), 1677 deletions(-) create mode 100644 src/adapters/definitions-fetcher.ts create mode 100644 src/adapters/items-fetcher.ts create mode 100644 src/adapters/lands-fetcher.ts create mode 100644 src/adapters/names-fetcher.ts delete mode 100644 src/adapters/nfts.ts create mode 100644 src/adapters/third-party-wearables-fetcher.ts create mode 100644 src/controllers/handlers/old-wearables-handler.ts delete mode 100644 src/logic/definitions.ts delete mode 100644 src/logic/emotes.ts delete mode 100644 src/logic/lands.ts delete mode 100644 src/logic/names.ts create mode 100644 src/logic/utils.ts delete mode 100644 src/logic/wearables.ts delete mode 100644 src/ports/wearables-caches.ts create mode 100644 test/data/emotes.ts create mode 100644 test/data/lands.ts create mode 100644 test/data/names.ts create mode 100644 test/data/wearables.ts create mode 100644 test/integration/emotes-handler.spec.ts create mode 100644 test/integration/lands-handler.spec.ts create mode 100644 test/integration/names-handler.spec.ts create mode 100644 test/integration/wearables-handler.spec.ts create mode 100644 test/mocks/content-mock.ts create mode 100644 test/mocks/the-graph-mock.ts create mode 100644 test/unit/pagination.spec.ts diff --git a/package.json b/package.json index c6333d09..e2fd9efa 100644 --- a/package.json +++ b/package.json @@ -14,6 +14,7 @@ "@types/lru-cache": "^7.10.10", "@types/node": "^18.11.18", "@well-known-components/test-helpers": "^1.4.0", + "ethereumjs-wallet": "^1.0.2", "typescript": "^4.9.5" }, "prettier": { @@ -33,7 +34,7 @@ "@well-known-components/logger": "^3.0.0", "@well-known-components/metrics": "^2.0.1", "@well-known-components/thegraph-component": "^1.4.1", - "dcl-catalyst-client": "^12.0.1", + "dcl-catalyst-client": "^14.0.9", "dcl-catalyst-commons": "9.0.16", "lodash": "^4.17.21", "lru-cache": "^7.14.1" diff --git a/src/adapters/definitions-fetcher.ts b/src/adapters/definitions-fetcher.ts new file mode 100644 index 00000000..c7d49b70 --- /dev/null +++ b/src/adapters/definitions-fetcher.ts @@ -0,0 +1,52 @@ +import LRU from 'lru-cache' +import { IBaseComponent } from '@well-known-components/interfaces' +import { AppComponents, Definition } from '../types' +import { extractEmoteDefinitionFromEntity, extractWearableDefinitionFromEntity } from './definitions' +import { Entity } from '@dcl/schemas' + +export type DefinitionsFetcher = IBaseComponent & { + fetchWearablesDefinitions(urns: string[]): Promise<(Definition | undefined)[]> + fetchEmotesDefinitions(urns: string[]): Promise<(Definition | undefined)[]> +} + +export async function createDefinitionsFetcherComponent({ + config, + content +}: Pick): Promise { + const itemsSize = (await config.getNumber('ITEMS_CACHE_MAX_SIZE')) ?? 1000 + const itemsAge = (await config.getNumber('ITEMS_CACHE_MAX_AGE')) ?? 600000 // 10 minutes by default + + // TODO create lower case cache, get/set wrapped to set the key to lowercase + const itemDefinitionsCache = new LRU({ + max: itemsSize, + ttl: itemsAge + }) + + async function fetchItemsDefinitions( + urns: string[], + mapEntityToDefinition: (components: Pick, entity: Entity) => Definition + ): Promise<(Definition | undefined)[]> { + const nonCachedURNs: string[] = [] + for (const urn of urns) { + const definition = itemDefinitionsCache.get(urn.toLowerCase()) + if (!definition) { + nonCachedURNs.push(urn) + } + } + + if (nonCachedURNs.length !== 0) { + const entities = await content.fetchEntitiesByPointers(nonCachedURNs) + for (const entity of entities) { + const definition = mapEntityToDefinition({ content }, entity) + itemDefinitionsCache.set(definition.id.toLowerCase(), definition) + } + } + + return urns.map((urn) => itemDefinitionsCache.get(urn.toLowerCase())) + } + + return { + fetchWearablesDefinitions: (urns) => fetchItemsDefinitions(urns, extractWearableDefinitionFromEntity), + fetchEmotesDefinitions: (urns) => fetchItemsDefinitions(urns, extractEmoteDefinitionFromEntity) + } +} diff --git a/src/adapters/definitions.ts b/src/adapters/definitions.ts index 1feae9a0..0fd6d095 100644 --- a/src/adapters/definitions.ts +++ b/src/adapters/definitions.ts @@ -2,6 +2,7 @@ import { EmoteCategory, Entity } from '@dcl/schemas' import { AppComponents } from '../types' export function extractWearableDefinitionFromEntity(components: Pick, entity: Entity) { + // TODO: metadata can be null according to the schema, should we add a check before access? const metadata = entity.metadata const representations = metadata.data.representations.map((representation: any) => mapRepresentation(components, representation, entity) diff --git a/src/adapters/items-fetcher.ts b/src/adapters/items-fetcher.ts new file mode 100644 index 00000000..1aa549cf --- /dev/null +++ b/src/adapters/items-fetcher.ts @@ -0,0 +1,160 @@ +import LRU from 'lru-cache' +import { AppComponents, Limits, ItemsResult, Item, ItemFetcher, ItemFetcherError, ItemFetcherErrorCode } from '../types' +import { ISubgraphComponent } from '@well-known-components/thegraph-component' +import { compareByRarity } from '../logic/utils' + +const THE_GRAPH_PAGE_SIZE = 1000 + +// TODO cache metrics +type ItemsQueryResponse = { + nfts: ItemFromQuery[] +} + +export type ItemFromQuery = { + urn: string + id: string + tokenId: string + transferredAt: number + item: { + rarity: string + price: number + } +} + +const QUERIES: Record = { + emote: createQueryForCategory('emote'), + wearable: createQueryForCategory('wearable') +} + +function groupItemsByURN(items: ItemFromQuery[]): Item[] { + const itemsByURN = new Map() + + items.forEach((item) => { + const individualData = { + id: item.id, + tokenId: item.tokenId, + transferredAt: item.transferredAt, + price: item.item.price + } + if (itemsByURN.has(item.urn)) { + const itemFromMap = itemsByURN.get(item.urn)! + itemFromMap.individualData.push(individualData) + itemFromMap.amount = itemFromMap.amount + 1 + } else { + itemsByURN.set(item.urn, { + urn: item.urn, + individualData: [individualData], + rarity: item.item.rarity, + amount: 1 + }) + } + }) + + return Array.from(itemsByURN.values()) +} + +type ItemCategory = 'wearable' | 'emote' + +function createQueryForCategory(category: ItemCategory) { + return `query fetchItemsByOwner($owner: String, $idFrom: String) { + nfts( + where: { id_gt: $idFrom, owner: $owner, category: "${category}"}, + orderBy: id, + orderDirection: asc, + first: ${THE_GRAPH_PAGE_SIZE} + ) { + urn, + id, + tokenId, + category + transferredAt, + item { + rarity, + price + } + } + }` +} + +async function runItemsQuery( + subgraph: ISubgraphComponent, + address: string, + category: ItemCategory +): Promise { + const items = [] + const owner = address.toLowerCase() + let idFrom = '' + let result: ItemsQueryResponse + const query = QUERIES[category] + do { + result = await subgraph.query(query, { + owner, + idFrom + }) + + if (result.nfts.length === 0) { + break + } + + for (const nft of result.nfts) { + items.push(nft) + } + + idFrom = items[items.length - 1].id + } while (result.nfts.length === THE_GRAPH_PAGE_SIZE) + return items +} + +export async function createWearableFetcherComponent(components: Pick) { + return createItemFetcherComponent(components, 'wearable', true) +} + +export async function createEmoteFetcherComponent(components: Pick) { + return createItemFetcherComponent(components, 'emote', false) +} + +async function createItemFetcherComponent( + { config, theGraph, logs }: Pick, + category: ItemCategory, + includeEthereum: boolean +): Promise { + const itemsSize = (await config.getNumber('ITEMS_CACHE_MAX_SIZE')) ?? 1000 + const itemsAge = (await config.getNumber('ITEMS_CACHE_MAX_AGE')) ?? 600000 // 10 minutes by default + const logger = logs.getLogger(`${category}-fetcher`) + + const cache = new LRU({ + max: itemsSize, + ttl: itemsAge, + fetchMethod: async function (address: string, staleValue: Item[]) { + try { + const [ethereumItems, maticItems] = await Promise.all([ + includeEthereum + ? runItemsQuery(theGraph.ethereumCollectionsSubgraph, address, category) + : ([] as ItemFromQuery[]), + runItemsQuery(theGraph.maticCollectionsSubgraph, address, category) + ]) + + return groupItemsByURN(ethereumItems.concat(maticItems)).sort(compareByRarity) + } catch (err: any) { + logger.error(err) + return staleValue + } + } + }) + + async function fetchByOwner(address: string, { offset, limit }: Limits): Promise { + const results = await cache.fetch(address) + if (results === undefined) { + throw new ItemFetcherError(ItemFetcherErrorCode.CANNOT_FETCH_ITEMS, `Cannot fetch ${category}s for ${address}`) + } + const totalAmount = results.length + return { + items: results.slice(offset, offset + limit), + totalAmount + } + } + + return { + fetchByOwner + } +} diff --git a/src/adapters/lands-fetcher.ts b/src/adapters/lands-fetcher.ts new file mode 100644 index 00000000..754e1227 --- /dev/null +++ b/src/adapters/lands-fetcher.ts @@ -0,0 +1,181 @@ +import LRU from 'lru-cache' +import { IBaseComponent } from '@well-known-components/interfaces' +import { AppComponents, Limits } from '../types' +import { ISubgraphComponent } from '@well-known-components/thegraph-component' + +export type LANDsResult = { + lands: LAND[] + totalAmount: number +} + +export type LANDsFetcher = IBaseComponent & { + fetchByOwner(address: string, limits: Limits): Promise +} + +export enum LANDsFetcherErrorCode { + CANNOT_FETCH_LANDS +} + +export class LANDsFetcherError extends Error { + constructor(public code: LANDsFetcherErrorCode, message: string) { + super(message) + Error.captureStackTrace(this, this.constructor) + } +} + +const THE_GRAPH_PAGE_SIZE = 1000 + +const QUERY_LANDS: string = ` + query fetchLANDsByOwner($owner: String, $idFrom: String) { + nfts( + where: { owner: $owner, category_in: [parcel, estate], id_gt: $idFrom }, + orderBy: transferredAt, + orderDirection: desc, + first: ${THE_GRAPH_PAGE_SIZE} + ) { + id + name, + contractAddress, + tokenId, + category, + parcel { + x, + y, + data { + description + } + } + estate { + data { + description + } + }, + activeOrder { + price + }, + image + } + }` + +interface LANDsQueryResponse { + nfts: LANDFromQuery[] +} + +export type LANDFromQuery = { + id: string + contractAddress: string + tokenId: string + category: string + name: string | null + parcel?: { + x: string + y: string + data?: { + description?: string + } + } + estate?: { + data?: { + description?: string + } + } + activeOrder?: { + price: string + } + image: string +} + +export type LAND = { + contractAddress: string + tokenId: string + category: string + name?: string + x?: string + y?: string + description?: string + price?: string + image?: string +} + +async function runLANDquery(subgraph: ISubgraphComponent, address: string): Promise { + const lands = [] + + const owner = address.toLowerCase() + let idFrom = '' + let result: LANDsQueryResponse + do { + result = await subgraph.query(QUERY_LANDS, { + owner, + idFrom + }) + + if (result.nfts.length === 0) { + break + } + + for (const nft of result.nfts) { + lands.push(nft) + } + + idFrom = lands[lands.length - 1].id + } while (result.nfts.length === THE_GRAPH_PAGE_SIZE) + return lands +} + +export async function createLANDsFetcherComponent({ + theGraph, + logs +}: Pick): Promise { + const logger = logs.getLogger('lands-fetcher') + + const cache = new LRU({ + max: 1000, + ttl: 600000, // 10 minutes + fetchMethod: async function (address: string, staleValue: LAND[]) { + try { + const lands = await runLANDquery(theGraph.ensSubgraph, address) + + return lands.map((land) => { + const { name, contractAddress, tokenId, category, parcel, estate, image, activeOrder } = land + + const isParcel = category === 'parcel' + const x = isParcel ? parcel?.x : undefined + const y = isParcel ? parcel?.x : undefined + const description = isParcel ? parcel?.data?.description : estate?.data?.description + return { + name: name === null ? undefined : name, + contractAddress, + tokenId, + category, + x, + y, + description, + price: activeOrder ? activeOrder.price : undefined, + image + } + }) + } catch (err: any) { + logger.error(err) + return staleValue + } + } + }) + + async function fetchByOwner(address: string, { offset, limit }: Limits): Promise { + const lands = await cache.fetch(address) + + if (lands === undefined) { + throw new LANDsFetcherError(LANDsFetcherErrorCode.CANNOT_FETCH_LANDS, `Cannot fetch lands for ${address}`) + } + + const totalAmount = lands.length + return { + lands: lands.slice(offset, offset + limit), + totalAmount + } + } + + return { + fetchByOwner + } +} diff --git a/src/adapters/names-fetcher.ts b/src/adapters/names-fetcher.ts new file mode 100644 index 00000000..1655a1b4 --- /dev/null +++ b/src/adapters/names-fetcher.ts @@ -0,0 +1,131 @@ +import LRU from 'lru-cache' +import { IBaseComponent } from '@well-known-components/interfaces' +import { AppComponents, Limits, Name } from '../types' +import { ISubgraphComponent } from '@well-known-components/thegraph-component' + +export type NamesResult = { + names: Name[] + totalAmount: number +} + +export type NamesFetcher = IBaseComponent & { + fetchByOwner(address: string, limits: Limits): Promise +} + +export enum NamesFetcherErrorCode { + CANNOT_FETCH_NAMES +} + +export class NamesFetcherError extends Error { + constructor(public code: NamesFetcherErrorCode, message: string) { + super(message) + Error.captureStackTrace(this, this.constructor) + } +} + +const THE_GRAPH_PAGE_SIZE = 1000 + +const QUERY_NAMES_PAGINATED: string = ` + query fetchNamesByOwner($owner: String, $idFrom: String) { + nfts( + where: {owner: $owner, category: "ens", id_gt: $idFrom } + orderBy: id, + orderDirection: asc, + first: ${THE_GRAPH_PAGE_SIZE} + ) { + id, + name, + contractAddress, + tokenId, + activeOrder { + price + } + } +}` + +interface NamesQueryResponse { + nfts: NameFromQuery[] +} + +export type NameFromQuery = { + id: string + name: string + contractAddress: string + tokenId: string + activeOrder?: { + price: string + } +} + +async function runNamesQuery(subgraph: ISubgraphComponent, address: string): Promise { + const names = [] + + const owner = address.toLowerCase() + let idFrom = '' + let result: NamesQueryResponse + do { + result = await subgraph.query(QUERY_NAMES_PAGINATED, { + owner, + idFrom + }) + + if (result.nfts.length === 0) { + break + } + + for (const nft of result.nfts) { + names.push(nft) + } + + idFrom = names[names.length - 1].id + } while (result.nfts.length === THE_GRAPH_PAGE_SIZE) + return names +} + +export async function createNamesFetcherComponent({ + theGraph, + logs +}: Pick): Promise { + const logger = logs.getLogger('names-fetcher') + + const cache = new LRU({ + max: 1000, + ttl: 600000, // 10 minutes + fetchMethod: async function (address: string, staleValue: Name[]) { + try { + const names = await runNamesQuery(theGraph.ensSubgraph, address) + + return names.map((n) => { + const { name, contractAddress, tokenId, activeOrder } = n + return { + name, + contractAddress, + tokenId, + price: activeOrder ? activeOrder.price : undefined + } + }) + } catch (err: any) { + logger.error(err) + return staleValue + } + } + }) + + async function fetchByOwner(address: string, { offset, limit }: Limits): Promise { + const names = await cache.fetch(address) + + if (names === undefined) { + throw new NamesFetcherError(NamesFetcherErrorCode.CANNOT_FETCH_NAMES, `Cannot fetch names for ${address}`) + } + + const totalAmount = names.length + return { + names: names.slice(offset, offset + limit), + totalAmount + } + } + + return { + fetchByOwner + } +} diff --git a/src/adapters/nfts.ts b/src/adapters/nfts.ts deleted file mode 100644 index ef18daf7..00000000 --- a/src/adapters/nfts.ts +++ /dev/null @@ -1,133 +0,0 @@ -import { - EmoteFromQuery, - LandForResponse, - LandFromQuery, - NameForResponse, - NameFromQuery, - EmoteForResponse, - WearableFromQuery, - WearableForResponse, - ThirdPartyAsset, - WearableForCache -} from '../types' - -/* - * Adapts the result from the wearables query to the desired schema for the cache - */ -export function transformWearableFromQueryToWearableForCache(wearable: WearableFromQuery): WearableForCache { - return { - urn: wearable.urn, - individualData: [ - { - id: wearable.id, - tokenId: wearable.tokenId, - transferredAt: wearable.transferredAt, - price: wearable.item.price - } - ], - rarity: wearable.item.rarity, - amount: 1 - } -} - -/* - * Excludes the rarity field since it's already present in the definition field - */ -export function transformWearableForCacheToWearableForResponse(wearable: WearableForCache): WearableForResponse { - return { - urn: wearable.urn, - individualData: wearable.individualData, - amount: wearable.amount - } -} - -/* - * Adapts the result from the emotes query to the desired schema for the response - */ -export function transformEmoteToResponseSchema(emote: EmoteFromQuery): EmoteForResponse { - return { - urn: emote.urn, - // id: emote.id, - // contractAddress: emote.contractAddress, - // tokenId: emote.tokenId, - // image: emote.image, - // transferredAt: emote.transferredAt, - // name: emote.item.metadata.emote.name, - // description: emote.item.metadata.emote.description, - // rarity: emote.item.rarity, - // price: emote.item.price - amount: 1 - } -} - -/* - * Adapts the result from the names query to the desired schema for the response - */ -export function transformNameToResponseSchema(name: NameFromQuery): NameForResponse { - // Set price depending on activeOrder. It could be null if is not at sale - let price = null - if (name.activeOrder) price = name.activeOrder.price - - return { - name: name.name, - contractAddress: name.contractAddress, - tokenId: name.tokenId, - price: price - } -} - -/* - * Adapts the result from the lands query to the desired schema for the response - */ -export function transformLandToResponseSchema(land: LandFromQuery): LandForResponse { - // Set price depending on activeOrder. It could be null if is not at sale - let price = null - if (land.activeOrder) price = land.activeOrder.price - - // Set category dependent fields - let x, y, description - if (land.category === 'parcel') { - x = land.parcel.x - y = land.parcel.y - if (land.parcel.data) description = land.parcel.data.description - } else if (land.category === 'estate') { - if (land.estate.data) description = land.estate.data.description - } - - return { - name: land.name, - contractAddress: land.contractAddress, - tokenId: land.tokenId, - category: land.category, - x: x, - y: y, - description: description, - price: price, - image: land.image - } -} - -/* - * Adapts the response from a third-party resolver to /nfts/wearables endpoint response - */ -export function transformThirdPartyAssetToWearableForCache(asset: ThirdPartyAsset): WearableForCache { - return { - urn: asset.urn.decentraland, - individualData: [ - { - id: asset.id - } - ], - amount: 1 - } -} - -/* - * Adapts the response from a third-party resolver to /nfts/emotes endpoint response - */ -export function transformThirdPartyAssetToEmoteForResponse(asset: ThirdPartyAsset): EmoteForResponse { - return { - urn: asset.urn.decentraland, - amount: 1 - } -} diff --git a/src/adapters/third-party-wearables-fetcher.ts b/src/adapters/third-party-wearables-fetcher.ts new file mode 100644 index 00000000..fb5ea05f --- /dev/null +++ b/src/adapters/third-party-wearables-fetcher.ts @@ -0,0 +1,261 @@ +import LRU from 'lru-cache' +import { IBaseComponent } from '@well-known-components/interfaces' +import { AppComponents, Limits, ThirdPartyAsset, ThirdPartyWearable } from '../types' +import { BlockchainCollectionThirdPartyCollection } from '@dcl/urn-resolver' +import { findAsync, parseUrn } from '../logic/utils' + +// TODO cache metrics + +const URN_THIRD_PARTY_NAME_TYPE = 'blockchain-collection-third-party-name' +const URN_THIRD_PARTY_ASSET_TYPE = 'blockchain-collection-third-party' + +export enum ThirdPartyFetcherErrorCode { + CANNOT_LOAD_THIRD_PARTY_WEARABLES, + THIRD_PARTY_NOT_FOUND +} + +export class ThirdPartyFetcherError extends Error { + constructor(public code: ThirdPartyFetcherErrorCode, message: string) { + super(message) + Error.captureStackTrace(this, this.constructor) + } +} + +type ThirdPartyAssets = { + address: string + total: number + page: number + assets: ThirdPartyAsset[] + next?: string +} + +export type ThirdPartyWearablesResult = { + wearables: ThirdPartyWearable[] + totalAmount: number +} + +export type ThirdPartyWearablesFetcher = IBaseComponent & { + fetchByOwner(address: string, limits: Limits): Promise + fetchCollectionByOwner( + address: string, + collectionUrn: BlockchainCollectionThirdPartyCollection, + limits: Limits + ): Promise +} + +const QUERY_ALL_THIRD_PARTY_RESOLVERS = ` +{ + thirdParties(where: {isApproved: true}) { + id, + resolver + } +} +` +// Example: +// "thirdParties": [ +// { +// "id": "urn:decentraland:matic:collections-thirdparty:baby-doge-coin", +// "resolver": "https://decentraland-api.babydoge.com/v1" +// }, +// { +// "id": "urn:decentraland:matic:collections-thirdparty:cryptoavatars", +// "resolver": "https://api.cryptoavatars.io/" +// }, +// { +// "id": "urn:decentraland:matic:collections-thirdparty:dolcegabbana-disco-drip", +// "resolver": "https://wearables-api.unxd.com" +// } +// ] + +type ThirdPartyResolversResponse = { + thirdParties: ThirdParty[] +} + +type ThirdParty = { + id: string + resolver: string +} + +function groupThirdPartyWearablesByURN(assets: ThirdPartyAsset[]): ThirdPartyWearable[] { + const wearablesByURN = new Map() + + for (const asset of assets) { + if (wearablesByURN.has(asset.urn.decentraland)) { + const wearableFromMap = wearablesByURN.get(asset.urn.decentraland)! + wearableFromMap.individualData.push({ id: asset.id }) + wearableFromMap.amount = wearableFromMap.amount + 1 + } else { + wearablesByURN.set(asset.urn.decentraland, { + urn: asset.urn.decentraland, + individualData: [ + { + id: asset.id + } + ], + amount: 1 + }) + } + } + + return Array.from(wearablesByURN.values()) +} + +export async function createThirdPartyWearablesFetcherComponent({ + config, + logs, + theGraph, + fetch +}: Pick): Promise { + const wearablesSize = (await config.getNumber('WEARABLES_CACHE_MAX_SIZE')) ?? 1000 + const wearablesAge = (await config.getNumber('WEARABLES_CACHE_MAX_AGE')) ?? 600000 // 10 minutes by default + const logger = logs.getLogger('third-party-wearables-fetcher') + + const thirdPartiesCache = new LRU({ + max: 1, + ttl: 1000 * 60 * 60 * 6, // 6 hours + fetchMethod: async function (_: number, staleValue: ThirdParty[]) { + try { + const tpProviders = ( + await theGraph.thirdPartyRegistrySubgraph.query( + QUERY_ALL_THIRD_PARTY_RESOLVERS, + {} + ) + ).thirdParties + return tpProviders + } catch (err: any) { + logger.error(err) + return staleValue + } + } + }) + + async function fetchAssets(owner: string, thirdParty: ThirdParty): Promise { + const urn = await parseUrn(thirdParty.id) + if (!urn || urn.type !== URN_THIRD_PARTY_NAME_TYPE) { + throw new Error(`Couldn't parse third party id: ${thirdParty.id}`) + } + + const baseUrl = new URL(thirdParty.resolver).href.replace(/\/$/, '') + let url: string | undefined = `${baseUrl}/registry/${urn.thirdPartyName}/address/${owner}/assets` + + const allAssets: ThirdPartyAsset[] = [] + try { + do { + const response = await fetch.fetch(url, { timeout: 5000 }) + if (!response.ok) { + logger.error(`Http status ${response.status} from ${url}`) + break + } + const responseVal = await response.json() + const assetsByOwner = responseVal as ThirdPartyAssets + if (!assetsByOwner) { + logger.error(`No assets found with owner: ${owner}, url: ${url}`) + break + } + + for (const asset of assetsByOwner.assets ?? []) { + allAssets.push(asset) + } + + url = assetsByOwner.next + } while (url) + } catch (err) { + logger.error(`Error fetching assets with owner: ${owner}, url: ${url}`) + } + + return allAssets + } + + const cache = new LRU({ + max: wearablesSize, + ttl: wearablesAge, + fetchMethod: async function (owner: string, _staleValue: ThirdPartyWearable[]) { + const thirdParties = thirdPartiesCache.get(0)! + + // TODO: test if stateValue is keept in case of an exception + const thirdPartyAssets = await Promise.all( + thirdParties.map((thirdParty: ThirdParty) => fetchAssets(owner, thirdParty)) + ) + + return groupThirdPartyWearablesByURN(thirdPartyAssets.flat()) + } + }) + + async function start() { + await thirdPartiesCache.fetch(0) + } + + async function fetchByOwner(address: string, { offset, limit }: Limits): Promise { + const results = await cache.fetch(address) + if (results === undefined) { + throw new ThirdPartyFetcherError( + ThirdPartyFetcherErrorCode.CANNOT_LOAD_THIRD_PARTY_WEARABLES, + `Cannot load third party wearables for ${address}` + ) + } + const totalAmount = results.length + return { + wearables: results.slice(offset, offset + limit), + totalAmount + } + } + + async function fetchCollectionByOwner( + address: string, + collectionUrn: BlockchainCollectionThirdPartyCollection, + { offset, limit }: Limits + ): Promise { + let results: ThirdPartyWearable[] = [] + + const allWearables = cache.get(address) + if (allWearables) { + // NOTE: if third party wearables are in cache + for (const wearable of allWearables) { + const wearableUrn = await parseUrn(wearable.urn) + if ( + wearableUrn && + wearableUrn.type === URN_THIRD_PARTY_ASSET_TYPE && + wearableUrn.collectionId === collectionUrn.collectionId && + wearableUrn.thirdPartyName === collectionUrn.thirdPartyName + ) { + results.push(wearable) + } + } + } + const thirdParty = await findAsync( + (await thirdPartiesCache.fetch(0))!, + async (thirdParty: ThirdParty): Promise => { + const urn = await parseUrn(thirdParty.id) + return !!urn && urn.type === URN_THIRD_PARTY_NAME_TYPE && urn.thirdPartyName === collectionUrn.thirdPartyName + } + ) + + if (!thirdParty) { + // NOTE: currently lambdas return an empty array with status code 200 for this case + throw new ThirdPartyFetcherError( + ThirdPartyFetcherErrorCode.THIRD_PARTY_NOT_FOUND, + `Third Party not found ${collectionUrn.thirdPartyName}` + ) + } + + const assets = await fetchAssets(address, thirdParty) + results = groupThirdPartyWearablesByURN( + assets.filter((asset: ThirdPartyAsset) => { + const [collectionId, _] = asset.id.split(':') + return collectionId === collectionUrn.collectionId + }) + ) + + const totalAmount = results.length + return { + wearables: results.slice(offset, offset + limit), + totalAmount + } + } + + return { + start, + fetchByOwner, + fetchCollectionByOwner + } +} diff --git a/src/components.ts b/src/components.ts index dfcb8fe9..291392ed 100644 --- a/src/components.ts +++ b/src/components.ts @@ -1,18 +1,26 @@ import { createDotEnvConfigComponent } from '@well-known-components/env-config-provider' -import { createServerComponent, createStatusCheckComponent } from '@well-known-components/http-server' +import { createServerComponent, createStatusCheckComponent, IFetchComponent } from '@well-known-components/http-server' import { createLogComponent } from '@well-known-components/logger' import { createFetchComponent } from './ports/fetch' import { createMetricsComponent, instrumentHttpServerWithMetrics } from '@well-known-components/metrics' import { AppComponents, GlobalContext } from './types' import { metricDeclarations } from './metrics' -import { createTheGraphComponent } from './ports/the-graph' +import { createTheGraphComponent, TheGraphComponent } from './ports/the-graph' import { createContentComponent } from './ports/content' import { createOwnershipCachesComponent } from './ports/ownership-caches' -import { createWearablesCachesComponent } from './ports/wearables-caches' import { createEmotesCachesComponent } from './ports/emotes-caches' +import { createDefinitionsFetcherComponent } from './adapters/definitions-fetcher' +import { createThirdPartyWearablesFetcherComponent } from './adapters/third-party-wearables-fetcher' +import { createEmoteFetcherComponent, createWearableFetcherComponent } from './adapters/items-fetcher' +import { createNamesFetcherComponent } from './adapters/names-fetcher' +import { createLANDsFetcherComponent } from './adapters/lands-fetcher' +import { createWearablesCachesComponent } from './controllers/handlers/old-wearables-handler' // Initialize all the components of the app -export async function initComponents(): Promise { +export async function initComponents( + fetchComponent?: IFetchComponent, + theGraphComponent?: TheGraphComponent +): Promise { const config = await createDotEnvConfigComponent({ path: ['.env.default', '.env'] }) const logs = await createLogComponent({}) const server = await createServerComponent( @@ -24,23 +32,28 @@ export async function initComponents(): Promise { } ) const statusChecks = await createStatusCheckComponent({ server, config }) - const fetch = await createFetchComponent() + const fetch = fetchComponent ? fetchComponent : await createFetchComponent() const metrics = await createMetricsComponent(metricDeclarations, { config }) await instrumentHttpServerWithMetrics({ server, metrics, config }) const content = await createContentComponent({ config }) - const theGraph = await createTheGraphComponent({ config, logs, fetch, metrics }) + const theGraph = theGraphComponent + ? theGraphComponent + : await createTheGraphComponent({ config, logs, fetch, metrics }) - // This component contains caches for ownership checking const ownershipCaches = await createOwnershipCachesComponent({ config }) + const emotesCaches = await createEmotesCachesComponent({ config }) + const wearablesFetcher = await createWearableFetcherComponent({ config, theGraph, logs }) + const thirdPartyWearablesFetcher = await createThirdPartyWearablesFetcherComponent({ config, logs, theGraph, fetch }) + const definitionsFetcher = await createDefinitionsFetcherComponent({ config, logs, content }) + const emotesFetcher = await createEmoteFetcherComponent({ config, theGraph, logs }) + const namesFetcher = await createNamesFetcherComponent({ logs, theGraph }) + const landsFetcher = await createLANDsFetcherComponent({ logs, theGraph }) - // This component contains caches for wearables checking + // old components const wearablesCaches = await createWearablesCachesComponent({ config }) - // This component contains caches for emotes checking - const emotesCaches = await createEmotesCachesComponent({ config }) - return { config, logs, @@ -51,7 +64,13 @@ export async function initComponents(): Promise { content, theGraph, ownershipCaches, - wearablesCaches, - emotesCaches + emotesCaches, + wearablesFetcher, + definitionsFetcher, + thirdPartyWearablesFetcher, + emotesFetcher, + namesFetcher, + landsFetcher, + wearablesCaches } } diff --git a/src/controllers/handlers/emotes-handler.ts b/src/controllers/handlers/emotes-handler.ts index bf3eed04..bef32236 100644 --- a/src/controllers/handlers/emotes-handler.ts +++ b/src/controllers/handlers/emotes-handler.ts @@ -1,32 +1,76 @@ -import { getEmotesForAddress, getEmotesForCollection } from '../../logic/emotes' -import { HandlerContextWithPath } from '../../types' +import { paginationObject } from '../../logic/utils' +import { + Definition, + ErrorResponse, + HandlerContextWithPath, + Item, + PaginatedResponse, + ItemFetcherError, + ItemFetcherErrorCode +} from '../../types' + +// TODO: change this name +type ItemResponse = Pick & { + definition?: Definition +} export async function emotesHandler( - context: HandlerContextWithPath<'config' | 'theGraph' | 'fetch' | 'content' | 'emotesCaches', '/nfts/emotes/:id'> -) { - // Get request params - const { id } = context.params - const pageSize = context.url.searchParams.get('pageSize') - const pageNum = context.url.searchParams.get('pageNum') - const collectionId = context.url.searchParams.get('collectionId') + context: HandlerContextWithPath<'logs' | 'emotesFetcher' | 'definitionsFetcher', '/users/:address/emotes'> +): Promise | ErrorResponse> { + const { logs, definitionsFetcher, emotesFetcher } = context.components + const { address } = context.params + const logger = logs.getLogger('emotes-handler') const includeDefinitions = context.url.searchParams.has('includeDefinitions') + const pagination = paginationObject(context.url) - let emotesResponse - if (collectionId) { - // If collectionId is present, only that collection third-party emotes are sent - emotesResponse = await getEmotesForCollection(context.components, collectionId, id, includeDefinitions) - } else { - // Get emotes response - emotesResponse = await getEmotesForAddress(context.components, id, includeDefinitions, pageSize, pageNum) - } + try { + const { totalAmount, items } = await emotesFetcher.fetchByOwner(address, pagination) + + const definitions = includeDefinitions + ? await definitionsFetcher.fetchEmotesDefinitions(items.map((item) => item.urn)) + : [] - return { - status: 200, - body: { - emotes: emotesResponse.emotes, - totalAmount: emotesResponse.totalAmount, - pageNum: pageNum, - pageSize: pageSize + const results: ItemResponse[] = [] + for (let i = 0; i < items.length; ++i) { + const { urn, amount, individualData, rarity } = items[i] + results.push({ + urn, + amount, + individualData, + rarity, + definition: includeDefinitions ? definitions[i] : undefined + }) + } + + return { + status: 200, + body: { + elements: results, + totalAmount: totalAmount, + pageNum: pagination.pageNum, + pageSize: pagination.pageSize + } + } + } catch (err: any) { + if (err instanceof ItemFetcherError) { + switch (err.code) { + case ItemFetcherErrorCode.CANNOT_FETCH_ITEMS: { + return { + status: 502, + body: { + error: 'Cannot fetch emotes right now' + } + } + } + } + } else { + logger.error(err) + return { + status: 500, + body: { + error: 'Internal Server Error' + } + } } } } diff --git a/src/controllers/handlers/lands-handler.ts b/src/controllers/handlers/lands-handler.ts index d0bc37a9..40e37d1e 100644 --- a/src/controllers/handlers/lands-handler.ts +++ b/src/controllers/handlers/lands-handler.ts @@ -1,21 +1,46 @@ -import { getLandsForAddress } from '../../logic/lands' -import { HandlerContextWithPath } from '../../types' +import { LAND, LANDsFetcherError, LANDsFetcherErrorCode } from '../../adapters/lands-fetcher' +import { paginationObject } from '../../logic/utils' +import { ErrorResponse, HandlerContextWithPath, PaginatedResponse } from '../../types' -export async function landsHandler(context: HandlerContextWithPath<'config' | 'theGraph', '/nfts/names/:id'>) { - // Get params - const { id } = context.params - const pageSize = context.url.searchParams.get('pageSize') - const pageNum = context.url.searchParams.get('pageNum') +export async function landsHandler( + context: HandlerContextWithPath<'landsFetcher' | 'logs', '/users/:address/lands'> +): Promise | ErrorResponse> { + const { address } = context.params + const { landsFetcher, logs } = context.components + const pagination = paginationObject(context.url) + const logger = logs.getLogger('lands-handler') - const landsResponse = await getLandsForAddress(context.components, id, pageSize, pageNum) - - return { - status: 200, - body: { - lands: landsResponse.lands, - totalAmount: landsResponse.totalAmount, - pageNum: pageNum, - pageSize: pageSize + try { + const { lands, totalAmount } = await landsFetcher.fetchByOwner(address, pagination) + return { + status: 200, + body: { + elements: lands, + totalAmount, + pageNum: pagination.pageNum, + pageSize: pagination.pageSize + } + } + } catch (err: any) { + if (err instanceof LANDsFetcherError) { + switch (err.code) { + case LANDsFetcherErrorCode.CANNOT_FETCH_LANDS: { + return { + status: 502, + body: { + error: 'Cannot fetch lands right now' + } + } + } + } + } else { + logger.error(err) + return { + status: 500, + body: { + error: 'Internal Server Error' + } + } } } } diff --git a/src/controllers/handlers/names-handler.ts b/src/controllers/handlers/names-handler.ts index be9c0917..ab8594ac 100644 --- a/src/controllers/handlers/names-handler.ts +++ b/src/controllers/handlers/names-handler.ts @@ -1,21 +1,46 @@ -import { getNamesForAddress } from '../../logic/names' -import { HandlerContextWithPath } from '../../types' +import { NamesFetcherError, NamesFetcherErrorCode } from '../../adapters/names-fetcher' +import { paginationObject } from '../../logic/utils' +import { ErrorResponse, HandlerContextWithPath, Name, PaginatedResponse } from '../../types' -export async function namesHandler(context: HandlerContextWithPath<'config' | 'theGraph', '/nfts/names/:id'>) { - // Get params - const { id } = context.params - const pageSize = context.url.searchParams.get('pageSize') - const pageNum = context.url.searchParams.get('pageNum') +export async function namesHandler( + context: HandlerContextWithPath<'namesFetcher' | 'logs', '/users/:address/names'> +): Promise | ErrorResponse> { + const { address } = context.params + const { namesFetcher, logs } = context.components + const pagination = paginationObject(context.url) + const logger = logs.getLogger('names-handler') - const namesResponse = await getNamesForAddress(context.components, id, pageSize, pageNum) - - return { - status: 200, - body: { - names: namesResponse.names, - totalAmount: namesResponse.totalAmount, - pageNum: pageNum, - pageSize: pageSize + try { + const { names, totalAmount } = await namesFetcher.fetchByOwner(address, pagination) + return { + status: 200, + body: { + elements: names, + totalAmount, + pageNum: pagination.pageNum, + pageSize: pagination.pageSize + } + } + } catch (err: any) { + if (err instanceof NamesFetcherError) { + switch (err.code) { + case NamesFetcherErrorCode.CANNOT_FETCH_NAMES: { + return { + status: 502, + body: { + error: 'Cannot fetch names right now' + } + } + } + } + } else { + logger.error(err) + return { + status: 500, + body: { + error: 'Internal Server Error' + } + } } } } diff --git a/src/controllers/handlers/old-wearables-handler.ts b/src/controllers/handlers/old-wearables-handler.ts new file mode 100644 index 00000000..33afa2b6 --- /dev/null +++ b/src/controllers/handlers/old-wearables-handler.ts @@ -0,0 +1,718 @@ +import { AppComponents, ThirdPartyAsset, TPWResolver, HandlerContextWithPath } from '../../types' +import { runQuery, TheGraphComponent } from '../../ports/the-graph' +import { cloneDeep } from 'lodash' +import LRU from 'lru-cache' +import { Entity, I18N } from '@dcl/schemas' + +import { EntityType } from 'dcl-catalyst-commons' +import { IBaseComponent } from '@well-known-components/interfaces' + +type UrnAndAmount = { + urn: string + amount: number +} + +type WearableFromQuery = { + urn: string + id: string + tokenId: string + transferredAt: number + item: { + rarity: string + price: number + } +} + +type ThirdPartyProvider = { + id: string + resolver: string +} + +type ThirdPartyAssets = { + address: string + total: number + page: number + assets: ThirdPartyAsset[] + next?: string +} + +type ThirdPartyResolversResponse = { + thirdParties: ThirdPartyProvider[] +} + +type WearableForCache = { + urn: string + amount: number + individualData?: { + id: string + tokenId?: string + transferredAt?: number + price?: number + }[] + rarity?: string // Rarity added in the cache to being able to sort by it. It wont be included in the response since it already appears in the definition. It's optional because third-party wearables doesn't have rarity +} + +type WearablesQueryResponse = { + nfts: WearableFromQuery[] +} + +type Definition = { + id: string + description: string + image: string + thumbnail: string + collectionAddress: string + rarity: string + createdAt: number + updatedAt: number + data: { + replaces: string[] + hides: string[] + tags: string[] + category: string + representations: Representation[] + } + i18n: I18N[] +} + +type Representation = { + bodyShapes: string[] + mainFile: string + overrideReplaces: string[] + overrideHides: string[] + contents: Content[] +} + +type Content = { + key: string + url: string +} + +type WearableForResponse = { + urn: string + amount: number + individualData?: { + id: string + tokenId?: string + transferredAt?: number + price?: number + }[] + definition?: Definition +} + +export type WearablesCachesComponent = IBaseComponent & { + dclWearablesCache: LRU + thirdPartyWearablesCache: LRU + definitionsCache: LRU +} + +export async function createWearablesCachesComponent( + components: Pick +): Promise { + const { config } = components + + const wearablesSize = parseInt((await config.getString('WEARABLES_CACHE_MAX_SIZE')) ?? '1000') + const wearablesAge = parseInt((await config.getString('WEARABLES_CACHE_MAX_AGE')) ?? '600000') // 10 minutes by default + + const dclWearablesCache: LRU = new LRU({ max: wearablesSize, ttl: wearablesAge }) + const thirdPartyWearablesCache: LRU = new LRU({ + max: wearablesSize, + ttl: wearablesAge + }) + const definitionsCache: LRU = new LRU({ max: wearablesSize, ttl: wearablesAge }) + + async function start() {} + + async function stop() {} + + return { + dclWearablesCache, + thirdPartyWearablesCache, + definitionsCache, + start, + stop + } +} + +async function createThirdPartyResolverForCollection( + components: Pick, + collectionId: string +): Promise { + // Parse collection Id + const { thirdPartyId, registryId } = parseCollectionId(collectionId) + + // Get resolver + const thirdPartyResolverAPI = await findThirdPartyResolver(components, thirdPartyId) + if (!thirdPartyResolverAPI) throw new Error(`Could not find third party resolver for collectionId: ${collectionId}`) + + return { + findThirdPartyAssetsByOwner: async (owner) => { + const assetsByOwner = await fetchAssets(components, thirdPartyResolverAPI, registryId, owner) + if (!assetsByOwner) throw new Error(`Could not fetch assets for owner: ${owner}`) + return assetsByOwner?.filter((asset) => asset.urn.decentraland.startsWith(thirdPartyId)) ?? [] + } + } +} + +export async function oldWearablesHandler( + context: HandlerContextWithPath< + 'config' | 'theGraph' | 'wearablesCaches' | 'fetch' | 'content', + '/nfts/wearables/:id' + > +) { + // Get request params + const { id } = context.params + const includeTPW = context.url.searchParams.has('includeThirdParty') + const includeDefinitions = context.url.searchParams.has('includeDefinitions') + const pageSize = context.url.searchParams.get('pageSize') + const pageNum = context.url.searchParams.get('pageNum') + const orderBy = context.url.searchParams.get('orderBy') + const collectionId = context.url.searchParams.get('collectionId') + + let wearablesResponse + if (collectionId) { + // If collectionId is present, only that collection third-party wearables are sent + wearablesResponse = await getWearablesForCollection(context.components, collectionId, id, includeDefinitions) + } else { + // Get full cached wearables response + wearablesResponse = await getWearablesForAddress( + context.components, + id, + includeTPW, + includeDefinitions, + pageSize, + pageNum, + orderBy + ) + } + + return { + status: 200, + body: { + wearables: wearablesResponse.wearables, + totalAmount: wearablesResponse.totalAmount, + pageNum: pageNum, + pageSize: pageSize + } + } +} + +function parseCollectionId(collectionId: string): { thirdPartyId: string; registryId: string } { + const parts = collectionId.split(':') + + // TODO: [TPW] Use urn parser here + if (!(parts.length === 5 || parts.length === 6)) { + throw new Error(`Couldn't parse collectionId ${collectionId}, valid ones are like: + \n - urn:decentraland:{protocol}:collections-thirdparty:{third-party-name} + \n - urn:decentraland:{protocol}:collections-thirdparty:{third-party-name}:{collection-id}`) + } + + return { + thirdPartyId: parts.slice(0, 5).join(':'), + registryId: parts[4] + } +} + +/** + * Returns the third party resolver API to be used to query assets from any collection + * of given third party integration + */ +async function findThirdPartyResolver( + components: Pick, + id: string +): Promise { + const queryResponse = await runQuery<{ thirdParties: [{ resolver: string }] }>( + components.theGraph.thirdPartyRegistrySubgraph, + QUERY_THIRD_PARTY_RESOLVER, + { id } + ) + return queryResponse.thirdParties[0]?.resolver +} + +const QUERY_THIRD_PARTY_RESOLVER = ` +query ThirdPartyResolver($id: String!) { + thirdParties(where: {id: $id, isApproved: true}) { + id, + resolver + } +} +` + +async function fetchAssets( + components: Pick, + thirdPartyResolverURL: string, + registryId: string, + owner: string +) { + let baseUrl: string | undefined = buildRegistryOwnerUrl(thirdPartyResolverURL, registryId, owner) + const allAssets: ThirdPartyAsset[] = [] + try { + do { + const response = await components.fetch.fetch(baseUrl, { timeout: 5000 }) + const responseVal = await response.json() + const assetsByOwner = responseVal as ThirdPartyAssets + if (!assetsByOwner) { + console.error( + `No assets found with owner: ${owner}, url: ${thirdPartyResolverURL} and registryId: ${registryId} at ${baseUrl}` + ) + break + } + + for (const asset of assetsByOwner?.assets ?? []) { + allAssets.push(asset) + } + + baseUrl = assetsByOwner.next + } while (baseUrl) + + return allAssets + } catch (err) { + console.error( + `Error fetching assets with owner: ${owner}, url: ${thirdPartyResolverURL} and registryId: ${registryId} (${baseUrl}). ${err}` + ) + return [] + } +} + +function buildRegistryOwnerUrl(thirdPartyResolverURL: string, registryId: string, owner: string): string { + const baseUrl = new URL(thirdPartyResolverURL).href.replace(/\/$/, '') + return `${baseUrl}/registry/${registryId}/address/${owner}/assets` +} + +/* + * Returns all third-party wearables for an address + */ +async function getThirdPartyWearables(components: Pick, userAddress: string) { + const { theGraph } = components + + // Get every resolver + const tpProviders = ( + await runQuery( + theGraph.thirdPartyRegistrySubgraph, + QUERY_ALL_THIRD_PARTY_RESOLVERS, + {} + ) + ).thirdParties + + // Fetch assets asynchronously + const providersPromises = tpProviders.map((provider: ThirdPartyProvider) => { + return fetchAssets(components, provider.resolver, parseCollectionId(provider.id).registryId, userAddress) + }) + + return (await Promise.all(providersPromises)).flat() +} + +const QUERY_ALL_THIRD_PARTY_RESOLVERS = ` +{ + thirdParties(where: {isApproved: true}) { + id, + resolver + } +} +` + +/* + * Returns only third-party wearables for the specified collection id, owned by the provided address + */ +async function getWearablesForCollection( + components: Pick, + collectionId: string, + address: string, + includeDefinitions: boolean +) { + const { definitionsCache } = components.wearablesCaches + + // Get API for collection + const resolver = await createThirdPartyResolverForCollection(components, collectionId) + + // Get owned wearables for the collection + let ownedTPWForCollection = (await resolver.findThirdPartyAssetsByOwner(address)).map( + transformThirdPartyAssetToWearableForCache + ) + + // Fetch for definitions, add it to the cache and add it to each wearable in the response + if (includeDefinitions) + ownedTPWForCollection = await decorateNFTsWithDefinitionsFromCache( + ownedTPWForCollection, + components, + definitionsCache, + EntityType.EMOTE, + extractWearableDefinitionFromEntity + ) + + return { + wearables: ownedTPWForCollection, + totalAmount: ownedTPWForCollection.length + } +} + +/* + * Adapts the result from the wearables query to the desired schema for the cache + */ +function transformWearableFromQueryToWearableForCache(wearable: WearableFromQuery): WearableForCache { + return { + urn: wearable.urn, + individualData: [ + { + id: wearable.id, + tokenId: wearable.tokenId, + transferredAt: wearable.transferredAt, + price: wearable.item.price + } + ], + rarity: wearable.item.rarity, + amount: 1 + } +} + +/* + * Excludes the rarity field since it's already present in the definition field + */ +function transformWearableForCacheToWearableForResponse(wearable: WearableForCache): WearableForResponse { + return { + urn: wearable.urn, + individualData: wearable.individualData, + amount: wearable.amount + } +} + +/* + * Adapts the response from a third-party resolver to /nfts/wearables endpoint response + */ +function transformThirdPartyAssetToWearableForCache(asset: ThirdPartyAsset): WearableForCache { + return { + urn: asset.urn.decentraland, + individualData: [ + { + id: asset.id + } + ], + amount: 1 + } +} + +function extractWearableDefinitionFromEntity(components: Pick, entity: Entity) { + const metadata = entity.metadata + const representations = metadata.data.representations.map((representation: any) => + mapRepresentation(components, representation, entity) + ) + const externalImage = createExternalContentUrl(components, entity, metadata.image) + const thumbnail = createExternalContentUrl(components, entity, metadata.thumbnail)! + const image = externalImage ?? metadata.image + + return { + ...metadata, + thumbnail, + image, + data: { + ...metadata.data, + representations + } + } +} + +function mapRepresentation( + components: Pick, + metadataRepresentation: T & { contents: string[] }, + entity: Entity +): T & { contents: { key: string; url: string }[] } { + const newContents = metadataRepresentation.contents.map((fileName) => ({ + key: fileName, + url: createExternalContentUrl(components, entity, fileName)! + })) + return { + ...metadataRepresentation, + contents: newContents + } +} + +function createExternalContentUrl( + components: Pick, + entity: Entity, + fileName: string | undefined +): string | undefined { + const hash = findHashForFile(entity, fileName) + if (hash) return components.content.getExternalContentServerUrl() + `/contents/` + hash + return undefined +} + +function findHashForFile(entity: Entity, fileName: string | undefined) { + if (fileName) return entity.content?.find((item) => item.file === fileName)?.hash + return undefined +} + +/* + * Looks for the definitions of the provided emotes' urns and add them to them. + */ +async function decorateNFTsWithDefinitionsFromCache( + nfts: UrnAndAmount[], + components: Pick, + definitionsCache: LRU, + entityType: EntityType, + extractDefinitionFromEntity: (components: Pick, entity: Entity) => Definition +) { + // Get a map with the definitions from the cache and an array with the non-cached urns + const { nonCachedURNs, definitionsByURN } = getDefinitionsFromCache(nfts, definitionsCache) + + // Fetch entities for non-cached urns + let entities: Entity[] = [] + if (nonCachedURNs.length !== 0) entities = await components.content.fetchEntitiesByPointers(nonCachedURNs) + + // Translate entities to definitions + const translatedDefinitions: Definition[] = entities.map((entity) => extractDefinitionFromEntity(components, entity)) + + // Store new definitions in cache and in map + translatedDefinitions.forEach((definition) => { + definitionsCache.set(definition.id.toLowerCase(), definition) + definitionsByURN.set(definition.id.toLowerCase(), definition) + }) + + // Decorate provided nfts with definitions + return nfts.map((nft) => { + return { + ...nft, + definition: definitionsByURN.get(nft.urn) + } + }) +} + +const QUERY_WEARABLES: string = ` +{ + nfts( + where: { owner: "$owner", category: "wearable"}, + orderBy: transferredAt, + orderDirection: desc, + ) { + urn, + id, + tokenId, + transferredAt, + item { + rarity, + price + } + } +}` + +async function getWearablesForAddress( + components: Pick, + id: string, + includeTPW: boolean, + includeDefinitions: boolean, + pageSize?: string | null, + pageNum?: string | null, + orderBy?: string | null +) { + const { wearablesCaches } = components + + // Retrieve wearables for id from cache. They are stored sorted by creation date + const dclWearables = await retrieveWearablesFromCache( + wearablesCaches.dclWearablesCache, + id, + components, + getDCLWearablesToBeCached + ) + + // Retrieve third-party wearables for id from cache + let tpWearables: WearableForCache[] = [] + if (includeTPW) + tpWearables = await retrieveWearablesFromCache( + wearablesCaches.thirdPartyWearablesCache, + id, + components, + getThirdPartyWearablesToBeCached + ) + + // Concatenate both types of wearables + let allWearables = [...tpWearables, ...dclWearables] + + // Set total amount of wearables + const wearablesTotal = allWearables.length + + // Sort them by another field if specified + if (orderBy === 'rarity') allWearables = cloneDeep(allWearables).sort(compareByRarity) + + // Virtually paginate the response if required + if (pageSize && pageNum) + allWearables = allWearables.slice( + (parseInt(pageNum) - 1) * parseInt(pageSize), + parseInt(pageNum) * parseInt(pageSize) + ) + + // Transform wearables to the response schema (exclude rarity) + let wearablesForResponse = allWearables.map(transformWearableForCacheToWearableForResponse) + + // Fetch for definitions, add it to the cache and add it to each wearable in the response + if (includeDefinitions) + wearablesForResponse = await decorateNFTsWithDefinitionsFromCache( + wearablesForResponse, + components, + wearablesCaches.definitionsCache, + EntityType.WEARABLE, + extractWearableDefinitionFromEntity + ) + + return { + wearables: wearablesForResponse, + totalAmount: wearablesTotal + } +} + +async function retrieveWearablesFromCache( + wearablesCache: LRU, + id: string, + components: Pick, + getWearablesToBeCached: ( + id: string, + components: Pick, + theGraph: TheGraphComponent + ) => Promise +) { + // Try to get them from cache + let allWearables = wearablesCache.get(id) + + // If it was a miss, a queries are done and the merged response is stored + if (!allWearables) { + // Get wearables + allWearables = await getWearablesToBeCached(id, components, components.theGraph) + + // Store the in the cache + wearablesCache.set(id, allWearables) + } + return allWearables +} + +async function getDCLWearablesToBeCached(id: string, components: Pick) { + const { theGraph } = components + + // Set query + const query = QUERY_WEARABLES.replace('$owner', id.toLowerCase()) + + // Query owned wearables from TheGraph for the address + const collectionsWearables = await runQuery( + theGraph.ethereumCollectionsSubgraph, + query, + {} + ).then((response) => response.nfts) + const maticWearables = await runQuery(theGraph.maticCollectionsSubgraph, query, {}).then( + (response) => response.nfts + ) + + // Merge the wearables responses, sort them by transferred date and group them by urn + return groupWearablesByURN(collectionsWearables.concat(maticWearables)).sort(compareByTransferredAt) +} + +async function getThirdPartyWearablesToBeCached(id: string, components: Pick) { + // Get all third-party wearables + const tpWearables = await getThirdPartyWearables(components, id) + + // Group third-party wearables by urn + return groupThirdPartyWearablesByURN(tpWearables) +} + +/* + * Groups every third-party wearable with the same URN. Each of them could have a different id. + * which is stored in an array binded to the corresponding urn. Returns an array of wearables in the response format. + */ +function groupThirdPartyWearablesByURN(tpWearables: ThirdPartyAsset[]): WearableForCache[] { + // Initialize the map + const wearablesByURN = new Map() + + // Set the map with the wearables data + tpWearables.forEach((wearable) => { + if (wearablesByURN.has(wearable.urn.decentraland)) { + // The wearable was present in the map, its individual data is added to the individualData array for that wearable + const wearableFromMap = wearablesByURN.get(wearable.urn.decentraland)! + wearableFromMap?.individualData?.push({ + id: wearable.id + }) + wearableFromMap.amount = wearableFromMap.amount + 1 + } else { + // The wearable was not present in the map, it is added and its individualData array is initialized with its data + wearablesByURN.set(wearable.urn.decentraland, transformThirdPartyAssetToWearableForCache(wearable)) + } + }) + + // Return the contents of the map as an array + return Array.from(wearablesByURN.values()) +} + +/* + * Groups every wearable with the same URN. Each of them has some data which differentiates them as individuals. + * That data is stored in an array binded to the corresponding urn. Returns an array of wearables in the response format. + */ +function groupWearablesByURN(wearables: WearableFromQuery[]): WearableForCache[] { + // Initialize the map + const wearablesByURN = new Map() + + // Set the map with the wearables data + wearables.forEach((wearable) => { + if (wearablesByURN.has(wearable.urn)) { + // The wearable was present in the map, its individual data is added to the individualData array for that wearable + const wearableFromMap = wearablesByURN.get(wearable.urn)! + wearableFromMap?.individualData?.push({ + id: wearable.id, + tokenId: wearable.tokenId, + transferredAt: wearable.transferredAt, + price: wearable.item.price + }) + wearableFromMap.amount = wearableFromMap.amount + 1 + } else { + // The wearable was not present in the map, it is added and its individualData array is initialized with its data + wearablesByURN.set(wearable.urn, transformWearableFromQueryToWearableForCache(wearable)) + } + }) + + // Return the contents of the map as an array + return Array.from(wearablesByURN.values()) +} + +/* + * Try to get the definitions from cache. Present ones are retrieved as a map urn -> definition. + * Not present ones are retrieved as an array to fetch later + */ +function getDefinitionsFromCache(nfts: UrnAndAmount[], definitionsCache: LRU) { + const nonCachedURNs: string[] = [] + const definitionsByURN = new Map() + nfts.forEach((nft) => { + const definition = definitionsCache.get(nft.urn) + if (definition) { + definitionsByURN.set(nft.urn, definition) + } else { + nonCachedURNs.push(nft.urn) + } + }) + + return { nonCachedURNs, definitionsByURN } +} + +const RARITIES = ['common', 'uncommon', 'rare', 'epic', 'legendary', 'mythic', 'unique'] + +/* + * Returns a positive number if wearable1 has a lower rarity than wearable2, zero if they are equal, and a negative + * number if wearable2 has a lower rarity than wearable1. Can be used to sort wearables by rarity, descending. + * It is only aplicable when definitions are being include in the response, if it's not include it will return 0. + */ +function compareByRarity(wearable1: WearableForCache, wearable2: WearableForCache) { + if (wearable1.rarity && wearable2.rarity) { + const w1RarityValue = RARITIES.findIndex((rarity) => rarity === wearable1.rarity) + const w2RarityValue = RARITIES.findIndex((rarity) => rarity === wearable2.rarity) + return w2RarityValue - w1RarityValue + } + return 0 +} + +/* + * Returns a positive number if wearable1 is older than wearable2, zero if they are equal, and a negative + * number if wearable2 is older than wearable1. Can be used to sort wearables by creationDate, descending + */ +function compareByTransferredAt(wearable1: WearableForResponse, wearable2: WearableForResponse) { + if ( + wearable1.individualData && + wearable1.individualData[0].transferredAt && + wearable2.individualData && + wearable2.individualData[0].transferredAt + ) + return wearable2.individualData[0].transferredAt - wearable1.individualData[0].transferredAt + else return 0 +} diff --git a/src/controllers/handlers/wearables-handler.ts b/src/controllers/handlers/wearables-handler.ts index 841907a6..a0b8c4c4 100644 --- a/src/controllers/handlers/wearables-handler.ts +++ b/src/controllers/handlers/wearables-handler.ts @@ -1,46 +1,245 @@ -import { getWearablesForCollection } from '../../logic/third-party-wearables' -import { getWearablesForAddress } from '../../logic/wearables' -import { HandlerContextWithPath } from '../../types' +import { ThirdPartyFetcherError, ThirdPartyFetcherErrorCode } from '../../adapters/third-party-wearables-fetcher' +import { parseUrn, paginationObject } from '../../logic/utils' +import { + Definition, + ErrorResponse, + HandlerContextWithPath, + Item, + PaginatedResponse, + ThirdPartyWearable, + ItemFetcherError, + ItemFetcherErrorCode +} from '../../types' + +// TODO: change this name +type ItemResponse = Pick & { + definition?: Definition +} export async function wearablesHandler( + context: HandlerContextWithPath<'logs' | 'wearablesFetcher' | 'definitionsFetcher', '/users/:address/wearables'> +): Promise | ErrorResponse> { + const { logs, definitionsFetcher, wearablesFetcher } = context.components + const { address } = context.params + const logger = logs.getLogger('wearables-handler') + const includeDefinitions = context.url.searchParams.has('includeDefinitions') + const pagination = paginationObject(context.url) + + try { + const { totalAmount, items } = await wearablesFetcher.fetchByOwner(address, pagination) + + const definitions = includeDefinitions + ? await definitionsFetcher.fetchWearablesDefinitions(items.map((item) => item.urn)) + : [] + + const results: ItemResponse[] = [] + for (let i = 0; i < items.length; ++i) { + const { urn, amount, individualData, rarity } = items[i] + results.push({ + urn, + amount, + individualData, + rarity, + definition: includeDefinitions ? definitions[i] : undefined + }) + } + + return { + status: 200, + body: { + elements: results, + totalAmount: totalAmount, + pageNum: pagination.pageNum, + pageSize: pagination.pageSize + } + } + } catch (err: any) { + if (err instanceof ItemFetcherError) { + switch (err.code) { + case ItemFetcherErrorCode.CANNOT_FETCH_ITEMS: { + return { + status: 502, + body: { + error: 'Cannot fetch wearables right now' + } + } + } + } + } else { + logger.error(err) + return { + status: 500, + body: { + error: 'Internal Server Error' + } + } + } + } +} + +// TODO: change this name +type ThirdPartyWearableResponse = Pick & { + definition?: Definition +} + +export async function thirdPartyWearablesHandler( context: HandlerContextWithPath< - 'config' | 'theGraph' | 'wearablesCaches' | 'fetch' | 'content', - '/nfts/wearables/:id' + 'thirdPartyWearablesFetcher' | 'definitionsFetcher' | 'logs', + '/users/:address/third-party-wearables' > -) { - // Get request params - const { id } = context.params - const includeTPW = context.url.searchParams.has('includeThirdParty') +): Promise | ErrorResponse> { + const { thirdPartyWearablesFetcher, definitionsFetcher, logs } = context.components + const { address } = context.params + const logger = logs.getLogger('third-party-wearables-handler') const includeDefinitions = context.url.searchParams.has('includeDefinitions') - const pageSize = context.url.searchParams.get('pageSize') - const pageNum = context.url.searchParams.get('pageNum') - const orderBy = context.url.searchParams.get('orderBy') - const collectionId = context.url.searchParams.get('collectionId') - - let wearablesResponse - if (collectionId) { - // If collectionId is present, only that collection third-party wearables are sent - wearablesResponse = await getWearablesForCollection(context.components, collectionId, id, includeDefinitions) - } else { - // Get full cached wearables response - wearablesResponse = await getWearablesForAddress( - context.components, - id, - includeTPW, - includeDefinitions, - pageSize, - pageNum, - orderBy - ) + const pagination = paginationObject(context.url) + + try { + const { totalAmount, wearables } = await thirdPartyWearablesFetcher.fetchByOwner(address, pagination) + + const definitions = includeDefinitions + ? await definitionsFetcher.fetchWearablesDefinitions(wearables.map((w) => w.urn)) + : [] + + const results: ThirdPartyWearableResponse[] = [] + for (let i = 0; i < wearables.length; ++i) { + const { urn, amount, individualData } = wearables[i] + results.push({ + urn, + amount, + individualData, + definition: includeDefinitions ? definitions[i] : undefined + }) + } + + return { + status: 200, + body: { + elements: results, + totalAmount: totalAmount, + pageNum: pagination.pageNum, + pageSize: pagination.pageSize + } + } + } catch (err: any) { + if (err instanceof ThirdPartyFetcherError) { + switch (err.code) { + case ThirdPartyFetcherErrorCode.CANNOT_LOAD_THIRD_PARTY_WEARABLES: { + return { + status: 502, + body: { + error: 'Cannot fetch third parties right now' + } + } + } + case ThirdPartyFetcherErrorCode.THIRD_PARTY_NOT_FOUND: { + return { + status: 502, + body: { + error: 'Cannot fetch third parties right now' + } + } + } + } + } else { + logger.error(err) + return { + status: 500, + body: { + error: 'Internal Server Error' + } + } + } + } +} + +export async function thirdPartyCollectionWearablesHandler( + context: HandlerContextWithPath< + 'thirdPartyWearablesFetcher' | 'definitionsFetcher' | 'logs', + '/users/:address/third-party-wearables/:collectionId' + > +): Promise | ErrorResponse> { + const { thirdPartyWearablesFetcher, definitionsFetcher, logs } = context.components + const logger = logs.getLogger('third-party-collections-handler') + const { address, collectionId } = context.params + + const urn = await parseUrn(collectionId) + if (!urn) { + return { + status: 400, + body: { + error: 'Invalid collection id: not a valid URN' + } + } } - return { - status: 200, - body: { - wearables: wearablesResponse.wearables, - totalAmount: wearablesResponse.totalAmount, - pageNum: pageNum, - pageSize: pageSize + if (urn.type !== 'blockchain-collection-third-party-collection') { + return { + status: 400, + body: { + error: 'Invalid collection id: not a blockchain-collection-third-party-collection URN' + } + } + } + + const includeDefinitions = context.url.searchParams.has('includeDefinitions') + const pagination = paginationObject(context.url) + + try { + const { totalAmount, wearables } = await thirdPartyWearablesFetcher.fetchCollectionByOwner(address, urn, pagination) + + const definitions = includeDefinitions + ? await definitionsFetcher.fetchWearablesDefinitions(wearables.map((w) => w.urn)) + : [] + + const results: ThirdPartyWearableResponse[] = [] + for (let i = 0; i < wearables.length; ++i) { + const { urn, amount, individualData } = wearables[i] + results.push({ + urn, + amount, + individualData, + definition: includeDefinitions ? definitions[i] : undefined + }) + } + + return { + status: 200, + body: { + elements: results, + totalAmount: totalAmount, + pageNum: pagination.pageNum, + pageSize: pagination.pageSize + } + } + } catch (err: any) { + if (err instanceof ThirdPartyFetcherError) { + switch (err.code) { + case ThirdPartyFetcherErrorCode.CANNOT_LOAD_THIRD_PARTY_WEARABLES: { + return { + status: 502, + body: { + error: 'Cannot fetch third parties right now' + } + } + } + case ThirdPartyFetcherErrorCode.THIRD_PARTY_NOT_FOUND: { + return { + status: 502, + body: { + error: 'Cannot fetch third parties right now' + } + } + } + } + } else { + logger.error(err) + return { + status: 500, + body: { + error: 'Internal Server Error' + } + } } } } diff --git a/src/controllers/routes.ts b/src/controllers/routes.ts index 6d84e385..44fc541b 100644 --- a/src/controllers/routes.ts +++ b/src/controllers/routes.ts @@ -3,20 +3,77 @@ import { GlobalContext } from '../types' import { emotesHandler } from './handlers/emotes-handler' import { landsHandler } from './handlers/lands-handler' import { namesHandler } from './handlers/names-handler' +import { oldWearablesHandler } from './handlers/old-wearables-handler' import { profilesHandler } from './handlers/profiles-handler' import { statusHandler } from './handlers/status-handler' -import { wearablesHandler } from './handlers/wearables-handler' +import { + wearablesHandler, + thirdPartyWearablesHandler, + thirdPartyCollectionWearablesHandler +} from './handlers/wearables-handler' // We return the entire router because it will be easier to test than a whole server export async function setupRouter(_: GlobalContext): Promise> { const router = new Router() router.get('/status', statusHandler) + + // TODO: passport en lugar de users? + router.get('/users/:address/wearables', wearablesHandler) + router.get('/users/:address/third-party-wearables', thirdPartyWearablesHandler) + router.get('/users/:address/third-party-wearables/:collectionId', thirdPartyCollectionWearablesHandler) + router.get('/users/:address/emotes', emotesHandler) + router.get('/users/:address/names', namesHandler) + router.get('/users/:address/lands', landsHandler) router.post('/profiles', profilesHandler) - router.get('/nfts/wearables/:id', wearablesHandler) - router.get('/nfts/names/:id', namesHandler) - router.get('/nfts/lands/:id', landsHandler) - router.get('/nfts/emotes/:id', emotesHandler) + + // old routes to be deprecated + router.get('/nfts/wearables/:id', oldWearablesHandler) + router.get('/nfts/names/:address', async (context) => { + const res = await namesHandler(context) + if ('error' in res.body) { + return res + } + return { + status: res.status, + body: { + names: res.body.elements, + totalAmount: res.body.totalAmount, + pageNum: res.body.pageNum, + pageSize: res.body.pageNum + } + } + }) + router.get('/nfts/lands/:address', async (context) => { + const res = await landsHandler(context) + if ('error' in res.body) { + return res + } + return { + status: res.status, + body: { + lands: res.body.elements, + totalAmount: res.body.totalAmount, + pageNum: res.body.pageNum, + pageSize: res.body.pageNum + } + } + }) + router.get('/nfts/emotes/:address', async (context) => { + const res = await emotesHandler(context) + if ('error' in res.body) { + return res + } + return { + status: res.status, + body: { + emotes: res.body.elements, + totalAmount: res.body.totalAmount, + pageNum: res.body.pageNum, + pageSize: res.body.pageNum + } + } + }) return router } diff --git a/src/logic/definitions.ts b/src/logic/definitions.ts deleted file mode 100644 index 42860969..00000000 --- a/src/logic/definitions.ts +++ /dev/null @@ -1,58 +0,0 @@ -import { Entity } from '@dcl/schemas' -import { EntityType } from 'dcl-catalyst-commons' -import LRU from 'lru-cache' -import { AppComponents, Definition, UrnAndAmount } from '../types' - -/* - * Looks for the definitions of the provided emotes' urns and add them to them. - */ -export async function decorateNFTsWithDefinitionsFromCache( - nfts: UrnAndAmount[], - components: Pick, - definitionsCache: LRU, - entityType: EntityType, - extractDefinitionFromEntity: (components: Pick, entity: Entity) => Definition -) { - // Get a map with the definitions from the cache and an array with the non-cached urns - const { nonCachedURNs, definitionsByURN } = getDefinitionsFromCache(nfts, definitionsCache) - - // Fetch entities for non-cached urns - let entities: Entity[] = [] - if (nonCachedURNs.length !== 0) entities = await components.content.fetchEntitiesByPointers(entityType, nonCachedURNs) - - // Translate entities to definitions - const translatedDefinitions: Definition[] = entities.map((entity) => extractDefinitionFromEntity(components, entity)) - - // Store new definitions in cache and in map - translatedDefinitions.forEach((definition) => { - definitionsCache.set(definition.id.toLowerCase(), definition) - definitionsByURN.set(definition.id.toLowerCase(), definition) - }) - - // Decorate provided nfts with definitions - return nfts.map((nft) => { - return { - ...nft, - definition: definitionsByURN.get(nft.urn) - } - }) -} - -/* - * Try to get the definitions from cache. Present ones are retrieved as a map urn -> definition. - * Not present ones are retrieved as an array to fetch later - */ -function getDefinitionsFromCache(nfts: UrnAndAmount[], definitionsCache: LRU) { - const nonCachedURNs: string[] = [] - const definitionsByURN = new Map() - nfts.forEach((nft) => { - const definition = definitionsCache.get(nft.urn) - if (definition) { - definitionsByURN.set(nft.urn, definition) - } else { - nonCachedURNs.push(nft.urn) - } - }) - - return { nonCachedURNs, definitionsByURN } -} diff --git a/src/logic/emotes.ts b/src/logic/emotes.ts deleted file mode 100644 index 53c971f6..00000000 --- a/src/logic/emotes.ts +++ /dev/null @@ -1,197 +0,0 @@ -import { Entity, EntityType } from '@dcl/schemas' -import { extractEmoteDefinitionFromEntity, extractWearableDefinitionFromEntity } from '../adapters/definitions' -import { transformEmoteToResponseSchema, transformThirdPartyAssetToEmoteForResponse } from '../adapters/nfts' -import { runQuery, TheGraphComponent } from '../ports/the-graph' -import { AppComponents, CategoryResponse, Definition, EmoteForResponse, EmotesQueryResponse } from '../types' -import { decorateNFTsWithDefinitionsFromCache } from './definitions' -import { createThirdPartyResolverForCollection } from './third-party-wearables' - -const QUERY_EMOTES: string = ` -{ - nfts( - where: { owner: "$owner", category: "emote" }, - orderBy: transferredAt, - orderDirection: desc - ) { - urn, - id, - contractAddress, - tokenId, - image, - transferredAt, - item { - metadata { - emote { - name, - description - } - }, - rarity, - price - } - } -}` - -const QUERY_EMOTES_PAGINATED: string = ` -{ - nfts( - where: { owner: "$owner", category: "emote" }, - orderBy: transferredAt, - orderDirection: desc, - first: $first, - skip: $skip - ) { - urn, - id, - contractAddress, - tokenId, - image, - transferredAt, - item { - metadata { - emote { - name, - description - } - }, - rarity, - price - } - } -}` - -const QUERY_EMOTES_TOTAL_AMOUNT = ` -{ - nfts( - where: { owner: "$owner", category: "emote" } - first: 1000 - ) { - category - } -}` - -export async function getEmotesForAddress( - components: Pick, - id: string, - includeDefinitions: boolean, - pageSize?: string | null, - pageNum?: string | null -) { - const { theGraph, emotesCaches } = components - - // If pagination is required, an extra query to retrieve the total amount of emotes is asynchronously made - let query - let emotes: EmoteForResponse[] - let totalAmount - if (pageSize && pageNum) { - // Get a promise for calculating total amount of owned emotes - const totalAmountPromise = runQueryForTotalAmount(id, theGraph) - - // Get a promise for a page of owned emotes - const emotesPromise = runQueryForPaginatedEmotes(query, id, pageSize, pageNum, theGraph) - - // Wait for both queries to finish - await Promise.all([totalAmountPromise, emotesPromise]) - - // Get totalAmount from the query response - totalAmount = (await totalAmountPromise).nfts.length - - // Get emotes from the query response - emotes = (await emotesPromise).nfts.map(transformEmoteToResponseSchema) - } else { - // Get all owned emotes - emotes = await runQueryForAllEmotes(query, id, theGraph) - - // Set totalAmount from the query response - totalAmount = emotes.length - } - - // Fetch for definitions, add it to the cache and add it to each emote in the response - if (includeDefinitions) - emotes = await decorateNFTsWithDefinitionsFromCache( - emotes, - components, - emotesCaches.definitionsCache, - EntityType.EMOTE, - extractEmoteDefinitionFromEntity - ) - - return { - emotes, - totalAmount - } -} - -/* - * Sets the query used to calculate total amount of emotes and runs it - */ -function runQueryForTotalAmount(id: string, theGraph: TheGraphComponent) { - // Set query for the total amount request - const totalAmountQuery = QUERY_EMOTES_TOTAL_AMOUNT.replace('$owner', id.toLowerCase()) - - // Query for every emote with one single field for minimum payload to calculate the total amount - return runQuery(theGraph.maticCollectionsSubgraph, totalAmountQuery, {}) -} - -/* - * Sets the query for paginated emotes and runs it - */ -function runQueryForPaginatedEmotes( - query: any, - id: string, - pageSize: string, - pageNum: string, - theGraph: TheGraphComponent -) { - // Set the query for the paginated request - query = QUERY_EMOTES_PAGINATED.replace('$owner', id.toLowerCase()) - .replace('$first', `${pageSize}`) - .replace('$skip', `${(parseInt(pageNum) - 1) * parseInt(pageSize)}`) - - // Query owned names from TheGraph for the address - return runQuery(theGraph.maticCollectionsSubgraph, query, {}) -} - -/* - * Sets the query for retrieving all emotes and runs it - */ -async function runQueryForAllEmotes(query: any, id: string, theGraph: TheGraphComponent) { - // Set the query for the full request - query = QUERY_EMOTES.replace('$owner', id.toLowerCase()) - - // Query owned names from TheGraph for the address - return (await runQuery(theGraph.maticCollectionsSubgraph, query, {})).nfts.map( - transformEmoteToResponseSchema - ) -} - -export async function getEmotesForCollection( - components: Pick, - collectionId: string, - address: string, - includeDefinitions: boolean -) { - const { definitionsCache } = components.emotesCaches - // Get API for collection - const resolver = await createThirdPartyResolverForCollection(components, collectionId) - - // Get owned wearables for the collection - let ownedTPEForCollection = (await resolver.findThirdPartyAssetsByOwner(address)).map( - transformThirdPartyAssetToEmoteForResponse - ) - - // Fetch for definitions and add it to each wearable in the response - if (includeDefinitions) - ownedTPEForCollection = await decorateNFTsWithDefinitionsFromCache( - ownedTPEForCollection, - components, - definitionsCache, - EntityType.EMOTE, - extractEmoteDefinitionFromEntity - ) - - return { - emotes: ownedTPEForCollection, - totalAmount: ownedTPEForCollection.length - } -} diff --git a/src/logic/lands.ts b/src/logic/lands.ts deleted file mode 100644 index 437b62c2..00000000 --- a/src/logic/lands.ts +++ /dev/null @@ -1,157 +0,0 @@ -import { transformLandToResponseSchema } from '../adapters/nfts' -import { runQuery, TheGraphComponent } from '../ports/the-graph' -import { AppComponents, CategoryResponse, LandForResponse, LandsQueryResponse } from '../types' - -const QUERY_LANDS: string = ` -{ - nfts( - where: { owner: "$owner", category_in: [parcel, estate] }, - orderBy: transferredAt, - orderDirection: desc - ) { - name, - contractAddress, - tokenId, - category, - parcel { - x, - y, - data { - description - } - } - estate { - data { - description - } - }, - activeOrder { - price - }, - image - } -}` - -const QUERY_LANDS_PAGINATED: string = ` -{ - nfts( - where: { owner: "$owner", category_in: [parcel, estate] }, - orderBy: transferredAt, - orderDirection: desc, - first: $first, - skip: $skip - ) { - name, - contractAddress, - tokenId, - category, - parcel { - x, - y, - data { - description - } - } - estate { - data { - description - } - }, - activeOrder { - price - }, - image - } -}` - -const QUERY_LANDS_TOTAL_AMOUNT: string = ` -{ - nfts( - where: { owner: "$owner", category_in: [parcel, estate] } - first: 1000 - ) { - category - } -}` - -export async function getLandsForAddress( - components: Pick, - id: string, - pageSize?: string | null, - pageNum?: string | null -) { - const { theGraph } = components - - // If pagination is required, an extra query to retrieve the total amount of emotes is asynchronously made - let query - let lands: LandForResponse[] - let totalAmount - if (pageSize && pageNum) { - // Get a promise for calculating total amount of owned emotes - const totalAmountPromise = runQueryForTotalAmount(id, theGraph) - - // Get a promise for a page of owned emotes - const namesPromise = runQueryForPaginatedLands(query, id, pageSize, pageNum, theGraph) - - // Wait for both queries to finish - await Promise.all([totalAmountPromise, namesPromise]) - - // Get totalAmount from the query response - totalAmount = (await totalAmountPromise).nfts.length - - // Get emotes from the query response - lands = (await namesPromise).nfts.map(transformLandToResponseSchema) - } else { - // Get all owned emotes - lands = await runQueryForAllLands(query, id, theGraph) - - // Set totalAmount from the query response - totalAmount = lands.length - } - - return { - lands, - totalAmount - } -} - -/* - * Sets the query used to calculate total amount of emotes and runs it - */ -function runQueryForTotalAmount(id: string, theGraph: TheGraphComponent) { - // Set query for the total amount request - const totalAmountQuery = QUERY_LANDS_TOTAL_AMOUNT.replace('$owner', id.toLowerCase()) - - // Query for every emote with one single field for minimum payload to calculate the total amount - return runQuery(theGraph.ensSubgraph, totalAmountQuery, {}) -} - -/* - * Sets the query for paginated emotes and runs it - */ -function runQueryForPaginatedLands( - query: any, - id: string, - pageSize: string, - pageNum: string, - theGraph: TheGraphComponent -) { - // Set the query for the paginated request - query = QUERY_LANDS_PAGINATED.replace('$owner', id.toLowerCase()) - .replace('$first', `${pageSize}`) - .replace('$skip', `${(parseInt(pageNum) - 1) * parseInt(pageSize)}`) - - // Query owned names from TheGraph for the address - return runQuery(theGraph.ensSubgraph, query, {}) -} - -/* - * Sets the query for retrieving all emotes and runs it - */ -async function runQueryForAllLands(query: any, id: string, theGraph: TheGraphComponent) { - // Set the query for the full request - query = QUERY_LANDS.replace('$owner', id.toLowerCase()) - - // Query owned names from TheGraph for the address - return (await runQuery(theGraph.ensSubgraph, query, {})).nfts.map(transformLandToResponseSchema) -} diff --git a/src/logic/names.ts b/src/logic/names.ts deleted file mode 100644 index 18fbbc2c..00000000 --- a/src/logic/names.ts +++ /dev/null @@ -1,129 +0,0 @@ -import { transformNameToResponseSchema } from '../adapters/nfts' -import { runQuery, TheGraphComponent } from '../ports/the-graph' -import { AppComponents, CategoryResponse, NameForResponse, NamesQueryResponse } from '../types' - -const QUERY_NAMES: string = ` -{ - nfts( - where: {owner: "$owner", category: "ens"} - orderBy: transferredAt, - orderDirection: desc, - ) { - name, - contractAddress, - tokenId, - activeOrder { - price - } - } -}` - -const QUERY_NAMES_PAGINATED: string = ` -{ - nfts( - where: {owner: "$owner", category: "ens"} - orderBy: transferredAt, - orderDirection: desc, - first: $first, - skip: $skip - ) { - name, - contractAddress, - tokenId, - activeOrder { - price - } - } -}` - -const QUERY_NAMES_TOTAL_AMOUNT: string = ` -{ - nfts( - where: {owner: "$owner", category: "ens"} - first: 1000 - ) { - category - } -}` - -export async function getNamesForAddress( - components: Pick, - id: string, - pageSize?: string | null, - pageNum?: string | null -) { - const { theGraph } = components - - // If pagination is required, an extra query to retrieve the total amount of emotes is asynchronously made - let query - let names: NameForResponse[] - let totalAmount - if (pageSize && pageNum) { - // Get a promise for calculating total amount of owned emotes - const totalAmountPromise = runQueryForTotalAmount(id, theGraph) - - // Get a promise for a page of owned emotes - const namesPromise = runQueryForPaginatedNames(query, id, pageSize, pageNum, theGraph) - - // Wait for both queries to finish - await Promise.all([totalAmountPromise, namesPromise]) - - // Get totalAmount from the query response - totalAmount = (await totalAmountPromise).nfts.length - - // Get emotes from the query response - names = (await namesPromise).nfts.map(transformNameToResponseSchema) - } else { - // Get all owned emotes - names = await runQueryForAllNames(query, id, theGraph) - - // Set totalAmount from the query response - totalAmount = names.length - } - - return { - names, - totalAmount - } -} - -/* - * Sets the query used to calculate total amount of emotes and runs it - */ -function runQueryForTotalAmount(id: string, theGraph: TheGraphComponent) { - // Set query for the total amount request - const totalAmountQuery = QUERY_NAMES_TOTAL_AMOUNT.replace('$owner', id.toLowerCase()) - - // Query for every emote with one single field for minimum payload to calculate the total amount - return runQuery(theGraph.ensSubgraph, totalAmountQuery, {}) -} - -/* - * Sets the query for paginated emotes and runs it - */ -function runQueryForPaginatedNames( - query: any, - id: string, - pageSize: string, - pageNum: string, - theGraph: TheGraphComponent -) { - // Set the query for the paginated request - query = QUERY_NAMES_PAGINATED.replace('$owner', id.toLowerCase()) - .replace('$first', `${pageSize}`) - .replace('$skip', `${(parseInt(pageNum) - 1) * parseInt(pageSize)}`) - - // Query owned names from TheGraph for the address - return runQuery(theGraph.ensSubgraph, query, {}) -} - -/* - * Sets the query for retrieving all emotes and runs it - */ -async function runQueryForAllNames(query: any, id: string, theGraph: TheGraphComponent) { - // Set the query for the full request - query = QUERY_NAMES.replace('$owner', id.toLowerCase()) - - // Query owned names from TheGraph for the address - return (await runQuery(theGraph.ensSubgraph, query, {})).nfts.map(transformNameToResponseSchema) -} diff --git a/src/logic/ownership.ts b/src/logic/ownership.ts index c414675f..b6883431 100644 --- a/src/logic/ownership.ts +++ b/src/logic/ownership.ts @@ -46,6 +46,7 @@ async function querySubgraphByFragments( result.set(owner, ownedNFTs) } } catch (error) { + // TODO: logger console.log(error) } finally { offset += nft_fragments_per_query diff --git a/src/logic/profiles.ts b/src/logic/profiles.ts index c991ef42..4fedb589 100644 --- a/src/logic/profiles.ts +++ b/src/logic/profiles.ts @@ -1,10 +1,49 @@ import { AppComponents, ProfileMetadata, Filename, Filehash, NFTsOwnershipChecker } from '../types' -import { Entity, EntityType, Snapshots } from '@dcl/schemas' +import { Entity, Snapshots } from '@dcl/schemas' import { IConfigComponent } from '@well-known-components/interfaces' -import { getBaseWearables, getValidNonBaseWearables, translateWearablesIdFormat } from './wearables' import { createWearablesOwnershipChecker } from '../ports/ownership-checker/wearables-ownership-checker' import { createNamesOwnershipChecker } from '../ports/ownership-checker/names-ownership-checker' import { createTPWOwnershipChecker } from '../ports/ownership-checker/tpw-ownership-checker' +import { parseUrn } from '@dcl/urn-resolver' + +export async function getValidNonBaseWearables(metadata: ProfileMetadata): Promise { + const wearablesInProfile: string[] = [] + for (const avatar of metadata.avatars) { + for (const wearableId of avatar.avatar.wearables) { + if (!isBaseWearable(wearableId)) { + const translatedWearableId = await translateWearablesIdFormat(wearableId) + if (translatedWearableId) wearablesInProfile.push(translatedWearableId) + } + } + } + const filteredWearables = wearablesInProfile.filter((wearableId): wearableId is string => !!wearableId) + return filteredWearables +} + +function isBaseWearable(wearable: string): boolean { + return wearable.includes('base-avatars') +} + +export async function translateWearablesIdFormat(wearableId: string): Promise { + if (!wearableId.startsWith('dcl://')) return wearableId + + const parsed = await parseUrn(wearableId) + return parsed?.uri?.toString() +} + +export async function getBaseWearables(wearables: string[]): Promise { + // Filter base wearables + const baseWearables = wearables.filter(isBaseWearable) + + // Translate old format ones to the new id format + const validBaseWearables = [] + for (const wearableId of baseWearables) { + const translatedWearableId = await translateWearablesIdFormat(wearableId) + if (translatedWearableId) validBaseWearables.push(translatedWearableId) + } + + return validBaseWearables +} export async function getProfiles( components: Pick, @@ -13,7 +52,7 @@ export async function getProfiles( ): Promise { try { // Fetch entities by pointers - let profileEntities: Entity[] = await components.content.fetchEntitiesByPointers(EntityType.PROFILE, ethAddresses) + let profileEntities: Entity[] = await components.content.fetchEntitiesByPointers(ethAddresses) // Avoid querying profiles if there wasn't any new deployment if (noNewDeployments(ifModifiedSinceTimestamp, profileEntities)) return @@ -50,6 +89,7 @@ export async function getProfiles( tpwOwnershipChecker ) } catch (error) { + // TODO: logger console.log(error) return [] } diff --git a/src/logic/third-party-wearables.ts b/src/logic/third-party-wearables.ts index 937f63e2..9a81ee56 100644 --- a/src/logic/third-party-wearables.ts +++ b/src/logic/third-party-wearables.ts @@ -1,17 +1,13 @@ -import { EntityType } from 'dcl-catalyst-commons' -import { extractWearableDefinitionFromEntity } from '../adapters/definitions' -import { transformThirdPartyAssetToWearableForCache } from '../adapters/nfts' import { runQuery } from '../ports/the-graph' -import { - AppComponents, - ThirdPartyResolversResponse, - ThirdPartyAsset, - ThirdPartyAssets, - TPWResolver, - ThirdPartyProvider -} from '../types' -import { decorateNFTsWithDefinitionsFromCache } from './definitions' - +import { AppComponents, ThirdPartyAsset, TPWResolver } from '../types' + +type ThirdPartyAssets = { + address: string + total: number + page: number + assets: ThirdPartyAsset[] + next?: string +} export async function createThirdPartyResolverForCollection( components: Pick, collectionId: string @@ -113,73 +109,3 @@ function buildRegistryOwnerUrl(thirdPartyResolverURL: string, registryId: string const baseUrl = new URL(thirdPartyResolverURL).href.replace(/\/$/, '') return `${baseUrl}/registry/${registryId}/address/${owner}/assets` } - -/* - * Returns all third-party wearables for an address - */ -export async function getThirdPartyWearables( - components: Pick, - userAddress: string -) { - const { theGraph } = components - - // Get every resolver - const tpProviders = ( - await runQuery( - theGraph.thirdPartyRegistrySubgraph, - QUERY_ALL_THIRD_PARTY_RESOLVERS, - {} - ) - ).thirdParties - - // Fetch assets asynchronously - const providersPromises = tpProviders.map((provider: ThirdPartyProvider) => { - return fetchAssets(components, provider.resolver, parseCollectionId(provider.id).registryId, userAddress) - }) - - return (await Promise.all(providersPromises)).flat() -} - -const QUERY_ALL_THIRD_PARTY_RESOLVERS = ` -{ - thirdParties(where: {isApproved: true}) { - id, - resolver - } -} -` - -/* - * Returns only third-party wearables for the specified collection id, owned by the provided address - */ -export async function getWearablesForCollection( - components: Pick, - collectionId: string, - address: string, - includeDefinitions: boolean -) { - const { definitionsCache } = components.wearablesCaches - - // Get API for collection - const resolver = await createThirdPartyResolverForCollection(components, collectionId) - - // Get owned wearables for the collection - let ownedTPWForCollection = (await resolver.findThirdPartyAssetsByOwner(address)).map( - transformThirdPartyAssetToWearableForCache - ) - - // Fetch for definitions, add it to the cache and add it to each wearable in the response - if (includeDefinitions) - ownedTPWForCollection = await decorateNFTsWithDefinitionsFromCache( - ownedTPWForCollection, - components, - definitionsCache, - EntityType.EMOTE, - extractWearableDefinitionFromEntity - ) - - return { - wearables: ownedTPWForCollection, - totalAmount: ownedTPWForCollection.length - } -} diff --git a/src/logic/utils.ts b/src/logic/utils.ts new file mode 100644 index 00000000..db92fb9e --- /dev/null +++ b/src/logic/utils.ts @@ -0,0 +1,37 @@ +import { Pagination, Wearable } from '../types' +import { parseUrn as resolverParseUrn } from '@dcl/urn-resolver' + +export function paginationObject(url: URL): Pagination { + const pageSize = url.searchParams.has('pageSize') ? parseInt(url.searchParams.get('pageSize')!, 10) : 100 + const pageNum = url.searchParams.has('pageNum') ? parseInt(url.searchParams.get('pageNum')!, 10) : 1 + + const offset = (pageNum - 1) * pageSize + const limit = pageSize + return { pageSize, pageNum, offset, limit } +} + +const RARITIES = ['common', 'uncommon', 'rare', 'epic', 'legendary', 'mythic', 'unique'] + +export function compareByRarity(wearable1: Wearable, wearable2: Wearable) { + const w1RarityValue = RARITIES.findIndex((rarity) => rarity === wearable1.rarity) + const w2RarityValue = RARITIES.findIndex((rarity) => rarity === wearable2.rarity) + return w2RarityValue - w1RarityValue +} + +export async function parseUrn(urn: string) { + try { + return await resolverParseUrn(urn) + } catch (err: any) { + return null + } +} + +export async function findAsync(elements: T[], f: (e: T) => Promise): Promise { + for (const e of elements) { + if (await f(e)) { + return e + } + } + + return undefined +} diff --git a/src/logic/wearables.ts b/src/logic/wearables.ts deleted file mode 100644 index f61f299d..00000000 --- a/src/logic/wearables.ts +++ /dev/null @@ -1,290 +0,0 @@ -import { - AppComponents, - ProfileMetadata, - WearableFromQuery, - WearablesQueryResponse, - WearableForResponse, - ThirdPartyAsset, - WearableForCache -} from '../types' -import { parseUrn } from '@dcl/urn-resolver' -import { runQuery, TheGraphComponent } from '../ports/the-graph' -import { - transformThirdPartyAssetToWearableForCache, - transformWearableForCacheToWearableForResponse, - transformWearableFromQueryToWearableForCache -} from '../adapters/nfts' -import { cloneDeep } from 'lodash' -import { getThirdPartyWearables } from './third-party-wearables' -import LRU from 'lru-cache' -import { EntityType } from '@dcl/schemas' -import { extractWearableDefinitionFromEntity } from '../adapters/definitions' -import { decorateNFTsWithDefinitionsFromCache } from './definitions' - -/* - * Extracts the non-base wearables from a profile and translate them to the new format - */ -export async function getValidNonBaseWearables(metadata: ProfileMetadata): Promise { - const wearablesInProfile: string[] = [] - for (const avatar of metadata.avatars) { - for (const wearableId of avatar.avatar.wearables) { - if (!isBaseWearable(wearableId)) { - const translatedWearableId = await translateWearablesIdFormat(wearableId) - if (translatedWearableId) wearablesInProfile.push(translatedWearableId) - } - } - } - const filteredWearables = wearablesInProfile.filter((wearableId): wearableId is string => !!wearableId) - return filteredWearables -} - -/* - * Filters base wearables from a wearables array and translate them to the new id format - */ -export async function getBaseWearables(wearables: string[]): Promise { - // Filter base wearables - const baseWearables = wearables.filter(isBaseWearable) - - // Translate old format ones to the new id format - const validBaseWearables = [] - for (const wearableId of baseWearables) { - const translatedWearableId = await translateWearablesIdFormat(wearableId) - if (translatedWearableId) validBaseWearables.push(translatedWearableId) - } - - return validBaseWearables -} - -function isBaseWearable(wearable: string): boolean { - return wearable.includes('base-avatars') -} - -/* - * Translates from the old id format into the new one - */ -export async function translateWearablesIdFormat(wearableId: string): Promise { - if (!wearableId.startsWith('dcl://')) return wearableId - - const parsed = await parseUrn(wearableId) - return parsed?.uri?.toString() -} - -const QUERY_WEARABLES: string = ` -{ - nfts( - where: { owner: "$owner", category: "wearable"}, - orderBy: transferredAt, - orderDirection: desc, - ) { - urn, - id, - tokenId, - transferredAt, - item { - rarity, - price - } - } -}` - -export async function getWearablesForAddress( - components: Pick, - id: string, - includeTPW: boolean, - includeDefinitions: boolean, - pageSize?: string | null, - pageNum?: string | null, - orderBy?: string | null -) { - const { wearablesCaches } = components - - // Retrieve wearables for id from cache. They are stored sorted by creation date - const dclWearables = await retrieveWearablesFromCache( - wearablesCaches.dclWearablesCache, - id, - components, - getDCLWearablesToBeCached - ) - - // Retrieve third-party wearables for id from cache - let tpWearables: WearableForCache[] = [] - if (includeTPW) - tpWearables = await retrieveWearablesFromCache( - wearablesCaches.thirdPartyWearablesCache, - id, - components, - getThirdPartyWearablesToBeCached - ) - - // Concatenate both types of wearables - let allWearables = [...tpWearables, ...dclWearables] - - // Set total amount of wearables - const wearablesTotal = allWearables.length - - // Sort them by another field if specified - if (orderBy === 'rarity') allWearables = cloneDeep(allWearables).sort(compareByRarity) - - // Virtually paginate the response if required - if (pageSize && pageNum) - allWearables = allWearables.slice( - (parseInt(pageNum) - 1) * parseInt(pageSize), - parseInt(pageNum) * parseInt(pageSize) - ) - - // Transform wearables to the response schema (exclude rarity) - let wearablesForResponse = allWearables.map(transformWearableForCacheToWearableForResponse) - - // Fetch for definitions, add it to the cache and add it to each wearable in the response - if (includeDefinitions) - wearablesForResponse = await decorateNFTsWithDefinitionsFromCache( - wearablesForResponse, - components, - wearablesCaches.definitionsCache, - EntityType.WEARABLE, - extractWearableDefinitionFromEntity - ) - - return { - wearables: wearablesForResponse, - totalAmount: wearablesTotal - } -} - -async function retrieveWearablesFromCache( - wearablesCache: LRU, - id: string, - components: Pick, - getWearablesToBeCached: ( - id: string, - components: Pick, - theGraph: TheGraphComponent - ) => Promise -) { - // Try to get them from cache - let allWearables = wearablesCache.get(id) - - // If it was a miss, a queries are done and the merged response is stored - if (!allWearables) { - // Get wearables - allWearables = await getWearablesToBeCached(id, components, components.theGraph) - - // Store the in the cache - wearablesCache.set(id, allWearables) - } - return allWearables -} - -async function getDCLWearablesToBeCached(id: string, components: Pick) { - const { theGraph } = components - - // Set query - const query = QUERY_WEARABLES.replace('$owner', id.toLowerCase()) - - // Query owned wearables from TheGraph for the address - const collectionsWearables = await runQuery(theGraph.collectionsSubgraph, query, {}).then( - (response) => response.nfts - ) - const maticWearables = await runQuery(theGraph.maticCollectionsSubgraph, query, {}).then( - (response) => response.nfts - ) - - // Merge the wearables responses, sort them by transferred date and group them by urn - return groupWearablesByURN(collectionsWearables.concat(maticWearables)).sort(compareByTransferredAt) -} - -async function getThirdPartyWearablesToBeCached(id: string, components: Pick) { - // Get all third-party wearables - const tpWearables = await getThirdPartyWearables(components, id) - - // Group third-party wearables by urn - return groupThirdPartyWearablesByURN(tpWearables) -} - -/* - * Groups every wearable with the same URN. Each of them has some data which differentiates them as individuals. - * That data is stored in an array binded to the corresponding urn. Returns an array of wearables in the response format. - */ -function groupWearablesByURN(wearables: WearableFromQuery[]): WearableForCache[] { - // Initialize the map - const wearablesByURN = new Map() - - // Set the map with the wearables data - wearables.forEach((wearable) => { - if (wearablesByURN.has(wearable.urn)) { - // The wearable was present in the map, its individual data is added to the individualData array for that wearable - const wearableFromMap = wearablesByURN.get(wearable.urn)! - wearableFromMap?.individualData?.push({ - id: wearable.id, - tokenId: wearable.tokenId, - transferredAt: wearable.transferredAt, - price: wearable.item.price - }) - wearableFromMap.amount = wearableFromMap.amount + 1 - } else { - // The wearable was not present in the map, it is added and its individualData array is initialized with its data - wearablesByURN.set(wearable.urn, transformWearableFromQueryToWearableForCache(wearable)) - } - }) - - // Return the contents of the map as an array - return Array.from(wearablesByURN.values()) -} - -/* - * Groups every third-party wearable with the same URN. Each of them could have a different id. - * which is stored in an array binded to the corresponding urn. Returns an array of wearables in the response format. - */ -function groupThirdPartyWearablesByURN(tpWearables: ThirdPartyAsset[]): WearableForCache[] { - // Initialize the map - const wearablesByURN = new Map() - - // Set the map with the wearables data - tpWearables.forEach((wearable) => { - if (wearablesByURN.has(wearable.urn.decentraland)) { - // The wearable was present in the map, its individual data is added to the individualData array for that wearable - const wearableFromMap = wearablesByURN.get(wearable.urn.decentraland)! - wearableFromMap?.individualData?.push({ - id: wearable.id - }) - wearableFromMap.amount = wearableFromMap.amount + 1 - } else { - // The wearable was not present in the map, it is added and its individualData array is initialized with its data - wearablesByURN.set(wearable.urn.decentraland, transformThirdPartyAssetToWearableForCache(wearable)) - } - }) - - // Return the contents of the map as an array - return Array.from(wearablesByURN.values()) -} - -/* - * Returns a positive number if wearable1 is older than wearable2, zero if they are equal, and a negative - * number if wearable2 is older than wearable1. Can be used to sort wearables by creationDate, descending - */ -function compareByTransferredAt(wearable1: WearableForResponse, wearable2: WearableForResponse) { - if ( - wearable1.individualData && - wearable1.individualData[0].transferredAt && - wearable2.individualData && - wearable2.individualData[0].transferredAt - ) - return wearable2.individualData[0].transferredAt - wearable1.individualData[0].transferredAt - else return 0 -} - -const RARITIES = ['common', 'uncommon', 'rare', 'epic', 'legendary', 'mythic', 'unique'] - -/* - * Returns a positive number if wearable1 has a lower rarity than wearable2, zero if they are equal, and a negative - * number if wearable2 has a lower rarity than wearable1. Can be used to sort wearables by rarity, descending. - * It is only aplicable when definitions are being include in the response, if it's not include it will return 0. - */ -function compareByRarity(wearable1: WearableForCache, wearable2: WearableForCache) { - if (wearable1.rarity && wearable2.rarity) { - const w1RarityValue = RARITIES.findIndex((rarity) => rarity === wearable1.rarity) - const w2RarityValue = RARITIES.findIndex((rarity) => rarity === wearable2.rarity) - return w2RarityValue - w1RarityValue - } - return 0 -} diff --git a/src/ports/content.ts b/src/ports/content.ts index 52e2e975..333e69fc 100644 --- a/src/ports/content.ts +++ b/src/ports/content.ts @@ -1,33 +1,27 @@ import { IBaseComponent } from '@well-known-components/interfaces' import { AppComponents } from '../types' import { ContentAPI, ContentClient } from 'dcl-catalyst-client' -import { EntityType } from 'dcl-catalyst-commons' import { Entity } from '@dcl/schemas' export type ContentComponent = IBaseComponent & { getExternalContentServerUrl(): string - fetchEntitiesByPointers(type: EntityType, pointers: string[]): Promise + fetchEntitiesByPointers(pointers: string[]): Promise } export async function createContentComponent(components: Pick): Promise { const contentServerURL = await getContentServerAddress(components) const contentClient: ContentAPI = new ContentClient({ contentUrl: contentServerURL }) - async function start() {} - - async function stop() {} - function getExternalContentServerUrl(): string { return contentServerURL } - function fetchEntitiesByPointers(type: EntityType, pointers: string[]) { - return contentClient.fetchEntitiesByPointers(type, pointers) + // TODO: typed response + function fetchEntitiesByPointers(pointers: string[]) { + return contentClient.fetchEntitiesByPointers(pointers) } return { - start, - stop, getExternalContentServerUrl, fetchEntitiesByPointers } diff --git a/src/ports/ownership-checker/wearables-ownership-checker.ts b/src/ports/ownership-checker/wearables-ownership-checker.ts index ad2f98b3..87460a6d 100644 --- a/src/ports/ownership-checker/wearables-ownership-checker.ts +++ b/src/ports/ownership-checker/wearables-ownership-checker.ts @@ -61,7 +61,7 @@ async function checkForWearablesOwnership( theGraph: TheGraphComponent, wearableIdsToCheck: [string, string[]][] ): Promise<{ owner: string; urns: string[] }[]> { - const ethereumWearablesOwnersPromise = getOwnedWearables(wearableIdsToCheck, theGraph.collectionsSubgraph) + const ethereumWearablesOwnersPromise = getOwnedWearables(wearableIdsToCheck, theGraph.ethereumCollectionsSubgraph) const maticWearablesOwnersPromise = getOwnedWearables(wearableIdsToCheck, theGraph.maticCollectionsSubgraph) const [ethereumWearablesOwners, maticWearablesOwners] = await Promise.all([ ethereumWearablesOwnersPromise, @@ -77,6 +77,7 @@ async function getOwnedWearables( try { return getOwnersByWearable(wearableIdsToCheck, subgraph) } catch (error) { + // TODO: logger console.log(error) return [] } diff --git a/src/ports/the-graph.ts b/src/ports/the-graph.ts index cbde02af..73f0d006 100644 --- a/src/ports/the-graph.ts +++ b/src/ports/the-graph.ts @@ -3,22 +3,21 @@ import { ISubgraphComponent, createSubgraphComponent } from '@well-known-compone import { AppComponents } from '../types' export type TheGraphComponent = IBaseComponent & { - collectionsSubgraph: ISubgraphComponent + ethereumCollectionsSubgraph: ISubgraphComponent maticCollectionsSubgraph: ISubgraphComponent ensSubgraph: ISubgraphComponent thirdPartyRegistrySubgraph: ISubgraphComponent } -const DEFAULT_COLLECTIONS_SUBGRAPH_ROPSTEN = - 'https://api.thegraph.com/subgraphs/name/decentraland/collections-ethereum-ropsten' +const DEFAULT_COLLECTIONS_SUBGRAPH_GOERLI = + 'https://api.thegraph.com/subgraphs/name/decentraland/collections-ethereum-goerli' const DEFAULT_COLLECTIONS_SUBGRAPH_MAINNET = 'https://api.thegraph.com/subgraphs/name/decentraland/collections-ethereum-mainnet' const DEFAULT_COLLECTIONS_SUBGRAPH_MATIC_MUMBAI = 'https://api.thegraph.com/subgraphs/name/decentraland/collections-matic-mumbai' const DEFAULT_COLLECTIONS_SUBGRAPH_MATIC_MAINNET = 'https://api.thegraph.com/subgraphs/name/decentraland/collections-matic-mainnet' -const DEFAULT_ENS_OWNER_PROVIDER_URL_ROPSTEN = - 'https://api.thegraph.com/subgraphs/name/decentraland/marketplace-ropsten' +const DEFAULT_ENS_OWNER_PROVIDER_URL_GOERLI = 'https://api.thegraph.com/subgraphs/name/decentraland/marketplace-goerli' const DEFAULT_ENS_OWNER_PROVIDER_URL_MAINNET = 'https://api.thegraph.com/subgraphs/name/decentraland/marketplace' const DEFAULT_THIRD_PARTY_REGISTRY_SUBGRAPH_MATIC_MUMBAI = 'https://api.thegraph.com/subgraphs/name/decentraland/tpr-matic-mumbai' @@ -31,9 +30,9 @@ export async function createTheGraphComponent( const { config } = components const ethNetwork = await config.getString('ETH_NETWORK') - const collectionsSubgraphURL: string = + const ethereumCollectionsSubgraphURL: string = (await config.getString('COLLECTIONS_L1_SUBGRAPH_URL')) ?? - (ethNetwork === 'mainnet' ? DEFAULT_COLLECTIONS_SUBGRAPH_MAINNET : DEFAULT_COLLECTIONS_SUBGRAPH_ROPSTEN) + (ethNetwork === 'mainnet' ? DEFAULT_COLLECTIONS_SUBGRAPH_MAINNET : DEFAULT_COLLECTIONS_SUBGRAPH_GOERLI) const maticCollectionsSubgraphURL: string = (await config.getString('COLLECTIONS_L2_SUBGRAPH_URL')) ?? (process.env.ETH_NETWORK === 'mainnet' @@ -41,28 +40,20 @@ export async function createTheGraphComponent( : DEFAULT_COLLECTIONS_SUBGRAPH_MATIC_MUMBAI) const ensSubgraphURL: string = (await config.getString('ENS_OWNER_PROVIDER_URL')) ?? - (process.env.ETH_NETWORK === 'mainnet' - ? DEFAULT_ENS_OWNER_PROVIDER_URL_MAINNET - : DEFAULT_ENS_OWNER_PROVIDER_URL_ROPSTEN) + (ethNetwork === 'mainnet' ? DEFAULT_ENS_OWNER_PROVIDER_URL_MAINNET : DEFAULT_ENS_OWNER_PROVIDER_URL_GOERLI) const thirdPartyRegistrySubgraphURL: string = (await config.getString('THIRD_PARTY_REGISTRY_SUBGRAPH_URL')) ?? - (process.env.ETH_NETWORK === 'mainnet' + (ethNetwork === 'mainnet' ? DEFAULT_THIRD_PARTY_REGISTRY_SUBGRAPH_MATIC_MAINNET : DEFAULT_THIRD_PARTY_REGISTRY_SUBGRAPH_MATIC_MUMBAI) - const collectionsSubgraph = await createSubgraphComponent(components, collectionsSubgraphURL) + const ethereumCollectionsSubgraph = await createSubgraphComponent(components, ethereumCollectionsSubgraphURL) const maticCollectionsSubgraph = await createSubgraphComponent(components, maticCollectionsSubgraphURL) const ensSubgraph = await createSubgraphComponent(components, ensSubgraphURL) const thirdPartyRegistrySubgraph = await createSubgraphComponent(components, thirdPartyRegistrySubgraphURL) - async function start() {} - - async function stop() {} - return { - start, - stop, - collectionsSubgraph, + ethereumCollectionsSubgraph, maticCollectionsSubgraph, ensSubgraph, thirdPartyRegistrySubgraph @@ -82,6 +73,7 @@ export async function runQuery( // `Failed to execute the following query to the subgraph ${this.urls[query.subgraph]} ${query.description}'.`, // error // ) + // TODO: logger console.log(error) throw new Error('Internal server error') } diff --git a/src/ports/wearables-caches.ts b/src/ports/wearables-caches.ts deleted file mode 100644 index 37f129ee..00000000 --- a/src/ports/wearables-caches.ts +++ /dev/null @@ -1,37 +0,0 @@ -import { AppComponents, Definition, WearableForResponse } from '../types' -import LRU from 'lru-cache' -import { IBaseComponent } from '@well-known-components/interfaces' - -export type WearablesCachesComponent = IBaseComponent & { - dclWearablesCache: LRU - thirdPartyWearablesCache: LRU - definitionsCache: LRU -} - -export async function createWearablesCachesComponent( - components: Pick -): Promise { - const { config } = components - - const wearablesSize = parseInt((await config.getString('WEARABLES_CACHE_MAX_SIZE')) ?? '1000') - const wearablesAge = parseInt((await config.getString('WEARABLES_CACHE_MAX_AGE')) ?? '600000') // 10 minutes by default - - const dclWearablesCache: LRU = new LRU({ max: wearablesSize, ttl: wearablesAge }) - const thirdPartyWearablesCache: LRU = new LRU({ - max: wearablesSize, - ttl: wearablesAge - }) - const definitionsCache: LRU = new LRU({ max: wearablesSize, ttl: wearablesAge }) - - async function start() {} - - async function stop() {} - - return { - dclWearablesCache, - thirdPartyWearablesCache, - definitionsCache, - start, - stop - } -} diff --git a/src/types.ts b/src/types.ts index b8e4542b..e84effff 100644 --- a/src/types.ts +++ b/src/types.ts @@ -12,8 +12,12 @@ import { Profile, IPFSv1, IPFSv2, I18N } from '@dcl/schemas' import { ContentComponent } from './ports/content' import { OwnershipCachesComponent } from './ports/ownership-caches' import { Variables } from '@well-known-components/thegraph-component' -import { WearablesCachesComponent } from './ports/wearables-caches' import { EmotesCachesComponent } from './ports/emotes-caches' +import { DefinitionsFetcher } from './adapters/definitions-fetcher' +import { ThirdPartyWearablesFetcher } from './adapters/third-party-wearables-fetcher' +import { NamesFetcher } from './adapters/names-fetcher' +import { LANDsFetcher } from './adapters/lands-fetcher' +import { WearablesCachesComponent } from './controllers/handlers/old-wearables-handler' export type GlobalContext = { components: BaseComponents @@ -29,8 +33,16 @@ export type BaseComponents = { content: ContentComponent theGraph: TheGraphComponent ownershipCaches: OwnershipCachesComponent - wearablesCaches: WearablesCachesComponent + wearablesFetcher: ItemFetcher + thirdPartyWearablesFetcher: ThirdPartyWearablesFetcher + emotesFetcher: ItemFetcher + definitionsFetcher: DefinitionsFetcher + namesFetcher: NamesFetcher emotesCaches: EmotesCachesComponent + landsFetcher: LANDsFetcher + + // old components + wearablesCaches: WearablesCachesComponent } // components used in runtime @@ -60,7 +72,6 @@ export type Context = IHttpServerComponent.PathAwareC export type Filename = string export type Filehash = IPFSv1 | IPFSv2 export type WearableId = string // These ids are used as pointers on the content server -export type Name = string export type ProfileMetadata = Profile & { timestamp: number @@ -84,14 +95,6 @@ export type ThirdPartyAsset = { } } -export type ThirdPartyAssets = { - address: string - total: number - page: number - assets: ThirdPartyAsset[] - next?: string -} - /** * Function used to fetch TheGraph * @public @@ -109,46 +112,62 @@ export type UrnAndAmount = { amount: number } -export interface WearablesQueryResponse { - nfts: WearableFromQuery[] -} - -export type WearableFromQuery = { +export type Wearable = { urn: string - id: string - tokenId: string - transferredAt: number - item: { - rarity: string + amount: number // TODO: maybe this could be individualData.length + individualData: { + id: string + tokenId: string + transferredAt: number price: number + }[] + rarity: string +} + +export type ItemFetcher = IBaseComponent & { + // NOTE: the result will be always orderer by rarity + fetchByOwner(address: string, limits: Limits): Promise +} + +export enum ItemFetcherErrorCode { + CANNOT_FETCH_ITEMS +} + +export class ItemFetcherError extends Error { + constructor(public code: ItemFetcherErrorCode, message: string) { + super(message) + Error.captureStackTrace(this, this.constructor) } } -export type WearableForCache = { +export type Item = { urn: string - amount: number - individualData?: { + amount: number // TODO: maybe this could be individualData.length + individualData: { id: string - tokenId?: string - transferredAt?: number - price?: number + tokenId: string + transferredAt: number + price: number }[] - rarity?: string // Rarity added in the cache to being able to sort by it. It wont be included in the response since it already appears in the definition. It's optional because third-party wearables doesn't have rarity + rarity: string +} + +export type ItemsResult = { + items: Item[] + totalAmount: number } -// The response is grouped by URN -export type WearableForResponse = { +export type ThirdPartyWearable = { urn: string - amount: number - individualData?: { + amount: number // TODO: maybe this could be individualData.length + individualData: { id: string - tokenId?: string - transferredAt?: number - price?: number }[] - definition?: Definition } +// TODO: review this type: (ref https://github.com/decentraland/catalyst/blob/main/lambdas/src/apis/collections/types.ts#L9) +// http://localhost:7272/users/0x5447C87068b3d99F50a439f98a2B420585B34A93/wearables?includeDefinitions=true +// https://peer-ec2.decentraland.org/lambdas/collections/wearables-by-owner/0x5447C87068b3d99F50a439f98a2B420585B34A93?includeDefinitions=true export type Definition = { id: string description: string @@ -218,24 +237,11 @@ export type EmoteForResponse = { amount: number } -export interface NamesQueryResponse { - nfts: NameFromQuery[] -} - -export type NameFromQuery = { - name: string - contractAddress: string - tokenId: string - activeOrder: { - price: string - } -} - -export type NameForResponse = { +export type Name = { name: string contractAddress: string tokenId: string - price: string | null + price?: string } export interface LandsQueryResponse { @@ -277,11 +283,29 @@ export type LandForResponse = { image: string } -export interface ThirdPartyResolversResponse { - thirdParties: ThirdPartyProvider[] +export type PaginatedResponse = { + status: number + body: { + elements: T[] + totalAmount: number + pageNum: number + pageSize: number + } } -export type ThirdPartyProvider = { - id: string - resolver: string +export type ErrorResponse = { + status: number + body: { + error: string + } +} + +export type Limits = { + offset: number + limit: number +} + +export type Pagination = Limits & { + pageSize: number + pageNum: number } diff --git a/test/components.ts b/test/components.ts index 3645d7fc..4dab378b 100644 --- a/test/components.ts +++ b/test/components.ts @@ -1,16 +1,19 @@ // This file is the "test-environment" analogous for src/components.ts // Here we define the test components to be used in the testing environment -import { createRunner, createLocalFetchCompoment } from '@well-known-components/test-helpers' +import { createRunner, createLocalFetchCompoment, defaultServerConfig } from '@well-known-components/test-helpers' import { main } from '../src/service' -import { QueryGraph, TestComponents } from '../src/types' +import { TestComponents } from '../src/types' import { initComponents as originalInitComponents } from '../src/components' -import { ISubgraphComponent } from '@well-known-components/thegraph-component' -import { TheGraphComponent } from '../src/ports/the-graph' import { createDotEnvConfigComponent } from '@well-known-components/env-config-provider' import { createTestMetricsComponent } from '@well-known-components/metrics' import { metricDeclarations } from '../src/metrics' +import { createLogComponent } from '@well-known-components/logger' +import { createEmoteFetcherComponent, createWearableFetcherComponent } from '../src/adapters/items-fetcher' +import { createTheGraphComponentMock } from './mocks/the-graph-mock' +import { createContentComponentMock } from './mocks/content-mock' +import { createDefinitionsFetcherComponent } from '../src/adapters/definitions-fetcher' /** * Behaves like Jest "describe" function, used to describe a test for a @@ -24,30 +27,32 @@ export const test = createRunner({ initComponents }) -export const createMockSubgraphComponent = (mock?: QueryGraph): ISubgraphComponent => ({ - query: mock ?? (jest.fn() as jest.MockedFunction) -}) +async function initComponents(): Promise { + const defaultFetchConfig = defaultServerConfig() + const config = await createDotEnvConfigComponent({}, { COMMIT_HASH: 'commit_hash', ...defaultFetchConfig }) + const fetch = await createLocalFetchCompoment(config) + const theGraphMock = createTheGraphComponentMock() -export function createTestTheGraphComponent(): TheGraphComponent { - return { - start: async () => {}, - stop: async () => {}, - collectionsSubgraph: createMockSubgraphComponent(), - maticCollectionsSubgraph: createMockSubgraphComponent(), - ensSubgraph: createMockSubgraphComponent(), - thirdPartyRegistrySubgraph: createMockSubgraphComponent() - } -} + const components = await originalInitComponents(fetch, theGraphMock) -async function initComponents(): Promise { - const components = await originalInitComponents() + const logs = await createLogComponent({}) + + const contentMock = createContentComponentMock() + const wearablesFetcher = await createWearableFetcherComponent({ config, theGraph: theGraphMock, logs }) + const emotesFetcher = await createEmoteFetcherComponent({ config, theGraph: theGraphMock, logs }) + const definitionsFetcher = await createDefinitionsFetcherComponent({ config, content: contentMock, logs }) - const config = await createDotEnvConfigComponent({}, { COMMIT_HASH: 'commit_hash' }) + jest.spyOn(theGraphMock.thirdPartyRegistrySubgraph, 'query').mockResolvedValueOnce({ thirdParties: [] }) return { ...components, config: config, metrics: createTestMetricsComponent(metricDeclarations), - localFetch: await createLocalFetchCompoment(config) + localFetch: await createLocalFetchCompoment(config), + theGraph: theGraphMock, + content: contentMock, + wearablesFetcher, + emotesFetcher, + definitionsFetcher } } diff --git a/test/data/emotes.ts b/test/data/emotes.ts new file mode 100644 index 00000000..bcf5b7fa --- /dev/null +++ b/test/data/emotes.ts @@ -0,0 +1,42 @@ +const TWO_DAYS = (2 * 24 * 60 * 60 * 1000) + +export function generateEmotes(quantity: number) { + const generatedEmotes = [] + for (let i = 0; i < quantity; i++) { + generatedEmotes.push({ + urn: 'urn-' + i, + id: 'id-' + i, + tokenId: 'tokenId-' + i, + category: 'emote', + transferredAt: Date.now() - TWO_DAYS, + item: { + rarity: 'unique', + price: 100 + i + } + }) + } + + return generatedEmotes +} + +export function generateDefinitions(urns: string[]) { + return urns.map((urn) => ({ + version: '1', + id: urn, + type: 'emote', + pointers: ['0x0', '0x1'], + timestamp: Date.now() - TWO_DAYS, + content: [{ + file: 'file', + hash: 'id' + }], + metadata: { + id: urn, + emoteDataADR74: { + representations: [ + { contents: ['fileName'] } + ] + } + } + })) +} diff --git a/test/data/lands.ts b/test/data/lands.ts new file mode 100644 index 00000000..6467b544 --- /dev/null +++ b/test/data/lands.ts @@ -0,0 +1,41 @@ +import { LANDFromQuery } from "../../src/adapters/lands-fetcher" + +export function generateLANDs(quantity: number): LANDFromQuery[] { + const generatedLANDs: LANDFromQuery[] = [] + for (let i = 0; i < quantity; i++) { + const isParcel = i % 2 == 0 + generatedLANDs.push({ + id: 'id-' + i, + contractAddress: 'contractAddress-' + i, + tokenId: 'tokenId-' + i, + category: isParcel ? 'parcel' : 'estate', + name: 'name-' + i, + image: 'image-' + i, + parcel: isParcel ? parcelInfoFor(i) : undefined, + estate: isParcel ? undefined : estateInfoFor(i), + activeOrder: { + price: 'price-' + i + } + }) + } + + return generatedLANDs +} + +function parcelInfoFor(i: number) { + return { + x: '0', + y: '0', + data: { + description: 'i am a parcel ' + i + } + } +} + +function estateInfoFor(i: number) { + return { + data: { + description: 'i am a estate ' + i + } + } +} \ No newline at end of file diff --git a/test/data/names.ts b/test/data/names.ts new file mode 100644 index 00000000..ce047383 --- /dev/null +++ b/test/data/names.ts @@ -0,0 +1,18 @@ +import { NameFromQuery } from "../../src/adapters/names-fetcher" + +export function generateNames(quantity: number): NameFromQuery[] { + const generatedNames: NameFromQuery[] = [] + for (let i = 0; i < quantity; i++) { + generatedNames.push({ + id: 'id-' + i, + name: 'name-' + i, + contractAddress: 'contractAddress-' + i, + tokenId: 'tokenId-' + i, + activeOrder: { + price: 'price-' + i + } + }) + } + + return generatedNames +} \ No newline at end of file diff --git a/test/data/wearables.ts b/test/data/wearables.ts new file mode 100644 index 00000000..d59f7fbb --- /dev/null +++ b/test/data/wearables.ts @@ -0,0 +1,42 @@ +const TWO_DAYS = (2 * 24 * 60 * 60 * 1000) + +export function generateWearables(quantity: number) { + const generatedWearables = [] + for (let i = 0; i < quantity; i++) { + generatedWearables.push({ + urn: 'urn-' + i, + id: 'id-' + i, + tokenId: 'tokenId-' + i, + category: 'wearable', + transferredAt: Date.now() - TWO_DAYS, + item: { + rarity: 'unique', + price: 100 + i + } + }) + } + + return generatedWearables +} + +export function generateDefinitions(urns: string[]) { + return urns.map((urn) => ({ + version: '1', + id: urn, + type: 'wearable', + pointers: ['0x0', '0x1'], + timestamp: Date.now() - TWO_DAYS, + content: [{ + file: 'file', + hash: 'id' + }], + metadata: { + id: urn, + data: { + representations: [ + { contents: ['fileName'] } + ] + } + } + })) +} diff --git a/test/integration/emotes-handler.spec.ts b/test/integration/emotes-handler.spec.ts new file mode 100644 index 00000000..ee59738a --- /dev/null +++ b/test/integration/emotes-handler.spec.ts @@ -0,0 +1,248 @@ +import { test } from '../components' +import { generateDefinitions, generateEmotes } from '../data/emotes' +import Wallet from 'ethereumjs-wallet' +import { ItemFromQuery } from '../../src/adapters/items-fetcher' +import { Item } from '../../src/types' + +// NOTE: each test generates a new wallet using ethereumjs-wallet to avoid matches on cache +test('emotes-handler: GET /users/:address/emotes should', function ({ components }) { + it('return empty when no emotes are found', async () => { + const { localFetch, theGraph } = components + + theGraph.maticCollectionsSubgraph.query = jest.fn().mockResolvedValueOnce({ nfts: [] }) + + const r = await localFetch.fetch(`/users/${Wallet.generate().getAddressString()}/emotes`) + + expect(r.status).toBe(200) + expect(await r.json()).toEqual({ + elements: [], + pageNum: 1, + totalAmount: 0, + pageSize: 100 + }) + }) + + it('return empty when no emotes are found with includeDefinitions set', async () => { + const { localFetch, theGraph, content } = components + + theGraph.maticCollectionsSubgraph.query = jest.fn().mockResolvedValueOnce({ nfts: [] }) + content.fetchEntitiesByPointers = jest.fn().mockResolvedValueOnce([]) + + const r = await localFetch.fetch(`/users/${Wallet.generate().getAddressString()}/emotes?includeDefinitions`) + + expect(r.status).toBe(200) + expect(await r.json()).toEqual({ + elements: [], + pageNum: 1, + totalAmount: 0, + pageSize: 100 + }) + }) + + it('return a emote from matic collection', async () => { + const { localFetch, theGraph } = components + const emotes = generateEmotes(1) + + jest.spyOn(theGraph.maticCollectionsSubgraph, 'query').mockResolvedValueOnce({ nfts: emotes }) + + const r = await localFetch.fetch(`/users/${Wallet.generate().getAddressString()}/emotes`) + + expect(r.status).toBe(200) + expect(await r.json()).toEqual({ + elements: convertToDataModel(emotes), + pageNum: 1, + pageSize: 100, + totalAmount: 1 + }) + }) + + it('return emotes with includeDefinitions set', async () => { + const { localFetch, theGraph, content } = components + const emotes = generateEmotes(1) + const definitions = generateDefinitions(emotes.map((emote) => emote.urn)) + + theGraph.maticCollectionsSubgraph.query = jest.fn().mockResolvedValueOnce({ nfts: emotes }) + content.fetchEntitiesByPointers = jest.fn().mockResolvedValueOnce(definitions) + + const r = await localFetch.fetch(`/users/${Wallet.generate().getAddressString()}/emotes?includeDefinitions`) + + expect(r.status).toBe(200) + expect(await r.json()).toEqual({ + elements: convertToDataModel(emotes, definitions), + pageNum: 1, + pageSize: 100, + totalAmount: 1 + }) + }) + + it('return a emote with definition and another one without definition', async () => { + const { localFetch, theGraph, content } = components + const emotes = generateEmotes(2) + const definitions = generateDefinitions([emotes[0].urn]) + + // modify emote urn to avoid cache hit + emotes[1] = { ...emotes[1], urn: 'anotherUrn' } + theGraph.maticCollectionsSubgraph.query = jest.fn().mockResolvedValueOnce({ nfts: emotes }) + content.fetchEntitiesByPointers = jest.fn().mockResolvedValueOnce(definitions) + + const r = await localFetch.fetch(`/users/${Wallet.generate().getAddressString()}/emotes?includeDefinitions`) + + expect(r.status).toBe(200) + expect(await r.json()).toEqual({ + elements: convertToDataModel(emotes, definitions), + pageNum: 1, + pageSize: 100, + totalAmount: 2 + }) + }) + + it('return emotes 2 and paginate them correctly (page 1, size 2, total 5)', async () => { + const { localFetch, theGraph } = components + const emotes = generateEmotes(5) + + theGraph.maticCollectionsSubgraph.query = jest.fn().mockResolvedValueOnce({ nfts: emotes }) + + const r = await localFetch.fetch(`/users/${Wallet.generate().getAddressString()}/emotes?pageSize=2&pageNum=1`) + + expect(r.status).toBe(200) + expect(await r.json()).toEqual({ + elements: convertToDataModel([emotes[0], emotes[1]]), + pageNum: 1, + pageSize: 2, + totalAmount: 5 + }) + }) + + it('return emotes 2 and paginate them correctly (page 2, size 2, total 5)', async () => { + const { localFetch, theGraph } = components + const emotes = generateEmotes(5) + + theGraph.maticCollectionsSubgraph.query = jest.fn().mockResolvedValueOnce({ nfts: emotes }) + + const r = await localFetch.fetch(`/users/${Wallet.generate().getAddressString()}/emotes?pageSize=2&pageNum=2`) + + expect(r.status).toBe(200) + expect(await r.json()).toEqual({ + elements: convertToDataModel([emotes[2], emotes[3]]), + pageNum: 2, + pageSize: 2, + totalAmount: 5 + }) + }) + + it('return emotes 2 and paginate them correctly (page 3, size 2, total 5)', async () => { + const { localFetch, theGraph } = components + const emotes = generateEmotes(5) + + theGraph.maticCollectionsSubgraph.query = jest.fn().mockResolvedValueOnce({ nfts: emotes }) + + const r = await localFetch.fetch(`/users/${Wallet.generate().getAddressString()}/emotes?pageSize=2&pageNum=3`) + + expect(r.status).toBe(200) + expect(await r.json()).toEqual({ + elements: convertToDataModel([emotes[4]]), + pageNum: 3, + pageSize: 2, + totalAmount: 5 + }) + }) + + it('return emotes from cache on second call for the same address', async () => { + const { localFetch, theGraph } = components + const emotes = generateEmotes(7) + const wallet = Wallet.generate().getAddressString() + + theGraph.maticCollectionsSubgraph.query = jest.fn().mockResolvedValueOnce({ nfts: emotes }) + + const r = await localFetch.fetch(`/users/${wallet}/emotes?pageSize=7&pageNum=1`) + const rBody = await r.json() + + expect(r.status).toBe(200) + expect(rBody).toEqual({ + elements: convertToDataModel(emotes), + pageNum: 1, + pageSize: 7, + totalAmount: 7 + }) + + const r2 = await localFetch.fetch(`/users/${wallet}/emotes?pageSize=7&pageNum=1`) + expect(r2.status).toBe(r.status) + expect(await r2.json()).toEqual(rBody) + expect(theGraph.maticCollectionsSubgraph.query).toHaveBeenCalledTimes(1) + }) + + it('return an error when emotes cannot be fetched from ethereum collection', async () => { + const { localFetch, theGraph } = components + + theGraph.ethereumCollectionsSubgraph.query = jest.fn().mockResolvedValueOnce(undefined) + + const r = await localFetch.fetch(`/users/${Wallet.generate().getAddressString()}/emotes`) + + expect(r.status).toBe(502) + expect(await r.json()).toEqual({ + error: 'Cannot fetch emotes right now' + }) + }) + + it('return an error when emotes cannot be fetched from matic collection', async () => { + const { localFetch, theGraph } = components + + theGraph.maticCollectionsSubgraph.query = jest.fn().mockResolvedValueOnce(undefined) + + const r = await localFetch.fetch(`/users/${Wallet.generate().getAddressString()}/emotes`) + + expect(r.status).toBe(502) + expect(await r.json()).toEqual({ + error: 'Cannot fetch emotes right now' + }) + }) + + it('return a generic error when an unexpected error occurs (definitions cannot be fetched)', async () => { + const { localFetch, theGraph, content } = components + const emotes = generateEmotes(2) + + // modify emote urn to avoid cache hit + emotes[1] = { ...emotes[1], urn: 'anotherUrn' } + + theGraph.maticCollectionsSubgraph.query = jest.fn().mockResolvedValueOnce({ nfts: emotes }) + content.fetchEntitiesByPointers = jest.fn().mockResolvedValueOnce(undefined) + + const r = await localFetch.fetch(`/users/${Wallet.generate().getAddressString()}/emotes?includeDefinitions`) + + expect(r.status).toBe(500) + expect(await r.json()).toEqual({ + error: 'Internal Server Error' + }) + }) +}) + +function convertToDataModel(emotes: ItemFromQuery[], definitions = undefined): Item[] { + return emotes.map(emote => { + const individualData = { + id: emote.id, + tokenId: emote.tokenId, + transferredAt: emote.transferredAt, + price: emote.item.price + } + const rarity = emote.item.rarity + const definition = definitions?.find(def => def.id === emote.urn) + const definitionData = definition?.metadata?.emoteDataADR74 + + return { + urn: emote.urn, + amount: 1, + individualData: [individualData], + rarity, + ...(definitions ? { + definition: definitionData && { + id: emote.urn, + emoteDataADR74: { + ...definitionData, + representations: [{ contents: [{ key: definitionData.representations[0]?.contents[0] }] }] + } + } + } : {}) + } + }) +} + diff --git a/test/integration/lands-handler.spec.ts b/test/integration/lands-handler.spec.ts new file mode 100644 index 00000000..9b892767 --- /dev/null +++ b/test/integration/lands-handler.spec.ts @@ -0,0 +1,153 @@ +import { test } from '../components' +import { generateWearables } from '../data/wearables' +import Wallet from 'ethereumjs-wallet' +import { LAND, LANDFromQuery } from '../../src/adapters/lands-fetcher' +import { generateLANDs } from '../data/lands' + +// NOTE: each test generates a new wallet using ethereumjs-wallet to avoid matches on cache +test('lands-handler: GET /users/:address/lands should', function ({ components }) { + it('return empty when no lands are found', async () => { + const { localFetch, theGraph } = components + + theGraph.ensSubgraph.query = jest.fn().mockResolvedValueOnce({ nfts: [] }) + + const r = await localFetch.fetch(`/users/${Wallet.generate().getAddressString()}/lands`) + + expect(r.status).toBe(200) + expect(await r.json()).toEqual({ + elements: [], + pageNum: 1, + totalAmount: 0, + pageSize: 100 + }) + }) + + it('return a LAND', async () => { + const { localFetch, theGraph } = components + + const lands = generateLANDs(1) + + theGraph.ensSubgraph.query = jest.fn().mockResolvedValueOnce({ nfts: lands }) + + const r = await localFetch.fetch(`/users/${Wallet.generate().getAddressString()}/lands`) + + expect(r.status).toBe(200) + const response = await r.json() + expect(response).toEqual({ + elements: convertToDataModel(lands), + pageNum: 1, + pageSize: 100, + totalAmount: 1 + }) + }) + + it('return 2 lands and paginate them correctly (page 1, size 2, total 5)', async () => { + const { localFetch, theGraph } = components + const lands = generateWearables(5) + + theGraph.ensSubgraph.query = jest.fn().mockResolvedValueOnce({ nfts: lands }) + + const r = await localFetch.fetch(`/users/${Wallet.generate().getAddressString()}/lands?pageSize=2&pageNum=1`) + + expect(r.status).toBe(200) + expect(await r.json()).toEqual({ + elements: convertToDataModel([lands[0], lands[1]]), + pageNum: 1, + pageSize: 2, + totalAmount: 5 + }) + }) + + it('return 2 lands and paginate them correctly (page 2, size 2, total 5)', async () => { + const { localFetch, theGraph } = components + const lands = generateWearables(5) + + theGraph.ensSubgraph.query = jest.fn().mockResolvedValueOnce({ nfts: lands }) + + const r = await localFetch.fetch(`/users/${Wallet.generate().getAddressString()}/lands?pageSize=2&pageNum=2`) + + expect(r.status).toBe(200) + expect(await r.json()).toEqual({ + elements: convertToDataModel([lands[2], lands[3]]), + pageNum: 2, + pageSize: 2, + totalAmount: 5 + }) + }) + + it('return 1 LAND and paginate them correctly (page 3, size 2, total 4)', async () => { + const { localFetch, theGraph } = components + const lands = generateWearables(5) + + theGraph.ensSubgraph.query = jest.fn().mockResolvedValueOnce({ nfts: lands }) + + const r = await localFetch.fetch(`/users/${Wallet.generate().getAddressString()}/lands?pageSize=2&pageNum=3`) + + expect(r.status).toBe(200) + expect(await r.json()).toEqual({ + elements: convertToDataModel([lands[4]]), + pageNum: 3, + pageSize: 2, + totalAmount: 5 + }) + }) + + it('return lands from cache on second call for the same address', async () => { + const { localFetch, theGraph } = components + const lands = generateLANDs(7) + const wallet = Wallet.generate().getAddressString() + + theGraph.ensSubgraph.query = jest.fn().mockResolvedValueOnce({ nfts: lands }) + + const r = await localFetch.fetch(`/users/${wallet}/lands?pageSize=7&pageNum=1`) + const rBody = await r.json() + + expect(r.status).toBe(200) + expect(rBody).toEqual({ + elements: convertToDataModel(lands), + pageNum: 1, + pageSize: 7, + totalAmount: 7 + }) + + const r2 = await localFetch.fetch(`/users/${wallet}/lands?pageSize=7&pageNum=1`) + expect(r2.status).toBe(r.status) + expect(await r2.json()).toEqual(rBody) + expect(theGraph.ensSubgraph.query).toHaveBeenCalledTimes(1) + }) + + it('return an error when lands cannot be fetched', async () => { + const { localFetch, theGraph } = components + + theGraph.ensSubgraph.query = jest.fn().mockResolvedValueOnce(undefined) + + const r = await localFetch.fetch(`/users/${Wallet.generate().getAddressString()}/lands`) + + expect(r.status).toBe(502) + expect(await r.json()).toEqual({ + error: 'Cannot fetch lands right now' + }) + }) +}) + +function convertToDataModel(lands: LANDFromQuery[]): LAND[] { + return lands.map(LAND => { + const { name, contractAddress, tokenId, category, parcel, estate, image, activeOrder } = LAND + + const isParcel = category === 'parcel' + const x = isParcel ? parcel?.x : undefined + const y = isParcel ? parcel?.x : undefined + const description = isParcel ? parcel?.data?.description : estate?.data?.description + return { + name: name === null ? undefined : name, + contractAddress, + tokenId, + category, + x, + y, + description, + price: activeOrder ? activeOrder.price : undefined, + image + } + }) +} diff --git a/test/integration/names-handler.spec.ts b/test/integration/names-handler.spec.ts new file mode 100644 index 00000000..7dba7894 --- /dev/null +++ b/test/integration/names-handler.spec.ts @@ -0,0 +1,145 @@ +import { test } from '../components' +import { generateDefinitions, generateWearables } from '../data/wearables' +import Wallet from 'ethereumjs-wallet' +import { NameFromQuery } from '../../src/adapters/names-fetcher' +import { Name } from '../../src/types' +import { generateNames } from '../data/names' +import { generateEmotes } from '../data/emotes' + +// NOTE: each test generates a new wallet using ethereumjs-wallet to avoid matches on cache +test('names-handler: GET /users/:address/names should', function ({ components }) { + it('return empty when no names are found', async () => { + const { localFetch, theGraph } = components + + theGraph.ensSubgraph.query = jest.fn().mockResolvedValueOnce({ nfts: [] }) + + const r = await localFetch.fetch(`/users/${Wallet.generate().getAddressString()}/names`) + + expect(r.status).toBe(200) + expect(await r.json()).toEqual({ + elements: [], + pageNum: 1, + totalAmount: 0, + pageSize: 100 + }) + }) + + it('return a name', async () => { + const { localFetch, theGraph } = components + + const names = generateNames(1) + + theGraph.ensSubgraph.query = jest.fn().mockResolvedValueOnce({ nfts: names }) + + const r = await localFetch.fetch(`/users/${Wallet.generate().getAddressString()}/names`) + + expect(r.status).toBe(200) + const response = await r.json() + expect(response).toEqual({ + elements: convertToDataModel(names), + pageNum: 1, + pageSize: 100, + totalAmount: 1 + }) + }) + + it('return 2 names and paginate them correctly (page 1, size 2, total 5)', async () => { + const { localFetch, theGraph } = components + const names = generateWearables(5) + + theGraph.ensSubgraph.query = jest.fn().mockResolvedValueOnce({ nfts: names }) + + const r = await localFetch.fetch(`/users/${Wallet.generate().getAddressString()}/names?pageSize=2&pageNum=1`) + + expect(r.status).toBe(200) + expect(await r.json()).toEqual({ + elements: convertToDataModel([names[0], names[1]]), + pageNum: 1, + pageSize: 2, + totalAmount: 5 + }) + }) + + it('return 2 names and paginate them correctly (page 2, size 2, total 5)', async () => { + const { localFetch, theGraph } = components + const names = generateWearables(5) + + theGraph.ensSubgraph.query = jest.fn().mockResolvedValueOnce({ nfts: names }) + + const r = await localFetch.fetch(`/users/${Wallet.generate().getAddressString()}/names?pageSize=2&pageNum=2`) + + expect(r.status).toBe(200) + expect(await r.json()).toEqual({ + elements: convertToDataModel([names[2], names[3]]), + pageNum: 2, + pageSize: 2, + totalAmount: 5 + }) + }) + + it('return 1 name and paginate them correctly (page 3, size 2, total 4)', async () => { + const { localFetch, theGraph } = components + const names = generateWearables(5) + + theGraph.ensSubgraph.query = jest.fn().mockResolvedValueOnce({ nfts: names }) + + const r = await localFetch.fetch(`/users/${Wallet.generate().getAddressString()}/names?pageSize=2&pageNum=3`) + + expect(r.status).toBe(200) + expect(await r.json()).toEqual({ + elements: convertToDataModel([names[4]]), + pageNum: 3, + pageSize: 2, + totalAmount: 5 + }) + }) + + it('return names from cache on second call for the same address', async () => { + const { localFetch, theGraph } = components + const names = generateNames(7) + const wallet = Wallet.generate().getAddressString() + + theGraph.ensSubgraph.query = jest.fn().mockResolvedValueOnce({ nfts: names }) + + const r = await localFetch.fetch(`/users/${wallet}/names?pageSize=7&pageNum=1`) + const rBody = await r.json() + + expect(r.status).toBe(200) + expect(rBody).toEqual({ + elements: convertToDataModel(names), + pageNum: 1, + pageSize: 7, + totalAmount: 7 + }) + + const r2 = await localFetch.fetch(`/users/${wallet}/names?pageSize=7&pageNum=1`) + expect(r2.status).toBe(r.status) + expect(await r2.json()).toEqual(rBody) + expect(theGraph.ensSubgraph.query).toHaveBeenCalledTimes(1) + }) + + it('return an error when names cannot be fetched', async () => { + const { localFetch, theGraph } = components + + theGraph.ensSubgraph.query = jest.fn().mockResolvedValueOnce(undefined) + + const r = await localFetch.fetch(`/users/${Wallet.generate().getAddressString()}/names`) + + expect(r.status).toBe(502) + expect(await r.json()).toEqual({ + error: 'Cannot fetch names right now' + }) + }) +}) + +function convertToDataModel(names: NameFromQuery[]): Name[] { + return names.map(name => { + return { + name: name.name, + tokenId: name.tokenId, + contractAddress: name.contractAddress, + price: name.activeOrder?.price + } + }) +} + diff --git a/test/integration/profiles-controller.spec.ts b/test/integration/profiles-controller.spec.ts index 28a70c64..52e3b071 100644 --- a/test/integration/profiles-controller.spec.ts +++ b/test/integration/profiles-controller.spec.ts @@ -1,88 +1,121 @@ -import { EntityType } from "@dcl/schemas" -import { test } from "../components" -import { Response } from "node-fetch" -import sinon from "sinon" -import { profileEntityFull, profileEntityFullAnother, profileEntityFullB, profileEntityOldBodyshape, profileEntitySeveralTPWFromDifferentCollections, profileEntitySnapshotsReferenceContentFile, profileEntityTwoEthWearables, profileEntityTwoMaticWearables, profileEntityTwoTPWFromSameCollection, profileEntityWithClaimedName, profileEntityWithNewTimestamp, profileEntityWithOldTimestamp, profileEntityWithoutNFTs, tpwResolverResponseFromDifferentCollection, tpwResolverResponseFull, tpwResolverResponseFullAnother, tpwResolverResponseOwnOnlyOne } from "./data/profiles-responses" - - -test("integration tests for /profiles", function ({ components, stubComponents }) { - it("calling without body should return 500", async () => { +import { test } from '../components' +import { Response } from 'node-fetch' +import sinon from 'sinon' +import { + profileEntityFull, + profileEntityFullAnother, + profileEntityFullB, + profileEntityOldBodyshape, + profileEntitySeveralTPWFromDifferentCollections, + profileEntitySnapshotsReferenceContentFile, + profileEntityTwoEthWearables, + profileEntityTwoMaticWearables, + profileEntityTwoTPWFromSameCollection, + profileEntityWithClaimedName, + profileEntityWithNewTimestamp, + profileEntityWithOldTimestamp, + profileEntityWithoutNFTs, + tpwResolverResponseFromDifferentCollection, + tpwResolverResponseFull, + tpwResolverResponseFullAnother, + tpwResolverResponseOwnOnlyOne +} from './data/profiles-responses' + +test('integration tests for /profiles', function ({ components, stubComponents }) { + it('calling without body should return 500', async () => { const { localFetch } = components - const r = await localFetch.fetch("/profiles", {method: 'post'}) + const r = await localFetch.fetch('/profiles', { method: 'post' }) expect(r.status).toEqual(500) - expect(await r.text()).toEqual("") + expect(await r.text()).toEqual('') }) - it("calling with an empty body should return 500", async () => { + it('calling with an empty body should return 500', async () => { const { localFetch } = components - const r = await localFetch.fetch("/profiles", {method: 'post', body: ''}) + const r = await localFetch.fetch('/profiles', { method: 'post', body: '' }) expect(r.status).toEqual(500) - expect(await r.text()).toEqual("") + expect(await r.text()).toEqual('') }) - it("calling with body with empty object should return 400", async () => { + it('calling with body with empty object should return 400', async () => { const { localFetch } = components - const r = await localFetch.fetch("/profiles", {method: 'post', body: '{}'}) + const r = await localFetch.fetch('/profiles', { method: 'post', body: '{}' }) expect(r.status).toEqual(400) - expect(await r.text()).toEqual("No profile ids were specified. Expected ids:string[] in body") + expect(await r.text()).toEqual('No profile ids were specified. Expected ids:string[] in body') }) - it("calling with an empty list", async () => { + it('calling with an empty list', async () => { const { localFetch } = components - const r = await localFetch.fetch("/profiles", {method: 'post', body: '{"ids":[]}'}) + const r = await localFetch.fetch('/profiles', { method: 'post', body: '{"ids":[]}' }) expect(r.status).toEqual(200) - expect(await r.text()).toEqual("[]") + expect(await r.text()).toEqual('[]') }) - it("calling with a single profile address, owning everything claimed", async () => { + it('calling with a single profile address, owning everything claimed', async () => { const { localFetch } = components const { theGraph, fetch, content } = stubComponents - const addresses = ["0x1"] - - content.fetchEntitiesByPointers.withArgs(EntityType.PROFILE, addresses).resolves(await Promise.all([profileEntityFull])) - const wearablesQuery = "{\n P0x1: nfts(where: { owner: \"0x1\", searchItemType_in: [\"wearable_v1\", \"wearable_v2\", \"smart_wearable_v1\", \"emote_v1\"], urn_in: [\"urn:decentraland:matic:collections-v2:0xa25c20f58ac447621a5f854067b857709cbd60eb:7\",\"urn:decentraland:matic:collections-v2:0x293d1ae40b28c39d7b013d4a1fe3c5a8c016bf19:1\",\"urn:decentraland:ethereum:collections-v1:ethermon_wearables:ethermon_feet\",\"urn:decentraland:ethereum:collections-v1:ethermon_wearables:ethermon_hand\",\"urn:decentraland:matic:collections-thirdparty:ntr1-meta:ntr1-meta-1ef79e7b:98ac122c-523f-403f-9730-f09c992f386f\",\"urn:decentraland:matic:collections-thirdparty:ntr1-meta:ntr1-meta-1ef79e7b:12341234-1234-3434-3434-f9dfde9f9393\"] }, first: 1000) {\n urn\n }\n }" - theGraph.collectionsSubgraph.query = sinon.stub().withArgs(wearablesQuery, {}).resolves({ + const addresses = ['0x1'] + + content.fetchEntitiesByPointers.withArgs(addresses).resolves(await Promise.all([profileEntityFull])) + const wearablesQuery = + '{\n P0x1: nfts(where: { owner: "0x1", searchItemType_in: ["wearable_v1", "wearable_v2", "smart_wearable_v1", "emote_v1"], urn_in: ["urn:decentraland:matic:collections-v2:0xa25c20f58ac447621a5f854067b857709cbd60eb:7","urn:decentraland:matic:collections-v2:0x293d1ae40b28c39d7b013d4a1fe3c5a8c016bf19:1","urn:decentraland:ethereum:collections-v1:ethermon_wearables:ethermon_feet","urn:decentraland:ethereum:collections-v1:ethermon_wearables:ethermon_hand","urn:decentraland:matic:collections-thirdparty:ntr1-meta:ntr1-meta-1ef79e7b:98ac122c-523f-403f-9730-f09c992f386f","urn:decentraland:matic:collections-thirdparty:ntr1-meta:ntr1-meta-1ef79e7b:12341234-1234-3434-3434-f9dfde9f9393"] }, first: 1000) {\n urn\n }\n }' + theGraph.ethereumCollectionsSubgraph.query = sinon + .stub() + .withArgs(wearablesQuery, {}) + .resolves({ P0x1: [ - {urn: "urn:decentraland:ethereum:collections-v1:ethermon_wearables:ethermon_feet"}, - {urn: "urn:decentraland:ethereum:collections-v1:ethermon_wearables:ethermon_hand"} + { urn: 'urn:decentraland:ethereum:collections-v1:ethermon_wearables:ethermon_feet' }, + { urn: 'urn:decentraland:ethereum:collections-v1:ethermon_wearables:ethermon_hand' } ] - }) - theGraph.maticCollectionsSubgraph.query = sinon.stub().withArgs(wearablesQuery, {}).resolves({ + }) + theGraph.maticCollectionsSubgraph.query = sinon + .stub() + .withArgs(wearablesQuery, {}) + .resolves({ P0x1: [ - {urn: "urn:decentraland:matic:collections-v2:0xa25c20f58ac447621a5f854067b857709cbd60eb:7"}, - {urn: "urn:decentraland:matic:collections-v2:0x293d1ae40b28c39d7b013d4a1fe3c5a8c016bf19:1"} + { urn: 'urn:decentraland:matic:collections-v2:0xa25c20f58ac447621a5f854067b857709cbd60eb:7' }, + { urn: 'urn:decentraland:matic:collections-v2:0x293d1ae40b28c39d7b013d4a1fe3c5a8c016bf19:1' } ] - }) - - const namesQuery = "{\n P0x1: nfts(where: { owner: \"0x1\", category: ens, name_in: [\"cryptonico\"] }, first: 1000) {\n name\n }\n }" - theGraph.ensSubgraph.query = sinon.stub().withArgs(namesQuery, {}).resolves({P0x1: [ { name: 'cryptonico' } ]}) - - const tpwQuery = "\nquery ThirdPartyResolver($id: String!) {\n thirdParties(where: {id: $id, isApproved: true}) {\n id\n resolver\n }\n}\n" - const tpwId = "urn:decentraland:matic:collections-thirdparty:ntr1-meta" - theGraph.thirdPartyRegistrySubgraph.query = sinon.stub().withArgs(tpwQuery, {tpwId}).resolves({ + }) + + const namesQuery = + '{\n P0x1: nfts(where: { owner: "0x1", category: ens, name_in: ["cryptonico"] }, first: 1000) {\n name\n }\n }' + theGraph.ensSubgraph.query = sinon + .stub() + .withArgs(namesQuery, {}) + .resolves({ P0x1: [{ name: 'cryptonico' }] }) + + const tpwQuery = + '\nquery ThirdPartyResolver($id: String!) {\n thirdParties(where: {id: $id, isApproved: true}) {\n id\n resolver\n }\n}\n' + const tpwId = 'urn:decentraland:matic:collections-thirdparty:ntr1-meta' + theGraph.thirdPartyRegistrySubgraph.query = sinon + .stub() + .withArgs(tpwQuery, { tpwId }) + .resolves({ thirdParties: [ { - id: "urn:decentraland:matic:collections-thirdparty:ntr1-meta", - resolver: "https://api.swappable.io/api/v1", - }, + id: 'urn:decentraland:matic:collections-thirdparty:ntr1-meta', + resolver: 'https://api.swappable.io/api/v1' + } ] - }) + }) fetch.fetch - .withArgs("https://api.swappable.io/api/v1/registry/ntr1-meta/address/0x1/assets") - .onCall(0).resolves(new Response(JSON.stringify(tpwResolverResponseFull))) - .onCall(1).resolves(new Response(JSON.stringify(tpwResolverResponseFull))) - - const response = await localFetch.fetch("/profiles", {method: 'post', body: JSON.stringify({ids:addresses})}) + .withArgs('https://api.swappable.io/api/v1/registry/ntr1-meta/address/0x1/assets') + .onCall(0) + .resolves(new Response(JSON.stringify(tpwResolverResponseFull))) + .onCall(1) + .resolves(new Response(JSON.stringify(tpwResolverResponseFull))) + + const response = await localFetch.fetch('/profiles', { method: 'post', body: JSON.stringify({ ids: addresses }) }) - sinon.assert.calledOnceWithMatch(content.fetchEntitiesByPointers, EntityType.PROFILE, addresses) + sinon.assert.calledOnceWithMatch(content.fetchEntitiesByPointers, addresses) expect(response.status).toEqual(200) const responseText = await response.text() @@ -90,43 +123,63 @@ test("integration tests for /profiles", function ({ components, stubComponents } expect(responseObj.length).toEqual(1) expect(responseObj[0].avatars.length).toEqual(1) expect(responseObj[0].avatars?.[0].hasClaimedName).toEqual(true) - expect(responseObj[0].avatars?.[0].ethAddress).toEqual("0x1") - expect(responseObj[0].avatars?.[0].name).toEqual("cryptonico") + expect(responseObj[0].avatars?.[0].ethAddress).toEqual('0x1') + expect(responseObj[0].avatars?.[0].name).toEqual('cryptonico') expect(responseObj[0].avatars?.[0].unclaimedName).toBeUndefined() - expect(responseObj[0].avatars?.[0].avatar.bodyShape).toEqual("urn:decentraland:off-chain:base-avatars:BaseMale") - expect(responseObj[0].avatars?.[0].avatar.snapshots.body).toEqual("https://peer.decentraland.org/content/contents/bafkreicilawwtbjyf6ahyzv64ssoamdm73rif75qncee5lv6j3a3352lua") - expect(responseObj[0].avatars?.[0].avatar.snapshots.face256).toEqual("https://peer.decentraland.org/content/contents/bafkreigi3yrgdhvjr2cqzxvfztnsubnll2cfdioo4vfzu6o6vibwoag2ma") + expect(responseObj[0].avatars?.[0].avatar.bodyShape).toEqual('urn:decentraland:off-chain:base-avatars:BaseMale') + expect(responseObj[0].avatars?.[0].avatar.snapshots.body).toEqual( + 'https://peer.decentraland.org/content/contents/bafkreicilawwtbjyf6ahyzv64ssoamdm73rif75qncee5lv6j3a3352lua' + ) + expect(responseObj[0].avatars?.[0].avatar.snapshots.face256).toEqual( + 'https://peer.decentraland.org/content/contents/bafkreigi3yrgdhvjr2cqzxvfztnsubnll2cfdioo4vfzu6o6vibwoag2ma' + ) expect(responseObj[0].avatars?.[0].avatar.wearables.length).toEqual(8) - expect(responseObj[0].avatars?.[0].avatar.wearables).toContain("urn:decentraland:off-chain:base-avatars:eyebrows_00") - expect(responseObj[0].avatars?.[0].avatar.wearables).toContain("urn:decentraland:off-chain:base-avatars:short_hair") - expect(responseObj[0].avatars?.[0].avatar.wearables).toContain("urn:decentraland:matic:collections-v2:0xa25c20f58ac447621a5f854067b857709cbd60eb:7") - expect(responseObj[0].avatars?.[0].avatar.wearables).toContain("urn:decentraland:matic:collections-v2:0x293d1ae40b28c39d7b013d4a1fe3c5a8c016bf19:1") - expect(responseObj[0].avatars?.[0].avatar.wearables).toContain("urn:decentraland:ethereum:collections-v1:ethermon_wearables:ethermon_feet") - expect(responseObj[0].avatars?.[0].avatar.wearables).toContain("urn:decentraland:ethereum:collections-v1:ethermon_wearables:ethermon_feet") - expect(responseObj[0].avatars?.[0].avatar.wearables).toContain("urn:decentraland:matic:collections-thirdparty:ntr1-meta:ntr1-meta-1ef79e7b:98ac122c-523f-403f-9730-f09c992f386f") - expect(responseObj[0].avatars?.[0].avatar.wearables).toContain("urn:decentraland:matic:collections-thirdparty:ntr1-meta:ntr1-meta-1ef79e7b:12341234-1234-3434-3434-f9dfde9f9393") + expect(responseObj[0].avatars?.[0].avatar.wearables).toContain( + 'urn:decentraland:off-chain:base-avatars:eyebrows_00' + ) + expect(responseObj[0].avatars?.[0].avatar.wearables).toContain('urn:decentraland:off-chain:base-avatars:short_hair') + expect(responseObj[0].avatars?.[0].avatar.wearables).toContain( + 'urn:decentraland:matic:collections-v2:0xa25c20f58ac447621a5f854067b857709cbd60eb:7' + ) + expect(responseObj[0].avatars?.[0].avatar.wearables).toContain( + 'urn:decentraland:matic:collections-v2:0x293d1ae40b28c39d7b013d4a1fe3c5a8c016bf19:1' + ) + expect(responseObj[0].avatars?.[0].avatar.wearables).toContain( + 'urn:decentraland:ethereum:collections-v1:ethermon_wearables:ethermon_feet' + ) + expect(responseObj[0].avatars?.[0].avatar.wearables).toContain( + 'urn:decentraland:ethereum:collections-v1:ethermon_wearables:ethermon_feet' + ) + expect(responseObj[0].avatars?.[0].avatar.wearables).toContain( + 'urn:decentraland:matic:collections-thirdparty:ntr1-meta:ntr1-meta-1ef79e7b:98ac122c-523f-403f-9730-f09c992f386f' + ) + expect(responseObj[0].avatars?.[0].avatar.wearables).toContain( + 'urn:decentraland:matic:collections-thirdparty:ntr1-meta:ntr1-meta-1ef79e7b:12341234-1234-3434-3434-f9dfde9f9393' + ) }) - it("calling with a single profile address, without nfts", async () => { + it('calling with a single profile address, without nfts', async () => { const { localFetch } = components const { theGraph, content } = stubComponents - const addresses = ["0x2"] + const addresses = ['0x2'] - content.fetchEntitiesByPointers.withArgs(EntityType.PROFILE, addresses).resolves(await Promise.all([profileEntityWithoutNFTs])) - - const wearablesQuery = '{ P0x2: nfts(where: { owner: "0x2", searchItemType_in: ["wearable_v1", "wearable_v2", "smart_wearable_v1", "emote_v1"], urn_in: [] }, first: 1000) { urn } }' - theGraph.collectionsSubgraph.query = sinon.stub().withArgs(wearablesQuery, {}).resolves({ + content.fetchEntitiesByPointers.withArgs(addresses).resolves(await Promise.all([profileEntityWithoutNFTs])) + + const wearablesQuery = + '{ P0x2: nfts(where: { owner: "0x2", searchItemType_in: ["wearable_v1", "wearable_v2", "smart_wearable_v1", "emote_v1"], urn_in: [] }, first: 1000) { urn } }' + theGraph.ethereumCollectionsSubgraph.query = sinon.stub().withArgs(wearablesQuery, {}).resolves({ P0x2: [] }) theGraph.maticCollectionsSubgraph.query = sinon.stub().withArgs(wearablesQuery, {}).resolves({ P0x2: [] }) - const namesQuery = "{\n P0x2: nfts(where: { owner: \"0x2\", category: ens, name_in: [\"cryptonico#e602\"] }, first: 1000) {\n name\n }\n }" + const namesQuery = + '{\n P0x2: nfts(where: { owner: "0x2", category: ens, name_in: ["cryptonico#e602"] }, first: 1000) {\n name\n }\n }' theGraph.ensSubgraph.query = sinon.stub().withArgs(namesQuery, {}).resolves({ P0x2: [] }) - - const response = await localFetch.fetch("/profiles", {method: 'post', body: JSON.stringify({ids:addresses})}) + + const response = await localFetch.fetch('/profiles', { method: 'post', body: JSON.stringify({ ids: addresses }) }) expect(response.status).toEqual(200) const responseText = await response.text() @@ -134,37 +187,44 @@ test("integration tests for /profiles", function ({ components, stubComponents } expect(responseObj.length).toEqual(1) expect(responseObj[0].avatars.length).toEqual(1) expect(responseObj[0].avatars?.[0].hasClaimedName).toEqual(false) - expect(responseObj[0].avatars?.[0].ethAddress).toEqual("0x2") - expect(responseObj[0].avatars?.[0].name).toEqual("cryptonico#e602") - expect(responseObj[0].avatars?.[0].unclaimedName).toEqual("cryptonico") - expect(responseObj[0].avatars?.[0].avatar.bodyShape).toEqual("urn:decentraland:off-chain:base-avatars:BaseMale") - expect(responseObj[0].avatars?.[0].avatar.snapshots.body).toEqual("https://peer.decentraland.org/content/contents/bafkreicilawwtbjyf6ahyzv64ssoamdm73rif75qncee5lv6j3a3352lua") - expect(responseObj[0].avatars?.[0].avatar.snapshots.face256).toEqual("https://peer.decentraland.org/content/contents/bafkreigi3yrgdhvjr2cqzxvfztnsubnll2cfdioo4vfzu6o6vibwoag2ma") + expect(responseObj[0].avatars?.[0].ethAddress).toEqual('0x2') + expect(responseObj[0].avatars?.[0].name).toEqual('cryptonico#e602') + expect(responseObj[0].avatars?.[0].unclaimedName).toEqual('cryptonico') + expect(responseObj[0].avatars?.[0].avatar.bodyShape).toEqual('urn:decentraland:off-chain:base-avatars:BaseMale') + expect(responseObj[0].avatars?.[0].avatar.snapshots.body).toEqual( + 'https://peer.decentraland.org/content/contents/bafkreicilawwtbjyf6ahyzv64ssoamdm73rif75qncee5lv6j3a3352lua' + ) + expect(responseObj[0].avatars?.[0].avatar.snapshots.face256).toEqual( + 'https://peer.decentraland.org/content/contents/bafkreigi3yrgdhvjr2cqzxvfztnsubnll2cfdioo4vfzu6o6vibwoag2ma' + ) expect(responseObj[0].avatars?.[0].avatar.wearables.length).toEqual(0) }) - it("calling with a single profile address, two eth wearables, one of them not owned", async () => { + it('calling with a single profile address, two eth wearables, one of them not owned', async () => { const { localFetch } = components const { theGraph, content } = stubComponents - const addresses = ["0x3"] + const addresses = ['0x3'] - content.fetchEntitiesByPointers.withArgs(EntityType.PROFILE, addresses).resolves(await Promise.all([profileEntityTwoEthWearables])) + content.fetchEntitiesByPointers.withArgs(addresses).resolves(await Promise.all([profileEntityTwoEthWearables])) - const wearablesQuery = "{\n P0x3: nfts(where: { owner: \"0x3\", searchItemType_in: [\"wearable_v1\", \"wearable_v2\", \"smart_wearable_v1\", \"emote_v1\"], urn_in: [\"urn:decentraland:ethereum:collections-v1:ethermon_wearables:ethermon_feet\",\"urn:decentraland:ethereum:collections-v1:ethermon_wearables:ethermon_hand\"] }, first: 1000) {\n urn\n }\n }" - theGraph.collectionsSubgraph.query = sinon.stub().withArgs(wearablesQuery, {}).resolves({ - P0x3: [ - {urn: "urn:decentraland:ethereum:collections-v1:ethermon_wearables:ethermon_feet"} - ] - }) + const wearablesQuery = + '{\n P0x3: nfts(where: { owner: "0x3", searchItemType_in: ["wearable_v1", "wearable_v2", "smart_wearable_v1", "emote_v1"], urn_in: ["urn:decentraland:ethereum:collections-v1:ethermon_wearables:ethermon_feet","urn:decentraland:ethereum:collections-v1:ethermon_wearables:ethermon_hand"] }, first: 1000) {\n urn\n }\n }' + theGraph.ethereumCollectionsSubgraph.query = sinon + .stub() + .withArgs(wearablesQuery, {}) + .resolves({ + P0x3: [{ urn: 'urn:decentraland:ethereum:collections-v1:ethermon_wearables:ethermon_feet' }] + }) theGraph.maticCollectionsSubgraph.query = sinon.stub().withArgs(wearablesQuery, {}).resolves({ - P0x3: [] + P0x3: [] }) - const namesQuery = "{\n P0x3: nfts(where: { owner: \"0x3\", category: ens, name_in: [\"cryptonico#e602\"] }, first: 1000) {\n name\n }\n }" + const namesQuery = + '{\n P0x3: nfts(where: { owner: "0x3", category: ens, name_in: ["cryptonico#e602"] }, first: 1000) {\n name\n }\n }' theGraph.ensSubgraph.query = sinon.stub().withArgs(namesQuery, {}).resolves({ - P0x3: [] + P0x3: [] }) - - const response = await localFetch.fetch("/profiles", {method: 'post', body: JSON.stringify({ids:addresses})}) + + const response = await localFetch.fetch('/profiles', { method: 'post', body: JSON.stringify({ ids: addresses }) }) expect(response.status).toEqual(200) const responseText = await response.text() @@ -172,39 +232,50 @@ test("integration tests for /profiles", function ({ components, stubComponents } expect(responseObj.length).toEqual(1) expect(responseObj[0].avatars.length).toEqual(1) expect(responseObj[0].avatars?.[0].hasClaimedName).toEqual(false) - expect(responseObj[0].avatars?.[0].ethAddress).toEqual("0x3") - expect(responseObj[0].avatars?.[0].name).toEqual("cryptonico#e602") + expect(responseObj[0].avatars?.[0].ethAddress).toEqual('0x3') + expect(responseObj[0].avatars?.[0].name).toEqual('cryptonico#e602') expect(responseObj[0].avatars?.[0].unclaimedName).toEqual('cryptonico') - expect(responseObj[0].avatars?.[0].avatar.bodyShape).toEqual("urn:decentraland:off-chain:base-avatars:BaseMale") - expect(responseObj[0].avatars?.[0].avatar.snapshots.body).toEqual("https://peer.decentraland.org/content/contents/bafkreicilawwtbjyf6ahyzv64ssoamdm73rif75qncee5lv6j3a3352lua") - expect(responseObj[0].avatars?.[0].avatar.snapshots.face256).toEqual("https://peer.decentraland.org/content/contents/bafkreigi3yrgdhvjr2cqzxvfztnsubnll2cfdioo4vfzu6o6vibwoag2ma") + expect(responseObj[0].avatars?.[0].avatar.bodyShape).toEqual('urn:decentraland:off-chain:base-avatars:BaseMale') + expect(responseObj[0].avatars?.[0].avatar.snapshots.body).toEqual( + 'https://peer.decentraland.org/content/contents/bafkreicilawwtbjyf6ahyzv64ssoamdm73rif75qncee5lv6j3a3352lua' + ) + expect(responseObj[0].avatars?.[0].avatar.snapshots.face256).toEqual( + 'https://peer.decentraland.org/content/contents/bafkreigi3yrgdhvjr2cqzxvfztnsubnll2cfdioo4vfzu6o6vibwoag2ma' + ) expect(responseObj[0].avatars?.[0].avatar.wearables.length).toEqual(3) - expect(responseObj[0].avatars?.[0].avatar.wearables).toContain("urn:decentraland:off-chain:base-avatars:eyebrows_00") - expect(responseObj[0].avatars?.[0].avatar.wearables).toContain("urn:decentraland:off-chain:base-avatars:short_hair") - expect(responseObj[0].avatars?.[0].avatar.wearables).toContain("urn:decentraland:ethereum:collections-v1:ethermon_wearables:ethermon_feet") + expect(responseObj[0].avatars?.[0].avatar.wearables).toContain( + 'urn:decentraland:off-chain:base-avatars:eyebrows_00' + ) + expect(responseObj[0].avatars?.[0].avatar.wearables).toContain('urn:decentraland:off-chain:base-avatars:short_hair') + expect(responseObj[0].avatars?.[0].avatar.wearables).toContain( + 'urn:decentraland:ethereum:collections-v1:ethermon_wearables:ethermon_feet' + ) }) - it("calling with a single profile address, two matic wearables, one of them not owned", async () => { + it('calling with a single profile address, two matic wearables, one of them not owned', async () => { const { localFetch } = components const { theGraph, content } = stubComponents - const addresses = ["0x4"] + const addresses = ['0x4'] - content.fetchEntitiesByPointers.withArgs(EntityType.PROFILE, addresses).resolves(await Promise.all([profileEntityTwoMaticWearables])) - const wearablesQuery = "{\n P0x4: nfts(where: { owner: \"0x4\", searchItemType_in: [\"wearable_v1\", \"wearable_v2\", \"smart_wearable_v1\", \"emote_v1\"], urn_in: [\"urn:decentraland:matic:collections-v2:0xa25c20f58ac447621a5f854067b857709cbd60eb:7\",\"urn:decentraland:matic:collections-v2:0x293d1ae40b28c39d7b013d4a1fe3c5a8c016bf19:1\"] }, first: 1000) {\n urn\n }\n }" - theGraph.collectionsSubgraph.query = sinon.stub().withArgs(wearablesQuery, {}).resolves({ - P0x4: [] - }) - theGraph.maticCollectionsSubgraph.query = sinon.stub().withArgs(wearablesQuery, {}).resolves({ - P0x4: [ - {urn: "urn:decentraland:matic:collections-v2:0x293d1ae40b28c39d7b013d4a1fe3c5a8c016bf19:1"} - ] + content.fetchEntitiesByPointers.withArgs(addresses).resolves(await Promise.all([profileEntityTwoMaticWearables])) + const wearablesQuery = + '{\n P0x4: nfts(where: { owner: "0x4", searchItemType_in: ["wearable_v1", "wearable_v2", "smart_wearable_v1", "emote_v1"], urn_in: ["urn:decentraland:matic:collections-v2:0xa25c20f58ac447621a5f854067b857709cbd60eb:7","urn:decentraland:matic:collections-v2:0x293d1ae40b28c39d7b013d4a1fe3c5a8c016bf19:1"] }, first: 1000) {\n urn\n }\n }' + theGraph.ethereumCollectionsSubgraph.query = sinon.stub().withArgs(wearablesQuery, {}).resolves({ + P0x4: [] }) - const namesQuery = "{\n P0x4: nfts(where: { owner: \"0x4\", category: ens, name_in: [\"cryptonico#e602\"] }, first: 1000) {\n name\n }\n }" + theGraph.maticCollectionsSubgraph.query = sinon + .stub() + .withArgs(wearablesQuery, {}) + .resolves({ + P0x4: [{ urn: 'urn:decentraland:matic:collections-v2:0x293d1ae40b28c39d7b013d4a1fe3c5a8c016bf19:1' }] + }) + const namesQuery = + '{\n P0x4: nfts(where: { owner: "0x4", category: ens, name_in: ["cryptonico#e602"] }, first: 1000) {\n name\n }\n }' theGraph.ensSubgraph.query = sinon.stub().withArgs(namesQuery, {}).resolves({ - P0x4: [] + P0x4: [] }) - - const response = await localFetch.fetch("/profiles", {method: 'post', body: JSON.stringify({ids:addresses})}) + + const response = await localFetch.fetch('/profiles', { method: 'post', body: JSON.stringify({ ids: addresses }) }) expect(response.status).toEqual(200) const responseText = await response.text() @@ -212,37 +283,50 @@ test("integration tests for /profiles", function ({ components, stubComponents } expect(responseObj.length).toEqual(1) expect(responseObj[0].avatars.length).toEqual(1) expect(responseObj[0].avatars?.[0].hasClaimedName).toEqual(false) - expect(responseObj[0].avatars?.[0].ethAddress).toEqual("0x4") - expect(responseObj[0].avatars?.[0].name).toEqual("cryptonico#e602") + expect(responseObj[0].avatars?.[0].ethAddress).toEqual('0x4') + expect(responseObj[0].avatars?.[0].name).toEqual('cryptonico#e602') expect(responseObj[0].avatars?.[0].unclaimedName).toEqual('cryptonico') - expect(responseObj[0].avatars?.[0].avatar.bodyShape).toEqual("urn:decentraland:off-chain:base-avatars:BaseMale") - expect(responseObj[0].avatars?.[0].avatar.snapshots.body).toEqual("https://peer.decentraland.org/content/contents/bafkreicilawwtbjyf6ahyzv64ssoamdm73rif75qncee5lv6j3a3352lua") - expect(responseObj[0].avatars?.[0].avatar.snapshots.face256).toEqual("https://peer.decentraland.org/content/contents/bafkreigi3yrgdhvjr2cqzxvfztnsubnll2cfdioo4vfzu6o6vibwoag2ma") + expect(responseObj[0].avatars?.[0].avatar.bodyShape).toEqual('urn:decentraland:off-chain:base-avatars:BaseMale') + expect(responseObj[0].avatars?.[0].avatar.snapshots.body).toEqual( + 'https://peer.decentraland.org/content/contents/bafkreicilawwtbjyf6ahyzv64ssoamdm73rif75qncee5lv6j3a3352lua' + ) + expect(responseObj[0].avatars?.[0].avatar.snapshots.face256).toEqual( + 'https://peer.decentraland.org/content/contents/bafkreigi3yrgdhvjr2cqzxvfztnsubnll2cfdioo4vfzu6o6vibwoag2ma' + ) expect(responseObj[0].avatars?.[0].avatar.wearables.length).toEqual(3) - expect(responseObj[0].avatars?.[0].avatar.wearables).toContain("urn:decentraland:off-chain:base-avatars:eyebrows_00") - expect(responseObj[0].avatars?.[0].avatar.wearables).toContain("urn:decentraland:off-chain:base-avatars:short_hair") - expect(responseObj[0].avatars?.[0].avatar.wearables).toContain("urn:decentraland:matic:collections-v2:0x293d1ae40b28c39d7b013d4a1fe3c5a8c016bf19:1") + expect(responseObj[0].avatars?.[0].avatar.wearables).toContain( + 'urn:decentraland:off-chain:base-avatars:eyebrows_00' + ) + expect(responseObj[0].avatars?.[0].avatar.wearables).toContain('urn:decentraland:off-chain:base-avatars:short_hair') + expect(responseObj[0].avatars?.[0].avatar.wearables).toContain( + 'urn:decentraland:matic:collections-v2:0x293d1ae40b28c39d7b013d4a1fe3c5a8c016bf19:1' + ) }) - it("calling with a single profile address, owning claimed name", async () => { + it('calling with a single profile address, owning claimed name', async () => { const { localFetch } = components const { theGraph, content } = stubComponents - const addresses = ["0x5"] + const addresses = ['0x5'] - content.fetchEntitiesByPointers.withArgs(EntityType.PROFILE, addresses).resolves(await Promise.all([profileEntityWithClaimedName])) - const wearablesQuery = '{ P0x5: nfts(where: { owner: "0x5", searchItemType_in: ["wearable_v1", "wearable_v2", "smart_wearable_v1", "emote_v1"], urn_in: [] }, first: 1000) { urn } }' - theGraph.collectionsSubgraph.query = sinon.stub().withArgs(wearablesQuery, {}).resolves({ + content.fetchEntitiesByPointers.withArgs(addresses).resolves(await Promise.all([profileEntityWithClaimedName])) + const wearablesQuery = + '{ P0x5: nfts(where: { owner: "0x5", searchItemType_in: ["wearable_v1", "wearable_v2", "smart_wearable_v1", "emote_v1"], urn_in: [] }, first: 1000) { urn } }' + theGraph.ethereumCollectionsSubgraph.query = sinon.stub().withArgs(wearablesQuery, {}).resolves({ P0x5: [] }) theGraph.maticCollectionsSubgraph.query = sinon.stub().withArgs(wearablesQuery, {}).resolves({ P0x5: [] }) - const namesQuery = "{\n P0x5: nfts(where: { owner: \"0x5\", category: ens, name_in: [\"cryptonico\"] }, first: 1000) {\n name\n }\n }" - theGraph.ensSubgraph.query = sinon.stub().withArgs(namesQuery, {}).resolves({ - P0x5: [ { name: 'cryptonico' },] - }) - - const response = await localFetch.fetch("/profiles", {method: 'post', body: JSON.stringify({ids:addresses})}) + const namesQuery = + '{\n P0x5: nfts(where: { owner: "0x5", category: ens, name_in: ["cryptonico"] }, first: 1000) {\n name\n }\n }' + theGraph.ensSubgraph.query = sinon + .stub() + .withArgs(namesQuery, {}) + .resolves({ + P0x5: [{ name: 'cryptonico' }] + }) + + const response = await localFetch.fetch('/profiles', { method: 'post', body: JSON.stringify({ ids: addresses }) }) expect(response.status).toEqual(200) const responseText = await response.text() @@ -250,45 +334,59 @@ test("integration tests for /profiles", function ({ components, stubComponents } expect(responseObj.length).toEqual(1) expect(responseObj[0].avatars.length).toEqual(1) expect(responseObj[0].avatars?.[0].hasClaimedName).toEqual(true) - expect(responseObj[0].avatars?.[0].ethAddress).toEqual("0x5") - expect(responseObj[0].avatars?.[0].name).toEqual("cryptonico") + expect(responseObj[0].avatars?.[0].ethAddress).toEqual('0x5') + expect(responseObj[0].avatars?.[0].name).toEqual('cryptonico') expect(responseObj[0].avatars?.[0].unclaimedName).toBeUndefined() - expect(responseObj[0].avatars?.[0].avatar.bodyShape).toEqual("urn:decentraland:off-chain:base-avatars:BaseMale") - expect(responseObj[0].avatars?.[0].avatar.snapshots.body).toEqual("https://peer.decentraland.org/content/contents/bafkreicilawwtbjyf6ahyzv64ssoamdm73rif75qncee5lv6j3a3352lua") - expect(responseObj[0].avatars?.[0].avatar.snapshots.face256).toEqual("https://peer.decentraland.org/content/contents/bafkreigi3yrgdhvjr2cqzxvfztnsubnll2cfdioo4vfzu6o6vibwoag2ma") + expect(responseObj[0].avatars?.[0].avatar.bodyShape).toEqual('urn:decentraland:off-chain:base-avatars:BaseMale') + expect(responseObj[0].avatars?.[0].avatar.snapshots.body).toEqual( + 'https://peer.decentraland.org/content/contents/bafkreicilawwtbjyf6ahyzv64ssoamdm73rif75qncee5lv6j3a3352lua' + ) + expect(responseObj[0].avatars?.[0].avatar.snapshots.face256).toEqual( + 'https://peer.decentraland.org/content/contents/bafkreigi3yrgdhvjr2cqzxvfztnsubnll2cfdioo4vfzu6o6vibwoag2ma' + ) expect(responseObj[0].avatars?.[0].avatar.wearables.length).toEqual(0) }) - it("calling with a single profile address, two tpw wearables from same collection, one of them not owned", async () => { + it('calling with a single profile address, two tpw wearables from same collection, one of them not owned', async () => { const { localFetch } = components const { theGraph, fetch, content } = stubComponents - const addresses = ["0x6"] + const addresses = ['0x6'] - content.fetchEntitiesByPointers.withArgs(EntityType.PROFILE, addresses).resolves(await Promise.all([profileEntityTwoTPWFromSameCollection])) - - const wearablesQuery = "{\n P0x6: nfts(where: { owner: \"0x6\", searchItemType_in: [\"wearable_v1\", \"wearable_v2\", \"smart_wearable_v1\", \"emote_v1\"], urn_in: [\"urn:decentraland:matic:collections-thirdparty:ntr1-meta:ntr1-meta-1ef79e7b:98ac122c-523f-403f-9730-f09c992f386f\",\"urn:decentraland:matic:collections-thirdparty:ntr1-meta:ntr1-meta-1ef79e7b:12341234-1234-3434-3434-f9dfde9f9393\"] }, first: 1000) {\n urn\n }\n }" - theGraph.collectionsSubgraph.query = sinon.stub().withArgs(wearablesQuery, {}).resolves({ P0x6: [] }) + content.fetchEntitiesByPointers + .withArgs(addresses) + .resolves(await Promise.all([profileEntityTwoTPWFromSameCollection])) + + const wearablesQuery = + '{\n P0x6: nfts(where: { owner: "0x6", searchItemType_in: ["wearable_v1", "wearable_v2", "smart_wearable_v1", "emote_v1"], urn_in: ["urn:decentraland:matic:collections-thirdparty:ntr1-meta:ntr1-meta-1ef79e7b:98ac122c-523f-403f-9730-f09c992f386f","urn:decentraland:matic:collections-thirdparty:ntr1-meta:ntr1-meta-1ef79e7b:12341234-1234-3434-3434-f9dfde9f9393"] }, first: 1000) {\n urn\n }\n }' + theGraph.ethereumCollectionsSubgraph.query = sinon.stub().withArgs(wearablesQuery, {}).resolves({ P0x6: [] }) theGraph.maticCollectionsSubgraph.query = sinon.stub().withArgs(wearablesQuery, {}).resolves({ P0x6: [] }) - - const namesQuery = "{\n P0x6: nfts(where: { owner: \"0x6\", category: ens, name_in: [\"cryptonico#e602\"] }, first: 1000) {\n name\n }\n }" + + const namesQuery = + '{\n P0x6: nfts(where: { owner: "0x6", category: ens, name_in: ["cryptonico#e602"] }, first: 1000) {\n name\n }\n }' theGraph.ensSubgraph.query = sinon.stub().withArgs(namesQuery, {}).resolves({ P0x6: [] }) - const tpwQuery = "\nquery ThirdPartyResolver($id: String!) {\n thirdParties(where: {id: $id, isApproved: true}) {\n id\n resolver\n }\n}\n" - const tpwId = "urn:decentraland:matic:collections-thirdparty:ntr1-meta" - theGraph.thirdPartyRegistrySubgraph.query = sinon.stub().withArgs(tpwQuery, {tpwId}).resolves({ + const tpwQuery = + '\nquery ThirdPartyResolver($id: String!) {\n thirdParties(where: {id: $id, isApproved: true}) {\n id\n resolver\n }\n}\n' + const tpwId = 'urn:decentraland:matic:collections-thirdparty:ntr1-meta' + theGraph.thirdPartyRegistrySubgraph.query = sinon + .stub() + .withArgs(tpwQuery, { tpwId }) + .resolves({ thirdParties: [ { - id: "urn:decentraland:matic:collections-thirdparty:ntr1-meta", - resolver: "https://api.swappable.io/api/v1", - }, + id: 'urn:decentraland:matic:collections-thirdparty:ntr1-meta', + resolver: 'https://api.swappable.io/api/v1' + } ] - }) + }) fetch.fetch - .withArgs("https://api.swappable.io/api/v1/registry/ntr1-meta/address/0x6/assets") - .onCall(0).resolves(new Response(JSON.stringify(tpwResolverResponseOwnOnlyOne))) - .onCall(1).resolves(new Response(JSON.stringify(tpwResolverResponseOwnOnlyOne))) - - const response = await localFetch.fetch("/profiles", {method: 'post', body: JSON.stringify({ids:addresses})}) + .withArgs('https://api.swappable.io/api/v1/registry/ntr1-meta/address/0x6/assets') + .onCall(0) + .resolves(new Response(JSON.stringify(tpwResolverResponseOwnOnlyOne))) + .onCall(1) + .resolves(new Response(JSON.stringify(tpwResolverResponseOwnOnlyOne))) + + const response = await localFetch.fetch('/profiles', { method: 'post', body: JSON.stringify({ ids: addresses }) }) expect(response.status).toEqual(200) const responseText = await response.text() @@ -296,60 +394,81 @@ test("integration tests for /profiles", function ({ components, stubComponents } expect(responseObj.length).toEqual(1) expect(responseObj[0].avatars.length).toEqual(1) expect(responseObj[0].avatars?.[0].hasClaimedName).toEqual(false) - expect(responseObj[0].avatars?.[0].ethAddress).toEqual("0x6") - expect(responseObj[0].avatars?.[0].name).toEqual("cryptonico#e602") + expect(responseObj[0].avatars?.[0].ethAddress).toEqual('0x6') + expect(responseObj[0].avatars?.[0].name).toEqual('cryptonico#e602') expect(responseObj[0].avatars?.[0].unclaimedName).toEqual('cryptonico') - expect(responseObj[0].avatars?.[0].avatar.bodyShape).toEqual("urn:decentraland:off-chain:base-avatars:BaseMale") - expect(responseObj[0].avatars?.[0].avatar.snapshots.body).toEqual("https://peer.decentraland.org/content/contents/bafkreicilawwtbjyf6ahyzv64ssoamdm73rif75qncee5lv6j3a3352lua") - expect(responseObj[0].avatars?.[0].avatar.snapshots.face256).toEqual("https://peer.decentraland.org/content/contents/bafkreigi3yrgdhvjr2cqzxvfztnsubnll2cfdioo4vfzu6o6vibwoag2ma") + expect(responseObj[0].avatars?.[0].avatar.bodyShape).toEqual('urn:decentraland:off-chain:base-avatars:BaseMale') + expect(responseObj[0].avatars?.[0].avatar.snapshots.body).toEqual( + 'https://peer.decentraland.org/content/contents/bafkreicilawwtbjyf6ahyzv64ssoamdm73rif75qncee5lv6j3a3352lua' + ) + expect(responseObj[0].avatars?.[0].avatar.snapshots.face256).toEqual( + 'https://peer.decentraland.org/content/contents/bafkreigi3yrgdhvjr2cqzxvfztnsubnll2cfdioo4vfzu6o6vibwoag2ma' + ) expect(responseObj[0].avatars?.[0].avatar.wearables.length).toEqual(3) - expect(responseObj[0].avatars?.[0].avatar.wearables).toContain("urn:decentraland:off-chain:base-avatars:eyebrows_00") - expect(responseObj[0].avatars?.[0].avatar.wearables).toContain("urn:decentraland:off-chain:base-avatars:short_hair") - expect(responseObj[0].avatars?.[0].avatar.wearables).toContain("urn:decentraland:matic:collections-thirdparty:ntr1-meta:ntr1-meta-1ef79e7b:98ac122c-523f-403f-9730-f09c992f386f") + expect(responseObj[0].avatars?.[0].avatar.wearables).toContain( + 'urn:decentraland:off-chain:base-avatars:eyebrows_00' + ) + expect(responseObj[0].avatars?.[0].avatar.wearables).toContain('urn:decentraland:off-chain:base-avatars:short_hair') + expect(responseObj[0].avatars?.[0].avatar.wearables).toContain( + 'urn:decentraland:matic:collections-thirdparty:ntr1-meta:ntr1-meta-1ef79e7b:98ac122c-523f-403f-9730-f09c992f386f' + ) }) - it("calling with a single profile address, five tpw wearables from two different collections, two of them not owned", async () => { + it('calling with a single profile address, five tpw wearables from two different collections, two of them not owned', async () => { const { localFetch } = components - const { theGraph, fetch , content} = stubComponents - const addresses = ["0x7"] - - content.fetchEntitiesByPointers.withArgs(EntityType.PROFILE, addresses).resolves(await Promise.all([profileEntitySeveralTPWFromDifferentCollections])) - const wearablesQuery = "{\n P0x7: nfts(where: { owner: \"0x7\", searchItemType_in: [\"wearable_v1\", \"wearable_v2\", \"smart_wearable_v1\", \"emote_v1\"], urn_in: [\"urn:decentraland:matic:collections-thirdparty:ntr1-meta:ntr1-meta-1ef79e7b:98ac122c-523f-403f-9730-f09c992f386f\",\"urn:decentraland:matic:collections-thirdparty:ntr1-meta:ntr1-meta-1ef79e7b:12341234-1234-3434-3434-f9dfde9f9393\",\"urn:decentraland:matic:collections-thirdparty:ntr2-meta:ntr2-meta-3h74jg0g:12341234-1234-3434-3434-f9dfde9f9393\",\"urn:decentraland:matic:collections-thirdparty:ntr2-meta:ntr2-meta-3h74jg0g:34564gf9-1234-3434-3434-f9dfde9f9393\",\"urn:decentraland:matic:collections-thirdparty:ntr2-meta:ntr2-meta-3h74jg0g:9fg9h3jh-1234-3434-3434-f9dfde9f9393\"] }, first: 1000) {\n urn\n }\n }" - theGraph.collectionsSubgraph.query = sinon.stub().withArgs(wearablesQuery, {}).resolves({ P0x7: [] }) + const { theGraph, fetch, content } = stubComponents + const addresses = ['0x7'] + + content.fetchEntitiesByPointers + .withArgs(addresses) + .resolves(await Promise.all([profileEntitySeveralTPWFromDifferentCollections])) + const wearablesQuery = + '{\n P0x7: nfts(where: { owner: "0x7", searchItemType_in: ["wearable_v1", "wearable_v2", "smart_wearable_v1", "emote_v1"], urn_in: ["urn:decentraland:matic:collections-thirdparty:ntr1-meta:ntr1-meta-1ef79e7b:98ac122c-523f-403f-9730-f09c992f386f","urn:decentraland:matic:collections-thirdparty:ntr1-meta:ntr1-meta-1ef79e7b:12341234-1234-3434-3434-f9dfde9f9393","urn:decentraland:matic:collections-thirdparty:ntr2-meta:ntr2-meta-3h74jg0g:12341234-1234-3434-3434-f9dfde9f9393","urn:decentraland:matic:collections-thirdparty:ntr2-meta:ntr2-meta-3h74jg0g:34564gf9-1234-3434-3434-f9dfde9f9393","urn:decentraland:matic:collections-thirdparty:ntr2-meta:ntr2-meta-3h74jg0g:9fg9h3jh-1234-3434-3434-f9dfde9f9393"] }, first: 1000) {\n urn\n }\n }' + theGraph.ethereumCollectionsSubgraph.query = sinon.stub().withArgs(wearablesQuery, {}).resolves({ P0x7: [] }) theGraph.maticCollectionsSubgraph.query = sinon.stub().withArgs(wearablesQuery, {}).resolves({ P0x7: [] }) - - const namesQuery = "{\n P0x7: nfts(where: { owner: \"0x7\", category: ens, name_in: [\"cryptonico#e602\"] }, first: 1000) {\n name\n }\n }" + + const namesQuery = + '{\n P0x7: nfts(where: { owner: "0x7", category: ens, name_in: ["cryptonico#e602"] }, first: 1000) {\n name\n }\n }' theGraph.ensSubgraph.query = sinon.stub().withArgs(namesQuery, {}).resolves({ P0x7: [] }) - const tpwQuery = "\nquery ThirdPartyResolver($id: String!) {\n thirdParties(where: {id: $id, isApproved: true}) {\n id\n resolver\n }\n}\n" - const tpwId1 = "urn:decentraland:matic:collections-thirdparty:ntr1-meta" - const tpwId2 = "urn:decentraland:matic:collections-thirdparty:ntr2-meta" - theGraph.thirdPartyRegistrySubgraph.query = sinon.stub() - .withArgs(tpwQuery, {tpwId1}).resolves({ + const tpwQuery = + '\nquery ThirdPartyResolver($id: String!) {\n thirdParties(where: {id: $id, isApproved: true}) {\n id\n resolver\n }\n}\n' + const tpwId1 = 'urn:decentraland:matic:collections-thirdparty:ntr1-meta' + const tpwId2 = 'urn:decentraland:matic:collections-thirdparty:ntr2-meta' + theGraph.thirdPartyRegistrySubgraph.query = sinon + .stub() + .withArgs(tpwQuery, { tpwId1 }) + .resolves({ thirdParties: [ { - id: "urn:decentraland:matic:collections-thirdparty:ntr1-meta", - resolver: "https://api.swappable.io/api/v1", - }, + id: 'urn:decentraland:matic:collections-thirdparty:ntr1-meta', + resolver: 'https://api.swappable.io/api/v1' + } ] }) - .withArgs(tpwQuery, {tpwId2}).resolves({ + .withArgs(tpwQuery, { tpwId2 }) + .resolves({ thirdParties: [ { - id: "urn:decentraland:matic:collections-thirdparty:ntr2-meta", - resolver: "https://api.swappable.io/api/v1", - }, + id: 'urn:decentraland:matic:collections-thirdparty:ntr2-meta', + resolver: 'https://api.swappable.io/api/v1' + } ] }) fetch.fetch - .withArgs("https://api.swappable.io/api/v1/registry/ntr1-meta/address/0x7/assets") - .onCall(0).resolves(new Response(JSON.stringify(tpwResolverResponseOwnOnlyOne))) - .onCall(1).resolves(new Response(JSON.stringify(tpwResolverResponseOwnOnlyOne))) - .withArgs("https://api.swappable.io/api/v1/registry/ntr2-meta/address/0x7/assets") - .onCall(0).resolves(new Response(JSON.stringify(tpwResolverResponseFromDifferentCollection))) - .onCall(1).resolves(new Response(JSON.stringify(tpwResolverResponseFromDifferentCollection))) - .onCall(2).resolves(new Response(JSON.stringify(tpwResolverResponseFromDifferentCollection))) - const response = await localFetch.fetch("/profiles", {method: 'post', body: JSON.stringify({ids:addresses})}) + .withArgs('https://api.swappable.io/api/v1/registry/ntr1-meta/address/0x7/assets') + .onCall(0) + .resolves(new Response(JSON.stringify(tpwResolverResponseOwnOnlyOne))) + .onCall(1) + .resolves(new Response(JSON.stringify(tpwResolverResponseOwnOnlyOne))) + .withArgs('https://api.swappable.io/api/v1/registry/ntr2-meta/address/0x7/assets') + .onCall(0) + .resolves(new Response(JSON.stringify(tpwResolverResponseFromDifferentCollection))) + .onCall(1) + .resolves(new Response(JSON.stringify(tpwResolverResponseFromDifferentCollection))) + .onCall(2) + .resolves(new Response(JSON.stringify(tpwResolverResponseFromDifferentCollection))) + const response = await localFetch.fetch('/profiles', { method: 'post', body: JSON.stringify({ ids: addresses }) }) expect(response.status).toEqual(200) const responseText = await response.text() @@ -357,127 +476,198 @@ test("integration tests for /profiles", function ({ components, stubComponents } expect(responseObj.length).toEqual(1) expect(responseObj[0].avatars.length).toEqual(1) expect(responseObj[0].avatars?.[0].hasClaimedName).toEqual(false) - expect(responseObj[0].avatars?.[0].ethAddress).toEqual("0x7") - expect(responseObj[0].avatars?.[0].name).toEqual("cryptonico#e602") + expect(responseObj[0].avatars?.[0].ethAddress).toEqual('0x7') + expect(responseObj[0].avatars?.[0].name).toEqual('cryptonico#e602') expect(responseObj[0].avatars?.[0].unclaimedName).toEqual('cryptonico') - expect(responseObj[0].avatars?.[0].avatar.bodyShape).toEqual("urn:decentraland:off-chain:base-avatars:BaseMale") - expect(responseObj[0].avatars?.[0].avatar.snapshots.body).toEqual("https://peer.decentraland.org/content/contents/bafkreicilawwtbjyf6ahyzv64ssoamdm73rif75qncee5lv6j3a3352lua") - expect(responseObj[0].avatars?.[0].avatar.snapshots.face256).toEqual("https://peer.decentraland.org/content/contents/bafkreigi3yrgdhvjr2cqzxvfztnsubnll2cfdioo4vfzu6o6vibwoag2ma") + expect(responseObj[0].avatars?.[0].avatar.bodyShape).toEqual('urn:decentraland:off-chain:base-avatars:BaseMale') + expect(responseObj[0].avatars?.[0].avatar.snapshots.body).toEqual( + 'https://peer.decentraland.org/content/contents/bafkreicilawwtbjyf6ahyzv64ssoamdm73rif75qncee5lv6j3a3352lua' + ) + expect(responseObj[0].avatars?.[0].avatar.snapshots.face256).toEqual( + 'https://peer.decentraland.org/content/contents/bafkreigi3yrgdhvjr2cqzxvfztnsubnll2cfdioo4vfzu6o6vibwoag2ma' + ) expect(responseObj[0].avatars?.[0].avatar.wearables.length).toEqual(5) - expect(responseObj[0].avatars?.[0].avatar.wearables).toContain("urn:decentraland:off-chain:base-avatars:eyebrows_00") - expect(responseObj[0].avatars?.[0].avatar.wearables).toContain("urn:decentraland:off-chain:base-avatars:short_hair") - expect(responseObj[0].avatars?.[0].avatar.wearables).toContain("urn:decentraland:matic:collections-thirdparty:ntr1-meta:ntr1-meta-1ef79e7b:98ac122c-523f-403f-9730-f09c992f386f") - expect(responseObj[0].avatars?.[0].avatar.wearables).toContain("urn:decentraland:matic:collections-thirdparty:ntr2-meta:ntr2-meta-3h74jg0g:12341234-1234-3434-3434-f9dfde9f9393") - expect(responseObj[0].avatars?.[0].avatar.wearables).toContain("urn:decentraland:matic:collections-thirdparty:ntr2-meta:ntr2-meta-3h74jg0g:34564gf9-1234-3434-3434-f9dfde9f9393") + expect(responseObj[0].avatars?.[0].avatar.wearables).toContain( + 'urn:decentraland:off-chain:base-avatars:eyebrows_00' + ) + expect(responseObj[0].avatars?.[0].avatar.wearables).toContain('urn:decentraland:off-chain:base-avatars:short_hair') + expect(responseObj[0].avatars?.[0].avatar.wearables).toContain( + 'urn:decentraland:matic:collections-thirdparty:ntr1-meta:ntr1-meta-1ef79e7b:98ac122c-523f-403f-9730-f09c992f386f' + ) + expect(responseObj[0].avatars?.[0].avatar.wearables).toContain( + 'urn:decentraland:matic:collections-thirdparty:ntr2-meta:ntr2-meta-3h74jg0g:12341234-1234-3434-3434-f9dfde9f9393' + ) + expect(responseObj[0].avatars?.[0].avatar.wearables).toContain( + 'urn:decentraland:matic:collections-thirdparty:ntr2-meta:ntr2-meta-3h74jg0g:34564gf9-1234-3434-3434-f9dfde9f9393' + ) }) - it("calling with two profile addresses, owning everything claimed", async () => { + it('calling with two profile addresses, owning everything claimed', async () => { const { localFetch } = components const { theGraph, fetch, content } = stubComponents - const addresses = ["0x1b", "0x8"] - - content.fetchEntitiesByPointers.withArgs(EntityType.PROFILE, addresses).resolves(await Promise.all([profileEntityFullB, profileEntityFullAnother])) - const wearablesQuery = "{\n P0x1b: nfts(where: { owner: \"0x1b\", searchItemType_in: [\"wearable_v1\", \"wearable_v2\", \"smart_wearable_v1\", \"emote_v1\"], urn_in: [\"urn:decentraland:matic:collections-v2:0xa25c20f58ac447621a5f854067b857709cbd60eb:7\",\"urn:decentraland:matic:collections-v2:0x293d1ae40b28c39d7b013d4a1fe3c5a8c016bf19:1\",\"urn:decentraland:ethereum:collections-v1:ethermon_wearables:ethermon_feet\",\"urn:decentraland:ethereum:collections-v1:ethermon_wearables:ethermon_hand\",\"urn:decentraland:matic:collections-thirdparty:ntr1-meta:ntr1-meta-1ef79e7b:98ac122c-523f-403f-9730-f09c992f386f\",\"urn:decentraland:matic:collections-thirdparty:ntr1-meta:ntr1-meta-1ef79e7b:12341234-1234-3434-3434-f9dfde9f9393\"] }, first: 1000) {\n urn\n }\n \n\n P0x8: nfts(where: { owner: \"0x8\", searchItemType_in: [\"wearable_v1\", \"wearable_v2\", \"smart_wearable_v1\", \"emote_v1\"], urn_in: [\"urn:decentraland:matic:collections-v2:0xa25c20f58ac447621a5f854067b857709cbd60eb:6\",\"urn:decentraland:matic:collections-v2:0x293d1ae40b28c39d7b013d4a1fe3c5a8c016bf19:2\",\"urn:decentraland:ethereum:collections-v1:ethermon_wearables:ethermon_hat\",\"urn:decentraland:ethereum:collections-v1:ethermon_wearables:ethermon_shirt\",\"urn:decentraland:matic:collections-thirdparty:ntr2-meta:ntr2-meta-123v289a:w3499wer-523f-403f-9730-f09c992f386f\",\"urn:decentraland:matic:collections-thirdparty:ntr2-meta:ntr2-meta-123v289a:12341234-9876-3434-3434-f9dfde9f9393\"] }, first: 1000) {\n urn\n }\n }" - theGraph.collectionsSubgraph.query = sinon.stub().withArgs(wearablesQuery, {}).resolves({ + const addresses = ['0x1b', '0x8'] + + content.fetchEntitiesByPointers + .withArgs(addresses) + .resolves(await Promise.all([profileEntityFullB, profileEntityFullAnother])) + const wearablesQuery = + '{\n P0x1b: nfts(where: { owner: "0x1b", searchItemType_in: ["wearable_v1", "wearable_v2", "smart_wearable_v1", "emote_v1"], urn_in: ["urn:decentraland:matic:collections-v2:0xa25c20f58ac447621a5f854067b857709cbd60eb:7","urn:decentraland:matic:collections-v2:0x293d1ae40b28c39d7b013d4a1fe3c5a8c016bf19:1","urn:decentraland:ethereum:collections-v1:ethermon_wearables:ethermon_feet","urn:decentraland:ethereum:collections-v1:ethermon_wearables:ethermon_hand","urn:decentraland:matic:collections-thirdparty:ntr1-meta:ntr1-meta-1ef79e7b:98ac122c-523f-403f-9730-f09c992f386f","urn:decentraland:matic:collections-thirdparty:ntr1-meta:ntr1-meta-1ef79e7b:12341234-1234-3434-3434-f9dfde9f9393"] }, first: 1000) {\n urn\n }\n \n\n P0x8: nfts(where: { owner: "0x8", searchItemType_in: ["wearable_v1", "wearable_v2", "smart_wearable_v1", "emote_v1"], urn_in: ["urn:decentraland:matic:collections-v2:0xa25c20f58ac447621a5f854067b857709cbd60eb:6","urn:decentraland:matic:collections-v2:0x293d1ae40b28c39d7b013d4a1fe3c5a8c016bf19:2","urn:decentraland:ethereum:collections-v1:ethermon_wearables:ethermon_hat","urn:decentraland:ethereum:collections-v1:ethermon_wearables:ethermon_shirt","urn:decentraland:matic:collections-thirdparty:ntr2-meta:ntr2-meta-123v289a:w3499wer-523f-403f-9730-f09c992f386f","urn:decentraland:matic:collections-thirdparty:ntr2-meta:ntr2-meta-123v289a:12341234-9876-3434-3434-f9dfde9f9393"] }, first: 1000) {\n urn\n }\n }' + theGraph.ethereumCollectionsSubgraph.query = sinon + .stub() + .withArgs(wearablesQuery, {}) + .resolves({ P0x1b: [ - {urn: "urn:decentraland:ethereum:collections-v1:ethermon_wearables:ethermon_feet"}, - {urn: "urn:decentraland:ethereum:collections-v1:ethermon_wearables:ethermon_hand"} + { urn: 'urn:decentraland:ethereum:collections-v1:ethermon_wearables:ethermon_feet' }, + { urn: 'urn:decentraland:ethereum:collections-v1:ethermon_wearables:ethermon_hand' } ], P0x8: [ - {urn: "urn:decentraland:ethereum:collections-v1:ethermon_wearables:ethermon_hat"}, - {urn: "urn:decentraland:ethereum:collections-v1:ethermon_wearables:ethermon_shirt"} + { urn: 'urn:decentraland:ethereum:collections-v1:ethermon_wearables:ethermon_hat' }, + { urn: 'urn:decentraland:ethereum:collections-v1:ethermon_wearables:ethermon_shirt' } ] - }) - theGraph.maticCollectionsSubgraph.query = sinon.stub().withArgs(wearablesQuery, {}).resolves({ + }) + theGraph.maticCollectionsSubgraph.query = sinon + .stub() + .withArgs(wearablesQuery, {}) + .resolves({ P0x1b: [ - {urn: "urn:decentraland:matic:collections-v2:0xa25c20f58ac447621a5f854067b857709cbd60eb:7"}, - {urn: "urn:decentraland:matic:collections-v2:0x293d1ae40b28c39d7b013d4a1fe3c5a8c016bf19:1"} + { urn: 'urn:decentraland:matic:collections-v2:0xa25c20f58ac447621a5f854067b857709cbd60eb:7' }, + { urn: 'urn:decentraland:matic:collections-v2:0x293d1ae40b28c39d7b013d4a1fe3c5a8c016bf19:1' } ], P0x8: [ - {urn: "urn:decentraland:matic:collections-v2:0xa25c20f58ac447621a5f854067b857709cbd60eb:6"}, - {urn: "urn:decentraland:matic:collections-v2:0x293d1ae40b28c39d7b013d4a1fe3c5a8c016bf19:2"} + { urn: 'urn:decentraland:matic:collections-v2:0xa25c20f58ac447621a5f854067b857709cbd60eb:6' }, + { urn: 'urn:decentraland:matic:collections-v2:0x293d1ae40b28c39d7b013d4a1fe3c5a8c016bf19:2' } ] - }) - const namesQuery = "{\n P0x1b: nfts(where: { owner: \"0x1b\", category: ens, name_in: [\"cryptonico\"] }, first: 1000) {\n name\n }\n \n\n P0x8: nfts(where: { owner: \"0x8\", category: ens, name_in: [\"testing\"] }, first: 1000) {\n name\n }\n }" - theGraph.ensSubgraph.query = sinon.stub().withArgs(namesQuery, {}).resolves({ - P0x1b: [ { name: 'cryptonico' } ], - P0x8: [ { name: 'testing' } ] - }) - const tpwQuery = "\nquery ThirdPartyResolver($id: String!) {\n thirdParties(where: {id: $id, isApproved: true}) {\n id\n resolver\n }\n}\n" - const tpwId = "urn:decentraland:matic:collections-thirdparty:ntr1-meta" - theGraph.thirdPartyRegistrySubgraph.query = sinon.stub().withArgs(tpwQuery, {tpwId}).resolves({ + }) + const namesQuery = + '{\n P0x1b: nfts(where: { owner: "0x1b", category: ens, name_in: ["cryptonico"] }, first: 1000) {\n name\n }\n \n\n P0x8: nfts(where: { owner: "0x8", category: ens, name_in: ["testing"] }, first: 1000) {\n name\n }\n }' + theGraph.ensSubgraph.query = sinon + .stub() + .withArgs(namesQuery, {}) + .resolves({ + P0x1b: [{ name: 'cryptonico' }], + P0x8: [{ name: 'testing' }] + }) + const tpwQuery = + '\nquery ThirdPartyResolver($id: String!) {\n thirdParties(where: {id: $id, isApproved: true}) {\n id\n resolver\n }\n}\n' + const tpwId = 'urn:decentraland:matic:collections-thirdparty:ntr1-meta' + theGraph.thirdPartyRegistrySubgraph.query = sinon + .stub() + .withArgs(tpwQuery, { tpwId }) + .resolves({ thirdParties: [ { - id: "urn:decentraland:matic:collections-thirdparty:ntr1-meta", - resolver: "https://api.swappable.io/api/v1", - }, + id: 'urn:decentraland:matic:collections-thirdparty:ntr1-meta', + resolver: 'https://api.swappable.io/api/v1' + } ] - }) + }) fetch.fetch - .withArgs("https://api.swappable.io/api/v1/registry/ntr1-meta/address/0x1b/assets") - .onCall(0).resolves(new Response(JSON.stringify(tpwResolverResponseFull))) - .onCall(1).resolves(new Response(JSON.stringify(tpwResolverResponseFull))) - .withArgs("https://api.swappable.io/api/v1/registry/ntr2-meta/address/0x8/assets") - .onCall(0).resolves(new Response(JSON.stringify(tpwResolverResponseFullAnother))) - .onCall(1).resolves(new Response(JSON.stringify(tpwResolverResponseFullAnother))) - - const response = await localFetch.fetch("/profiles", {method: 'post', body: JSON.stringify({ids:addresses})}) + .withArgs('https://api.swappable.io/api/v1/registry/ntr1-meta/address/0x1b/assets') + .onCall(0) + .resolves(new Response(JSON.stringify(tpwResolverResponseFull))) + .onCall(1) + .resolves(new Response(JSON.stringify(tpwResolverResponseFull))) + .withArgs('https://api.swappable.io/api/v1/registry/ntr2-meta/address/0x8/assets') + .onCall(0) + .resolves(new Response(JSON.stringify(tpwResolverResponseFullAnother))) + .onCall(1) + .resolves(new Response(JSON.stringify(tpwResolverResponseFullAnother))) + + const response = await localFetch.fetch('/profiles', { method: 'post', body: JSON.stringify({ ids: addresses }) }) expect(response.status).toEqual(200) const responseText = await response.text() const responseObj = JSON.parse(responseText) expect(responseObj.length).toEqual(2) - + expect(responseObj[0].avatars.length).toEqual(1) expect(responseObj[0].avatars?.[0].hasClaimedName).toEqual(true) - expect(responseObj[0].avatars?.[0].ethAddress).toEqual("0x1b") - expect(responseObj[0].avatars?.[0].name).toEqual("cryptonico") + expect(responseObj[0].avatars?.[0].ethAddress).toEqual('0x1b') + expect(responseObj[0].avatars?.[0].name).toEqual('cryptonico') expect(responseObj[0].avatars?.[0].unclaimedName).toBeUndefined() - expect(responseObj[0].avatars?.[0].avatar.bodyShape).toEqual("urn:decentraland:off-chain:base-avatars:BaseMale") - expect(responseObj[0].avatars?.[0].avatar.snapshots.body).toEqual("https://peer.decentraland.org/content/contents/bafkreicilawwtbjyf6ahyzv64ssoamdm73rif75qncee5lv6j3a3352lua") - expect(responseObj[0].avatars?.[0].avatar.snapshots.face256).toEqual("https://peer.decentraland.org/content/contents/bafkreigi3yrgdhvjr2cqzxvfztnsubnll2cfdioo4vfzu6o6vibwoag2ma") + expect(responseObj[0].avatars?.[0].avatar.bodyShape).toEqual('urn:decentraland:off-chain:base-avatars:BaseMale') + expect(responseObj[0].avatars?.[0].avatar.snapshots.body).toEqual( + 'https://peer.decentraland.org/content/contents/bafkreicilawwtbjyf6ahyzv64ssoamdm73rif75qncee5lv6j3a3352lua' + ) + expect(responseObj[0].avatars?.[0].avatar.snapshots.face256).toEqual( + 'https://peer.decentraland.org/content/contents/bafkreigi3yrgdhvjr2cqzxvfztnsubnll2cfdioo4vfzu6o6vibwoag2ma' + ) expect(responseObj[0].avatars?.[0].avatar.wearables.length).toEqual(8) - expect(responseObj[0].avatars?.[0].avatar.wearables).toContain("urn:decentraland:off-chain:base-avatars:eyebrows_00") - expect(responseObj[0].avatars?.[0].avatar.wearables).toContain("urn:decentraland:off-chain:base-avatars:short_hair") - expect(responseObj[0].avatars?.[0].avatar.wearables).toContain("urn:decentraland:matic:collections-v2:0xa25c20f58ac447621a5f854067b857709cbd60eb:7") - expect(responseObj[0].avatars?.[0].avatar.wearables).toContain("urn:decentraland:matic:collections-v2:0x293d1ae40b28c39d7b013d4a1fe3c5a8c016bf19:1") - expect(responseObj[0].avatars?.[0].avatar.wearables).toContain("urn:decentraland:ethereum:collections-v1:ethermon_wearables:ethermon_feet") - expect(responseObj[0].avatars?.[0].avatar.wearables).toContain("urn:decentraland:ethereum:collections-v1:ethermon_wearables:ethermon_feet") - expect(responseObj[0].avatars?.[0].avatar.wearables).toContain("urn:decentraland:matic:collections-thirdparty:ntr1-meta:ntr1-meta-1ef79e7b:98ac122c-523f-403f-9730-f09c992f386f") - expect(responseObj[0].avatars?.[0].avatar.wearables).toContain("urn:decentraland:matic:collections-thirdparty:ntr1-meta:ntr1-meta-1ef79e7b:12341234-1234-3434-3434-f9dfde9f9393") + expect(responseObj[0].avatars?.[0].avatar.wearables).toContain( + 'urn:decentraland:off-chain:base-avatars:eyebrows_00' + ) + expect(responseObj[0].avatars?.[0].avatar.wearables).toContain('urn:decentraland:off-chain:base-avatars:short_hair') + expect(responseObj[0].avatars?.[0].avatar.wearables).toContain( + 'urn:decentraland:matic:collections-v2:0xa25c20f58ac447621a5f854067b857709cbd60eb:7' + ) + expect(responseObj[0].avatars?.[0].avatar.wearables).toContain( + 'urn:decentraland:matic:collections-v2:0x293d1ae40b28c39d7b013d4a1fe3c5a8c016bf19:1' + ) + expect(responseObj[0].avatars?.[0].avatar.wearables).toContain( + 'urn:decentraland:ethereum:collections-v1:ethermon_wearables:ethermon_feet' + ) + expect(responseObj[0].avatars?.[0].avatar.wearables).toContain( + 'urn:decentraland:ethereum:collections-v1:ethermon_wearables:ethermon_feet' + ) + expect(responseObj[0].avatars?.[0].avatar.wearables).toContain( + 'urn:decentraland:matic:collections-thirdparty:ntr1-meta:ntr1-meta-1ef79e7b:98ac122c-523f-403f-9730-f09c992f386f' + ) + expect(responseObj[0].avatars?.[0].avatar.wearables).toContain( + 'urn:decentraland:matic:collections-thirdparty:ntr1-meta:ntr1-meta-1ef79e7b:12341234-1234-3434-3434-f9dfde9f9393' + ) expect(responseObj[1].avatars.length).toEqual(1) expect(responseObj[1].avatars?.[0].hasClaimedName).toEqual(true) - expect(responseObj[1].avatars?.[0].ethAddress).toEqual("0x8") - expect(responseObj[1].avatars?.[0].name).toEqual("testing") + expect(responseObj[1].avatars?.[0].ethAddress).toEqual('0x8') + expect(responseObj[1].avatars?.[0].name).toEqual('testing') expect(responseObj[1].avatars?.[0].unclaimedName).toBeUndefined() - expect(responseObj[1].avatars?.[0].avatar.bodyShape).toEqual("urn:decentraland:off-chain:base-avatars:BaseFemale") - expect(responseObj[1].avatars?.[0].avatar.snapshots.body).toEqual("https://peer.decentraland.org/content/contents/bafkreicilawwtbjyf6ahyzv64ssoamdm73rif75qncee5lv6j3a3352lue") - expect(responseObj[1].avatars?.[0].avatar.snapshots.face256).toEqual("https://peer.decentraland.org/content/contents/bafkreigi3yrgdhvjr2cqzxvfztnsubnll2cfdioo4vfzu6o6vibwoag2me") + expect(responseObj[1].avatars?.[0].avatar.bodyShape).toEqual('urn:decentraland:off-chain:base-avatars:BaseFemale') + expect(responseObj[1].avatars?.[0].avatar.snapshots.body).toEqual( + 'https://peer.decentraland.org/content/contents/bafkreicilawwtbjyf6ahyzv64ssoamdm73rif75qncee5lv6j3a3352lue' + ) + expect(responseObj[1].avatars?.[0].avatar.snapshots.face256).toEqual( + 'https://peer.decentraland.org/content/contents/bafkreigi3yrgdhvjr2cqzxvfztnsubnll2cfdioo4vfzu6o6vibwoag2me' + ) expect(responseObj[1].avatars?.[0].avatar.wearables.length).toEqual(8) - expect(responseObj[0].avatars?.[0].avatar.wearables).toContain("urn:decentraland:off-chain:base-avatars:eyebrows_00") - expect(responseObj[0].avatars?.[0].avatar.wearables).toContain("urn:decentraland:off-chain:base-avatars:short_hair") - expect(responseObj[1].avatars?.[0].avatar.wearables).toContain("urn:decentraland:matic:collections-v2:0xa25c20f58ac447621a5f854067b857709cbd60eb:6") - expect(responseObj[1].avatars?.[0].avatar.wearables).toContain("urn:decentraland:matic:collections-v2:0x293d1ae40b28c39d7b013d4a1fe3c5a8c016bf19:2") - expect(responseObj[1].avatars?.[0].avatar.wearables).toContain("urn:decentraland:ethereum:collections-v1:ethermon_wearables:ethermon_hat") - expect(responseObj[1].avatars?.[0].avatar.wearables).toContain("urn:decentraland:ethereum:collections-v1:ethermon_wearables:ethermon_shirt") - expect(responseObj[1].avatars?.[0].avatar.wearables).toContain("urn:decentraland:matic:collections-thirdparty:ntr2-meta:ntr2-meta-123v289a:w3499wer-523f-403f-9730-f09c992f386f") - expect(responseObj[1].avatars?.[0].avatar.wearables).toContain("urn:decentraland:matic:collections-thirdparty:ntr2-meta:ntr2-meta-123v289a:12341234-9876-3434-3434-f9dfde9f9393") + expect(responseObj[0].avatars?.[0].avatar.wearables).toContain( + 'urn:decentraland:off-chain:base-avatars:eyebrows_00' + ) + expect(responseObj[0].avatars?.[0].avatar.wearables).toContain('urn:decentraland:off-chain:base-avatars:short_hair') + expect(responseObj[1].avatars?.[0].avatar.wearables).toContain( + 'urn:decentraland:matic:collections-v2:0xa25c20f58ac447621a5f854067b857709cbd60eb:6' + ) + expect(responseObj[1].avatars?.[0].avatar.wearables).toContain( + 'urn:decentraland:matic:collections-v2:0x293d1ae40b28c39d7b013d4a1fe3c5a8c016bf19:2' + ) + expect(responseObj[1].avatars?.[0].avatar.wearables).toContain( + 'urn:decentraland:ethereum:collections-v1:ethermon_wearables:ethermon_hat' + ) + expect(responseObj[1].avatars?.[0].avatar.wearables).toContain( + 'urn:decentraland:ethereum:collections-v1:ethermon_wearables:ethermon_shirt' + ) + expect(responseObj[1].avatars?.[0].avatar.wearables).toContain( + 'urn:decentraland:matic:collections-thirdparty:ntr2-meta:ntr2-meta-123v289a:w3499wer-523f-403f-9730-f09c992f386f' + ) + expect(responseObj[1].avatars?.[0].avatar.wearables).toContain( + 'urn:decentraland:matic:collections-thirdparty:ntr2-meta:ntr2-meta-123v289a:12341234-9876-3434-3434-f9dfde9f9393' + ) }) - - it("calling with a single profile address with old body shape format", async () => { + + it('calling with a single profile address with old body shape format', async () => { const { localFetch } = components const { theGraph, content } = stubComponents - const addresses = ["0x9"] + const addresses = ['0x9'] + + content.fetchEntitiesByPointers.withArgs(addresses).resolves(await Promise.all([profileEntityOldBodyshape])) + const wearablesQuery = + '{ P0x9: nfts(where: { owner: "0x9", searchItemType_in: ["wearable_v1", "wearable_v2", "smart_wearable_v1", "emote_v1"], urn_in: [] }, first: 1000) { urn } }' + theGraph.ethereumCollectionsSubgraph.query = sinon.stub().withArgs(wearablesQuery, {}).resolves({ P0x9: [] }) + theGraph.maticCollectionsSubgraph.query = sinon.stub().withArgs(wearablesQuery, {}).resolves({ P0x9: [] }) + const namesQuery = + '{\n P0x9: nfts(where: { owner: "0x9", category: ens, name_in: ["cryptonico#e602"] }, first: 1000) {\n name\n }\n }' + theGraph.ensSubgraph.query = sinon.stub().withArgs(namesQuery, {}).resolves({ P0x9: [] }) - content.fetchEntitiesByPointers.withArgs(EntityType.PROFILE, addresses).resolves(await Promise.all([profileEntityOldBodyshape])) - const wearablesQuery = '{ P0x9: nfts(where: { owner: "0x9", searchItemType_in: ["wearable_v1", "wearable_v2", "smart_wearable_v1", "emote_v1"], urn_in: [] }, first: 1000) { urn } }' - theGraph.collectionsSubgraph.query = sinon.stub().withArgs(wearablesQuery, {}).resolves({P0x9: []}) - theGraph.maticCollectionsSubgraph.query = sinon.stub().withArgs(wearablesQuery, {}).resolves({P0x9: []}) - const namesQuery = "{\n P0x9: nfts(where: { owner: \"0x9\", category: ens, name_in: [\"cryptonico#e602\"] }, first: 1000) {\n name\n }\n }" - theGraph.ensSubgraph.query = sinon.stub().withArgs(namesQuery, {}).resolves({P0x9: []}) - - const response = await localFetch.fetch("/profiles", {method: 'post', body: JSON.stringify({ids:addresses})}) + const response = await localFetch.fetch('/profiles', { method: 'post', body: JSON.stringify({ ids: addresses }) }) expect(response.status).toEqual(200) const responseText = await response.text() @@ -485,28 +675,36 @@ test("integration tests for /profiles", function ({ components, stubComponents } expect(responseObj.length).toEqual(1) expect(responseObj[0].avatars.length).toEqual(1) expect(responseObj[0].avatars?.[0].hasClaimedName).toEqual(false) - expect(responseObj[0].avatars?.[0].ethAddress).toEqual("0x9") - expect(responseObj[0].avatars?.[0].name).toEqual("cryptonico#e602") - expect(responseObj[0].avatars?.[0].unclaimedName).toEqual("cryptonico") - expect(responseObj[0].avatars?.[0].avatar.bodyShape).toEqual("urn:decentraland:off-chain:base-avatars:BaseFemale") - expect(responseObj[0].avatars?.[0].avatar.snapshots.body).toEqual("https://peer.decentraland.org/content/contents/bafkreicilawwtbjyf6ahyzv64ssoamdm73rif75qncee5lv6j3a3352lua") - expect(responseObj[0].avatars?.[0].avatar.snapshots.face256).toEqual("https://peer.decentraland.org/content/contents/bafkreigi3yrgdhvjr2cqzxvfztnsubnll2cfdioo4vfzu6o6vibwoag2ma") + expect(responseObj[0].avatars?.[0].ethAddress).toEqual('0x9') + expect(responseObj[0].avatars?.[0].name).toEqual('cryptonico#e602') + expect(responseObj[0].avatars?.[0].unclaimedName).toEqual('cryptonico') + expect(responseObj[0].avatars?.[0].avatar.bodyShape).toEqual('urn:decentraland:off-chain:base-avatars:BaseFemale') + expect(responseObj[0].avatars?.[0].avatar.snapshots.body).toEqual( + 'https://peer.decentraland.org/content/contents/bafkreicilawwtbjyf6ahyzv64ssoamdm73rif75qncee5lv6j3a3352lua' + ) + expect(responseObj[0].avatars?.[0].avatar.snapshots.face256).toEqual( + 'https://peer.decentraland.org/content/contents/bafkreigi3yrgdhvjr2cqzxvfztnsubnll2cfdioo4vfzu6o6vibwoag2ma' + ) expect(responseObj[0].avatars?.[0].avatar.wearables.length).toEqual(0) }) - it("calling with a single profile address with snapshots referencing content", async () => { + it('calling with a single profile address with snapshots referencing content', async () => { const { localFetch } = components const { theGraph, content } = stubComponents - const addresses = ["0x10"] + const addresses = ['0x10'] + + content.fetchEntitiesByPointers + .withArgs(addresses) + .resolves(await Promise.all([profileEntitySnapshotsReferenceContentFile])) + const wearablesQuery = + '{ P0x10: nfts(where: { owner: "0x10", searchItemType_in: ["wearable_v1", "wearable_v2", "smart_wearable_v1", "emote_v1"], urn_in: [] }, first: 1000) { urn } }' + theGraph.ethereumCollectionsSubgraph.query = sinon.stub().withArgs(wearablesQuery, {}).resolves({ P0x10: [] }) + theGraph.maticCollectionsSubgraph.query = sinon.stub().withArgs(wearablesQuery, {}).resolves({ P0x10: [] }) + const namesQuery = + '{\n P0x10: nfts(where: { owner: "0x10", category: ens, name_in: ["cryptonico#e602"] }, first: 1000) {\n name\n }\n }' + theGraph.ensSubgraph.query = sinon.stub().withArgs(namesQuery, {}).resolves({ P0x10: [] }) - content.fetchEntitiesByPointers.withArgs(EntityType.PROFILE, addresses).resolves(await Promise.all([profileEntitySnapshotsReferenceContentFile])) - const wearablesQuery = '{ P0x10: nfts(where: { owner: "0x10", searchItemType_in: ["wearable_v1", "wearable_v2", "smart_wearable_v1", "emote_v1"], urn_in: [] }, first: 1000) { urn } }' - theGraph.collectionsSubgraph.query = sinon.stub().withArgs(wearablesQuery, {}).resolves({P0x10: []}) - theGraph.maticCollectionsSubgraph.query = sinon.stub().withArgs(wearablesQuery, {}).resolves({P0x10: []}) - const namesQuery = "{\n P0x10: nfts(where: { owner: \"0x10\", category: ens, name_in: [\"cryptonico#e602\"] }, first: 1000) {\n name\n }\n }" - theGraph.ensSubgraph.query = sinon.stub().withArgs(namesQuery, {}).resolves({P0x10: []}) - - const response = await localFetch.fetch("/profiles", {method: 'post', body: JSON.stringify({ids:addresses})}) + const response = await localFetch.fetch('/profiles', { method: 'post', body: JSON.stringify({ ids: addresses }) }) expect(response.status).toEqual(200) const responseText = await response.text() @@ -514,30 +712,48 @@ test("integration tests for /profiles", function ({ components, stubComponents } expect(responseObj.length).toEqual(1) expect(responseObj[0].avatars.length).toEqual(1) expect(responseObj[0].avatars?.[0].hasClaimedName).toEqual(false) - expect(responseObj[0].avatars?.[0].ethAddress).toEqual("0x10") - expect(responseObj[0].avatars?.[0].name).toEqual("cryptonico#e602") - expect(responseObj[0].avatars?.[0].unclaimedName).toEqual("cryptonico") - expect(responseObj[0].avatars?.[0].avatar.bodyShape).toEqual("urn:decentraland:off-chain:base-avatars:BaseMale") - expect(responseObj[0].avatars?.[0].avatar.snapshots.body).toEqual("https://peer.decentraland.org/content/contents/qwerqwerqwerqwerqwerqwerqwerqwerqwerqwerqwerqwerqwerqwerqwe") - expect(responseObj[0].avatars?.[0].avatar.snapshots.face256).toEqual("https://peer.decentraland.org/content/contents/asdfasdfasdfasdfasdfasdfasdfasdfasdfasdfasdfasdfasdfasdfasd") + expect(responseObj[0].avatars?.[0].ethAddress).toEqual('0x10') + expect(responseObj[0].avatars?.[0].name).toEqual('cryptonico#e602') + expect(responseObj[0].avatars?.[0].unclaimedName).toEqual('cryptonico') + expect(responseObj[0].avatars?.[0].avatar.bodyShape).toEqual('urn:decentraland:off-chain:base-avatars:BaseMale') + expect(responseObj[0].avatars?.[0].avatar.snapshots.body).toEqual( + 'https://peer.decentraland.org/content/contents/qwerqwerqwerqwerqwerqwerqwerqwerqwerqwerqwerqwerqwerqwerqwe' + ) + expect(responseObj[0].avatars?.[0].avatar.snapshots.face256).toEqual( + 'https://peer.decentraland.org/content/contents/asdfasdfasdfasdfasdfasdfasdfasdfasdfasdfasdfasdfasdfasdfasd' + ) expect(responseObj[0].avatars?.[0].avatar.wearables.length).toEqual(0) }) it("calling with two profiles, with if-modified-since header, with one of them modified after the header's date", async () => { const { localFetch } = components const { theGraph, content } = stubComponents - const addresses = ["0x11", "0x12"] - - content.fetchEntitiesByPointers.withArgs(EntityType.PROFILE, addresses).resolves(await Promise.all([profileEntityWithOldTimestamp, profileEntityWithNewTimestamp])) - - const wearablesQuery = "{\n P0x11: nfts(where: { owner: \"0x11\", searchItemType_in: [\"wearable_v1\", \"wearable_v2\", \"smart_wearable_v1\", \"emote_v1\"], urn_in: [] }, first: 1000) {\n urn\n }\n \n\n P0x12: nfts(where: { owner: \"0x12\", searchItemType_in: [\"wearable_v1\", \"wearable_v2\", \"smart_wearable_v1\", \"emote_v1\"], urn_in: [] }, first: 1000) {\n urn\n }\n }" - theGraph.collectionsSubgraph.query = sinon.stub().withArgs(wearablesQuery, {}).resolves({P0x11: [],P0x12: []}) - theGraph.maticCollectionsSubgraph.query = sinon.stub().withArgs(wearablesQuery, {}).resolves({P0x11: [],P0x12: []}) - - const namesQuery = "{\n P0x11: nfts(where: { owner: \"0x11\", category: ens, name_in: [\"cryptonico#e602\"] }, first: 1000) {\n name\n }\n \n\n P0x12: nfts(where: { owner: \"0x12\", category: ens, name_in: [\"cryptonico#e602\"] }, first: 1000) {\n name\n }\n }" - theGraph.ensSubgraph.query = sinon.stub().withArgs(namesQuery, {}).resolves({P0x11: [],P0x12: []}) - - const response = await localFetch.fetch("/profiles", {method: 'post', body: JSON.stringify({ids:addresses}), headers: {"If-Modified-Since": "Mon Jul 11 2022 15:53:46 GMT-0300 (Argentina Standard Time)"}}) + const addresses = ['0x11', '0x12'] + + content.fetchEntitiesByPointers + .withArgs(addresses) + .resolves(await Promise.all([profileEntityWithOldTimestamp, profileEntityWithNewTimestamp])) + + const wearablesQuery = + '{\n P0x11: nfts(where: { owner: "0x11", searchItemType_in: ["wearable_v1", "wearable_v2", "smart_wearable_v1", "emote_v1"], urn_in: [] }, first: 1000) {\n urn\n }\n \n\n P0x12: nfts(where: { owner: "0x12", searchItemType_in: ["wearable_v1", "wearable_v2", "smart_wearable_v1", "emote_v1"], urn_in: [] }, first: 1000) {\n urn\n }\n }' + theGraph.ethereumCollectionsSubgraph.query = sinon + .stub() + .withArgs(wearablesQuery, {}) + .resolves({ P0x11: [], P0x12: [] }) + theGraph.maticCollectionsSubgraph.query = sinon + .stub() + .withArgs(wearablesQuery, {}) + .resolves({ P0x11: [], P0x12: [] }) + + const namesQuery = + '{\n P0x11: nfts(where: { owner: "0x11", category: ens, name_in: ["cryptonico#e602"] }, first: 1000) {\n name\n }\n \n\n P0x12: nfts(where: { owner: "0x12", category: ens, name_in: ["cryptonico#e602"] }, first: 1000) {\n name\n }\n }' + theGraph.ensSubgraph.query = sinon.stub().withArgs(namesQuery, {}).resolves({ P0x11: [], P0x12: [] }) + + const response = await localFetch.fetch('/profiles', { + method: 'post', + body: JSON.stringify({ ids: addresses }), + headers: { 'If-Modified-Since': 'Mon Jul 11 2022 15:53:46 GMT-0300 (Argentina Standard Time)' } + }) expect(response.status).toEqual(200) const responseText = await response.text() @@ -546,37 +762,50 @@ test("integration tests for /profiles", function ({ components, stubComponents } expect(responseObj[0].avatars.length).toEqual(1) expect(responseObj[0].avatars?.[0].hasClaimedName).toEqual(false) - expect(responseObj[0].avatars?.[0].ethAddress).toEqual("0x11") - expect(responseObj[0].avatars?.[0].name).toEqual("cryptonico#e602") - expect(responseObj[0].avatars?.[0].unclaimedName).toEqual("cryptonico") - expect(responseObj[0].avatars?.[0].avatar.bodyShape).toEqual("urn:decentraland:off-chain:base-avatars:BaseMale") - expect(responseObj[0].avatars?.[0].avatar.snapshots.body).toEqual("https://peer.decentraland.org/content/contents/bafkreicilawwtbjyf6ahyzv64ssoamdm73rif75qncee5lv6j3a3352lua") - expect(responseObj[0].avatars?.[0].avatar.snapshots.face256).toEqual("https://peer.decentraland.org/content/contents/bafkreigi3yrgdhvjr2cqzxvfztnsubnll2cfdioo4vfzu6o6vibwoag2ma") + expect(responseObj[0].avatars?.[0].ethAddress).toEqual('0x11') + expect(responseObj[0].avatars?.[0].name).toEqual('cryptonico#e602') + expect(responseObj[0].avatars?.[0].unclaimedName).toEqual('cryptonico') + expect(responseObj[0].avatars?.[0].avatar.bodyShape).toEqual('urn:decentraland:off-chain:base-avatars:BaseMale') + expect(responseObj[0].avatars?.[0].avatar.snapshots.body).toEqual( + 'https://peer.decentraland.org/content/contents/bafkreicilawwtbjyf6ahyzv64ssoamdm73rif75qncee5lv6j3a3352lua' + ) + expect(responseObj[0].avatars?.[0].avatar.snapshots.face256).toEqual( + 'https://peer.decentraland.org/content/contents/bafkreigi3yrgdhvjr2cqzxvfztnsubnll2cfdioo4vfzu6o6vibwoag2ma' + ) expect(responseObj[0].avatars?.[0].avatar.wearables.length).toEqual(0) expect(responseObj[1].avatars.length).toEqual(1) expect(responseObj[1].avatars?.[0].hasClaimedName).toEqual(false) - expect(responseObj[1].avatars?.[0].ethAddress).toEqual("0x12") - expect(responseObj[1].avatars?.[0].name).toEqual("cryptonico#e602") - expect(responseObj[1].avatars?.[0].unclaimedName).toEqual("cryptonico") - expect(responseObj[1].avatars?.[0].avatar.bodyShape).toEqual("urn:decentraland:off-chain:base-avatars:BaseMale") - expect(responseObj[1].avatars?.[0].avatar.snapshots.body).toEqual("https://peer.decentraland.org/content/contents/bafkreicilawwtbjyf6ahyzv64ssoamdm73rif75qncee5lv6j3a3352lua") - expect(responseObj[1].avatars?.[0].avatar.snapshots.face256).toEqual("https://peer.decentraland.org/content/contents/bafkreigi3yrgdhvjr2cqzxvfztnsubnll2cfdioo4vfzu6o6vibwoag2ma") + expect(responseObj[1].avatars?.[0].ethAddress).toEqual('0x12') + expect(responseObj[1].avatars?.[0].name).toEqual('cryptonico#e602') + expect(responseObj[1].avatars?.[0].unclaimedName).toEqual('cryptonico') + expect(responseObj[1].avatars?.[0].avatar.bodyShape).toEqual('urn:decentraland:off-chain:base-avatars:BaseMale') + expect(responseObj[1].avatars?.[0].avatar.snapshots.body).toEqual( + 'https://peer.decentraland.org/content/contents/bafkreicilawwtbjyf6ahyzv64ssoamdm73rif75qncee5lv6j3a3352lua' + ) + expect(responseObj[1].avatars?.[0].avatar.snapshots.face256).toEqual( + 'https://peer.decentraland.org/content/contents/bafkreigi3yrgdhvjr2cqzxvfztnsubnll2cfdioo4vfzu6o6vibwoag2ma' + ) expect(responseObj[1].avatars?.[0].avatar.wearables.length).toEqual(0) }) it("calling with two profiles, with if-modified-since header, with both of them modified before the header's date", async () => { const { localFetch } = components const { content } = stubComponents - const addresses = ["0x11", "0x12"] + const addresses = ['0x11', '0x12'] - content.fetchEntitiesByPointers.withArgs(EntityType.PROFILE, addresses).resolves(await Promise.all([profileEntityWithOldTimestamp, profileEntityWithNewTimestamp])) - - const response = await localFetch.fetch("/profiles", {method: 'post', body: JSON.stringify({ids:addresses}), headers: {"If-Modified-Since": "Mon Jul 11 2023 15:53:46 GMT-0300 (Argentina Standard Time)"}}) + content.fetchEntitiesByPointers + .withArgs(addresses) + .resolves(await Promise.all([profileEntityWithOldTimestamp, profileEntityWithNewTimestamp])) + + const response = await localFetch.fetch('/profiles', { + method: 'post', + body: JSON.stringify({ ids: addresses }), + headers: { 'If-Modified-Since': 'Mon Jul 11 2023 15:53:46 GMT-0300 (Argentina Standard Time)' } + }) expect(response.status).toEqual(304) const responseText = await response.text() expect(responseText).toEqual('') - }) -}) \ No newline at end of file +}) diff --git a/test/integration/wearables-handler.spec.ts b/test/integration/wearables-handler.spec.ts new file mode 100644 index 00000000..336fc97d --- /dev/null +++ b/test/integration/wearables-handler.spec.ts @@ -0,0 +1,368 @@ +import { test } from '../components' +import { generateDefinitions, generateWearables } from '../data/wearables' +import Wallet from 'ethereumjs-wallet' +import { ItemFromQuery } from '../../src/adapters/items-fetcher' +import { Item } from '../../src/types' + +// NOTE: each test generates a new wallet using ethereumjs-wallet to avoid matches on cache +test('wearables-handler: GET /users/:address/wearables should', function ({ components }) { + it('return empty when no wearables are found', async () => { + const { localFetch, theGraph } = components + + theGraph.ethereumCollectionsSubgraph.query = jest.fn().mockResolvedValueOnce({ nfts: [] }) + theGraph.maticCollectionsSubgraph.query = jest.fn().mockResolvedValueOnce({ nfts: [] }) + + const r = await localFetch.fetch(`/users/${Wallet.generate().getAddressString()}/wearables`) + + expect(r.status).toBe(200) + expect(await r.json()).toEqual({ + elements: [], + pageNum: 1, + totalAmount: 0, + pageSize: 100 + }) + }) + + it('return empty when no wearables are found with includeDefinitions set', async () => { + const { localFetch, theGraph, content } = components + + theGraph.ethereumCollectionsSubgraph.query = jest.fn().mockResolvedValueOnce({ nfts: [] }) + theGraph.maticCollectionsSubgraph.query = jest.fn().mockResolvedValueOnce({ nfts: [] }) + content.fetchEntitiesByPointers = jest.fn().mockResolvedValueOnce([]) + + const r = await localFetch.fetch(`/users/${Wallet.generate().getAddressString()}/wearables?includeDefinitions`) + + expect(r.status).toBe(200) + expect(await r.json()).toEqual({ + elements: [], + pageNum: 1, + totalAmount: 0, + pageSize: 100 + }) + }) + + it('return a wearable from ethereum collection', async () => { + const { localFetch, theGraph } = components + const wearables = generateWearables(1) + + theGraph.ethereumCollectionsSubgraph.query = jest.fn().mockResolvedValueOnce({ nfts: wearables }) + theGraph.maticCollectionsSubgraph.query = jest.fn().mockResolvedValueOnce({ nfts: [] }) + + const r = await localFetch.fetch(`/users/${Wallet.generate().getAddressString()}/wearables`) + + expect(r.status).toBe(200) + expect(await r.json()).toEqual({ + elements: convertToDataModel(wearables), + pageNum: 1, + pageSize: 100, + totalAmount: 1 + }) + }) + + it('return a wearable from matic collection', async () => { + const { localFetch, theGraph } = components + const wearables = generateWearables(1) + + theGraph.ethereumCollectionsSubgraph.query = jest.fn().mockResolvedValueOnce({ nfts: [] }) + theGraph.maticCollectionsSubgraph.query = jest.fn().mockResolvedValueOnce({ nfts: wearables }) + + const r = await localFetch.fetch(`/users/${Wallet.generate().getAddressString()}/wearables`) + + expect(r.status).toBe(200) + expect(await r.json()).toEqual({ + elements: convertToDataModel(wearables), + pageNum: 1, + pageSize: 100, + totalAmount: 1 + }) + }) + + it('return wearables from both collections', async () => { + const { localFetch, theGraph } = components + const wearables = generateWearables(2) + + theGraph.ethereumCollectionsSubgraph.query = jest.fn().mockResolvedValueOnce({ nfts: [wearables[0]] }) + theGraph.maticCollectionsSubgraph.query = jest.fn().mockResolvedValueOnce({ nfts: [wearables[1]] }) + + const r = await localFetch.fetch(`/users/${Wallet.generate().getAddressString()}/wearables`) + + expect(r.status).toBe(200) + expect(await r.json()).toEqual({ + elements: convertToDataModel(wearables), + pageNum: 1, + pageSize: 100, + totalAmount: 2 + }) + }) + + it('return wearables from both collections with includeDefinitions set', async () => { + const { localFetch, theGraph, content } = components + const wearables = generateWearables(2) + const definitions = generateDefinitions(wearables.map((wearable) => wearable.urn)) + + theGraph.ethereumCollectionsSubgraph.query = jest.fn().mockResolvedValueOnce({ nfts: [wearables[0]] }) + theGraph.maticCollectionsSubgraph.query = jest.fn().mockResolvedValueOnce({ nfts: [wearables[1]] }) + content.fetchEntitiesByPointers = jest.fn().mockResolvedValueOnce(definitions) + + const r = await localFetch.fetch(`/users/${Wallet.generate().getAddressString()}/wearables?includeDefinitions`) + + expect(r.status).toBe(200) + expect(await r.json()).toEqual({ + elements: convertToDataModel(wearables, definitions), + pageNum: 1, + pageSize: 100, + totalAmount: 2 + }) + }) + + it('return a wearable with definition and another one without definition', async () => { + const { localFetch, theGraph, content } = components + const wearables = generateWearables(2) + const definitions = generateDefinitions([wearables[0].urn]) + + // modify wearable urn to avoid cache hit + wearables[1] = { ...wearables[1], urn: 'anotherUrn' } + + theGraph.ethereumCollectionsSubgraph.query = jest.fn().mockResolvedValueOnce({ nfts: [wearables[0]] }) + theGraph.maticCollectionsSubgraph.query = jest.fn().mockResolvedValueOnce({ nfts: [wearables[1]] }) + content.fetchEntitiesByPointers = jest.fn().mockResolvedValueOnce(definitions) + + const r = await localFetch.fetch(`/users/${Wallet.generate().getAddressString()}/wearables?includeDefinitions`) + + expect(r.status).toBe(200) + expect(await r.json()).toEqual({ + elements: convertToDataModel(wearables, definitions), + pageNum: 1, + pageSize: 100, + totalAmount: 2 + }) + }) + + it('return wearables 2 from each collection and paginate them correctly (page 1, size 2, total 4)', async () => { + const { localFetch, theGraph } = components + const wearables = generateWearables(4) + + theGraph.ethereumCollectionsSubgraph.query = jest.fn().mockResolvedValueOnce({ nfts: [wearables[0], wearables[1]] }) + theGraph.maticCollectionsSubgraph.query = jest.fn().mockResolvedValueOnce({ nfts: [wearables[2], wearables[3]] }) + + const r = await localFetch.fetch(`/users/${Wallet.generate().getAddressString()}/wearables?pageSize=2&pageNum=1`) + + expect(r.status).toBe(200) + expect(await r.json()).toEqual({ + elements: convertToDataModel([wearables[0], wearables[1]]), + pageNum: 1, + pageSize: 2, + totalAmount: 4 + }) + }) + + it('return wearables 2 from each collection and paginate them correctly (page 2, size 2, total 4)', async () => { + const { localFetch, theGraph } = components + const wearables = generateWearables(4) + + theGraph.ethereumCollectionsSubgraph.query = jest.fn().mockResolvedValueOnce({ nfts: [wearables[0], wearables[1]] }) + theGraph.maticCollectionsSubgraph.query = jest.fn().mockResolvedValueOnce({ nfts: [wearables[2], wearables[3]] }) + + const r = await localFetch.fetch(`/users/${Wallet.generate().getAddressString()}/wearables?pageSize=2&pageNum=2`) + + expect(r.status).toBe(200) + expect(await r.json()).toEqual({ + elements: convertToDataModel([wearables[2], wearables[3]]), + pageNum: 2, + pageSize: 2, + totalAmount: 4 + }) + }) + + it('return wearables (3 eth and 1 matic) and paginate them correctly (page 1, size 2, total 4)', async () => { + const { localFetch, theGraph } = components + const wearables = generateWearables(4) + + theGraph.ethereumCollectionsSubgraph.query = jest.fn().mockResolvedValueOnce({ nfts: [wearables[0], wearables[1], wearables[2]] }) + theGraph.maticCollectionsSubgraph.query = jest.fn().mockResolvedValueOnce({ nfts: [wearables[3]] }) + + const r = await localFetch.fetch(`/users/${Wallet.generate().getAddressString()}/wearables?pageSize=2&pageNum=1`) + + expect(r.status).toBe(200) + expect(await r.json()).toEqual({ + elements: convertToDataModel([wearables[0], wearables[1]]), + pageNum: 1, + pageSize: 2, + totalAmount: 4 + }) + }) + + it('return wearables (3 eth and 1 matic) and paginate them correctly (page 2, size 2, total 4)', async () => { + const { localFetch, theGraph } = components + const wearables = generateWearables(4) + + theGraph.ethereumCollectionsSubgraph.query = jest.fn().mockResolvedValueOnce({ nfts: [wearables[0], wearables[1], wearables[2]] }) + theGraph.maticCollectionsSubgraph.query = jest.fn().mockResolvedValueOnce({ nfts: [wearables[3]] }) + + const r = await localFetch.fetch(`/users/${Wallet.generate().getAddressString()}/wearables?pageSize=2&pageNum=2`) + + expect(r.status).toBe(200) + expect(await r.json()).toEqual({ + elements: convertToDataModel([wearables[2], wearables[3]]), + pageNum: 2, + pageSize: 2, + totalAmount: 4 + }) + }) + + it('return wearables (4 eth and 3 matic) and paginate them correctly (page 1, size 3, total 7)', async () => { + const { localFetch, theGraph } = components + const wearables = generateWearables(7) + + theGraph.ethereumCollectionsSubgraph.query = jest.fn().mockResolvedValueOnce({ nfts: [wearables[0], wearables[1], wearables[2], wearables[3]] }) + theGraph.maticCollectionsSubgraph.query = jest.fn().mockResolvedValueOnce({ nfts: [wearables[4], wearables[5], wearables[6]] }) + + const r = await localFetch.fetch(`/users/${Wallet.generate().getAddressString()}/wearables?pageSize=3&pageNum=1`) + + expect(r.status).toBe(200) + expect(await r.json()).toEqual({ + elements: convertToDataModel([wearables[0], wearables[1], wearables[2]]), + pageNum: 1, + pageSize: 3, + totalAmount: 7 + }) + }) + + it('return wearables (4 eth and 3 matic) and paginate them correctly (page 2, size 3, total 7)', async () => { + const { localFetch, theGraph } = components + const wearables = generateWearables(7) + + theGraph.ethereumCollectionsSubgraph.query = jest.fn().mockResolvedValueOnce({ nfts: [wearables[0], wearables[1], wearables[2], wearables[3]] }) + theGraph.maticCollectionsSubgraph.query = jest.fn().mockResolvedValueOnce({ nfts: [wearables[4], wearables[5], wearables[6]] }) + + const r = await localFetch.fetch(`/users/${Wallet.generate().getAddressString()}/wearables?pageSize=3&pageNum=2`) + + expect(r.status).toBe(200) + expect(await r.json()).toEqual({ + elements: convertToDataModel([wearables[3], wearables[4], wearables[5]]), + pageNum: 2, + pageSize: 3, + totalAmount: 7 + }) + }) + + it('return wearables (4 eth and 3 matic) and paginate them correctly (page 3, size 3, total 7)', async () => { + const { localFetch, theGraph } = components + const wearables = generateWearables(7) + + theGraph.ethereumCollectionsSubgraph.query = jest.fn().mockResolvedValueOnce({ nfts: [wearables[0], wearables[1], wearables[2], wearables[3]] }) + theGraph.maticCollectionsSubgraph.query = jest.fn().mockResolvedValueOnce({ nfts: [wearables[4], wearables[5], wearables[6]] }) + + const r = await localFetch.fetch(`/users/${Wallet.generate().getAddressString()}/wearables?pageSize=3&pageNum=3`) + + expect(r.status).toBe(200) + expect(await r.json()).toEqual({ + elements: convertToDataModel([wearables[6]]), + pageNum: 3, + pageSize: 3, + totalAmount: 7 + }) + }) + + it('return wearables from cache on second call for the same address', async () => { + const { localFetch, theGraph } = components + const wearables = generateWearables(7) + const wallet = Wallet.generate().getAddressString() + + theGraph.ethereumCollectionsSubgraph.query = jest.fn().mockResolvedValueOnce({ nfts: [wearables[0], wearables[1], wearables[2], wearables[3]] }) + theGraph.maticCollectionsSubgraph.query = jest.fn().mockResolvedValueOnce({ nfts: [wearables[4], wearables[5], wearables[6]] }) + + const r = await localFetch.fetch(`/users/${wallet}/wearables?pageSize=7&pageNum=1`) + const rBody = await r.json() + + expect(r.status).toBe(200) + expect(rBody).toEqual({ + elements: convertToDataModel(wearables), + pageNum: 1, + pageSize: 7, + totalAmount: 7 + }) + + const r2 = await localFetch.fetch(`/users/${wallet}/wearables?pageSize=7&pageNum=1`) + expect(r2.status).toBe(r.status) + expect(await r2.json()).toEqual(rBody) + expect(theGraph.ethereumCollectionsSubgraph.query).toHaveBeenCalledTimes(1) + expect(theGraph.maticCollectionsSubgraph.query).toHaveBeenCalledTimes(1) + }) + + it('return an error when wearables cannot be fetched from ethereum collection', async () => { + const { localFetch, theGraph } = components + + theGraph.ethereumCollectionsSubgraph.query = jest.fn().mockResolvedValueOnce(undefined) + + const r = await localFetch.fetch(`/users/${Wallet.generate().getAddressString()}/wearables`) + + expect(r.status).toBe(502) + expect(await r.json()).toEqual({ + error: 'Cannot fetch wearables right now' + }) + }) + + it('return an error when wearables cannot be fetched from matic collection', async () => { + const { localFetch, theGraph } = components + + theGraph.maticCollectionsSubgraph.query = jest.fn().mockResolvedValueOnce(undefined) + + const r = await localFetch.fetch(`/users/${Wallet.generate().getAddressString()}/wearables`) + + expect(r.status).toBe(502) + expect(await r.json()).toEqual({ + error: 'Cannot fetch wearables right now' + }) + }) + + it('return a generic error when an unexpected error occurs (definitions cannot be fetched)', async () => { + const { localFetch, theGraph, content } = components + const wearables = generateWearables(2) + + // modify wearable urn to avoid cache hit + wearables[1] = { ...wearables[1], urn: 'anotherUrn' } + + theGraph.ethereumCollectionsSubgraph.query = jest.fn().mockResolvedValueOnce({ nfts: [wearables[0]] }) + theGraph.maticCollectionsSubgraph.query = jest.fn().mockResolvedValueOnce({ nfts: [wearables[1]] }) + content.fetchEntitiesByPointers = jest.fn().mockResolvedValueOnce(undefined) + + const r = await localFetch.fetch(`/users/${Wallet.generate().getAddressString()}/wearables?includeDefinitions`) + + expect(r.status).toBe(500) + expect(await r.json()).toEqual({ + error: 'Internal Server Error' + }) + }) +}) + +function convertToDataModel(wearables: ItemFromQuery[], definitions = undefined): Item[] { + return wearables.map(wearable => { + const individualData = { + id: wearable.id, + tokenId: wearable.tokenId, + transferredAt: wearable.transferredAt, + price: wearable.item.price + } + const rarity = wearable.item.rarity + const definition = definitions?.find(def => def.id === wearable.urn) + const definitionData = definition?.metadata?.data + + return { + urn: wearable.urn, + amount: 1, + individualData: [individualData], + rarity, + ...(definitions ? { + definition: definitionData && { + id: wearable.urn, + data: { + ...definitionData, + representations: [{ contents: [{ key: definitionData.representations[0]?.contents[0] }] }] + } + } + } : {}) + } + }) +} + diff --git a/test/mocks/content-mock.ts b/test/mocks/content-mock.ts new file mode 100644 index 00000000..79a82109 --- /dev/null +++ b/test/mocks/content-mock.ts @@ -0,0 +1,11 @@ +import { ContentComponent } from "../../src/ports/content"; + +export function createContentComponentMock(): ContentComponent { + const getExternalContentServerUrl = jest.fn() + const fetchEntitiesByPointers = jest.fn() + + return { + getExternalContentServerUrl, + fetchEntitiesByPointers + } +} \ No newline at end of file diff --git a/test/mocks/the-graph-mock.ts b/test/mocks/the-graph-mock.ts new file mode 100644 index 00000000..a125ee4e --- /dev/null +++ b/test/mocks/the-graph-mock.ts @@ -0,0 +1,28 @@ +import { ISubgraphComponent } from "@well-known-components/thegraph-component"; +import { TheGraphComponent } from "../../src/ports/the-graph"; +import { QueryGraph } from "../../src/types"; + +const createMockSubgraphComponent = (mock?: QueryGraph): ISubgraphComponent => ({ + query: mock ?? (jest.fn() as jest.MockedFunction) + }) + +export function createTheGraphComponentMock(): TheGraphComponent { + return { + start: async () => {}, + stop: async () => {}, + ethereumCollectionsSubgraph: createMockSubgraphComponent(jest.fn().mockResolvedValueOnce({ nfts: [{ + urn: 'urn', + id: 'id', + tokenId: 'tokenId', + category: 'category', + transferredAt: Date.now(), + item: { + rarity: 'unique', + price: 100 + } + }]})), + maticCollectionsSubgraph: createMockSubgraphComponent(), + ensSubgraph: createMockSubgraphComponent(), + thirdPartyRegistrySubgraph: createMockSubgraphComponent() + } + } \ No newline at end of file diff --git a/test/unit/ownership.spec.ts b/test/unit/ownership.spec.ts index cbbc9e05..0682eea4 100644 --- a/test/unit/ownership.spec.ts +++ b/test/unit/ownership.spec.ts @@ -1,16 +1,14 @@ -import { createConfigComponent, createDotEnvConfigComponent } from "@well-known-components/env-config-provider" -import { createLogComponent } from "@well-known-components/logger" -import { createTestMetricsComponent } from "@well-known-components/metrics" +import { createDotEnvConfigComponent } from "@well-known-components/env-config-provider" import sinon from "sinon" import { ownedNFTsByAddress } from "../../src/logic/ownership" -import { createTestTheGraphComponent } from "../components" +import { createTheGraphComponentMock } from "../mocks/the-graph-mock" describe("ownership unit tests", () => { it("ownedNFTsByAddress must return an empty map when receives an empty map", async () => { const components = { - theGraph: createTestTheGraphComponent(), - config: await createDotEnvConfigComponent({}, {NFT_FRAGMENTS_PER_QUERY: '10'}) + theGraph: createTheGraphComponentMock(), + config: await createDotEnvConfigComponent({}, { NFT_FRAGMENTS_PER_QUERY: '10' }) } const nftIdsByAddressToCheck = new Map() const querySubgraph = sinon.stub() @@ -21,8 +19,8 @@ describe("ownership unit tests", () => { it("ownedNFTsByAddress must return a map with the address but no owned nfts", async () => { const components = { - theGraph: createTestTheGraphComponent(), - config: await createDotEnvConfigComponent({}, {NFT_FRAGMENTS_PER_QUERY: '10'}) + theGraph: createTheGraphComponentMock(), + config: await createDotEnvConfigComponent({}, { NFT_FRAGMENTS_PER_QUERY: '10' }) } const nftIdsByAddressToCheck = new Map([ ['address', ['nft']] @@ -40,8 +38,8 @@ describe("ownership unit tests", () => { it("ownedNFTsByAddress must return a map with the addresses and their owned nfts", async () => { const components = { - theGraph: createTestTheGraphComponent(), - config: await createDotEnvConfigComponent({}, {NFT_FRAGMENTS_PER_QUERY: '10'}) + theGraph: createTheGraphComponentMock(), + config: await createDotEnvConfigComponent({}, { NFT_FRAGMENTS_PER_QUERY: '10' }) } const nftIdsByAddressToCheck = new Map([ ['0x1', ['nft1', 'nft2']], @@ -69,8 +67,8 @@ describe("ownership unit tests", () => { it("ownedNFTsByAddress must return a map with the addresses and some of the nfts not owned", async () => { const components = { - theGraph: createTestTheGraphComponent(), - config: await createDotEnvConfigComponent({}, {NFT_FRAGMENTS_PER_QUERY: '10'}) + theGraph: createTheGraphComponentMock(), + config: await createDotEnvConfigComponent({}, { NFT_FRAGMENTS_PER_QUERY: '10' }) } const nftIdsByAddressToCheck = new Map([ ['0x1', ['nft1', 'nft2']], @@ -98,8 +96,8 @@ describe("ownership unit tests", () => { it("ownedNFTsByAddress must return a map with the addresses and their owned nfts, with multiple subgraph calls", async () => { const components = { - theGraph: createTestTheGraphComponent(), - config: await createDotEnvConfigComponent({}, {NFT_FRAGMENTS_PER_QUERY: '1'}) + theGraph: createTheGraphComponentMock(), + config: await createDotEnvConfigComponent({}, { NFT_FRAGMENTS_PER_QUERY: '1' }) } const nftIdsByAddressToCheck = new Map([ ['0x1', ['nft1', 'nft2']], @@ -141,15 +139,15 @@ describe("ownership unit tests", () => { it('ownedNFTsByAddress must consider the nfts as owned if the query to subgraph fails', async () => { const components = { - theGraph: createTestTheGraphComponent(), - config: await createDotEnvConfigComponent({}, {NFT_FRAGMENTS_PER_QUERY: '10'}) + theGraph: createTheGraphComponentMock(), + config: await createDotEnvConfigComponent({}, { NFT_FRAGMENTS_PER_QUERY: '10' }) } const nftIdsByAddressToCheck = new Map([ ['0x1', ['nft1', 'nft2']], ['0x2', ['nft1', 'nft2']] ]) const querySubgraph = sinon.stub() - querySubgraph.withArgs(components.theGraph, [['0x1', ['nft1', 'nft2']],['0x2', ['nft1', 'nft2']]]).resolves([ + querySubgraph.withArgs(components.theGraph, [['0x1', ['nft1', 'nft2']], ['0x2', ['nft1', 'nft2']]]).resolves([ { owner: '0x2', ownedNFTs: ['nft1'] diff --git a/test/unit/pagination.spec.ts b/test/unit/pagination.spec.ts new file mode 100644 index 00000000..6c10535f --- /dev/null +++ b/test/unit/pagination.spec.ts @@ -0,0 +1,28 @@ +import { paginationObject } from "../../src/logic/utils" + +describe('paginationObject should', () => { + const BASE_URL = 'https://host/users/0x000/wearables' + const sut = paginationObject + + test('return values from query-string as expected (pageSize & pageNum)', () => { + const url = BASE_URL + '?pageSize=10&pageNum=2' + const { pageSize, pageNum } = sut(new URL(url)) + + expect(pageSize).toBe(10) + expect(pageNum).toBe(2) + }) + + test('return calculated value offset as expected', () => { + const url = BASE_URL + '?pageSize=10&pageNum=3' + const { offset } = sut(new URL(url)) + + expect(offset).toBe(20) + }) + + test('return calculated value limit as expected', () => { + const url = BASE_URL + '?pageSize=11&pageNum=2' + const { offset } = sut(new URL(url)) + + expect(offset).toBe(11) + }) +}) \ No newline at end of file diff --git a/yarn.lock b/yarn.lock index 1ec3af1d..3c7eb3b5 100644 --- a/yarn.lock +++ b/yarn.lock @@ -252,9 +252,10 @@ version "0.2.3" resolved "https://registry.npmjs.org/@bcoe/v8-coverage/-/v8-coverage-0.2.3.tgz" -"@dcl/catalyst-contracts@^2.0.0": - version "2.0.0" - resolved "https://registry.npmjs.org/@dcl/catalyst-contracts/-/catalyst-contracts-2.0.0.tgz" +"@dcl/catalyst-contracts@^3.0.0": + version "3.1.3" + resolved "https://registry.yarnpkg.com/@dcl/catalyst-contracts/-/catalyst-contracts-3.1.3.tgz#317f9b1795dc8c61e99dfa41ccfad69cd598873d" + integrity sha512-Wq9naCp3WoxfZr/rXNlmKYhVx7uKDrztQ2x1EYDL54Fjj41FSO+McbfVgv4Coia3wjwx1e+wxfUdoBvD5KsYeA== dependencies: eth-connect "^6.0.2" @@ -293,14 +294,6 @@ ipfs-unixfs-importer "^7.0.3" multiformats "^9.6.3" -"@dcl/schemas@^5.0.3": - version "5.4.2" - resolved "https://registry.npmjs.org/@dcl/schemas/-/schemas-5.4.2.tgz" - dependencies: - ajv "^8.11.0" - ajv-errors "^3.0.0" - ajv-keywords "^5.1.0" - "@dcl/schemas@^6.4.2", "@dcl/schemas@^6.6.0": version "6.6.0" resolved "https://registry.yarnpkg.com/@dcl/schemas/-/schemas-6.6.0.tgz#8ecbc4ee3b873c24447a39492aec9726cf3c5987" @@ -730,6 +723,13 @@ dependencies: "@babel/types" "^7.3.0" +"@types/bn.js@^5.1.0": + version "5.1.1" + resolved "https://registry.yarnpkg.com/@types/bn.js/-/bn.js-5.1.1.tgz#b51e1b55920a4ca26e9285ff79936bbdec910682" + integrity sha512-qNrYbZqMx0uJAfKnKclPh+dTwK33KfLHYqtyODwd5HnXOjnkhc4qgn3BrK6RWyGZm5+sIFE7Q7Vz6QQtJB7w7g== + dependencies: + "@types/node" "*" + "@types/body-parser@*": version "1.19.2" resolved "https://registry.npmjs.org/@types/body-parser/-/body-parser-1.19.2.tgz" @@ -853,6 +853,13 @@ version "16.11.44" resolved "https://registry.npmjs.org/@types/node/-/node-16.11.44.tgz" +"@types/pbkdf2@^3.0.0": + version "3.1.0" + resolved "https://registry.yarnpkg.com/@types/pbkdf2/-/pbkdf2-3.1.0.tgz#039a0e9b67da0cdc4ee5dab865caa6b267bb66b1" + integrity sha512-Cf63Rv7jCQ0LaL8tNXmEyqTHuIJxRdlS5vMh1mj5voN4+QFhVZnlZruezqpWYDiJ8UTzhP0VmeLXCmBk66YrMQ== + dependencies: + "@types/node" "*" + "@types/prettier@^2.1.5": version "2.6.3" resolved "https://registry.npmjs.org/@types/prettier/-/prettier-2.6.3.tgz" @@ -865,6 +872,13 @@ version "1.2.4" resolved "https://registry.npmjs.org/@types/range-parser/-/range-parser-1.2.4.tgz" +"@types/secp256k1@^4.0.1": + version "4.0.3" + resolved "https://registry.yarnpkg.com/@types/secp256k1/-/secp256k1-4.0.3.tgz#1b8e55d8e00f08ee7220b4d59a6abe89c37a901c" + integrity sha512-Da66lEIFeIz9ltsdMZcpQvmrmmoqrfju8pm1BH8WbYjZSwUgCwXLb9C+9XYogwBITnbsSaMdVPb2ekf7TV+03w== + dependencies: + "@types/node" "*" + "@types/semver@^7.3.12": version "7.3.13" resolved "https://registry.yarnpkg.com/@types/semver/-/semver-7.3.13.tgz#da4bfd73f49bd541d28920ab0e2bf0ee80f71c91" @@ -1091,6 +1105,11 @@ acorn@^8.8.0: resolved "https://registry.yarnpkg.com/acorn/-/acorn-8.8.2.tgz#1b2f25db02af965399b9776b0c2c391276d37c4a" integrity sha512-xjIYgE8HBrkpd/sJqOGNspf8uHG+NOHGOw6a/Urj8taM2EXfdNAH2oFcPeIFfsv3+kz/mJrS5VuMqbNLjCa2vw== +aes-js@^3.1.2: + version "3.1.2" + resolved "https://registry.yarnpkg.com/aes-js/-/aes-js-3.1.2.tgz#db9aabde85d5caabbfc0d4f2a4446960f627146a" + integrity sha512-e5pEa2kBnBOgR4Y/p20pskXI74UEz7de8ZGVo58asOtvSVG5YAbJeELPZxOmt+Bnz3rX753YKhfIn4X4l1PPRQ== + agent-base@6: version "6.0.2" resolved "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz" @@ -1279,6 +1298,13 @@ balanced-match@^1.0.0: version "1.0.2" resolved "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz" +base-x@^3.0.2: + version "3.0.9" + resolved "https://registry.yarnpkg.com/base-x/-/base-x-3.0.9.tgz#6349aaabb58526332de9f60995e548a53fe21320" + integrity sha512-H7JU6iBHTal1gp56aKoaa//YUxEaAOUiydvrV/pILqIHXTtqxSkATOnDA2u+jZ/61sD+L/412+7kzXRtWukhpQ== + dependencies: + safe-buffer "^5.0.1" + base64-js@^1.3.1: version "1.5.1" resolved "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz" @@ -1299,6 +1325,16 @@ blakejs@^1.1.0: version "1.2.1" resolved "https://registry.npmjs.org/blakejs/-/blakejs-1.2.1.tgz" +bn.js@^4.11.9: + version "4.12.0" + resolved "https://registry.yarnpkg.com/bn.js/-/bn.js-4.12.0.tgz#775b3f278efbb9718eec7361f483fb36fbbfea88" + integrity sha512-c98Bf3tPniI+scsdk237ku1Dc3ujXQTSgyiPUDEOe7tRkhrqridvh8klBv0HCEso1OLOYcHuCv/cS6DNxKH+ZA== + +bn.js@^5.1.2, bn.js@^5.2.0: + version "5.2.1" + resolved "https://registry.yarnpkg.com/bn.js/-/bn.js-5.2.1.tgz#0bc527a6a0d18d0aa8d5b0538ce4a77dccfa7b70" + integrity sha512-eXRvHzWyYPBuB4NBy0cmYQjGitUrtqwbvlzP3G6VFnNRbsZQIxQ10PbKKHt8gZ/HW/D/747aDl+QkDqg3KQLMQ== + body-parser@1.20.1: version "1.20.1" resolved "https://registry.yarnpkg.com/body-parser/-/body-parser-1.20.1.tgz#b1812a8912c195cd371a3ee5e66faa2338a5c668" @@ -1329,10 +1365,27 @@ braces@^3.0.2: dependencies: fill-range "^7.0.1" +brorand@^1.1.0: + version "1.1.0" + resolved "https://registry.yarnpkg.com/brorand/-/brorand-1.1.0.tgz#12c25efe40a45e3c323eb8675a0a0ce57b22371f" + integrity sha512-cKV8tMCEpQs4hK/ik71d6LrPOnpkpGBR0wzxqr68g2m/LB2GxVYQroAjMJZRVM1Y4BCjCKc3vAamxSzOY2RP+w== + browser-process-hrtime@^1.0.0: version "1.0.0" resolved "https://registry.npmjs.org/browser-process-hrtime/-/browser-process-hrtime-1.0.0.tgz" +browserify-aes@^1.2.0: + version "1.2.0" + resolved "https://registry.yarnpkg.com/browserify-aes/-/browserify-aes-1.2.0.tgz#326734642f403dabc3003209853bb70ad428ef48" + integrity sha512-+7CHXqGuspUn/Sl5aO7Ea0xWGAtETPXNSAjHo48JfLdPWcMng33Xe4znFvQweqc/uzk5zSOI3H52CYnjCfb5hA== + dependencies: + buffer-xor "^1.0.3" + cipher-base "^1.0.0" + create-hash "^1.1.0" + evp_bytestokey "^1.0.3" + inherits "^2.0.1" + safe-buffer "^5.0.1" + browserslist@^4.20.2: version "4.21.2" resolved "https://registry.npmjs.org/browserslist/-/browserslist-4.21.2.tgz" @@ -1348,6 +1401,22 @@ bs-logger@0.x: dependencies: fast-json-stable-stringify "2.x" +bs58@^4.0.0: + version "4.0.1" + resolved "https://registry.yarnpkg.com/bs58/-/bs58-4.0.1.tgz#be161e76c354f6f788ae4071f63f34e8c4f0a42a" + integrity sha512-Ok3Wdf5vOIlBrgCvTq96gBkJw+JUEzdBgyaza5HLtPm7yTHkjRy8+JzNyHF7BHa0bNWOQIp3m5YF0nnFcOIKLw== + dependencies: + base-x "^3.0.2" + +bs58check@^2.1.2: + version "2.1.2" + resolved "https://registry.yarnpkg.com/bs58check/-/bs58check-2.1.2.tgz#53b018291228d82a5aa08e7d796fdafda54aebfc" + integrity sha512-0TS1jicxdU09dwJMNZtVAfzPi6Q6QeN0pM1Fkzrjn+XYHvzMKPU3pHVpva+769iNVSfIYWf7LJ6WR+BuuMf8cA== + dependencies: + bs58 "^4.0.0" + create-hash "^1.1.0" + safe-buffer "^5.1.2" + bser@2.1.1: version "2.1.1" resolved "https://registry.npmjs.org/bser/-/bser-2.1.1.tgz" @@ -1358,6 +1427,11 @@ buffer-from@^1.0.0: version "1.1.2" resolved "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz" +buffer-xor@^1.0.3: + version "1.0.3" + resolved "https://registry.yarnpkg.com/buffer-xor/-/buffer-xor-1.0.3.tgz#26e61ed1422fb70dd42e6e36729ed51d855fe8d9" + integrity sha512-571s0T7nZWK6vB67HI5dyUF7wXiNcfaPPPTl6zYCNApANjIvYJTg7hlud/+cJpdAhS7dVzqMLmfhfHR3rAcOjQ== + buffer@^6.0.3: version "6.0.3" resolved "https://registry.npmjs.org/buffer/-/buffer-6.0.3.tgz" @@ -1428,6 +1502,14 @@ cids@^1.0.0, cids@^1.1.5, cids@^1.1.6: multihashes "^4.0.1" uint8arrays "^3.0.0" +cipher-base@^1.0.0, cipher-base@^1.0.1, cipher-base@^1.0.3: + version "1.0.4" + resolved "https://registry.yarnpkg.com/cipher-base/-/cipher-base-1.0.4.tgz#8760e4ecc272f4c363532f926d874aae2c1397de" + integrity sha512-Kkht5ye6ZGmwv40uUDZztayT2ThLQGfnj/T71N/XzeZeo3nf8foyW7zGTsPYkEya3m5f3cAypH+qe7YOrM1U2Q== + dependencies: + inherits "^2.0.1" + safe-buffer "^5.0.1" + cjs-module-lexer@^1.0.0: version "1.2.2" resolved "https://registry.npmjs.org/cjs-module-lexer/-/cjs-module-lexer-1.2.2.tgz" @@ -1516,7 +1598,7 @@ cookie-signature@1.0.6: version "1.0.6" resolved "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.6.tgz" -cookie@0.5.0: +cookie@0.5.0, cookie@^0.5.0: version "0.5.0" resolved "https://registry.npmjs.org/cookie/-/cookie-0.5.0.tgz" @@ -1531,6 +1613,29 @@ cors@^2.8.5: object-assign "^4" vary "^1" +create-hash@^1.1.0, create-hash@^1.1.2, create-hash@^1.2.0: + version "1.2.0" + resolved "https://registry.yarnpkg.com/create-hash/-/create-hash-1.2.0.tgz#889078af11a63756bcfb59bd221996be3a9ef196" + integrity sha512-z00bCGNHDG8mHAkP7CtT1qVu+bFQUPjYq/4Iv3C3kWjTFV10zIjfSoeqXo9Asws8gwSHDGj/hl2u4OGIjapeCg== + dependencies: + cipher-base "^1.0.1" + inherits "^2.0.1" + md5.js "^1.3.4" + ripemd160 "^2.0.1" + sha.js "^2.4.0" + +create-hmac@^1.1.4, create-hmac@^1.1.7: + version "1.1.7" + resolved "https://registry.yarnpkg.com/create-hmac/-/create-hmac-1.1.7.tgz#69170c78b3ab957147b2b8b04572e47ead2243ff" + integrity sha512-MJG9liiZ+ogc4TzUwuvbER1JRdgvUFSB5+VR/g5h82fGaIRWMWddtKBHi7/sVhfjQZ6SehlyhvQYrcYkaUIpLg== + dependencies: + cipher-base "^1.0.3" + create-hash "^1.1.0" + inherits "^2.0.1" + ripemd160 "^2.0.0" + safe-buffer "^5.0.1" + sha.js "^2.4.8" + cross-fetch@^3.1.4: version "3.1.5" resolved "https://registry.npmjs.org/cross-fetch/-/cross-fetch-3.1.5.tgz" @@ -1567,19 +1672,20 @@ data-urls@^2.0.0: whatwg-mimetype "^2.3.0" whatwg-url "^8.0.0" -dcl-catalyst-client@^12.0.1: - version "12.0.5" - resolved "https://registry.npmjs.org/dcl-catalyst-client/-/dcl-catalyst-client-12.0.5.tgz" +dcl-catalyst-client@^14.0.9: + version "14.0.9" + resolved "https://registry.yarnpkg.com/dcl-catalyst-client/-/dcl-catalyst-client-14.0.9.tgz#82afb34f8fc42d31fb9f369e49520e26db057510" + integrity sha512-ljFYUve3c3LKbQUwTlM3N3BvWq18II5bRGQyXGnP7bF1y5U+YIl77q6V3HyJKMreeb5Zgh7lKppJ9jRnve70qw== dependencies: - "@dcl/catalyst-contracts" "^2.0.0" + "@dcl/catalyst-contracts" "^3.0.0" "@dcl/hashing" "^1.1.0" - "@dcl/schemas" "^5.0.3" + "@dcl/schemas" "^6.4.2" "@types/form-data" "^2.5.0" - cookie "^0.4.1" - dcl-catalyst-commons "^9.0.0" + cookie "^0.5.0" + dcl-catalyst-commons "^9.0.6" form-data "^4.0.0" -dcl-catalyst-commons@9.0.16, dcl-catalyst-commons@^9.0.0: +dcl-catalyst-commons@9.0.16, dcl-catalyst-commons@^9.0.6: version "9.0.16" resolved "https://registry.yarnpkg.com/dcl-catalyst-commons/-/dcl-catalyst-commons-9.0.16.tgz#ce63769aedde2abd9ea24288866382c6290996be" dependencies: @@ -1704,6 +1810,19 @@ electron-to-chromium@^1.4.188: version "1.4.189" resolved "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.4.189.tgz" +elliptic@^6.5.4: + version "6.5.4" + resolved "https://registry.yarnpkg.com/elliptic/-/elliptic-6.5.4.tgz#da37cebd31e79a1367e941b592ed1fbebd58abbb" + integrity sha512-iLhC6ULemrljPZb+QutR5TQGB+pdW6KGD5RSegS+8sorOZT+rdQFbsQFJgvN3eRqNALqJer4oQ16YvJHlU8hzQ== + dependencies: + bn.js "^4.11.9" + brorand "^1.1.0" + hash.js "^1.0.0" + hmac-drbg "^1.0.1" + inherits "^2.0.4" + minimalistic-assert "^1.0.1" + minimalistic-crypto-utils "^1.0.1" + emittery@^0.8.1: version "0.8.1" resolved "https://registry.npmjs.org/emittery/-/emittery-0.8.1.tgz" @@ -2055,6 +2174,27 @@ eth-connect@^6.0.2, eth-connect@^6.0.3: version "6.0.3" resolved "https://registry.npmjs.org/eth-connect/-/eth-connect-6.0.3.tgz" +ethereum-cryptography@^0.1.3: + version "0.1.3" + resolved "https://registry.yarnpkg.com/ethereum-cryptography/-/ethereum-cryptography-0.1.3.tgz#8d6143cfc3d74bf79bbd8edecdf29e4ae20dd191" + integrity sha512-w8/4x1SGGzc+tO97TASLja6SLd3fRIK2tLVcV2Gx4IB21hE19atll5Cq9o3d0ZmAYC/8aw0ipieTSiekAea4SQ== + dependencies: + "@types/pbkdf2" "^3.0.0" + "@types/secp256k1" "^4.0.1" + blakejs "^1.1.0" + browserify-aes "^1.2.0" + bs58check "^2.1.2" + create-hash "^1.2.0" + create-hmac "^1.1.7" + hash.js "^1.1.7" + keccak "^3.0.0" + pbkdf2 "^3.0.17" + randombytes "^2.1.0" + safe-buffer "^5.1.2" + scrypt-js "^3.0.0" + secp256k1 "^4.0.1" + setimmediate "^1.0.5" + ethereum-cryptography@^1.0.3: version "1.1.2" resolved "https://registry.npmjs.org/ethereum-cryptography/-/ethereum-cryptography-1.1.2.tgz" @@ -2064,10 +2204,43 @@ ethereum-cryptography@^1.0.3: "@scure/bip32" "1.1.0" "@scure/bip39" "1.1.0" +ethereumjs-util@^7.1.2: + version "7.1.5" + resolved "https://registry.yarnpkg.com/ethereumjs-util/-/ethereumjs-util-7.1.5.tgz#9ecf04861e4fbbeed7465ece5f23317ad1129181" + integrity sha512-SDl5kKrQAudFBUe5OJM9Ac6WmMyYmXX/6sTmLZ3ffG2eY6ZIGBes3pEDxNN6V72WyOw4CPD5RomKdsa8DAAwLg== + dependencies: + "@types/bn.js" "^5.1.0" + bn.js "^5.1.2" + create-hash "^1.1.2" + ethereum-cryptography "^0.1.3" + rlp "^2.2.4" + +ethereumjs-wallet@^1.0.2: + version "1.0.2" + resolved "https://registry.yarnpkg.com/ethereumjs-wallet/-/ethereumjs-wallet-1.0.2.tgz#2c000504b4c71e8f3782dabe1113d192522e99b6" + integrity sha512-CCWV4RESJgRdHIvFciVQFnCHfqyhXWchTPlkfp28Qc53ufs+doi5I/cV2+xeK9+qEo25XCWfP9MiL+WEPAZfdA== + dependencies: + aes-js "^3.1.2" + bs58check "^2.1.2" + ethereum-cryptography "^0.1.3" + ethereumjs-util "^7.1.2" + randombytes "^2.1.0" + scrypt-js "^3.0.1" + utf8 "^3.0.0" + uuid "^8.3.2" + event-target-shim@^5.0.0: version "5.0.1" resolved "https://registry.npmjs.org/event-target-shim/-/event-target-shim-5.0.1.tgz" +evp_bytestokey@^1.0.3: + version "1.0.3" + resolved "https://registry.yarnpkg.com/evp_bytestokey/-/evp_bytestokey-1.0.3.tgz#7fcbdb198dc71959432efe13842684e0525acb02" + integrity sha512-/f2Go4TognH/KvCISP7OUsHn85hT9nUkxxA9BEWxFn+Oj9o8ZNLm/40hdlgSLyuOimsrTKLUMEorQexp/aPQeA== + dependencies: + md5.js "^1.3.4" + safe-buffer "^5.1.1" + execa@^5.0.0: version "5.1.1" resolved "https://registry.npmjs.org/execa/-/execa-5.1.1.tgz" @@ -2486,6 +2659,32 @@ has@^1.0.3: dependencies: function-bind "^1.1.1" +hash-base@^3.0.0: + version "3.1.0" + resolved "https://registry.yarnpkg.com/hash-base/-/hash-base-3.1.0.tgz#55c381d9e06e1d2997a883b4a3fddfe7f0d3af33" + integrity sha512-1nmYp/rhMDiE7AYkDw+lLwlAzz0AntGIe51F3RfFfEqyQ3feY2eI/NcwC6umIQVOASPMsWJLJScWKSSvzL9IVA== + dependencies: + inherits "^2.0.4" + readable-stream "^3.6.0" + safe-buffer "^5.2.0" + +hash.js@^1.0.0, hash.js@^1.0.3, hash.js@^1.1.7: + version "1.1.7" + resolved "https://registry.yarnpkg.com/hash.js/-/hash.js-1.1.7.tgz#0babca538e8d4ee4a0f8988d68866537a003cf42" + integrity sha512-taOaskGt4z4SOANNseOviYDvjEJinIkRgmp7LbKP2YTTmVxWBl87s/uzK9r+44BclBSp2X7K1hqeNfz9JbBeXA== + dependencies: + inherits "^2.0.3" + minimalistic-assert "^1.0.1" + +hmac-drbg@^1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/hmac-drbg/-/hmac-drbg-1.0.1.tgz#d2745701025a6c775a6c545793ed502fc0c649a1" + integrity sha512-Tti3gMqLdZfhOQY1Mzf/AanLiqh1WTiJgEj26ZuYQ9fbkLomzGchCws4FyrSd4VkpBfiNhaE1On+lOz894jvXg== + dependencies: + hash.js "^1.0.3" + minimalistic-assert "^1.0.0" + minimalistic-crypto-utils "^1.0.1" + html-encoding-sniffer@^2.0.1: version "2.0.1" resolved "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-2.0.1.tgz" @@ -2576,7 +2775,7 @@ inflight@^1.0.4: once "^1.3.0" wrappy "1" -inherits@2, inherits@2.0.4, inherits@^2.0.3, inherits@^2.0.4: +inherits@2, inherits@2.0.4, inherits@^2.0.1, inherits@^2.0.3, inherits@^2.0.4: version "2.0.4" resolved "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz" @@ -3343,6 +3542,15 @@ just-extend@^4.0.2: version "4.2.1" resolved "https://registry.npmjs.org/just-extend/-/just-extend-4.2.1.tgz" +keccak@^3.0.0: + version "3.0.3" + resolved "https://registry.yarnpkg.com/keccak/-/keccak-3.0.3.tgz#4bc35ad917be1ef54ff246f904c2bbbf9ac61276" + integrity sha512-JZrLIAJWuZxKbCilMpNz5Vj7Vtb4scDG3dMXLOsbzBmQGyjwE61BbW7bJkfKKCShXiQZt3T6sBgALRtmd+nZaQ== + dependencies: + node-addon-api "^2.0.0" + node-gyp-build "^4.2.0" + readable-stream "^3.6.0" + kleur@^3.0.3: version "3.0.3" resolved "https://registry.npmjs.org/kleur/-/kleur-3.0.3.tgz" @@ -3439,6 +3647,15 @@ makeerror@1.0.12: dependencies: tmpl "1.0.5" +md5.js@^1.3.4: + version "1.3.5" + resolved "https://registry.yarnpkg.com/md5.js/-/md5.js-1.3.5.tgz#b5d07b8e3216e3e27cd728d72f70d1e6a342005f" + integrity sha512-xitP+WxNPcTTOgnTJcrhM0xvdPepipPSf3I8EIpGKeFLjt3PlJLIDG3u8EX53ZIubkb+5U2+3rELYpEhHhzdkg== + dependencies: + hash-base "^3.0.0" + inherits "^2.0.1" + safe-buffer "^5.1.2" + media-typer@0.3.0: version "0.3.0" resolved "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz" @@ -3491,6 +3708,16 @@ mimic-fn@^2.1.0: version "2.1.0" resolved "https://registry.npmjs.org/mimic-fn/-/mimic-fn-2.1.0.tgz" +minimalistic-assert@^1.0.0, minimalistic-assert@^1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/minimalistic-assert/-/minimalistic-assert-1.0.1.tgz#2e194de044626d4a10e7f7fbc00ce73e83e4d5c7" + integrity sha512-UtJcAD4yEaGtjPezWuO9wC4nwUnVH/8/Im3yEHQP4b67cXlD/Qr9hdITCU1xDbSEXg2XKNaP8jsReV7vQd00/A== + +minimalistic-crypto-utils@^1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/minimalistic-crypto-utils/-/minimalistic-crypto-utils-1.0.1.tgz#f6c00c1c0b082246e5c4d99dfb8c7c083b2b582a" + integrity sha512-JIYlbt6g8i5jKfJ3xz7rF0LXmv2TkDxBLUkiBeZ7bAx4GnnNMr8xFpGnOxn6GhTEHx3SjRrZEoU+j04prX1ktg== + minimatch@^3.0.4, minimatch@^3.0.5, minimatch@^3.1.1, minimatch@^3.1.2: version "3.1.2" resolved "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz" @@ -3581,12 +3808,22 @@ nise@^5.0.1: just-extend "^4.0.2" path-to-regexp "^1.7.0" +node-addon-api@^2.0.0: + version "2.0.2" + resolved "https://registry.yarnpkg.com/node-addon-api/-/node-addon-api-2.0.2.tgz#432cfa82962ce494b132e9d72a15b29f71ff5d32" + integrity sha512-Ntyt4AIXyaLIuMHF6IOoTakB3K+RWxwtsHNRxllEoA6vPwP9o4866g6YWDLUdnucilZhmkxiHwHr11gAENw+QA== + node-fetch@2.6.7, node-fetch@^2.6.1, node-fetch@^2.6.7: version "2.6.7" resolved "https://registry.npmjs.org/node-fetch/-/node-fetch-2.6.7.tgz" dependencies: whatwg-url "^5.0.0" +node-gyp-build@^4.2.0: + version "4.6.0" + resolved "https://registry.yarnpkg.com/node-gyp-build/-/node-gyp-build-4.6.0.tgz#0c52e4cbf54bbd28b709820ef7b6a3c2d6209055" + integrity sha512-NTZVKn9IylLwUzaKjkas1e4u2DLNcV4rdYagA4PWdPwW87Bi7z+BznyKSRwS/761tV/lzCGXplWsiaMjLqP2zQ== + node-int64@^0.4.0: version "0.4.0" resolved "https://registry.npmjs.org/node-int64/-/node-int64-0.4.0.tgz" @@ -3801,6 +4038,17 @@ path-type@^4.0.0: resolved "https://registry.yarnpkg.com/path-type/-/path-type-4.0.0.tgz#84ed01c0a7ba380afe09d90a8c180dcd9d03043b" integrity sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw== +pbkdf2@^3.0.17: + version "3.1.2" + resolved "https://registry.yarnpkg.com/pbkdf2/-/pbkdf2-3.1.2.tgz#dd822aa0887580e52f1a039dc3eda108efae3075" + integrity sha512-iuh7L6jA7JEGu2WxDwtQP1ddOpaJNC4KlDEFfdQajSGgGPNi4OyDc2R7QnbY2bR9QjBVGwgvTdNJZoE7RaxUMA== + dependencies: + create-hash "^1.1.2" + create-hmac "^1.1.4" + ripemd160 "^2.0.1" + safe-buffer "^5.0.1" + sha.js "^2.4.8" + picocolors@^1.0.0: version "1.0.0" resolved "https://registry.npmjs.org/picocolors/-/picocolors-1.0.0.tgz" @@ -3924,6 +4172,13 @@ rabin-wasm@^0.1.4: node-fetch "^2.6.1" readable-stream "^3.6.0" +randombytes@^2.1.0: + version "2.1.0" + resolved "https://registry.yarnpkg.com/randombytes/-/randombytes-2.1.0.tgz#df6f84372f0270dc65cdf6291349ab7a473d4f2a" + integrity sha512-vYl3iOX+4CKUWuxGi9Ukhie6fsqXqS9FE2Zaic4tNFD2N2QQaXOMFbuKK4QmDHC0JO6B1Zp41J0LpT0oR68amQ== + dependencies: + safe-buffer "^5.1.0" + range-parser@~1.2.1: version "1.2.1" resolved "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz" @@ -4009,6 +4264,21 @@ rimraf@^3.0.0, rimraf@^3.0.2: dependencies: glob "^7.1.3" +ripemd160@^2.0.0, ripemd160@^2.0.1: + version "2.0.2" + resolved "https://registry.yarnpkg.com/ripemd160/-/ripemd160-2.0.2.tgz#a1c1a6f624751577ba5d07914cbc92850585890c" + integrity sha512-ii4iagi25WusVoiC4B4lq7pbXfAp3D9v5CwfkY33vffw2+pkDjY1D8GaN7spsxvCSx8dkPqOZCEZyfxcmJG2IA== + dependencies: + hash-base "^3.0.0" + inherits "^2.0.1" + +rlp@^2.2.4: + version "2.2.7" + resolved "https://registry.yarnpkg.com/rlp/-/rlp-2.2.7.tgz#33f31c4afac81124ac4b283e2bd4d9720b30beaf" + integrity sha512-d5gdPmgQ0Z+AklL2NVXr/IoSjNZFfTVvQWzL/AM2AOcSzYP2xjlb0AC8YyCLc41MSNf6P6QVtjgPdmVtzb+4lQ== + dependencies: + bn.js "^5.2.0" + run-parallel@^1.1.9: version "1.2.0" resolved "https://registry.yarnpkg.com/run-parallel/-/run-parallel-1.2.0.tgz#66d1368da7bdf921eb9d95bd1a9229e7f21a43ee" @@ -4020,7 +4290,7 @@ safe-buffer@5.1.2, safe-buffer@~5.1.1: version "5.1.2" resolved "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz" -safe-buffer@5.2.1, safe-buffer@~5.2.0: +safe-buffer@5.2.1, safe-buffer@^5.0.1, safe-buffer@^5.1.0, safe-buffer@^5.1.1, safe-buffer@^5.1.2, safe-buffer@^5.2.0, safe-buffer@~5.2.0: version "5.2.1" resolved "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz" @@ -4043,6 +4313,20 @@ saxes@^5.0.1: dependencies: xmlchars "^2.2.0" +scrypt-js@^3.0.0, scrypt-js@^3.0.1: + version "3.0.1" + resolved "https://registry.yarnpkg.com/scrypt-js/-/scrypt-js-3.0.1.tgz#d314a57c2aef69d1ad98a138a21fe9eafa9ee312" + integrity sha512-cdwTTnqPu0Hyvf5in5asVdZocVDTNRmR7XEcJuIzMjJeSHybHl7vpB66AzwTaIg6CLSbtjcxc8fqcySfnTkccA== + +secp256k1@^4.0.1: + version "4.0.3" + resolved "https://registry.yarnpkg.com/secp256k1/-/secp256k1-4.0.3.tgz#c4559ecd1b8d3c1827ed2d1b94190d69ce267303" + integrity sha512-NLZVf+ROMxwtEj3Xa562qgv2BK5e2WNmXPiOdVIPLgs6lyTzMvBq0aWTYMI5XCP9jZMVKOcqZLw/Wc4vDkuxhA== + dependencies: + elliptic "^6.5.4" + node-addon-api "^2.0.0" + node-gyp-build "^4.2.0" + semver@7.x, semver@^7.3.2: version "7.3.7" resolved "https://registry.npmjs.org/semver/-/semver-7.3.7.tgz" @@ -4087,10 +4371,23 @@ serve-static@1.15.0: parseurl "~1.3.3" send "0.18.0" +setimmediate@^1.0.5: + version "1.0.5" + resolved "https://registry.yarnpkg.com/setimmediate/-/setimmediate-1.0.5.tgz#290cbb232e306942d7d7ea9b83732ab7856f8285" + integrity sha512-MATJdZp8sLqDl/68LfQmbP8zKPLQNV6BIZoIgrscFDQ+RsvK/BxeDQOgyxKKoh0y/8h3BqVFnCqQ/gd+reiIXA== + setprototypeof@1.2.0: version "1.2.0" resolved "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz" +sha.js@^2.4.0, sha.js@^2.4.8: + version "2.4.11" + resolved "https://registry.yarnpkg.com/sha.js/-/sha.js-2.4.11.tgz#37a5cf0b81ecbc6943de109ba2960d1b26584ae7" + integrity sha512-QMEp5B7cftE7APOjk5Y6xgrbWu+WkLVQwk8JNjZ8nKRciZaByEW6MubieAiToS7+dwvrjGhH8jRXz3MVd0AYqQ== + dependencies: + inherits "^2.0.1" + safe-buffer "^5.0.1" + shebang-command@^2.0.0: version "2.0.0" resolved "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz" @@ -4505,6 +4802,11 @@ uri-js@^4.2.2: dependencies: punycode "^2.1.0" +utf8@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/utf8/-/utf8-3.0.0.tgz#f052eed1364d696e769ef058b183df88c87f69d1" + integrity sha512-E8VjFIQ/TyQgp+TZfS6l8yp/xWppSAHzidGiRrqe4bK4XP9pTRyKFgGJpO3SN7zdX4DeomTrwaseCHovfpFcqQ== + util-deprecate@^1.0.1: version "1.0.2" resolved "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz" @@ -4513,6 +4815,11 @@ utils-merge@1.0.1: version "1.0.1" resolved "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz" +uuid@^8.3.2: + version "8.3.2" + resolved "https://registry.yarnpkg.com/uuid/-/uuid-8.3.2.tgz#80d5b5ced271bb9af6c445f21a1a04c606cefbe2" + integrity sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg== + v8-to-istanbul@^8.1.0: version "8.1.1" resolved "https://registry.npmjs.org/v8-to-istanbul/-/v8-to-istanbul-8.1.1.tgz"