ソースを参照

test(server): Create e2e tests for ProductResolver queries & mutations

Relates to #11
Michael Bromley 7 年 前
コミット
fd2c1b141d

+ 195 - 1
server/e2e/__snapshots__/product.e2e-spec.ts.snap

@@ -1,6 +1,6 @@
 // Jest Snapshot v1, https://goo.gl/fbAQLP
 
-exports[`Product resolver createProduct mutation creates a new Product 1`] = `
+exports[`Product resolver product mutation createProduct creates a new Product 1`] = `
 Object {
   "description": "A baked potato",
   "id": "21",
@@ -26,3 +26,197 @@ Object {
   "variants": Array [],
 }
 `;
+
+exports[`Product resolver product mutation updateProduct updates a Product 1`] = `
+Object {
+  "description": "A blob of mashed potato",
+  "id": "21",
+  "image": "mashed-potato",
+  "languageCode": "en",
+  "name": "en Mashed Potato",
+  "optionGroups": Array [],
+  "slug": "en-mashed-potato",
+  "translations": Array [
+    Object {
+      "description": "A blob of mashed potato",
+      "languageCode": "en",
+      "name": "en Mashed Potato",
+      "slug": "en-mashed-potato",
+    },
+    Object {
+      "description": "Eine blob von gemashed Erdapfel",
+      "languageCode": "de",
+      "name": "de Mashed Potato",
+      "slug": "de-mashed-potato",
+    },
+  ],
+  "variants": Array [],
+}
+`;
+
+exports[`Product resolver product mutation variants applyFacetValuesToProductVariants adds facets to variants 1`] = `
+Array [
+  Object {
+    "facetValues": Array [
+      Object {
+        "code": "Wisoky_-_Spinka",
+        "id": "1",
+        "name": "Wisoky - Spinka",
+      },
+      Object {
+        "code": "Bosco_LLC",
+        "id": "3",
+        "name": "Bosco LLC",
+      },
+      Object {
+        "code": "Hilll_-_Auer",
+        "id": "5",
+        "name": "Hilll - Auer",
+      },
+    ],
+    "id": "41",
+    "image": "new-image",
+    "languageCode": "en",
+    "name": "en Mashed Potato Small",
+    "options": Array [
+      Object {
+        "code": "small",
+        "id": "1",
+        "languageCode": "en",
+        "name": "Small",
+      },
+    ],
+    "price": 432,
+    "sku": "ABC",
+    "translations": Array [
+      Object {
+        "id": "81",
+        "languageCode": "en",
+        "name": "en Mashed Potato Small",
+      },
+    ],
+  },
+  Object {
+    "facetValues": Array [
+      Object {
+        "code": "Wisoky_-_Spinka",
+        "id": "1",
+        "name": "Wisoky - Spinka",
+      },
+      Object {
+        "code": "Bosco_LLC",
+        "id": "3",
+        "name": "Bosco LLC",
+      },
+      Object {
+        "code": "Hilll_-_Auer",
+        "id": "5",
+        "name": "Hilll - Auer",
+      },
+    ],
+    "id": "42",
+    "image": "",
+    "languageCode": "en",
+    "name": "en Mashed Potato Large",
+    "options": Array [
+      Object {
+        "code": "large",
+        "id": "2",
+        "languageCode": "en",
+        "name": "Large",
+      },
+    ],
+    "price": 123,
+    "sku": "ABC",
+    "translations": Array [
+      Object {
+        "id": "82",
+        "languageCode": "en",
+        "name": "en Mashed Potato Large",
+      },
+    ],
+  },
+]
+`;
+
+exports[`Product resolver product mutation variants generateVariantsForProduct generates variants 1`] = `
+Array [
+  Object {
+    "facetValues": Array [],
+    "id": "41",
+    "image": "",
+    "languageCode": "en",
+    "name": "en Mashed Potato Small",
+    "options": Array [
+      Object {
+        "code": "small",
+        "id": "1",
+        "languageCode": "en",
+        "name": "Small",
+      },
+    ],
+    "price": 123,
+    "sku": "ABC",
+    "translations": Array [
+      Object {
+        "id": "81",
+        "languageCode": "en",
+        "name": "en Mashed Potato Small",
+      },
+    ],
+  },
+  Object {
+    "facetValues": Array [],
+    "id": "42",
+    "image": "",
+    "languageCode": "en",
+    "name": "en Mashed Potato Large",
+    "options": Array [
+      Object {
+        "code": "large",
+        "id": "2",
+        "languageCode": "en",
+        "name": "Large",
+      },
+    ],
+    "price": 123,
+    "sku": "ABC",
+    "translations": Array [
+      Object {
+        "id": "82",
+        "languageCode": "en",
+        "name": "en Mashed Potato Large",
+      },
+    ],
+  },
+]
+`;
+
+exports[`Product resolver product mutation variants updateProductVariants updates variants 1`] = `
+Array [
+  Object {
+    "facetValues": Array [],
+    "id": "41",
+    "image": "new-image",
+    "languageCode": "en",
+    "name": "en Mashed Potato Small",
+    "options": Array [
+      Object {
+        "code": "small",
+        "id": "1",
+        "languageCode": "en",
+        "name": "Small",
+      },
+    ],
+    "price": 432,
+    "sku": "ABC",
+    "translations": Array [
+      Object {
+        "id": "81",
+        "languageCode": "en",
+        "name": "en Mashed Potato Small",
+      },
+    ],
+  },
+]
+`;

+ 278 - 4
server/e2e/product.e2e-spec.ts

@@ -1,15 +1,37 @@
 import {
+    AddOptionGroupToProduct,
+    AddOptionGroupToProductVariables,
+    ApplyFacetValuesToProductVariants,
+    ApplyFacetValuesToProductVariantsVariables,
     CreateProduct,
+    CreateProduct_createProduct,
     CreateProductVariables,
+    GenerateProductVariants,
+    GenerateProductVariants_generateVariantsForProduct_variants,
+    GenerateProductVariantsVariables,
     GetProductList,
     GetProductListVariables,
     GetProductWithVariants,
     GetProductWithVariantsVariables,
     LanguageCode,
+    RemoveOptionGroupFromProduct,
+    RemoveOptionGroupFromProductVariables,
     SortOrder,
+    UpdateProduct,
+    UpdateProductVariables,
+    UpdateProductVariants,
+    UpdateProductVariantsVariables,
 } from 'shared/generated-types';
 
-import { CREATE_PRODUCT } from '../../admin-ui/src/app/data/mutations/product-mutations';
+import {
+    ADD_OPTION_GROUP_TO_PRODUCT,
+    APPLY_FACET_VALUE_TO_PRODUCT_VARIANTS,
+    CREATE_PRODUCT,
+    GENERATE_PRODUCT_VARIANTS,
+    REMOVE_OPTION_GROUP_FROM_PRODUCT,
+    UPDATE_PRODUCT,
+    UPDATE_PRODUCT_VARIANTS,
+} from '../../admin-ui/src/app/data/mutations/product-mutations';
 import {
     GET_PRODUCT_LIST,
     GET_PRODUCT_WITH_VARIANTS,
@@ -170,8 +192,10 @@ describe('Product resolver', () => {
         });
     });
 
-    describe('createProduct mutation', () => {
-        it('creates a new Product', async () => {
+    describe('product mutation', () => {
+        let newProduct: CreateProduct_createProduct;
+
+        it('createProduct creates a new Product', async () => {
             const result = await client.query<CreateProduct, CreateProductVariables>(CREATE_PRODUCT, {
                 input: {
                     image: 'baked-potato',
@@ -191,7 +215,257 @@ describe('Product resolver', () => {
                     ],
                 },
             });
-            expect(result.createProduct).toMatchSnapshot();
+            newProduct = result.createProduct;
+            expect(newProduct).toMatchSnapshot();
+        });
+
+        it('updateProduct updates a Product', async () => {
+            const result = await client.query<UpdateProduct, UpdateProductVariables>(UPDATE_PRODUCT, {
+                input: {
+                    id: newProduct.id,
+                    image: 'mashed-potato',
+                    translations: [
+                        {
+                            languageCode: LanguageCode.en,
+                            name: 'en Mashed Potato',
+                            slug: 'en-mashed-potato',
+                            description: 'A blob of mashed potato',
+                        },
+                        {
+                            languageCode: LanguageCode.de,
+                            name: 'de Mashed Potato',
+                            slug: 'de-mashed-potato',
+                            description: 'Eine blob von gemashed Erdapfel',
+                        },
+                    ],
+                },
+            });
+            expect(result.updateProduct).toMatchSnapshot();
+        });
+
+        it('updateProduct errors with an invalid productId', async () => {
+            try {
+                await client.query<UpdateProduct, UpdateProductVariables>(UPDATE_PRODUCT, {
+                    input: {
+                        id: '999',
+                        image: 'mashed-potato',
+                        translations: [
+                            {
+                                languageCode: LanguageCode.en,
+                                name: 'en Mashed Potato',
+                                slug: 'en-mashed-potato',
+                                description: 'A blob of mashed potato',
+                            },
+                            {
+                                languageCode: LanguageCode.de,
+                                name: 'de Mashed Potato',
+                                slug: 'de-mashed-potato',
+                                description: 'Eine blob von gemashed Erdapfel',
+                            },
+                        ],
+                    },
+                });
+                fail('Should have thrown');
+            } catch (err) {
+                expect(err.message).toEqual(
+                    expect.stringContaining("No Product with the id '999' could be found"),
+                );
+            }
+        });
+
+        it('addOptionGroupToProduct adds an option group', async () => {
+            const result = await client.query<AddOptionGroupToProduct, AddOptionGroupToProductVariables>(
+                ADD_OPTION_GROUP_TO_PRODUCT,
+                {
+                    optionGroupId: '1',
+                    productId: newProduct.id,
+                },
+            );
+            expect(result.addOptionGroupToProduct.optionGroups.length).toBe(1);
+            expect(result.addOptionGroupToProduct.optionGroups[0].id).toBe('1');
+        });
+
+        it('addOptionGroupToProduct errors with an invalid productId', async () => {
+            try {
+                await client.query<AddOptionGroupToProduct, AddOptionGroupToProductVariables>(
+                    ADD_OPTION_GROUP_TO_PRODUCT,
+                    {
+                        optionGroupId: '1',
+                        productId: '999',
+                    },
+                );
+                fail('Should have thrown');
+            } catch (err) {
+                expect(err.message).toEqual(
+                    expect.stringContaining("No Product with the id '999' could be found"),
+                );
+            }
+        });
+
+        it('addOptionGroupToProduct errors with an invalid optionGroupId', async () => {
+            try {
+                await client.query<AddOptionGroupToProduct, AddOptionGroupToProductVariables>(
+                    ADD_OPTION_GROUP_TO_PRODUCT,
+                    {
+                        optionGroupId: '999',
+                        productId: newProduct.id,
+                    },
+                );
+                fail('Should have thrown');
+            } catch (err) {
+                expect(err.message).toEqual(
+                    expect.stringContaining("No OptionGroup with the id '999' could be found"),
+                );
+            }
+        });
+
+        it('removeOptionGroupFromProduct removes an option group', async () => {
+            const result = await client.query<
+                RemoveOptionGroupFromProduct,
+                RemoveOptionGroupFromProductVariables
+            >(REMOVE_OPTION_GROUP_FROM_PRODUCT, {
+                optionGroupId: '1',
+                productId: '1',
+            });
+            expect(result.removeOptionGroupFromProduct.optionGroups.length).toBe(0);
+        });
+
+        it('removeOptionGroupFromProduct errors with an invalid productId', async () => {
+            try {
+                await client.query<RemoveOptionGroupFromProduct, RemoveOptionGroupFromProductVariables>(
+                    REMOVE_OPTION_GROUP_FROM_PRODUCT,
+                    {
+                        optionGroupId: '1',
+                        productId: '999',
+                    },
+                );
+                fail('Should have thrown');
+            } catch (err) {
+                expect(err.message).toEqual(
+                    expect.stringContaining("No Product with the id '999' could be found"),
+                );
+            }
+        });
+
+        describe('variants', () => {
+            let variants: GenerateProductVariants_generateVariantsForProduct_variants[];
+
+            it('generateVariantsForProduct generates variants', async () => {
+                const result = await client.query<GenerateProductVariants, GenerateProductVariantsVariables>(
+                    GENERATE_PRODUCT_VARIANTS,
+                    {
+                        productId: newProduct.id,
+                        defaultPrice: 123,
+                        defaultSku: 'ABC',
+                    },
+                );
+                variants = result.generateVariantsForProduct.variants;
+                expect(variants).toMatchSnapshot();
+            });
+
+            it('generateVariantsForProduct throws with an invalid productId', async () => {
+                try {
+                    await client.query<GenerateProductVariants, GenerateProductVariantsVariables>(
+                        GENERATE_PRODUCT_VARIANTS,
+                        {
+                            productId: '999',
+                        },
+                    );
+                    fail('Should have thrown');
+                } catch (err) {
+                    expect(err.message).toEqual(
+                        expect.stringContaining("No Product with the id '999' could be found"),
+                    );
+                }
+            });
+
+            it('updateProductVariants updates variants', async () => {
+                const firstVariant = variants[0];
+                const result = await client.query<UpdateProductVariants, UpdateProductVariantsVariables>(
+                    UPDATE_PRODUCT_VARIANTS,
+                    {
+                        input: [
+                            {
+                                id: firstVariant.id,
+                                translations: firstVariant.translations,
+                                sku: 'ABC',
+                                image: 'new-image',
+                                price: 432,
+                            },
+                        ],
+                    },
+                );
+                expect(result.updateProductVariants).toMatchSnapshot();
+            });
+
+            it('updateProductVariants throws with an invalid variant id', async () => {
+                try {
+                    await client.query<UpdateProductVariants, UpdateProductVariantsVariables>(
+                        UPDATE_PRODUCT_VARIANTS,
+                        {
+                            input: [
+                                {
+                                    id: '999',
+                                    translations: variants[0].translations,
+                                    sku: 'ABC',
+                                    image: 'new-image',
+                                    price: 432,
+                                },
+                            ],
+                        },
+                    );
+                    fail('Should have thrown');
+                } catch (err) {
+                    expect(err.message).toEqual(
+                        expect.stringContaining("No ProductVariant with the id '999' could be found"),
+                    );
+                }
+            });
+
+            it('applyFacetValuesToProductVariants adds facets to variants', async () => {
+                const result = await client.query<
+                    ApplyFacetValuesToProductVariants,
+                    ApplyFacetValuesToProductVariantsVariables
+                >(APPLY_FACET_VALUE_TO_PRODUCT_VARIANTS, {
+                    facetValueIds: ['1', '3', '5'],
+                    productVariantIds: variants.map(v => v.id),
+                });
+                expect(result.applyFacetValuesToProductVariants).toMatchSnapshot();
+            });
+
+            it('applyFacetValuesToProductVariants errors with invalid facet value id', async () => {
+                try {
+                    await client.query<
+                        ApplyFacetValuesToProductVariants,
+                        ApplyFacetValuesToProductVariantsVariables
+                    >(APPLY_FACET_VALUE_TO_PRODUCT_VARIANTS, {
+                        facetValueIds: ['999', '888'],
+                        productVariantIds: variants.map(v => v.id),
+                    });
+                    fail('Should have thrown');
+                } catch (err) {
+                    expect(err.message).toEqual(
+                        expect.stringContaining("No FacetValue with the id '999' could be found"),
+                    );
+                }
+            });
+
+            it('applyFacetValuesToProductVariants errors with invalid variant id', async () => {
+                try {
+                    await client.query<
+                        ApplyFacetValuesToProductVariants,
+                        ApplyFacetValuesToProductVariantsVariables
+                    >(APPLY_FACET_VALUE_TO_PRODUCT_VARIANTS, {
+                        facetValueIds: ['1', '3', '5'],
+                        productVariantIds: ['999'],
+                    });
+                    fail('Should have thrown');
+                } catch (err) {
+                    expect(err.message).toEqual(
+                        expect.stringContaining("No ProductVariant with the id '999' could be found"),
+                    );
+                }
+            });
         });
     });
 });

+ 1 - 1
server/src/api/facet/facet.resolver.ts

@@ -61,7 +61,7 @@ export class FacetResolver {
         const facetId = input[0].facetId;
         const facet = await this.facetService.findOne(facetId, DEFAULT_LANGUAGE_CODE);
         if (!facet) {
-            throw new I18nError(`error.invalid-facetId`, { facetId });
+            throw new I18nError('error.entity-with-id-not-found', { entityName: 'Facet', id: facetId });
         }
         return Promise.all(input.map(facetValue => this.facetValueService.create(facet, facetValue)));
     }

+ 19 - 7
server/src/api/product/product.resolver.ts

@@ -1,5 +1,11 @@
 import { Mutation, Query, Resolver } from '@nestjs/graphql';
-import { CreateProductVariables, UpdateProductVariantsVariables } from 'shared/generated-types';
+import {
+    CreateProductVariables,
+    GenerateProductVariantsVariables,
+    GetProductListVariables,
+    GetProductWithVariantsVariables,
+    UpdateProductVariantsVariables,
+} from 'shared/generated-types';
 import { ID, PaginatedList } from 'shared/shared-types';
 
 import { DEFAULT_LANGUAGE_CODE } from '../../common/constants';
@@ -23,14 +29,14 @@ export class ProductResolver {
 
     @Query('products')
     @ApplyIdCodec()
-    async products(obj, args): Promise<PaginatedList<Translated<Product>>> {
-        return this.productService.findAll(args.languageCode, args.options);
+    async products(obj, args: GetProductListVariables): Promise<PaginatedList<Translated<Product>>> {
+        return this.productService.findAll(args.languageCode, args.options || undefined);
     }
 
     @Query('product')
     @ApplyIdCodec()
-    async product(obj, args): Promise<Translated<Product> | undefined> {
-        return this.productService.findOne(args.id, args.languageCode);
+    async product(obj, args: GetProductWithVariantsVariables): Promise<Translated<Product> | undefined> {
+        return this.productService.findOne(args.id, args.languageCode || undefined);
     }
 
     @Mutation()
@@ -63,7 +69,10 @@ export class ProductResolver {
 
     @Mutation()
     @ApplyIdCodec()
-    async generateVariantsForProduct(_, args): Promise<Translated<Product>> {
+    async generateVariantsForProduct(
+        _,
+        args: GenerateProductVariantsVariables,
+    ): Promise<Translated<Product>> {
         const { productId, defaultPrice, defaultSku } = args;
         await this.productVariantService.generateVariantsForProduct(productId, defaultPrice, defaultSku);
         return assertFound(this.productService.findOne(productId, DEFAULT_LANGUAGE_CODE));
@@ -87,7 +96,10 @@ export class ProductResolver {
             (facetValueIds as ID[]).map(async facetValueId => {
                 const facetValue = await this.facetValueService.findOne(facetValueId, DEFAULT_LANGUAGE_CODE);
                 if (!facetValue) {
-                    throw new I18nError(`error.facet-value-not-found`, { facetValueId });
+                    throw new I18nError('error.entity-with-id-not-found', {
+                        entityName: 'FacetValue',
+                        id: facetValueId,
+                    });
                 }
                 return facetValue;
             }),

+ 7 - 3
server/src/common/build-list-query.ts

@@ -1,5 +1,5 @@
 import { Type } from 'shared/shared-types';
-import { Connection, SelectQueryBuilder } from 'typeorm';
+import { Connection, FindManyOptions, SelectQueryBuilder } from 'typeorm';
 import { FindOptionsUtils } from 'typeorm/find-options/FindOptionsUtils';
 
 import { VendureEntity } from '../entity/base/base.entity';
@@ -14,7 +14,7 @@ import { parseSortParams } from './parse-sort-params';
 export function buildListQuery<T extends VendureEntity>(
     connection: Connection,
     entity: Type<T>,
-    options: ListQueryOptions<T>,
+    options: ListQueryOptions<T> = {},
     relations?: string[],
 ): SelectQueryBuilder<T> {
     const skip = options.skip;
@@ -26,7 +26,11 @@ export function buildListQuery<T extends VendureEntity>(
     const filter = parseFilterParams(connection, entity, options.filter);
 
     const qb = connection.createQueryBuilder<T>(entity, entity.name.toLowerCase());
-    FindOptionsUtils.applyFindManyOptionsOrConditionsToQueryBuilder(qb, { relations, take, skip });
+    FindOptionsUtils.applyFindManyOptionsOrConditionsToQueryBuilder(qb, {
+        relations,
+        take,
+        skip,
+    } as FindManyOptions<T>);
     // tslint:disable-next-line:no-non-null-assertion
     FindOptionsUtils.joinEagerRelations(qb, qb.alias, qb.expressionMap.mainAlias!.metadata);
 

+ 19 - 5
server/src/common/common-types.ts

@@ -7,6 +7,11 @@ import { LocaleString } from '../locale/locale-types';
  */
 export type ReadOnlyRequired<T> = { +readonly [K in keyof T]-?: T[K] };
 
+/**
+ * Creates a mutable version of a type with readonly properties.
+ */
+export type Mutable<T> = { -readonly [K in keyof T]: T[K] };
+
 /**
  * Given an array type e.g. Array<string>, return the inner type e.g. string.
  */
@@ -16,12 +21,21 @@ export type UnwrappedArray<T extends any[]> = T[number];
  * Parameters for list queries
  */
 export interface ListQueryOptions<T extends VendureEntity> {
-    take: number;
-    skip: number;
-    sort: SortParameter<T>;
-    filter: FilterParameter<T>;
+    take?: number | null;
+    skip?: number | null;
+    sort?: NullOptionals<SortParameter<T>> | null;
+    filter?: NullOptionals<FilterParameter<T>> | null;
 }
 
+/**
+ * Returns a type T where any optional fields also have the "null" type added.
+ * This is needed to provide interop with the Apollo-generated interfaces, where
+ * nullable fields have the type `field?: <type> | null`.
+ */
+export type NullOptionals<T> = {
+    [K in keyof T]: undefined extends T[K] ? NullOptionals<T[K]> | null : NullOptionals<T[K]>
+};
+
 export type SortOrder = 'ASC' | 'DESC';
 
 // prettier-ignore
@@ -32,7 +46,7 @@ export type PrimitiveFields<T extends VendureEntity> = {
 // prettier-ignore
 export type SortParameter<T extends VendureEntity> = {
     [K in PrimitiveFields<T>]?: SortOrder
-} & CustomFieldSortParameter;
+};
 
 // prettier-ignore
 export type CustomFieldSortParameter = {

+ 1 - 1
server/src/common/create-translatable.ts

@@ -26,6 +26,6 @@ export function createTranslatable<T extends Translatable>(
         if (typeof beforeSave === 'function') {
             await beforeSave(entity);
         }
-        return connection.manager.save(entity);
+        return await connection.manager.save(entity);
     };
 }

+ 2 - 1
server/src/common/parse-filter-params.ts

@@ -10,6 +10,7 @@ import {
     BooleanOperators,
     DateOperators,
     FilterParameter,
+    NullOptionals,
     NumberOperators,
     StringOperators,
 } from './common-types';
@@ -25,7 +26,7 @@ type Operator = { [K in keyof AllOperators]-?: K }[keyof AllOperators];
 export function parseFilterParams<T extends VendureEntity>(
     connection: Connection,
     entity: Type<T>,
-    filterParams: FilterParameter<T> | undefined,
+    filterParams?: NullOptionals<FilterParameter<T>> | null,
 ): WhereCondition[] {
     if (!filterParams) {
         return [];

+ 3 - 3
server/src/common/parse-sort-params.spec.ts

@@ -80,7 +80,7 @@ describe('parseSortParams()', () => {
         const connection = new MockConnection();
         connection.setColumns(Product, [{ propertyName: 'id' }, { propertyName: 'infoUrl' }]);
 
-        const sortParams: SortParameter<Product> = {
+        const sortParams: SortParameter<Product & { infoUrl: any }> = {
             infoUrl: 'ASC',
         };
 
@@ -96,7 +96,7 @@ describe('parseSortParams()', () => {
         connection.setRelations(Product, [{ propertyName: 'translations', type: ProductTranslation }]);
         connection.setColumns(ProductTranslation, [{ propertyName: 'id' }, { propertyName: 'shortName' }]);
 
-        const sortParams: SortParameter<Product> = {
+        const sortParams: SortParameter<Product & { shortName: any }> = {
             shortName: 'ASC',
         };
 
@@ -116,7 +116,7 @@ describe('parseSortParams()', () => {
             { propertyName: 'base', relationMetadata: {} as any },
         ]);
 
-        const sortParams: SortParameter<Product> = {
+        const sortParams: SortParameter<Product & { invalid: any }> = {
             invalid: 'ASC',
         };
 

+ 2 - 2
server/src/common/parse-sort-params.ts

@@ -5,7 +5,7 @@ import { ColumnMetadata } from 'typeorm/metadata/ColumnMetadata';
 import { VendureEntity } from '../entity/base/base.entity';
 import { I18nError } from '../i18n/i18n-error';
 
-import { SortParameter } from './common-types';
+import { NullOptionals, SortParameter } from './common-types';
 
 /**
  * Parses the provided SortParameter array against the metadata of the given entity, ensuring that only
@@ -17,7 +17,7 @@ import { SortParameter } from './common-types';
 export function parseSortParams<T extends VendureEntity>(
     connection: Connection,
     entity: Type<T>,
-    sortParams: SortParameter<T> | undefined,
+    sortParams?: NullOptionals<SortParameter<T>> | null,
 ): OrderByCondition {
     if (!sortParams || Object.keys(sortParams).length === 0) {
         return {};

+ 1 - 3
server/src/i18n/messages/en.json

@@ -1,9 +1,7 @@
 {
   "error": {
-    "customer-with-id-not-found": "No customer with the id '{ customerId }' was found",
     "entity-has-no-translation-in-language": "Translatable entity '{ entityName }' has not been translated into the requested language ({ languageCode })",
-    "facet-value-not-found": "A FacetValue with the id '{ facetValueId }' was not found",
-    "invalid-facetId": "The facetId '{ facetId }' was not found",
+    "entity-with-id-not-found": "No { entityName } with the id '{ id }' could be found",
     "invalid-sort-field": "The sort field '{ fieldName }' is invalid. Valid fields are: { validFields }"
   }
 }

+ 13 - 4
server/src/locale/translation-updater.ts

@@ -1,7 +1,8 @@
-import { DeepPartial, Type } from 'shared/shared-types';
+import { DeepPartial } from 'shared/shared-types';
 import { EntityManager } from 'typeorm';
 
 import { foundIn, not } from '../common/utils';
+import { I18nError } from '../i18n/i18n-error';
 
 import { Translatable, Translation, TranslationInput } from './locale-types';
 
@@ -56,9 +57,17 @@ export class TranslationUpdater<Entity extends Translatable> {
         if (toAdd.length) {
             for (const translation of toAdd) {
                 translation.base = entity;
-                const newTranslation = await this.manager
-                    .getRepository(this.translationCtor)
-                    .save(translation as any);
+                let newTranslation: any;
+                try {
+                    newTranslation = await this.manager
+                        .getRepository(this.translationCtor)
+                        .save(translation as any);
+                } catch (err) {
+                    const entityName = entity.constructor.name;
+                    const id = (entity as any).id || 'undefined';
+                    throw new I18nError('error.entity-with-id-not-found', { entityName, id });
+                }
+
                 entity.translations.push(newTranslation);
             }
         }

+ 1 - 1
server/src/service/customer.service.ts

@@ -60,7 +60,7 @@ export class CustomerService {
         });
 
         if (!customer) {
-            throw new I18nError('error.customer-with-id-not-found', { customerId });
+            throw new I18nError('error.entity-with-id-not-found', { entityName: 'Customer', id: customerId });
         }
 
         const address = new Address(createAddressDto);

+ 16 - 6
server/src/service/product-variant.service.ts

@@ -40,7 +40,7 @@ export class ProductVariantService {
             }
             variant.product = product;
         });
-        return save(this.connection, createProductVariantDto);
+        return await save(this.connection, createProductVariantDto);
     }
 
     async update(updateProductVariantsDto: UpdateProductVariantInput): Promise<Translated<ProductVariant>> {
@@ -60,15 +60,15 @@ export class ProductVariantService {
 
     async generateVariantsForProduct(
         productId: ID,
-        defaultPrice?: number,
-        defaultSku?: string,
+        defaultPrice?: number | null,
+        defaultSku?: string | null,
     ): Promise<Array<Translated<ProductVariant>>> {
         const product = await this.connection.getRepository(Product).findOne(productId, {
             relations: ['optionGroups', 'optionGroups.options'],
         });
 
         if (!product) {
-            throw new I18nError('error.product-with-id-not-found', { productId });
+            throw new I18nError('error.entity-with-id-not-found', { entityName: 'Product', id: productId });
         }
         const defaultTranslation = product.translations.find(t => t.languageCode === DEFAULT_LANGUAGE_CODE);
 
@@ -76,9 +76,9 @@ export class ProductVariantService {
         const optionCombinations = product.optionGroups.length
             ? generateAllCombinations(product.optionGroups.map(g => g.options))
             : [[]];
-        const createVariants = optionCombinations.map(options => {
+        const createVariants = optionCombinations.map(async options => {
             const name = this.createVariantName(productName, options);
-            return this.create(product, {
+            return await this.create(product, {
                 sku: defaultSku || 'sku-not-set',
                 price: defaultPrice || 0,
                 image: '',
@@ -104,6 +104,16 @@ export class ProductVariantService {
         const variants = await this.connection.getRepository(ProductVariant).findByIds(productVariantIds, {
             relations: ['options', 'facetValues'],
         });
+
+        const notFoundIds = productVariantIds.filter(
+            id => !variants.find(v => v.id.toString() === id.toString()),
+        );
+        if (notFoundIds.length) {
+            throw new I18nError('error.entity-with-id-not-found', {
+                entityName: 'ProductVariant',
+                id: notFoundIds[0],
+            });
+        }
         for (const variant of variants) {
             for (const facetValue of facetValues) {
                 if (!variant.facetValues.map(fv => fv.id).includes(facetValue.id)) {

+ 11 - 8
server/src/service/product.service.ts

@@ -5,7 +5,7 @@ import { ID, PaginatedList } from 'shared/shared-types';
 import { Connection } from 'typeorm';
 
 import { buildListQuery } from '../common/build-list-query';
-import { ListQueryOptions } from '../common/common-types';
+import { ListQueryOptions, NullOptionals } from '../common/common-types';
 import { DEFAULT_LANGUAGE_CODE } from '../common/constants';
 import { createTranslatable } from '../common/create-translatable';
 import { updateTranslatable } from '../common/update-translatable';
@@ -26,8 +26,8 @@ export class ProductService {
     ) {}
 
     findAll(
-        lang: LanguageCode,
-        options: ListQueryOptions<Product>,
+        lang?: LanguageCode | null,
+        options?: ListQueryOptions<Product>,
     ): Promise<PaginatedList<Translated<Product>>> {
         const relations = ['variants', 'optionGroups', 'variants.options', 'variants.facetValues'];
 
@@ -35,7 +35,7 @@ export class ProductService {
             .getManyAndCount()
             .then(([products, totalItems]) => {
                 const items = products.map(product =>
-                    translateDeep(product, lang, [
+                    translateDeep(product, lang || DEFAULT_LANGUAGE_CODE, [
                         'optionGroups',
                         'variants',
                         ['variants', 'options'],
@@ -49,7 +49,7 @@ export class ProductService {
             });
     }
 
-    findOne(productId: ID, lang: LanguageCode): Promise<Translated<Product> | undefined> {
+    findOne(productId: ID, lang?: LanguageCode): Promise<Translated<Product> | undefined> {
         const relations = ['variants', 'optionGroups', 'variants.options', 'variants.facetValues'];
 
         return this.connection.manager
@@ -57,7 +57,7 @@ export class ProductService {
             .then(
                 product =>
                     product &&
-                    translateDeep(product, lang, [
+                    translateDeep(product, lang || DEFAULT_LANGUAGE_CODE, [
                         'optionGroups',
                         'variants',
                         ['variants', 'options'],
@@ -89,7 +89,10 @@ export class ProductService {
         const product = await this.getProductWithOptionGroups(productId);
         const optionGroup = await this.connection.getRepository(ProductOptionGroup).findOne(optionGroupId);
         if (!optionGroup) {
-            throw new I18nError(`error.option-group-with-id-not-found`, { optionGroupId });
+            throw new I18nError('error.entity-with-id-not-found', {
+                entityName: 'OptionGroup',
+                id: optionGroupId,
+            });
         }
 
         if (Array.isArray(product.optionGroups)) {
@@ -115,7 +118,7 @@ export class ProductService {
             .getRepository(Product)
             .findOne(productId, { relations: ['optionGroups'] });
         if (!product) {
-            throw new I18nError(`error.product-with-id-not-found`, { productId });
+            throw new I18nError('error.entity-with-id-not-found', { entityName: 'Product', id: productId });
         }
         return product;
     }