Browse Source

fix(elasticsearch-plugin): Products without variants are indexed

Relates to #609
Michael Bromley 5 years ago
parent
commit
21b6aa3304

+ 86 - 5
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;

+ 49 - 4
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<BulkOperation | BulkOperationDoc<ProductIndexItem>> = [];
 
             if (updatedProductVariants.length) {
                 Logger.verbose(`Updating 1 Product (${productId})`, loggerCtx);
-                const operations: Array<BulkOperation | BulkOperationDoc<ProductIndexItem>> = [];
                 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<T extends Translatable>(
         translatable: T,
         languageCode: LanguageCode,