Ver Fonte

Implement createProduct gql mutation

Also quite a bit of file reorganization. The best arrangement is still emerging as I build out the system.
Michael Bromley há 7 anos atrás
pai
commit
a5f01123d1

+ 0 - 0
modules/core/api/customer/customer.types.graphql → modules/core/api/customer/customer.api.graphql


+ 9 - 0
modules/core/api/product/product.api.graphql

@@ -0,0 +1,9 @@
+type Query {
+  products(lang: LanguageCode): [Product]
+  product(id: Int!, lang: LanguageCode): Product
+}
+
+type Mutation {
+  "Create a new Product"
+  createProduct(input: CreateProductInput): Product
+}

+ 23 - 4
modules/core/api/product/product.resolver.ts

@@ -1,10 +1,12 @@
-import { Query, Resolver } from '@nestjs/graphql';
+import { Mutation, Query, Resolver } from '@nestjs/graphql';
+import { CreateProductDto } from '../../entity/product/create-product.dto';
 import { Product } from '../../entity/product/product.interface';
-import { ProductService } from './product.service';
+import { ProductVariantService } from '../../service/product-variant.service';
+import { ProductService } from '../../service/product.service';
 
 @Resolver('Product')
 export class ProductResolver {
-    constructor(private productService: ProductService) {}
+    constructor(private productService: ProductService, private productVariantService: ProductVariantService) {}
 
     @Query('products')
     products(obj, args): Promise<Product[]> {
@@ -12,7 +14,24 @@ export class ProductResolver {
     }
 
     @Query('product')
-    product(obj, args): Promise<Product> {
+    product(obj, args): Promise<Product | undefined> {
         return this.productService.findOne(args.id, args.lang);
     }
+
+    @Mutation()
+    async createProduct(_, args: MutationInput<CreateProductDto>): Promise<Product> {
+        const product = await this.productService.create(args.input);
+
+        if (args.input.variants && args.input.variants.length) {
+            for (const variant of args.input.variants) {
+                await this.productVariantService.create(product, variant);
+            }
+        }
+
+        return product;
+    }
+}
+
+export interface MutationInput<T> {
+    input: T;
 }

+ 0 - 22
modules/core/api/product/product.service.ts

@@ -1,22 +0,0 @@
-import { Injectable } from '@nestjs/common';
-import { InjectConnection } from '@nestjs/typeorm';
-import { Connection, createQueryBuilder } from 'typeorm';
-import { ProductVariantEntity } from '../../entity/product-variant/product-variant.entity';
-import { ProductVariant } from '../../entity/product-variant/product-variant.interface';
-import { ProductEntity } from '../../entity/product/product.entity';
-import { Product } from '../../entity/product/product.interface';
-import { Translatable, Translation } from '../../locale/locale-types';
-import { ProductRepository } from '../../repository/product-repository';
-
-@Injectable()
-export class ProductService {
-    constructor(@InjectConnection() private connection: Connection) {}
-
-    findAll(lang?: string): Promise<Product[]> {
-        return this.connection.getCustomRepository(ProductRepository).localeFind(lang);
-    }
-
-    findOne(productId: number, lang?: string): Promise<Product> {
-        return this.connection.getCustomRepository(ProductRepository).localeFindOne(productId, lang);
-    }
-}

+ 0 - 4
modules/core/api/product/product.types.graphql

@@ -1,4 +0,0 @@
-type Query {
-  products(lang: String): [Product]
-  product(id: Int!, lang: String): Product
-}

+ 5 - 4
modules/core/app.module.ts

@@ -1,17 +1,17 @@
 import { MiddlewareConsumer, Module, NestModule } from '@nestjs/common';
-import { GraphQLModule } from '@nestjs/graphql';
-import { GraphQLFactory } from '@nestjs/graphql';
+import { GraphQLFactory, GraphQLModule } from '@nestjs/graphql';
 import { TypeOrmModule } from '@nestjs/typeorm';
 import { graphiqlExpress, graphqlExpress } from 'apollo-server-express';
 import { AuthController } from './api/auth/auth.controller';
 import { CustomerController } from './api/customer/customer.controller';
 import { CustomerResolver } from './api/customer/customer.resolver';
-import { CustomerService } from './api/customer/customer.service';
 import { ProductResolver } from './api/product/product.resolver';
-import { ProductService } from './api/product/product.service';
 import { AuthService } from './auth/auth.service';
 import { JwtStrategy } from './auth/jwt.strategy';
 import { PasswordService } from './auth/password.service';
+import { CustomerService } from './service/customer.service';
+import { ProductVariantService } from './service/product-variant.service';
+import { ProductService } from './service/product.service';
 
 @Module({
     imports: [
@@ -36,6 +36,7 @@ import { PasswordService } from './auth/password.service';
         CustomerService,
         CustomerResolver,
         ProductService,
+        ProductVariantService,
         ProductResolver,
         PasswordService,
     ],

+ 9 - 0
modules/core/entity/product-variant/create-product-variant.dto.ts

@@ -0,0 +1,9 @@
+import { TranslatedInput } from '../../locale/locale-types';
+import { ProductVariant } from './product-variant.interface';
+
+export interface CreateProductVariantDto extends TranslatedInput<ProductVariant> {
+    sku: string;
+    price: number;
+    image?: string;
+    optionCodes?: string[];
+}

+ 4 - 4
modules/core/entity/product-variant/product-variant.entity.ts

@@ -9,7 +9,7 @@ import {
     PrimaryGeneratedColumn,
     UpdateDateColumn,
 } from 'typeorm';
-import { Translatable } from '../../locale/locale-types';
+import { Translatable, Translation } from '../../locale/locale-types';
 import { ProductOptionEntity } from '../product-option/product-option.entity';
 import { ProductEntity } from '../product/product.entity';
 import { ProductVariantTranslationEntity } from './product-variant-translation.entity';
@@ -23,17 +23,17 @@ export class ProductVariantEntity implements Translatable<ProductVariant> {
 
     @Column() image: string;
 
-    @Column() price: string;
+    @Column() price: number;
 
     @CreateDateColumn() createdAt: string;
 
     @UpdateDateColumn() updatedAt: string;
 
     @OneToMany(type => ProductVariantTranslationEntity, translation => translation.base)
-    translations: ProductVariantTranslationEntity[];
+    translations: Translation<ProductVariant>[];
 
     @ManyToOne(type => ProductEntity, product => product.variants)
-    product: ProductEntity[];
+    product: ProductEntity;
 
     @ManyToMany(type => ProductOptionEntity)
     @JoinTable()

+ 1 - 1
modules/core/entity/product-variant/product-variant.interface.ts

@@ -6,7 +6,7 @@ export class ProductVariant {
     sku: string;
     name: LocaleString;
     image: string;
-    price: string;
+    price: number;
     options: ProductOption[];
     createdAt: string;
     updatedAt: string;

+ 9 - 0
modules/core/entity/product/create-product.dto.ts

@@ -0,0 +1,9 @@
+import { TranslatedInput } from '../../locale/locale-types';
+import { CreateProductVariantDto } from '../product-variant/create-product-variant.dto';
+import { Product } from './product.interface';
+
+export interface CreateProductDto extends TranslatedInput<Product> {
+    image?: string;
+    optionGroupCodes?: [string];
+    variants?: CreateProductVariantDto[];
+}

+ 14 - 3
modules/core/locale/locale-types.ts

@@ -1,9 +1,9 @@
+import { LanguageCode } from './language-code';
+
 /**
  * This type should be used in any interfaces where the value is to be
  * localized into different languages.
  */
-import { Product } from '../entity/product/product.interface';
-
 export type LocaleString = string & { _opaqueType: 'LocaleString' };
 
 export type TranslatableKeys<T> = { [K in keyof T]: T[K] extends LocaleString ? K : never }[keyof T];
@@ -33,7 +33,18 @@ export type Translation<T> =
     { [K in TranslatableKeys<T>]: string } &
     { [key: string]: any };
 
+// prettier-ignore
 export type TranslatedEntity<T> =
     // Translatable must include all non-translatable keys of the interface
     { [K in NonTranslateableKeys<T>]: T[K] extends Array<any> ? Array<Translatable<T[K][number]>> : T[K] } &
-        { [K in TranslatableKeys<T>]: string };
+    { [K in TranslatableKeys<T>]: string };
+
+export type LocalizedInput<T> = { [K in TranslatableKeys<T>]: string } & { languageCode: LanguageCode };
+
+/**
+ * This interface defines the shape of a DTO used to create / update an entity which has one or more LocaleString
+ * properties.
+ */
+export interface TranslatedInput<T> {
+    translations: Array<LocalizedInput<T>>;
+}

+ 13 - 1
modules/core/locale/translate-entity.spec.ts

@@ -56,7 +56,9 @@ describe('translateEntity()', () => {
     it('throw if there are no translations available', () => {
         product.translations = [];
 
-        expect(() => translateEntity(product)).toThrow('Translatable entity "ProductEntity" has no translations');
+        expect(() => translateEntity(product)).toThrow(
+            'Translatable entity "ProductEntity" has not been translated into the requested language',
+        );
     });
 });
 
@@ -140,6 +142,16 @@ describe('translateDeep()', () => {
         expect(() => translateDeep(testProduct)).not.toThrow();
     });
 
+    it('should not throw if first-level nested entity is not defined', () => {
+        testProduct.singleRealVariant = undefined as any;
+        expect(() => translateDeep(testProduct, ['singleRealVariant'])).not.toThrow();
+    });
+
+    it('should not throw if second-level nested entity is not defined', () => {
+        testProduct.singleRealVariant.options = undefined as any;
+        expect(() => translateDeep(testProduct, [['singleRealVariant', 'options']])).not.toThrow();
+    });
+
     it('should translate a first-level nested non-array entity', () => {
         const result = translateDeep(testProduct, ['singleRealVariant']);
 

+ 14 - 4
modules/core/locale/translate-entity.ts

@@ -25,13 +25,19 @@ export type NestedTranslatableRelationKeys<T> = NestedTranslatableRelations<T>[k
 
 export type DeepTranslatableRelations<T> = Array<TranslatableRelationsKeys<T> | NestedTranslatableRelationKeys<T>>;
 
+export class NotTranslatedError extends Error {}
+
 /**
  * Converts a Translatable entity into the public-facing entity by unwrapping
  * the translated strings from the first of the Translation entities.
  */
 export function translateEntity<T>(translatable: Translatable<T>): TranslatedEntity<T> {
     if (!translatable.translations || translatable.translations.length === 0) {
-        throw new Error(`Translatable entity "${translatable.constructor.name}" has no translations`);
+        throw new NotTranslatedError(
+            `Translatable entity "${
+                translatable.constructor.name
+            }" has not been translated into the requested language`,
+        );
     }
     const translation = translatable.translations[0];
 
@@ -52,7 +58,7 @@ export function translateEntity<T>(translatable: Translatable<T>): TranslatedEnt
 export function translateDeep<T>(
     translatable: Translatable<T>,
     translatableRelations: DeepTranslatableRelations<T> = [],
-): TranslatedEntity<T> {
+): T {
     let translatedEntity: TranslatedEntity<T>;
     try {
         translatedEntity = translateEntity(translatable);
@@ -85,7 +91,9 @@ export function translateDeep<T>(
             property = path as any;
             value = translateLeaf(object, property);
         }
-        object[property] = value;
+        if (object && property!) {
+            object[property] = value;
+        }
     }
 
     return translatedEntity as any;
@@ -94,7 +102,9 @@ export function translateDeep<T>(
 function translateLeaf(object: any, property: string): any {
     if (Array.isArray(object[property])) {
         return object[property].map(nested2 => translateEntity(nested2));
-    } else {
+    } else if (object[property]) {
         return translateEntity(object[property]);
+    } else {
+        return undefined;
     }
 }

+ 21 - 16
modules/core/repository/product-repository.ts

@@ -1,38 +1,43 @@
-import { EntityRepository, Repository, SelectQueryBuilder } from 'typeorm';
-import { ProductOptionGroup } from '../entity/product-option-group/product-option-group.interface';
-import { ProductOption } from '../entity/product-option/product-option.interface';
-import { ProductVariant } from '../entity/product-variant/product-variant.interface';
+import { AbstractRepository, EntityRepository, SelectQueryBuilder } from 'typeorm';
+import { CreateProductDto } from '../entity/product/create-product.dto';
+import { ProductTranslationEntity } from '../entity/product/product-translation.entity';
 import { ProductEntity } from '../entity/product/product.entity';
 import { Product } from '../entity/product/product.interface';
-import { Translatable, TranslatableKeys, TranslatedEntity } from '../locale/locale-types';
+import { LanguageCode } from '../locale/language-code';
 import { translateDeep } from '../locale/translate-entity';
 
 @EntityRepository(ProductEntity)
-export class ProductRepository extends Repository<ProductEntity> {
+export class ProductRepository extends AbstractRepository<ProductEntity> {
     /**
      * Returns an array of Products including ProductVariants, translated into the
      * specified language.
      */
-    localeFind(languageCode: string): Promise<Product[]> {
-        return this.getProductQueryBuilder(languageCode)
-            .getMany()
-            .then(result => result.map(res => translateDeep(res, ['variants', 'optionGroups']) as any));
+    find(languageCode: LanguageCode): Promise<ProductEntity[]> {
+        return this.getProductQueryBuilder(languageCode).getMany();
     }
 
     /**
      * Returns single Product including ProductVariants, translated into the
      * specified language.
      */
-    localeFindOne(id: number, languageCode: string): Promise<Product> {
+    findOne(id: number, languageCode: LanguageCode): Promise<ProductEntity | undefined> {
         return this.getProductQueryBuilder(languageCode)
             .andWhere('product.id = :id', { id })
-            .getOne()
-            .then(result => translateDeep(result, ['variants', 'optionGroups', ['variants', 'options']]) as any);
+            .getOne();
     }
 
-    private getProductQueryBuilder(languageCode: string): SelectQueryBuilder<ProductEntity> {
-        const code = languageCode || 'en';
+    /**
+     * Creates a new Product with one or more ProductTranslations.
+     */
+    async create(productEntity: ProductEntity, translations: ProductTranslationEntity[]): Promise<ProductEntity> {
+        for (const translation of translations) {
+            await this.manager.save(translation);
+        }
+        productEntity.translations = translations;
+        return this.manager.save(productEntity);
+    }
 
+    private getProductQueryBuilder(languageCode: LanguageCode): SelectQueryBuilder<ProductEntity> {
         return this.manager
             .createQueryBuilder(ProductEntity, 'product')
             .leftJoinAndSelect('product.variants', 'variant')
@@ -58,6 +63,6 @@ export class ProductRepository extends Repository<ProductEntity> {
                 'variant_options_translation',
                 'variant_options_translation.languageCode = :code',
             )
-            .setParameters({ code });
+            .setParameters({ code: languageCode });
     }
 }

+ 60 - 0
modules/core/repository/product-variant-repository.ts

@@ -0,0 +1,60 @@
+import { AbstractRepository, EntityRepository, SelectQueryBuilder } from 'typeorm';
+import { ProductVariantTranslationEntity } from '../entity/product-variant/product-variant-translation.entity';
+import { ProductVariantEntity } from '../entity/product-variant/product-variant.entity';
+import { ProductVariant } from '../entity/product-variant/product-variant.interface';
+import { ProductTranslationEntity } from '../entity/product/product-translation.entity';
+import { ProductEntity } from '../entity/product/product.entity';
+import { Product } from '../entity/product/product.interface';
+import { LanguageCode } from '../locale/language-code';
+import { translateDeep } from '../locale/translate-entity';
+
+@EntityRepository(ProductEntity)
+export class ProductVariantRepository extends AbstractRepository<ProductVariantEntity> {
+    /**
+     * Returns an array of Products including ProductVariants, translated into the
+     * specified language.
+     */
+    localeFindByProductId(productId: number, languageCode: LanguageCode): Promise<ProductVariantEntity[]> {
+        return this.getProductVariantQueryBuilder(productId, languageCode).getMany();
+    }
+
+    /**
+     * Creates a new Product with one or more ProductTranslations.
+     */
+    async create(
+        product: ProductEntity | Product,
+        productVariantEntity: ProductVariantEntity,
+        translations: ProductVariantTranslationEntity[],
+    ): Promise<ProductVariantEntity> {
+        for (const translation of translations) {
+            await this.manager.save(translation);
+        }
+        productVariantEntity.product = product as any;
+        productVariantEntity.translations = translations;
+        return this.manager.save(productVariantEntity);
+    }
+
+    private getProductVariantQueryBuilder(
+        productId: number,
+        languageCode: LanguageCode,
+    ): SelectQueryBuilder<ProductVariantEntity> {
+        const code = languageCode || LanguageCode.EN;
+
+        return this.manager
+            .createQueryBuilder(ProductVariantEntity, 'variant')
+            .leftJoinAndSelect('product_variant.options', 'option')
+            .leftJoinAndSelect(
+                'variant.translations',
+                'product_variant_translation',
+                'product_variant_translation.languageCode = :code',
+            )
+            .leftJoinAndSelect('variant.options', 'variant_options')
+            .leftJoinAndSelect(
+                'variant_options.translations',
+                'variant_options_translation',
+                'variant_options_translation.languageCode = :code',
+            )
+            .where('variant.productId = :productId')
+            .setParameters({ productId, code });
+    }
+}

+ 96 - 0
modules/core/repository/repository.mock.ts

@@ -0,0 +1,96 @@
+import { AbstractRepository, Repository } from 'typeorm';
+import { MockClass } from '../testing/testing-types';
+import { ProductRepository } from './product-repository';
+import { ProductVariantRepository } from './product-variant-repository';
+
+export interface Type<T> {
+    new (): T;
+}
+
+/**
+ * A mock of the TypeORM Connection class for use in testing.
+ */
+export class MockConnection {
+    private customRepositoryMap = new Map<Type<AbstractRepository<any>>, any>([
+        [ProductRepository, new MockProductRepository()],
+        [ProductVariantRepository, new MockProductVariantRepository()],
+    ]);
+    private repositoryMap = new Map<Type<any>, any>();
+
+    constructor() {}
+
+    registerMockCustomRepository(token: Type<AbstractRepository<any>>, use: AbstractRepository<any>): void {
+        this.customRepositoryMap.set(token, use);
+    }
+
+    registerMockRepository<T extends Type<any>>(entity: T): MockRepository<T> {
+        const repository = new MockRepository();
+        this.repositoryMap.set(entity, repository);
+        return repository;
+    }
+
+    getRepository<T extends Type<any>>(entity: T): MockRepository<T> {
+        const repository = this.repositoryMap.get(entity);
+        if (repository) {
+            return repository;
+        } else {
+            throw new Error(`No mock repository registered for "${entity.name}". Use registerRepository() first.`);
+        }
+    }
+
+    getCustomRepository<T extends AbstractRepository<any>>(customRepository: Type<T>): MockClass<T> {
+        const repository = this.customRepositoryMap.get(customRepository);
+        if (repository) {
+            return repository;
+        } else {
+            throw new Error(
+                `No mock repository registered for "${customRepository.name}". Use registerCustomRepository() first.`,
+            );
+        }
+    }
+}
+
+const stubReturningPromise = () => jest.fn().mockReturnValue(Promise.resolve({}));
+
+export class MockProductRepository implements MockClass<ProductRepository> {
+    find = stubReturningPromise();
+    findOne = stubReturningPromise();
+    create = stubReturningPromise();
+}
+
+export class MockProductVariantRepository implements MockClass<ProductVariantRepository> {
+    localeFindByProductId = stubReturningPromise();
+    create = stubReturningPromise();
+}
+
+export class MockRepository<T> implements MockClass<Repository<T>> {
+    manager: any;
+    metadata: any;
+    queryRunner: any;
+    target: any;
+    createQueryBuilder = jest.fn();
+    hasId = jest.fn();
+    getId = jest.fn();
+    create = jest.fn();
+    merge = jest.fn();
+    preload = jest.fn();
+    save = jest.fn();
+    remove = jest.fn();
+    insert = jest.fn();
+    update = jest.fn();
+    delete = jest.fn();
+    count = jest.fn();
+    find = jest.fn();
+    findAndCount = jest.fn();
+    findByIds = jest.fn();
+    findOne = jest.fn();
+    findOneOrFail = jest.fn();
+    query = jest.fn();
+    clear = jest.fn();
+    increment = jest.fn();
+    decrement = jest.fn();
+}
+
+class MockProductOptionGroupRepository {
+    find = jest.fn();
+}

+ 5 - 5
modules/core/api/customer/customer.service.ts → modules/core/service/customer.service.ts

@@ -1,10 +1,10 @@
 import { Injectable } from '@nestjs/common';
 import { InjectConnection } from '@nestjs/typeorm';
 import { Connection } from 'typeorm';
-import { AddressEntity } from '../../entity/address/address.entity';
-import { Address } from '../../entity/address/address.interface';
-import { CustomerEntity } from '../../entity/customer/customer.entity';
-import { Customer } from '../../entity/customer/customer.interface';
+import { AddressEntity } from '../entity/address/address.entity';
+import { Address } from '../entity/address/address.interface';
+import { CustomerEntity } from '../entity/customer/customer.entity';
+import { Customer } from '../entity/customer/customer.interface';
 
 @Injectable()
 export class CustomerService {
@@ -14,7 +14,7 @@ export class CustomerService {
         return this.connection.manager.find(CustomerEntity);
     }
 
-    findOne(userId: number): Promise<Customer> {
+    findOne(userId: number): Promise<Customer | undefined> {
         return this.connection.manager.findOne(CustomerEntity, userId);
     }
 

+ 73 - 0
modules/core/service/product-variant.service.spec.ts

@@ -0,0 +1,73 @@
+import { Test } from '@nestjs/testing';
+import { Connection } from 'typeorm';
+import { ProductOptionEntity } from '../entity/product-option/product-option.entity';
+import { ProductVariantTranslationEntity } from '../entity/product-variant/product-variant-translation.entity';
+import { ProductVariantEntity } from '../entity/product-variant/product-variant.entity';
+import { ProductEntity } from '../entity/product/product.entity';
+import { LanguageCode } from '../locale/language-code';
+import { ProductVariantRepository } from '../repository/product-variant-repository';
+import { MockConnection } from '../repository/repository.mock';
+import { ProductVariantService } from './product-variant.service';
+
+describe('ProductVariantService', () => {
+    let productVariantService: ProductVariantService;
+    let connection: MockConnection;
+
+    beforeEach(async () => {
+        const module = await Test.createTestingModule({
+            providers: [ProductVariantService, { provide: Connection, useClass: MockConnection }],
+        }).compile();
+
+        productVariantService = module.get(ProductVariantService);
+        connection = module.get(Connection) as any;
+    });
+
+    describe('create()', () => {
+        it('calls ProductVariantRepository.create with product and translation entities', () => {
+            const productEntity = new ProductEntity();
+            productVariantService.create(productEntity, {
+                sku: '123456',
+                price: 123,
+                translations: [
+                    {
+                        languageCode: LanguageCode.EN,
+                        name: 'Test EN',
+                    },
+                    {
+                        languageCode: LanguageCode.DE,
+                        name: 'Test DE',
+                    },
+                ],
+            });
+
+            const [arg1, arg2, arg3] = connection.getCustomRepository(ProductVariantRepository).create.mock.calls[0];
+            expect(arg1).toBe(productEntity);
+            expect(arg2 instanceof ProductVariantEntity).toBe(true);
+            expect(Array.isArray(arg3)).toBe(true);
+            expect(arg3.length).toBe(2);
+            expect(arg3[0] instanceof ProductVariantTranslationEntity).toBe(true);
+        });
+
+        it('adds Options to the productVariant when specified', async () => {
+            const productEntity = new ProductEntity();
+            const productOptionRepository = connection.registerMockRepository(ProductOptionEntity);
+            const mockOptions = [{ code: 'option1' }, { code: 'option2' }, { code: 'option3' }];
+            productOptionRepository.find.mockReturnValue(mockOptions);
+
+            await productVariantService.create(productEntity, {
+                sku: '123456',
+                price: 123,
+                translations: [
+                    {
+                        languageCode: LanguageCode.EN,
+                        name: 'Test EN',
+                    },
+                ],
+                optionCodes: ['option2'],
+            });
+
+            const [arg1, arg2] = connection.getCustomRepository(ProductVariantRepository).create.mock.calls[0];
+            expect(arg2.options).toEqual([mockOptions[1]]);
+        });
+    });
+});

+ 49 - 0
modules/core/service/product-variant.service.ts

@@ -0,0 +1,49 @@
+import { Injectable } from '@nestjs/common';
+import { InjectConnection } from '@nestjs/typeorm';
+import { Connection } from 'typeorm';
+import { ProductOptionEntity } from '../entity/product-option/product-option.entity';
+import { CreateProductVariantDto } from '../entity/product-variant/create-product-variant.dto';
+import { ProductVariantTranslationEntity } from '../entity/product-variant/product-variant-translation.entity';
+import { ProductVariantEntity } from '../entity/product-variant/product-variant.entity';
+import { ProductVariant } from '../entity/product-variant/product-variant.interface';
+import { ProductEntity } from '../entity/product/product.entity';
+import { Product } from '../entity/product/product.interface';
+import { translateDeep } from '../locale/translate-entity';
+import { ProductVariantRepository } from '../repository/product-variant-repository';
+
+@Injectable()
+export class ProductVariantService {
+    constructor(@InjectConnection() private connection: Connection) {}
+
+    async create(
+        product: Product | ProductEntity,
+        createProductVariantDto: CreateProductVariantDto,
+    ): Promise<ProductVariant> {
+        const { sku, price, image, optionCodes, translations } = createProductVariantDto;
+        const productVariant = new ProductVariantEntity();
+        productVariant.sku = sku;
+        productVariant.price = price;
+        productVariant.image = image!;
+
+        if (optionCodes && optionCodes.length) {
+            const options = await this.connection.getRepository(ProductOptionEntity).find();
+            const selectedOptions = options.filter(o => optionCodes.includes(o.code));
+            productVariant.options = selectedOptions;
+        }
+
+        const variantTranslations: ProductVariantTranslationEntity[] = [];
+
+        for (const input of translations) {
+            const { languageCode, name } = input;
+            const translation = new ProductVariantTranslationEntity();
+            translation.languageCode = languageCode;
+            translation.name = name;
+            variantTranslations.push(translation);
+        }
+
+        return this.connection
+            .getCustomRepository(ProductVariantRepository)
+            .create(product, productVariant, variantTranslations)
+            .then(variant => translateDeep(variant));
+    }
+}

+ 72 - 0
modules/core/service/product.service.spec.ts

@@ -0,0 +1,72 @@
+import { Test } from '@nestjs/testing';
+import { Connection } from 'typeorm';
+import { ProductOptionGroupEntity } from '../entity/product-option-group/product-option-group.entity';
+import { ProductTranslationEntity } from '../entity/product/product-translation.entity';
+import { ProductEntity } from '../entity/product/product.entity';
+import { LanguageCode } from '../locale/language-code';
+import { ProductRepository } from '../repository/product-repository';
+import { MockConnection } from '../repository/repository.mock';
+import { ProductService } from './product.service';
+
+describe('ProductService', () => {
+    let productService: ProductService;
+    let connection: MockConnection;
+
+    beforeEach(async () => {
+        const module = await Test.createTestingModule({
+            providers: [ProductService, { provide: Connection, useClass: MockConnection }],
+        }).compile();
+
+        productService = module.get(ProductService);
+        connection = module.get(Connection) as any;
+    });
+
+    describe('create()', () => {
+        it('calls ProductRepository.create with product and translation entities', () => {
+            productService.create({
+                translations: [
+                    {
+                        languageCode: LanguageCode.EN,
+                        name: 'Test EN',
+                        slug: 'test-en',
+                        description: 'Test description EN',
+                    },
+                    {
+                        languageCode: LanguageCode.DE,
+                        name: 'Test DE',
+                        slug: 'test-de',
+                        description: 'Test description DE',
+                    },
+                ],
+            });
+
+            const [arg1, arg2] = connection.getCustomRepository(ProductRepository).create.mock.calls[0];
+            expect(arg1 instanceof ProductEntity).toBe(true);
+            expect(Array.isArray(arg2)).toBe(true);
+            expect(arg2.length).toBe(2);
+            expect(arg2[0] instanceof ProductTranslationEntity).toBe(true);
+        });
+
+        it('adds OptionGroups to the product when specified', async () => {
+            const productOptionGroupRepository = connection.registerMockRepository(ProductOptionGroupEntity);
+            const productRepository = connection.getCustomRepository(ProductRepository);
+            const mockOptionGroups = [{ code: 'optionGroup1' }, { code: 'optionGroup2' }, { code: 'optionGroup3' }];
+            productOptionGroupRepository.find.mockReturnValue(mockOptionGroups);
+
+            await productService.create({
+                translations: [
+                    {
+                        languageCode: LanguageCode.EN,
+                        name: 'Test EN',
+                        slug: 'test-en',
+                        description: 'Test description EN',
+                    },
+                ],
+                optionGroupCodes: ['optionGroup2'],
+            });
+
+            const [arg1] = productRepository.create.mock.calls[0];
+            expect(arg1.optionGroups).toEqual([mockOptionGroups[1]]);
+        });
+    });
+});

+ 61 - 0
modules/core/service/product.service.ts

@@ -0,0 +1,61 @@
+import { Injectable } from '@nestjs/common';
+import { InjectConnection } from '@nestjs/typeorm';
+import { Connection } from 'typeorm';
+import { ProductOptionGroupEntity } from '../entity/product-option-group/product-option-group.entity';
+import { CreateProductDto } from '../entity/product/create-product.dto';
+import { ProductTranslationEntity } from '../entity/product/product-translation.entity';
+import { ProductEntity } from '../entity/product/product.entity';
+import { Product } from '../entity/product/product.interface';
+import { LanguageCode } from '../locale/language-code';
+import { translateDeep } from '../locale/translate-entity';
+import { ProductRepository } from '../repository/product-repository';
+
+@Injectable()
+export class ProductService {
+    constructor(@InjectConnection() private connection: Connection) {}
+
+    findAll(lang: LanguageCode): Promise<Product[]> {
+        return this.connection
+            .getCustomRepository(ProductRepository)
+            .find(lang)
+            .then(products => products.map(product => this.translateProductEntity(product)));
+    }
+
+    findOne(productId: number, lang: LanguageCode): Promise<Product | undefined> {
+        return this.connection
+            .getCustomRepository(ProductRepository)
+            .findOne(productId, lang)
+            .then(product => product && this.translateProductEntity(product));
+    }
+
+    async create(createProductDto: CreateProductDto): Promise<Product> {
+        const { variants, optionGroupCodes, image, translations } = createProductDto;
+        const productEntity = new ProductEntity();
+        const productTranslations: ProductTranslationEntity[] = [];
+
+        if (optionGroupCodes && optionGroupCodes.length) {
+            const optionGroups = await this.connection.getRepository(ProductOptionGroupEntity).find();
+            const selectedOptionGroups = optionGroups.filter(og => optionGroupCodes.includes(og.code));
+            productEntity.optionGroups = selectedOptionGroups;
+        }
+
+        for (const input of createProductDto.translations) {
+            const { languageCode, name, description, slug } = input;
+            const translation = new ProductTranslationEntity();
+            translation.languageCode = languageCode;
+            translation.name = name;
+            translation.slug = slug;
+            translation.description = description;
+            productTranslations.push(translation);
+        }
+
+        return this.connection
+            .getCustomRepository(ProductRepository)
+            .create(productEntity, productTranslations)
+            .then(product => translateDeep(product));
+    }
+
+    private translateProductEntity(product: ProductEntity): Product {
+        return translateDeep(product, ['optionGroups', 'variants', ['variants', 'options']]);
+    }
+}

+ 1 - 0
modules/core/testing/testing-types.ts

@@ -0,0 +1 @@
+export type MockClass<T> = { [K in keyof T]: jest.Mock<any> };