diff --git a/packages/elasticsearch-plugin/e2e/elasticsearch-plugin.e2e-spec.ts b/packages/elasticsearch-plugin/e2e/elasticsearch-plugin.e2e-spec.ts index e860228d99..78674bb3e9 100644 --- a/packages/elasticsearch-plugin/e2e/elasticsearch-plugin.e2e-spec.ts +++ b/packages/elasticsearch-plugin/e2e/elasticsearch-plugin.e2e-spec.ts @@ -1,16 +1,14 @@ /* tslint:disable:no-non-null-assertion no-console */ -import { Client } from '@elastic/elasticsearch'; import { SortOrder } from '@vendure/common/lib/generated-types'; import { pick } from '@vendure/common/lib/pick'; import { DefaultJobQueuePlugin, DefaultLogger, facetValueCollectionFilter, - Logger, LogLevel, mergeConfig, } from '@vendure/core'; -import { createTestEnvironment, E2E_DEFAULT_CHANNEL_TOKEN, SimpleGraphQLClient } from '@vendure/testing'; +import { createTestEnvironment, E2E_DEFAULT_CHANNEL_TOKEN } from '@vendure/testing'; import gql from 'graphql-tag'; import path from 'path'; @@ -23,6 +21,8 @@ import { CreateChannel, CreateCollection, CreateFacet, + CreateProduct, + CreateProductVariants, CurrencyCode, DeleteAsset, DeleteProduct, @@ -39,13 +39,15 @@ import { UpdateProductVariants, UpdateTaxRate, } from '../../core/e2e/graphql/generated-e2e-admin-types'; -import { LogicalOperator, SearchProductsShop } from '../../core/e2e/graphql/generated-e2e-shop-types'; +import { SearchProductsShop } from '../../core/e2e/graphql/generated-e2e-shop-types'; import { ASSIGN_PRODUCTVARIANT_TO_CHANNEL, ASSIGN_PRODUCT_TO_CHANNEL, CREATE_CHANNEL, CREATE_COLLECTION, CREATE_FACET, + CREATE_PRODUCT, + CREATE_PRODUCT_VARIANTS, DELETE_ASSET, DELETE_PRODUCT, DELETE_PRODUCT_VARIANT, @@ -59,7 +61,6 @@ import { } from '../../core/e2e/graphql/shared-definitions'; import { SEARCH_PRODUCTS_SHOP } from '../../core/e2e/graphql/shop-definitions'; import { awaitRunningJobs } from '../../core/e2e/utils/await-running-jobs'; -import { loggerCtx } from '../src/constants'; import { ElasticsearchPlugin } from '../src/plugin'; import { @@ -713,6 +714,86 @@ describe('Elasticsearch plugin', () => { }); }); + // https://github.com/vendure-ecommerce/vendure/issues/609 + describe('Synthetic index items', () => { + let createdProductId: string; + + it('creates synthetic index item for Product with no variants', async () => { + const { createProduct } = await adminClient.query< + CreateProduct.Mutation, + CreateProduct.Variables + >(CREATE_PRODUCT, { + input: { + facetValueIds: ['T_1'], + translations: [ + { + languageCode: LanguageCode.en, + name: 'Strawberry cheesecake', + slug: 'strawberry-cheesecake', + description: 'A yummy dessert', + }, + ], + }, + }); + + await awaitRunningJobs(adminClient); + const result = await doAdminSearchQuery(adminClient, { + groupByProduct: true, + term: 'strawberry', + }); + expect( + result.search.items.map( + pick([ + 'productId', + 'enabled', + 'productName', + 'productVariantName', + 'slug', + 'description', + ]), + ), + ).toEqual([ + { + productId: createProduct.id, + enabled: false, + productName: 'Strawberry cheesecake', + productVariantName: 'Strawberry cheesecake', + slug: 'strawberry-cheesecake', + description: 'A yummy dessert', + }, + ]); + createdProductId = createProduct.id; + }); + + it('removes synthetic index item once a variant is created', async () => { + const { createProductVariants } = await adminClient.query< + CreateProductVariants.Mutation, + CreateProductVariants.Variables + >(CREATE_PRODUCT_VARIANTS, { + input: [ + { + productId: createdProductId, + sku: 'SC01', + price: 1399, + translations: [ + { languageCode: LanguageCode.en, name: 'Strawberry Cheesecake Pie' }, + ], + }, + ], + }); + + await awaitRunningJobs(adminClient); + + const result = await doAdminSearchQuery(adminClient, { + groupByProduct: true, + term: 'strawberry', + }); + expect(result.search.items.map(pick(['productVariantName']))).toEqual([ + { productVariantName: 'Strawberry Cheesecake Pie' }, + ]); + }); + }); + describe('channel handling', () => { const SECOND_CHANNEL_TOKEN = 'second-channel-token'; let secondChannel: ChannelFragment; diff --git a/packages/elasticsearch-plugin/src/indexer.controller.ts b/packages/elasticsearch-plugin/src/indexer.controller.ts index bf0754ca50..5902f14770 100644 --- a/packages/elasticsearch-plugin/src/indexer.controller.ts +++ b/packages/elasticsearch-plugin/src/indexer.controller.ts @@ -577,12 +577,11 @@ export class ElasticsearchIndexerController implements OnModuleInit, OnModuleDes } private async updateProductInternal(ctx: RequestContext, productId: ID) { - let updatedProductVariants: ProductVariant[] = []; const product = await this.connection.getRepository(Product).findOne(productId, { relations: ['variants', 'channels', 'channels.defaultTaxZone'], }); if (product) { - updatedProductVariants = await this.connection.getRepository(ProductVariant).findByIds( + const updatedProductVariants = await this.connection.getRepository(ProductVariant).findByIds( product.variants.map(v => v.id), { relations: variantRelations, @@ -594,10 +593,10 @@ export class ElasticsearchIndexerController implements OnModuleInit, OnModuleDes if (product.enabled === false) { updatedProductVariants.forEach(v => (v.enabled = false)); } + const operations: Array> = []; if (updatedProductVariants.length) { Logger.verbose(`Updating 1 Product (${productId})`, loggerCtx); - const operations: Array> = []; const languageVariants = product.translations.map(t => t.languageCode); for (const channel of product.channels) { @@ -636,8 +635,18 @@ export class ElasticsearchIndexerController implements OnModuleInit, OnModuleDes ); } } - await this.executeBulkOperations(PRODUCT_INDEX_NAME, operations); + } else { + const syntheticIndexItem = this.createSyntheticProductIndexItem(ctx, product); + operations.push( + { + update: { + _id: this.getId(syntheticIndexItem.productId, ctx.channelId, ctx.languageCode), + }, + }, + { doc: syntheticIndexItem, doc_as_upsert: true }, + ); } + await this.executeBulkOperations(PRODUCT_INDEX_NAME, operations); } } @@ -866,6 +875,42 @@ export class ElasticsearchIndexerController implements OnModuleInit, OnModuleDes return item; } + /** + * If a Product has no variants, we create a synthetic variant for the purposes + * of making that product visible via the search query. + */ + private createSyntheticProductIndexItem(ctx: RequestContext, product: Product): ProductIndexItem { + const productTranslation = this.getTranslation(product, ctx.languageCode); + return { + channelId: ctx.channelId, + languageCode: ctx.languageCode, + sku: '', + slug: productTranslation.slug, + productId: product.id, + productName: productTranslation.name, + productAssetId: product.featuredAsset?.id ?? undefined, + productPreview: product.featuredAsset?.preview ?? '', + productPreviewFocalPoint: product.featuredAsset?.focalPoint ?? undefined, + productVariantId: 0, + productVariantName: productTranslation.name, + productVariantAssetId: undefined, + productVariantPreview: '', + productVariantPreviewFocalPoint: undefined, + priceMin: 0, + priceMax: 0, + priceWithTaxMin: 0, + priceWithTaxMax: 0, + currencyCode: ctx.channel.currencyCode, + description: productTranslation.description, + facetIds: product.facetValues?.map(fv => fv.facet.id.toString()) ?? [], + facetValueIds: product.facetValues?.map(fv => fv.id.toString()) ?? [], + collectionIds: [], + collectionSlugs: [], + channelIds: [ctx.channelId], + enabled: false, + }; + } + private getTranslation( translatable: T, languageCode: LanguageCode,