Skip to content

Commit

Permalink
feat: Create shipping cost (#94)
Browse files Browse the repository at this point in the history
  • Loading branch information
mmarchois authored May 30, 2021
1 parent 8299532 commit 28db6bb
Show file tree
Hide file tree
Showing 19 changed files with 379 additions and 3 deletions.
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
import { ICommand } from 'src/Application/ICommand';

export class CreateShippingCostCommand implements ICommand {
constructor(
public readonly grams: number,
public readonly price: number
) {}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
import { mock, instance, when, verify, deepEqual, anything } from 'ts-mockito';
import { ShippingCostRepository } from 'src/Infrastructure/Order/Repository/ShippingCostRepository';
import { IsShippingCostAlreadyExist } from 'src/Domain/Order/Specification/IsShippingCostAlreadyExist';
import { ShippingCost } from 'src/Domain/Order/ShippingCost.entity';
import { ShippingCostAlreadyExistException } from 'src/Domain/Order/Exception/ShippingCostAlreadyExistException';
import { CreateShippingCostCommandHandler } from './CreateShippingCostCommandHandler';
import { CreateShippingCostCommand } from './CreateShippingCostCommand';

describe('CreateShippingCostCommandHandler', () => {
let shippingcostRepository: ShippingCostRepository;
let isShippingCostAlreadyExist: IsShippingCostAlreadyExist;
let createdShippingCost: ShippingCost;
let handler: CreateShippingCostCommandHandler;

const command = new CreateShippingCostCommand(
1000,
9.99,
);

beforeEach(() => {
shippingcostRepository = mock(ShippingCostRepository);
isShippingCostAlreadyExist = mock(IsShippingCostAlreadyExist);
createdShippingCost = mock(ShippingCost);

handler = new CreateShippingCostCommandHandler(
instance(shippingcostRepository),
instance(isShippingCostAlreadyExist)
);
});

it('testShippingCostCreatedSuccessfully', async () => {
when(isShippingCostAlreadyExist.isSatisfiedBy(1000)).thenResolve(false);
when(createdShippingCost.getId()).thenReturn(
'2d5fb4da-12c2-11ea-8d71-362b9e155667'
);
when(
shippingcostRepository.save(deepEqual(new ShippingCost(1000,999)))
).thenResolve(instance(createdShippingCost));

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

verify(isShippingCostAlreadyExist.isSatisfiedBy(1000)).once();
verify(
shippingcostRepository.save(deepEqual(new ShippingCost(1000, 999)))
).once();
verify(createdShippingCost.getId()).once();
});

it('testShippingCostAlreadyExist', async () => {
when(isShippingCostAlreadyExist.isSatisfiedBy(1000)).thenResolve(true);

try {
expect(await handler.execute(command)).toBeUndefined();
} catch (e) {
expect(e).toBeInstanceOf(ShippingCostAlreadyExistException);
expect(e.message).toBe('shipping_costs.errors.already_exist');
verify(isShippingCostAlreadyExist.isSatisfiedBy(1000)).once();
verify(shippingcostRepository.save(anything())).never();
verify(createdShippingCost.getId()).never();
}
});
});
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 { ShippingCostAlreadyExistException } from 'src/Domain/Order/Exception/ShippingCostAlreadyExistException';
import { IShippingCostRepository } from 'src/Domain/Order/Repository/IShippingCostRepository';
import { ShippingCost } from 'src/Domain/Order/ShippingCost.entity';
import { IsShippingCostAlreadyExist } from 'src/Domain/Order/Specification/IsShippingCostAlreadyExist';
import { CreateShippingCostCommand } from './CreateShippingCostCommand';

@CommandHandler(CreateShippingCostCommand)
export class CreateShippingCostCommandHandler {
constructor(
@Inject('IShippingCostRepository')
private readonly shippingcostRepository: IShippingCostRepository,
private readonly isShippingCostAlreadyExist: IsShippingCostAlreadyExist
) {}

public async execute(command: CreateShippingCostCommand): Promise<string> {
const { grams, price } = command;

if (true === (await this.isShippingCostAlreadyExist.isSatisfiedBy(grams))) {
throw new ShippingCostAlreadyExistException();
}

const shippingCost = await this.shippingcostRepository.save(
new ShippingCost(grams, Math.round(price * 100))
);

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

export interface IShippingCostRepository {
save(shippingcost: ShippingCost): Promise<ShippingCost>;
findOneByGrams(grams: number): Promise<ShippingCost | undefined>;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
import { mock, instance, when, verify } from 'ts-mockito';
import { IsShippingCostAlreadyExist } from 'src/Domain/Order/Specification/IsShippingCostAlreadyExist';
import { ShippingCost } from 'src/Domain/Order/ShippingCost.entity';
import { ShippingCostRepository } from 'src/Infrastructure/Order/Repository/ShippingCostRepository';

describe('IsShippingCostAlreadyExist', () => {
let shippingCostRepository: ShippingCostRepository;
let isShippingCostAlreadyExist: IsShippingCostAlreadyExist;

beforeEach(() => {
shippingCostRepository = mock(ShippingCostRepository);
isShippingCostAlreadyExist = new IsShippingCostAlreadyExist(
instance(shippingCostRepository)
);
});

it('testShippingCostAlreadyExist', async () => {
when(shippingCostRepository.findOneByGrams(100)).thenResolve(
new ShippingCost(100, 99)
);
expect(await isShippingCostAlreadyExist.isSatisfiedBy(100)).toBe(
true
);
verify(shippingCostRepository.findOneByGrams(100)).once();
});

it('testShippingCostDontExist', async () => {
when(shippingCostRepository.findOneByGrams(100)).thenResolve(null);
expect(await isShippingCostAlreadyExist.isSatisfiedBy(100)).toBe(
false
);
verify(shippingCostRepository.findOneByGrams(100)).once();
});
});
16 changes: 16 additions & 0 deletions api/src/Domain/Order/Specification/IsShippingCostAlreadyExist.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
import { Inject } from '@nestjs/common';
import { IShippingCostRepository } from '../Repository/IShippingCostRepository';
import { ShippingCost } from '../ShippingCost.entity';

export class IsShippingCostAlreadyExist {
constructor(
@Inject('IShippingCostRepository')
private readonly shippingcostRepository: IShippingCostRepository
) {}

public async isSatisfiedBy(grams: number): Promise<boolean> {
return (
(await this.shippingcostRepository.findOneByGrams(grams)) instanceof ShippingCost
);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
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 { CreateShippingCostCommand } from 'src/Application/Order/Command/ShippingCost/CreateShippingCostCommand';
import { UserRole } from 'src/Domain/User/User.entity';
import { Roles } from 'src/Infrastructure/User/Decorator/Roles';
import { RolesGuard } from 'src/Infrastructure/User/Security/RolesGuard';
import { ShippingCostDTO } from '../../DTO/ShippingCostDTO';

@Controller('shipping-costs')
@ApiTags('Order')
@ApiBearerAuth()
@UseGuards(AuthGuard('bearer'), RolesGuard)
export class CreateShippingCostAction {
constructor(
@Inject('ICommandBus')
private readonly commandBus: ICommandBus
) {}

@Post()
@Roles(UserRole.PHOTOGRAPHER)
@ApiOperation({ summary: 'Create new shipping cost' })
public async index(@Body() dto: ShippingCostDTO) {
const { grams, price } = dto;

try {
const id = await this.commandBus.execute(
new CreateShippingCostCommand(grams, price)
);

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

describe('ShippingCostDTO', () => {
it('testValidDTO', async () => {
const dto = new ShippingCostDTO();
dto.grams = 1000;
dto.price = 9.99;

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

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

const validation = await validate(dto);
expect(validation).toHaveLength(2);
expect(validation[0].constraints).toMatchObject({
isPositive: 'grams must be a positive number'
});
expect(validation[1].constraints).toMatchObject({
isPositive: 'price must be a positive number'
});
});
});
14 changes: 14 additions & 0 deletions api/src/Infrastructure/Order/DTO/ShippingCostDTO.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
import { ApiProperty } from '@nestjs/swagger';
import { IsNotEmpty, IsPositive } from 'class-validator';

export class ShippingCostDTO {
@ApiProperty()
@IsPositive()
@IsNotEmpty()
public grams: number;

@ApiProperty()
@IsPositive()
@IsNotEmpty()
public price: number;
}
Original file line number Diff line number Diff line change
Expand Up @@ -14,4 +14,12 @@ export class ShippingCostRepository implements IShippingCostRepository {
public save(shippingCost: ShippingCost): Promise<ShippingCost> {
return this.repository.save(shippingCost);
}

public findOneByGrams(grams: number): Promise<ShippingCost | undefined> {
return this.repository
.createQueryBuilder('shippingCost')
.select([ 'shippingCost.id' ])
.where('shippingCost.grams = :grams', { grams })
.getOne();
}
}
9 changes: 7 additions & 2 deletions api/src/Infrastructure/Order/order.module.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,11 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { CreateShippingCostCommandHandler } from 'src/Application/Order/Command/ShippingCost/CreateShippingCostCommandHandler';
import { ShippingCost } from 'src/Domain/Order/ShippingCost.entity';
import { IsShippingCostAlreadyExist } from 'src/Domain/Order/Specification/IsShippingCostAlreadyExist';
import { BusModule } from '../bus.module';
import { ShippingCostRepository } from './Repository/Repository/ShippingCostRepository';
import { CreateShippingCostAction } from './Action/ShippingCost/CreateShippingCostAction';
import { ShippingCostRepository } from './Repository/ShippingCostRepository';

@Module({
imports: [
Expand All @@ -11,9 +14,11 @@ import { ShippingCostRepository } from './Repository/Repository/ShippingCostRepo
ShippingCost
])
],
controllers: [],
controllers: [CreateShippingCostAction],
providers: [
{ provide: 'IShippingCostRepository', useClass: ShippingCostRepository },
IsShippingCostAlreadyExist,
CreateShippingCostCommandHandler
]
})
export class OrderModule {}
13 changes: 13 additions & 0 deletions client/i18n/fr.json
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,19 @@
"event_not_found": "Le rendez-vous que vous cherchez n'existe pas."
}
},
"shipping_costs": {
"breadcrumb": "Frais de port",
"add": {
"title": "Ajouter un frais de port"
},
"form": {
"price": "Prix",
"grams": "Poids en grammes"
},
"errors": {
"already_exist": "Un frais de port a déjà été configuré pour ce poids."
}
},
"leads": {
"breadcrumb": "Prospects",
"statutes": {
Expand Down
8 changes: 8 additions & 0 deletions client/src/components/icons/ShippingCostIcon.svelte
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
<script>
export let className;
</script>

<svg class={className} fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path d="M9 17a2 2 0 11-4 0 2 2 0 014 0zM19 17a2 2 0 11-4 0 2 2 0 014 0z" />
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 16V6a1 1 0 00-1-1H4a1 1 0 00-1 1v10a1 1 0 001 1h1m8-1a1 1 0 01-1 1H9m4-1V8a1 1 0 011-1h2.586a1 1 0 01.707.293l3.414 3.414a1 1 0 01.293.707V16a1 1 0 01-1 1h-1m-6-1a1 1 0 001 1h1M5 17a2 2 0 104 0m-4 0a2 2 0 114 0m6 0a2 2 0 104 0m-4 0a2 2 0 114 0" />
</svg>
11 changes: 11 additions & 0 deletions client/src/components/nav/Admin.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
import UsersIcon from 'components/icons/UsersIcon.svelte';
import CalendarIcon from 'components/icons/CalendarIcon.svelte';
import OrderIcon from 'components/icons/OrderIcon.svelte';
import ShippingCostIcon from 'components/icons/ShippingCostIcon.svelte';
import { settings, currentPath } from 'store';
const schoolsPath = '/admin/schools';
Expand All @@ -15,6 +16,7 @@
const usersPath = '/admin/users';
const leadsPath = '/admin/leads';
const ordersPath = '/admin/orders';
const shippingCostsPath = '/admin/shipping-costs';
const activeClass =
'absolute inset-y-0 left-0 w-1 bg-purple-600 rounded-tr-lg rounded-br-lg';
const linkClass =
Expand Down Expand Up @@ -70,6 +72,15 @@
<span class="ml-4">{$_('orders.breadcrumb')}</span>
</a>
</li>
<li class="relative px-6 py-3">
{#if $currentPath.includes(shippingCostsPath)}
<span class={activeClass} aria-hidden="true"></span>
{/if}
<a class={$currentPath.includes(shippingCostsPath) ? activeLinkClass : linkClass} href={shippingCostsPath}>
<ShippingCostIcon className={'w-5 h-5'} />
<span class="ml-4">{$_('shipping_costs.breadcrumb')}</span>
</a>
</li>
<li class="relative px-6 py-3">
{#if $currentPath.includes(productsPath)}
<span class={activeClass} aria-hidden="true"></span>
Expand Down
1 change: 0 additions & 1 deletion client/src/routes/admin/orders/index.svelte
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
<script>
import { onMount } from 'svelte';
import { _ } from 'svelte-i18n';
import Breadcrumb from 'components/Breadcrumb.svelte';
import H4Title from 'components/H4Title.svelte';
Expand Down
33 changes: 33 additions & 0 deletions client/src/routes/admin/shipping-costs/_Form.svelte
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
<script>
import { _ } from 'svelte-i18n';
import { createEventDispatcher } from 'svelte';
import Input from 'components/inputs/Input.svelte';
import Button from 'components/inputs/Button.svelte';
export let price = '';
export let grams = 0;
export let loading;
const dispatch = createEventDispatcher();
const submit = () => {
dispatch('save', { price, grams });
};
</script>

<form
on:submit|preventDefault={submit}
class="px-4 py-3 mb-8 bg-white rounded-lg shadow-md dark:bg-gray-800">
<Input
type={"number"}
label={$_('shipping_costs.form.grams')}
bind:value={grams} />
<Input
type={'money'}
label={$_('shipping_costs.form.price')}
bind:value={price} />
<Button
value={$_('common.form.save')}
loading={loading}
disabled={!grams || !price || loading} />
</form>
Loading

0 comments on commit 28db6bb

Please sign in to comment.