Skip to content

Commit

Permalink
Create product (#37)
Browse files Browse the repository at this point in the history
  • Loading branch information
mmarchois authored Jan 4, 2021
1 parent ac6f2ca commit 57cf34b
Show file tree
Hide file tree
Showing 13 changed files with 280 additions and 3 deletions.
9 changes: 9 additions & 0 deletions api/src/Application/Product/Command/CreateProductCommand.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
import { ICommand } from 'src/Application/ICommand';

export class CreateProductCommand implements ICommand {
constructor(
public readonly title: string,
public readonly description: string,
public readonly unitPrice: number
) {}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
import { mock, instance, when, verify, deepEqual, anything } from 'ts-mockito';
import { ProductRepository } from 'src/Infrastructure/Product/Repository/ProductRepository';
import { IsProductAlreadyExist } from 'src/Domain/Product/Specification/IsProductAlreadyExist';
import { Product } from 'src/Domain/Product/Product.entity';
import { CreateProductCommandHandler } from 'src/Application/Product/Command/CreateProductCommandHandler';
import { CreateProductCommand } from 'src/Application/Product/Command/CreateProductCommand';
import { ProductAlreadyExistException } from 'src/Domain/Product/Exception/ProductAlreadyExistException';
import { Photographer } from 'src/Domain/User/Photographer.entity';

describe('CreateProductCommandHandler', () => {
let productRepository: ProductRepository;
let isProductAlreadyExist: IsProductAlreadyExist;
let createdProduct: Product;
let handler: CreateProductCommandHandler;

const photographer = mock(Photographer);
const command = new CreateProductCommand(
'Mug',
'Mug portrait enfant',
9.99,
);

beforeEach(() => {
productRepository = mock(ProductRepository);
isProductAlreadyExist = mock(IsProductAlreadyExist);
createdProduct = mock(Product);

handler = new CreateProductCommandHandler(
instance(productRepository),
instance(isProductAlreadyExist)
);
});

it('testProductCreatedSuccessfully', async () => {
when(isProductAlreadyExist.isSatisfiedBy('Mug')).thenResolve(false);
when(createdProduct.getId()).thenReturn(
'2d5fb4da-12c2-11ea-8d71-362b9e155667'
);
when(
productRepository.save(
deepEqual(
new Product(
'Mug',
'Mug portrait enfant',
999,
)
)
)
).thenResolve(instance(createdProduct));

expect(await handler.execute(command)).toBe(
'2d5fb4da-12c2-11ea-8d71-362b9e155667'
);

verify(isProductAlreadyExist.isSatisfiedBy('Mug')).once();
verify(
productRepository.save(
deepEqual(
new Product(
'Mug',
'Mug portrait enfant',
999,
)
)
)
).once();
verify(createdProduct.getId()).once();
});

it('testProductAlreadyExist', async () => {
when(isProductAlreadyExist.isSatisfiedBy('Mug')).thenResolve(true);

try {
await handler.execute(command);
} catch (e) {
expect(e).toBeInstanceOf(ProductAlreadyExistException);
expect(e.message).toBe('products.errors.already_exist');
verify(isProductAlreadyExist.isSatisfiedBy('Mug')).once();
verify(productRepository.save(anything())).never();
verify(createdProduct.getId()).never();
}
});
});
30 changes: 30 additions & 0 deletions api/src/Application/Product/Command/CreateProductCommandHandler.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
import { Inject } from '@nestjs/common';
import { CommandHandler } from '@nestjs/cqrs';
import { ProductAlreadyExistException } from 'src/Domain/Product/Exception/ProductAlreadyExistException';
import { IProductRepository } from 'src/Domain/Product/Repository/IProductRepository';
import { Product } from 'src/Domain/Product/Product.entity';
import { IsProductAlreadyExist } from 'src/Domain/Product/Specification/IsProductAlreadyExist';
import { CreateProductCommand } from './CreateProductCommand';

@CommandHandler(CreateProductCommand)
export class CreateProductCommandHandler {
constructor(
@Inject('IProductRepository')
private readonly productRepository: IProductRepository,
private readonly isProductAlreadyExist: IsProductAlreadyExist
) {}

public async execute(command: CreateProductCommand): Promise<string> {
const { title, description, unitPrice } = command;

if (true === (await this.isProductAlreadyExist.isSatisfiedBy(title))) {
throw new ProductAlreadyExistException();
}

const product = await this.productRepository.save(
new Product(title, description, Math.round(unitPrice * 100))
);

return product.getId();
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
export class ProductAlreadyExistException extends Error {
constructor() {
super('products.errors.already_exist');
}
}
1 change: 1 addition & 0 deletions api/src/Domain/Product/Repository/IProductRepository.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,4 +2,5 @@ import { Product } from '../Product.entity';

export interface IProductRepository {
save(task: Product): Promise<Product>;
findOneByTitle(title: string): Promise<Product | undefined>;
}
34 changes: 34 additions & 0 deletions api/src/Domain/Product/Specification/IsProductAlreadyExist.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
import { mock, instance, when, verify, anything } from 'ts-mockito';
import { ProductRepository } from 'src/Infrastructure/Product/Repository/ProductRepository';
import { IsProductAlreadyExist } from 'src/Domain/Product/Specification/IsProductAlreadyExist';
import { Product } from 'src/Domain/Product/Product.entity';

describe('IsProductAlreadyExist', () => {
let productRepository: ProductRepository;
let isProductAlreadyExist: IsProductAlreadyExist;

beforeEach(() => {
productRepository = mock(ProductRepository);
isProductAlreadyExist = new IsProductAlreadyExist(
instance(productRepository)
);
});

it('testProductAlreadyExist', async () => {
when(productRepository.findOneByTitle('Mug')).thenResolve(
new Product('Mug', anything(), anything())
);
expect(await isProductAlreadyExist.isSatisfiedBy('Mug')).toBe(
true
);
verify(productRepository.findOneByTitle('Mug')).once();
});

it('testProductDontExist', async () => {
when(productRepository.findOneByTitle('Mug')).thenResolve(null);
expect(await isProductAlreadyExist.isSatisfiedBy('Mug')).toBe(
false
);
verify(productRepository.findOneByTitle('Mug')).once();
});
});
16 changes: 16 additions & 0 deletions api/src/Domain/Product/Specification/IsProductAlreadyExist.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
import { Inject } from '@nestjs/common';
import { IProductRepository } from '../Repository/IProductRepository';
import { Product } from '../Product.entity';

export class IsProductAlreadyExist {
constructor(
@Inject('IProductRepository')
private readonly productRepository: IProductRepository
) {}

public async isSatisfiedBy(title: string): Promise<boolean> {
return (
(await this.productRepository.findOneByTitle(title)) instanceof Product
);
}
}
40 changes: 40 additions & 0 deletions api/src/Infrastructure/Product/Action/CreateProductAction.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
import {
Controller,
Inject,
Post,
Body,
BadRequestException,
UseGuards
} from '@nestjs/common';
import { AuthGuard } from '@nestjs/passport';
import { ApiTags, ApiOperation, ApiBearerAuth } from '@nestjs/swagger';
import { ICommandBus } from 'src/Application/ICommandBus';
import { CreateProductCommand } from 'src/Application/Product/Command/CreateProductCommand';
import { ProductDTO } from '../DTO/ProductDTO';

@Controller('products')
@ApiTags('Product')
@ApiBearerAuth()
@UseGuards(AuthGuard('bearer'))
export class CreateProductAction {
constructor(
@Inject('ICommandBus')
private readonly commandBus: ICommandBus
) {}

@Post()
@ApiOperation({ summary: 'Create new product' })
public async index(@Body() dto: ProductDTO) {
const { title, description, unitPrice } = dto;

try {
const id = await this.commandBus.execute(
new CreateProductCommand(title, description, unitPrice)
);

return { id };
} catch (e) {
throw new BadRequestException(e.message);
}
}
}
27 changes: 27 additions & 0 deletions api/src/Infrastructure/Product/DTO/ProductDTO.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
import { ProductDTO } from './ProductDTO';
import { validate } from 'class-validator';

describe('ProductDTO', () => {
it('testValidDTO', async () => {
const dto = new ProductDTO();
dto.title = 'Mug';
dto.description = 'Mug portrait enfant';
dto.unitPrice = 999;

const validation = await validate(dto);
expect(validation).toHaveLength(0);
});

it('testInvalidDTO', async () => {
const dto = new ProductDTO();

const validation = await validate(dto);
expect(validation).toHaveLength(2);
expect(validation[0].constraints).toMatchObject({
isNotEmpty: "title should not be empty"
});
expect(validation[1].constraints).toMatchObject({
isPositive: 'unitPrice must be a positive number'
});
});
});
16 changes: 16 additions & 0 deletions api/src/Infrastructure/Product/DTO/ProductDTO.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
import { ApiProperty } from '@nestjs/swagger';
import { IsNotEmpty, IsPositive } from 'class-validator';

export class ProductDTO {
@IsNotEmpty()
@ApiProperty()
public title: string;

@ApiProperty()
public description: string;

@ApiProperty()
@IsPositive()
@IsNotEmpty()
public unitPrice: number;
}
10 changes: 10 additions & 0 deletions api/src/Infrastructure/Product/Repository/ProductRepository.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,4 +14,14 @@ export class ProductRepository implements IProductRepository {
public save(product: Product): Promise<Product> {
return this.repository.save(product);
}

public findOneByTitle(title: string): Promise<Product | undefined> {
return this.repository
.createQueryBuilder('product')
.select([
'product.id'
])
.where('lower(product.title) = :title', { title: title.toLowerCase() })
.getOne();
}
}
9 changes: 7 additions & 2 deletions api/src/Infrastructure/Product/product.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,12 +3,17 @@ import { TypeOrmModule } from '@nestjs/typeorm';
import { BusModule } from '../bus.module';
import { Product } from 'src/Domain/Product/Product.entity';
import { ProductRepository } from './Repository/ProductRepository';
import { CreateProductCommandHandler } from 'src/Application/Product/Command/CreateProductCommandHandler';
import { IsProductAlreadyExist } from 'src/Domain/Product/Specification/IsProductAlreadyExist';
import { CreateProductAction } from './Action/CreateProductAction';

@Module({
imports: [BusModule, TypeOrmModule.forFeature([Product])],
controllers: [],
controllers: [CreateProductAction],
providers: [
{ provide: 'IProductRepository', useClass: ProductRepository }
{ provide: 'IProductRepository', useClass: ProductRepository },
CreateProductCommandHandler,
IsProductAlreadyExist,
]
})
export class ProductModule {}
3 changes: 2 additions & 1 deletion api/src/app.module.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,11 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { ProductModule } from './Infrastructure/Product/product.module';
import { SchoolModule } from './Infrastructure/School/school.module';
import { UserModule } from './Infrastructure/User/user.module';

@Module({
imports: [TypeOrmModule.forRoot(), UserModule, SchoolModule],
imports: [TypeOrmModule.forRoot(), UserModule, SchoolModule, ProductModule],
controllers: [],
providers: []
})
Expand Down

0 comments on commit 57cf34b

Please sign in to comment.