diff --git a/e2e/tests/productV1.test.ts b/e2e/tests/productV1.test.ts index e86d8e329..06d572f10 100644 --- a/e2e/tests/productV1.test.ts +++ b/e2e/tests/productV1.test.ts @@ -1030,3 +1030,258 @@ describe("core-sdk-check-exchange-policy", () => { expect(result.errors[0].value).toEqual("unfairExchangePolicy"); }); }); + +describe("search products tests", () => { + let coreSDK: CoreSDK; + // Use random keywords to allow test to be repeated again and again + const random = Math.floor(Math.random() * 10000); + // Define 3 keywords and use them in different fields of product metadata + // (name, description, tags) to test that search is working on all these fields + const keywords = [ + `Keyword_1_${random}`, + `Keyword_2_${random}`, + `Keyword_3_${random}` + ]; + const voidedOfferUuid = buildUuid(); + const productMetadatas: { + uuid: string; + name: string; + description: string; + tags: string[]; + }[] = [ + { + uuid: buildUuid(), + name: `${keywords[0]} red shirt`, + description: "a red shirt", + tags: ["clothing", "red", "shirt"] + }, + { + uuid: buildUuid(), + name: `blue shirt`, + description: `${keywords[1]} a blue shirt`, + tags: ["clothing", "blue", "shirt"] + }, + { + uuid: buildUuid(), + name: "green shirt", + description: "a green shirt", + tags: ["clothing", "green", "shirt", keywords[2]] + }, + { + uuid: buildUuid(), + name: `${keywords[0]} white shirt`, + description: `a white shirt ${keywords[1]}`, + tags: ["clothing", "white", "shirt", keywords[2]] + }, + { + uuid: buildUuid(), + name: `rainbow shirt ${keywords[0]} ${keywords[1]} ${keywords[2]}`, + description: `a rainbow shirt ${keywords[1]} ${keywords[2]} ${keywords[0]} `, + tags: [ + "clothing", + "rainbow", + "shirt", + keywords[2], + keywords[1], + keywords[0] + ] + }, + { + uuid: voidedOfferUuid, + name: `voided offer ${keywords[0]} ${keywords[1]} ${keywords[2]}`, + description: `a voided offer ${keywords[1]} ${keywords[2]} ${keywords[0]} `, + tags: [ + "clothing", + "rainbow", + "shirt", + keywords[2], + keywords[1], + keywords[0] + ] + }, + { + uuid: buildUuid(), + name: "black shirt", + description: "a black shirt", + tags: ["clothing", "black", "shirt"] + } + ]; + beforeAll(async () => { + let sellerWallet: Wallet; + ({ coreSDK, fundedWallet: sellerWallet } = + await initCoreSDKWithFundedWallet(seedWallet)); + const template = + "Hello World!! {{sellerTradingName}} {{disputeResolverContactMethod}} {{sellerContactMethod}} {{returnPeriodInDays}}"; + const offerArgsList: CreateOfferArgs[] = []; + for (const productMetadata of productMetadatas) { + const metadata = mockProductV1Metadata(template, productMetadata.uuid, { + name: productMetadata.name, + description: productMetadata.description, + product: { + title: productMetadata.name, + description: productMetadata.description, + uuid: productMetadata.uuid, + details_tags: productMetadata.tags + } + }); + const offerArgs = await createOfferArgs(coreSDK, metadata); + resolveDateValidity(offerArgs); + offerArgsList.push(offerArgs); + } + const offers = await createOfferBatch(coreSDK, sellerWallet, offerArgsList); + expect(offers.length).toEqual(productMetadatas.length); + const voidedOfferId = offers.find( + (offer) => + (offer.metadata as { product: { uuid: string } })?.product?.uuid === + voidedOfferUuid + )?.id; + expect(voidedOfferId).toBeTruthy(); + const tx = await coreSDK.voidOffer(voidedOfferId as string); + await coreSDK.waitForGraphNodeIndexing(tx); + }); + test("search with a unused keyword should not find any products", async () => { + const results = await coreSDK.searchProducts(["unused_keyword"]); + expect(results.length).toEqual(0); + }); + test("search with a keyword put in product name should find matching products", async () => { + const results = await coreSDK.searchProducts([keywords[0]]); + expect(results.length).toEqual(3); + let foundProduct = results.find( + (result) => result.uuid === productMetadatas[0].uuid + ); + expect(foundProduct).toBeTruthy(); + foundProduct = results.find( + (result) => result.uuid === productMetadatas[3].uuid + ); + expect(foundProduct).toBeTruthy(); + foundProduct = results.find( + (result) => result.uuid === productMetadatas[4].uuid + ); + expect(foundProduct).toBeTruthy(); + }); + test("search with a keyword put in product description should find matching products", async () => { + const results = await coreSDK.searchProducts([keywords[1]]); + expect(results.length).toEqual(3); + let foundProduct = results.find( + (result) => result.uuid === productMetadatas[1].uuid + ); + expect(foundProduct).toBeTruthy(); + foundProduct = results.find( + (result) => result.uuid === productMetadatas[3].uuid + ); + expect(foundProduct).toBeTruthy(); + foundProduct = results.find( + (result) => result.uuid === productMetadatas[4].uuid + ); + expect(foundProduct).toBeTruthy(); + }); + test("search with a keyword put in product tags should find matching products", async () => { + const results = await coreSDK.searchProducts([keywords[2]]); + expect(results.length).toEqual(3); + let foundProduct = results.find( + (result) => result.uuid === productMetadatas[2].uuid + ); + expect(foundProduct).toBeTruthy(); + foundProduct = results.find( + (result) => result.uuid === productMetadatas[3].uuid + ); + expect(foundProduct).toBeTruthy(); + foundProduct = results.find( + (result) => result.uuid === productMetadatas[4].uuid + ); + expect(foundProduct).toBeTruthy(); + }); + test("search with multiple keywords should find all matching products - 1", async () => { + const results = await coreSDK.searchProducts([keywords[0], keywords[1]]); + expect(results.length).toEqual(4); + let foundProduct = results.find( + (result) => result.uuid === productMetadatas[0].uuid + ); + expect(foundProduct).toBeTruthy(); + foundProduct = results.find( + (result) => result.uuid === productMetadatas[1].uuid + ); + expect(foundProduct).toBeTruthy(); + foundProduct = results.find( + (result) => result.uuid === productMetadatas[3].uuid + ); + expect(foundProduct).toBeTruthy(); + foundProduct = results.find( + (result) => result.uuid === productMetadatas[4].uuid + ); + expect(foundProduct).toBeTruthy(); + }); + test("search with multiple keywords should find all matching products - 2", async () => { + const results = await coreSDK.searchProducts([keywords[2], keywords[1]]); + expect(results.length).toEqual(4); + let foundProduct = results.find( + (result) => result.uuid === productMetadatas[2].uuid + ); + expect(foundProduct).toBeTruthy(); + foundProduct = results.find( + (result) => result.uuid === productMetadatas[1].uuid + ); + expect(foundProduct).toBeTruthy(); + foundProduct = results.find( + (result) => result.uuid === productMetadatas[3].uuid + ); + expect(foundProduct).toBeTruthy(); + foundProduct = results.find( + (result) => result.uuid === productMetadatas[4].uuid + ); + expect(foundProduct).toBeTruthy(); + }); + test("search with all keywords should find all matching products", async () => { + const results = await coreSDK.searchProducts(keywords, { + includeInvalidOffers: false + }); + expect(results.length).toEqual(5); + for (let i = 0; i < 5; i++) { + const foundProduct = results.find( + (result) => result.uuid === productMetadatas[i].uuid + ); + expect(foundProduct).toBeTruthy(); + } + }); + test("search with pagination", async () => { + let results = await coreSDK.searchProducts(keywords, { + productsFirst: 4, + productsSkip: 0 + }); + expect(results.length).toEqual(4); + results = await coreSDK.searchProducts(keywords, { + productsFirst: 4, + productsSkip: 4 + }); + expect(results.length).toEqual(1); + }); + test("search with additional criteria", async () => { + let results = await coreSDK.searchProducts(keywords, { + productsFilter: { + uuid_in: [ + productMetadatas[0].uuid, + productMetadatas[1].uuid, + voidedOfferUuid + ] + } + }); + expect(results.length).toEqual(2); + + results = await coreSDK.searchProducts(keywords, { + productsFilter: { + uuid_in: [ + productMetadatas[0].uuid, + productMetadatas[1].uuid, + voidedOfferUuid + ] + }, + includeInvalidOffers: true + }); + expect(results.length).toEqual(3); + }); + test("search with empty keywords should return no results", async () => { + const results = await coreSDK.searchProducts([]); + expect(Array.isArray(results)).toBe(true); + expect(results.length).toEqual(0); + }); +}); diff --git a/package.json b/package.json index 883c938c7..6926ee870 100644 --- a/package.json +++ b/package.json @@ -52,6 +52,7 @@ "update-contract-uri": "ts-node -P tsconfig.base.json ./scripts/change-contract-uri.ts", "check-offer-exchange-policy": "ts-node -P tsconfig.base.json ./scripts/checkOfferExchangePolicy.ts", "get-buyers": "ts-node -P tsconfig.base.json ./scripts/get-buyers.ts", + "search-products": "ts-node -P tsconfig.base.json ./scripts/search-products.ts", "ts-coverage": "typescript-coverage-report", "update-protocol-addresses": "ts-node -P tsconfig.base.json ./scripts/update-protocol-addresses.ts" }, diff --git a/packages/core-sdk/src/core-sdk.ts b/packages/core-sdk/src/core-sdk.ts index 82b05fdd5..e7ca4fdef 100644 --- a/packages/core-sdk/src/core-sdk.ts +++ b/packages/core-sdk/src/core-sdk.ts @@ -29,6 +29,7 @@ import { ErrorMixin } from "./errors/mixin"; import { SubgraphMixin } from "./subgraph/mixin"; import { PriceDiscoveryMixin } from "./price-discovery/mixin"; import { MarketplaceMixin } from "./marketplaces/mixin"; +import { SearchMixin } from "./search/mixin"; export class CoreSDK extends BaseCoreSDK { /** @@ -153,7 +154,8 @@ export interface CoreSDK ErrorMixin, SubgraphMixin, PriceDiscoveryMixin, - MarketplaceMixin {} + MarketplaceMixin, + SearchMixin {} applyMixins(CoreSDK, [ MetadataMixin, AccountsMixin, @@ -175,5 +177,6 @@ applyMixins(CoreSDK, [ ErrorMixin, SubgraphMixin, PriceDiscoveryMixin, - MarketplaceMixin + MarketplaceMixin, + SearchMixin ]); diff --git a/packages/core-sdk/src/search/index.ts b/packages/core-sdk/src/search/index.ts new file mode 100644 index 000000000..81a1d022d --- /dev/null +++ b/packages/core-sdk/src/search/index.ts @@ -0,0 +1 @@ +export * as subgraph from "./subgraph"; diff --git a/packages/core-sdk/src/search/mixin.ts b/packages/core-sdk/src/search/mixin.ts new file mode 100644 index 000000000..54f5b9dfc --- /dev/null +++ b/packages/core-sdk/src/search/mixin.ts @@ -0,0 +1,69 @@ +import { Web3LibAdapter } from "@bosonprotocol/common"; +import { BaseCoreSDK } from "../mixins/base-core-sdk"; +import { searchProducts } from "./subgraph"; +import * as subgraph from "../subgraph"; + +export class SearchMixin extends BaseCoreSDK { + /** + * Search for products matching the given keywords. + * + * By default, only products that are currently valid (based on their validity dates) are returned. + * This behavior can be controlled via the `includeInvalidOffers` option in the queryVars parameter. + * + * @param keywords - List of keywords to match against product title, description, and tags. + * @param queryVars - Optional query variables including pagination (productsSkip, productsFirst), + * ordering (productsOrderBy, productsOrderDirection), filtering (productsFilter), + * and includeInvalidOffers flag to control validity filtering. + * @returns A promise that resolves to an array of product search result fragments. + */ + public async searchProducts( + keywords: string[], + queryVars?: subgraph.SearchProductsQueryQueryVariables & { + includeInvalidOffers?: boolean; + } + ): Promise { + if (!keywords || keywords.length === 0) { + return []; + } + const now = Math.floor(Date.now() / 1000); + const validOffersFilter: subgraph.ProductV1Product_Filter = { + allVariantsVoided_not: true, + minValidFromDate_lte: now + 60 + "", // Add 1 minute to ensure we include offers not valid yet, but valid in a very little time + maxValidUntilDate_gte: now + "" + }; + const productsSearchFilter: subgraph.ProductV1Product_Filter = { + or: keywords + .map((keyword) => [ + { title_contains_nocase: keyword }, + { description_contains_nocase: keyword }, + { details_tags_contains_nocase: [keyword] } + ]) + .flat() + }; + const productsFilter: subgraph.ProductV1Product_Filter = + queryVars?.includeInvalidOffers + ? queryVars?.productsFilter + ? { + and: [ + { + ...queryVars.productsFilter + }, + productsSearchFilter + ] + } + : productsSearchFilter + : { + and: [ + { + ...validOffersFilter, + ...queryVars?.productsFilter + }, + productsSearchFilter + ] + }; + return searchProducts(this._subgraphUrl, { + ...queryVars, + productsFilter + }); + } +} diff --git a/packages/core-sdk/src/search/queries.graphql b/packages/core-sdk/src/search/queries.graphql new file mode 100644 index 000000000..099940c65 --- /dev/null +++ b/packages/core-sdk/src/search/queries.graphql @@ -0,0 +1,64 @@ +fragment ProductSearchResultFields on ProductV1Product { + id + uuid + version + title + description + productionInformation_brandName + brand { + ...BaseProductV1BrandFields + } + details_category + details_subCategory + details_subCategory2 + details_offerCategory + details_tags + visuals_images { + ...BaseProductV1MediaFields + } + visuals_videos { + ...BaseProductV1MediaFields + } + productV1Seller { + sellerId + name + description + } + notVoidedVariants { + offer { + id + price + priceType + quantityAvailable + validUntilDate + protocolFee + exchangeToken { + name + address + decimals + } + } + variations { + type + option + } + } +} + +query searchProductsQuery( + $productsSkip: Int + $productsFirst: Int + $productsOrderBy: ProductV1Product_orderBy + $productsOrderDirection: OrderDirection + $productsFilter: ProductV1Product_filter +) { + productV1Products( + skip: $productsSkip + first: $productsFirst + orderBy: $productsOrderBy + orderDirection: $productsOrderDirection + where: $productsFilter + ) { + ...ProductSearchResultFields + } +} diff --git a/packages/core-sdk/src/search/subgraph.ts b/packages/core-sdk/src/search/subgraph.ts new file mode 100644 index 000000000..43cc3a007 --- /dev/null +++ b/packages/core-sdk/src/search/subgraph.ts @@ -0,0 +1,17 @@ +import { + SearchProductsQueryQuery, + SearchProductsQueryQueryVariables +} from "../subgraph"; +import { getSubgraphSdk } from "../utils/graphql"; + +export async function searchProducts( + subgraphUrl: string, + queryVars: SearchProductsQueryQueryVariables = {} +): Promise { + const subgraphSdk = getSubgraphSdk(subgraphUrl); + const { productV1Products = [] } = await subgraphSdk.searchProductsQuery({ + ...queryVars + }); + + return productV1Products; +} diff --git a/packages/core-sdk/src/subgraph.ts b/packages/core-sdk/src/subgraph.ts index 502e14d12..7f0c61ac7 100644 --- a/packages/core-sdk/src/subgraph.ts +++ b/packages/core-sdk/src/subgraph.ts @@ -75443,6 +75443,143 @@ export type BaseRangeFieldsFragment = { minted: string; }; +export type ProductSearchResultFieldsFragment = { + __typename?: "ProductV1Product"; + id: string; + uuid: string; + version: number; + title: string; + description: string; + productionInformation_brandName: string; + details_category?: string | null; + details_subCategory?: string | null; + details_subCategory2?: string | null; + details_offerCategory: string; + details_tags?: Array | null; + brand: { __typename?: "ProductV1Brand"; id: string; name: string }; + visuals_images: Array<{ + __typename?: "ProductV1Media"; + id: string; + url: string; + tag?: string | null; + type: ProductV1MediaType; + width?: number | null; + height?: number | null; + }>; + visuals_videos?: Array<{ + __typename?: "ProductV1Media"; + id: string; + url: string; + tag?: string | null; + type: ProductV1MediaType; + width?: number | null; + height?: number | null; + }> | null; + productV1Seller?: { + __typename?: "ProductV1Seller"; + sellerId?: string | null; + name?: string | null; + description?: string | null; + } | null; + notVoidedVariants?: Array<{ + __typename?: "ProductV1Variant"; + offer: { + __typename?: "Offer"; + id: string; + price: string; + priceType: number; + quantityAvailable: string; + validUntilDate: string; + protocolFee: string; + exchangeToken: { + __typename?: "ExchangeToken"; + name: string; + address: string; + decimals: string; + }; + }; + variations?: Array<{ + __typename?: "ProductV1Variation"; + type: string; + option: string; + }> | null; + }> | null; +}; + +export type SearchProductsQueryQueryVariables = Exact<{ + productsSkip?: InputMaybe; + productsFirst?: InputMaybe; + productsOrderBy?: InputMaybe; + productsOrderDirection?: InputMaybe; + productsFilter?: InputMaybe; +}>; + +export type SearchProductsQueryQuery = { + __typename?: "Query"; + productV1Products: Array<{ + __typename?: "ProductV1Product"; + id: string; + uuid: string; + version: number; + title: string; + description: string; + productionInformation_brandName: string; + details_category?: string | null; + details_subCategory?: string | null; + details_subCategory2?: string | null; + details_offerCategory: string; + details_tags?: Array | null; + brand: { __typename?: "ProductV1Brand"; id: string; name: string }; + visuals_images: Array<{ + __typename?: "ProductV1Media"; + id: string; + url: string; + tag?: string | null; + type: ProductV1MediaType; + width?: number | null; + height?: number | null; + }>; + visuals_videos?: Array<{ + __typename?: "ProductV1Media"; + id: string; + url: string; + tag?: string | null; + type: ProductV1MediaType; + width?: number | null; + height?: number | null; + }> | null; + productV1Seller?: { + __typename?: "ProductV1Seller"; + sellerId?: string | null; + name?: string | null; + description?: string | null; + } | null; + notVoidedVariants?: Array<{ + __typename?: "ProductV1Variant"; + offer: { + __typename?: "Offer"; + id: string; + price: string; + priceType: number; + quantityAvailable: string; + validUntilDate: string; + protocolFee: string; + exchangeToken: { + __typename?: "ExchangeToken"; + name: string; + address: string; + decimals: string; + }; + }; + variations?: Array<{ + __typename?: "ProductV1Variation"; + type: string; + option: string; + }> | null; + }> | null; + }>; +}; + export const BaseOfferCollectionFieldsFragmentDoc = gql` fragment BaseOfferCollectionFields on OfferCollection { id @@ -76735,6 +76872,56 @@ export const OfferFieldsFragmentDoc = gql` ${BaseOfferFieldsFragmentDoc} ${BaseExchangeFieldsFragmentDoc} `; +export const ProductSearchResultFieldsFragmentDoc = gql` + fragment ProductSearchResultFields on ProductV1Product { + id + uuid + version + title + description + productionInformation_brandName + brand { + ...BaseProductV1BrandFields + } + details_category + details_subCategory + details_subCategory2 + details_offerCategory + details_tags + visuals_images { + ...BaseProductV1MediaFields + } + visuals_videos { + ...BaseProductV1MediaFields + } + productV1Seller { + sellerId + name + description + } + notVoidedVariants { + offer { + id + price + priceType + quantityAvailable + validUntilDate + protocolFee + exchangeToken { + name + address + decimals + } + } + variations { + type + option + } + } + } + ${BaseProductV1BrandFieldsFragmentDoc} + ${BaseProductV1MediaFieldsFragmentDoc} +`; export const GetSellerByIdQueryDocument = gql` query getSellerByIdQuery( $sellerId: ID! @@ -77520,6 +77707,26 @@ export const GetOffersMediaQueryDocument = gql` } } `; +export const SearchProductsQueryDocument = gql` + query searchProductsQuery( + $productsSkip: Int + $productsFirst: Int + $productsOrderBy: ProductV1Product_orderBy + $productsOrderDirection: OrderDirection + $productsFilter: ProductV1Product_filter + ) { + productV1Products( + skip: $productsSkip + first: $productsFirst + orderBy: $productsOrderBy + orderDirection: $productsOrderDirection + where: $productsFilter + ) { + ...ProductSearchResultFields + } + } + ${ProductSearchResultFieldsFragmentDoc} +`; export type SdkFunctionWrapper = ( action: (requestHeaders?: Record) => Promise, @@ -78133,6 +78340,24 @@ export function getSdk( "query", variables ); + }, + searchProductsQuery( + variables?: SearchProductsQueryQueryVariables, + requestHeaders?: GraphQLClientRequestHeaders, + signal?: RequestInit["signal"] + ): Promise { + return withWrapper( + (wrappedRequestHeaders) => + client.request({ + document: SearchProductsQueryDocument, + variables, + requestHeaders: { ...requestHeaders, ...wrappedRequestHeaders }, + signal + }), + "searchProductsQuery", + "query", + variables + ); } }; } diff --git a/scripts/search-products.ts b/scripts/search-products.ts new file mode 100644 index 000000000..253ff8432 --- /dev/null +++ b/scripts/search-products.ts @@ -0,0 +1,53 @@ +import { EnvironmentType } from "@bosonprotocol/common/src/types/configs"; +import { providers } from "ethers"; +import { program } from "commander"; +import { getEnvConfigById } from "@bosonprotocol/common/src"; +import { CoreSDK } from "../packages/core-sdk/src"; +import { EthersAdapter } from "../packages/ethers-sdk/src"; + +program + .description("Search for products.") + .argument( + "", + "Comma-separated list of keywords to search for in product metadata" + ) + .option("-e, --env ", "Target environment", "testing") + .option("-c, --configId ", "Config id", "testing-80002-0") + .parse(process.argv); + +async function main() { + const [keywordsArg] = program.args; + const keywords = keywordsArg + .split(",") + .map((keyword) => keyword.trim()) + .filter((keyword) => keyword.length > 0); + + const opts = program.opts(); + const envName = opts.env || "testing"; + const configId = opts.configId || "testing-80002-0"; + const defaultConfig = getEnvConfigById(envName as EnvironmentType, configId); + + const coreSDK = CoreSDK.fromDefaultConfig({ + web3Lib: new EthersAdapter( + new providers.JsonRpcProvider(defaultConfig.jsonRpcUrl) + ), + envName, + configId + }); + + const result = await coreSDK.searchProducts(keywords); + console.log(`Found ${result.length} products:`); + result.forEach((product, index) => { + console.log(`${index + 1}. ${product.title}`); + }); +} + +main() + .then(() => { + console.log("success"); + process.exit(0); + }) + .catch((e) => { + console.error(e); + process.exit(1); + });