improve tests, handle coordinates precision

This commit is contained in:
sbriat 2023-06-23 11:37:26 +02:00
parent 4ad00b96c0
commit 211bee2c70
33 changed files with 354 additions and 168 deletions

View File

@ -38,8 +38,8 @@ CREATE TABLE "waypoint" (
"uuid" UUID NOT NULL, "uuid" UUID NOT NULL,
"adUuid" UUID NOT NULL, "adUuid" UUID NOT NULL,
"position" SMALLINT NOT NULL, "position" SMALLINT NOT NULL,
"lon" DOUBLE PRECISION NOT NULL, "lon" DECIMAL(9,6) NOT NULL,
"lat" DOUBLE PRECISION NOT NULL, "lat" DECIMAL(8,6) NOT NULL,
"name" TEXT, "name" TEXT,
"houseNumber" TEXT, "houseNumber" TEXT,
"street" TEXT, "street" TEXT,

View File

@ -47,8 +47,8 @@ model Waypoint {
uuid String @id @default(uuid()) @db.Uuid uuid String @id @default(uuid()) @db.Uuid
adUuid String @db.Uuid adUuid String @db.Uuid
position Int @db.SmallInt position Int @db.SmallInt
lon Float lon Decimal @db.Decimal(9, 6)
lat Float lat Decimal @db.Decimal(8, 6)
name String? name String?
houseNumber String? houseNumber String?
street String? street String?

View File

@ -1,8 +1,8 @@
import { Paginated } from '../ddd'; import { Paginated } from '../ddd';
export abstract class PaginatedResponseDto<T> extends Paginated<T> { export abstract class PaginatedResponseDto<T> extends Paginated<T> {
readonly count: number; readonly total: number;
readonly limit: number; readonly perPage: number;
readonly page: number; readonly page: number;
abstract readonly data: readonly T[]; abstract readonly data: readonly T[];
} }

View File

@ -2,13 +2,16 @@ import { EventEmitter2 } from '@nestjs/event-emitter';
import { AggregateRoot, Mapper, RepositoryPort } from '../ddd'; import { AggregateRoot, Mapper, RepositoryPort } from '../ddd';
import { ObjectLiteral } from '../types'; import { ObjectLiteral } from '../types';
import { LoggerPort } from '../ports/logger.port'; import { LoggerPort } from '../ports/logger.port';
import { None, Option, Some } from 'oxide.ts';
import { import {
PrismaRawRepositoryPort, PrismaRawRepositoryPort,
PrismaRepositoryPort, PrismaRepositoryPort,
} from '../ports/prisma-repository.port'; } from '../ports/prisma-repository.port';
import { Prisma } from '@prisma/client'; import { Prisma } from '@prisma/client';
import { ConflictException, DatabaseErrorException } from '@libs/exceptions'; import {
ConflictException,
DatabaseErrorException,
NotFoundException,
} from '@libs/exceptions';
export abstract class PrismaRepositoryBase< export abstract class PrismaRepositoryBase<
Aggregate extends AggregateRoot<any>, Aggregate extends AggregateRoot<any>,
@ -24,12 +27,13 @@ export abstract class PrismaRepositoryBase<
protected readonly logger: LoggerPort, protected readonly logger: LoggerPort,
) {} ) {}
async findOneById(id: string, include?: any): Promise<Option<Aggregate>> { async findOneById(id: string, include?: any): Promise<Aggregate> {
const entity = await this.prisma.findUnique({ const entity = await this.prisma.findUnique({
where: { uuid: id }, where: { uuid: id },
include, include,
}); });
return entity ? Some(this.mapper.toDomain(entity)) : None; if (entity) return this.mapper.toDomain(entity);
throw new NotFoundException('Record not found');
} }
async insert(entity: Aggregate): Promise<void> { async insert(entity: Aggregate): Promise<void> {
@ -52,7 +56,6 @@ export abstract class PrismaRepositoryBase<
await this.prisma.$queryRaw`SELECT 1`; await this.prisma.$queryRaw`SELECT 1`;
return true; return true;
} catch (e) { } catch (e) {
console.log(e);
if (e instanceof Prisma.PrismaClientKnownRequestError) { if (e instanceof Prisma.PrismaClientKnownRequestError) {
throw new DatabaseErrorException(e.message); throw new DatabaseErrorException(e.message);
} }

View File

@ -0,0 +1,31 @@
import { OrderBy, PaginatedQueryParams } from './repository.port';
/**
* Base class for regular queries
*/
export abstract class QueryBase {}
/**
* Base class for paginated queries
*/
export abstract class PaginatedQueryBase extends QueryBase {
perPage: number;
offset: number;
orderBy: OrderBy;
page: number;
constructor(props: PaginatedParams<PaginatedQueryBase>) {
super();
this.perPage = props.perPage || 10;
this.offset = props.page ? props.page * this.perPage : 0;
this.page = props.page || 0;
this.orderBy = props.orderBy || { field: true, param: 'desc' };
}
}
// Paginated query parameters
export type PaginatedParams<T> = Omit<
T,
'perPage' | 'offset' | 'orderBy' | 'page'
> &
Partial<Omit<PaginatedQueryParams, 'offset'>>;

View File

@ -1,5 +1,3 @@
import { Option } from 'oxide.ts';
/* Most of repositories will probably need generic /* Most of repositories will probably need generic
save/find/delete operations, so it's easier save/find/delete operations, so it's easier
to have some shared interfaces. to have some shared interfaces.
@ -8,14 +6,14 @@ import { Option } from 'oxide.ts';
*/ */
export class Paginated<T> { export class Paginated<T> {
readonly count: number; readonly total: number;
readonly limit: number; readonly perPage: number;
readonly page: number; readonly page: number;
readonly data: readonly T[]; readonly data: readonly T[];
constructor(props: Paginated<T>) { constructor(props: Paginated<T>) {
this.count = props.count; this.total = props.total;
this.limit = props.limit; this.perPage = props.perPage;
this.page = props.page; this.page = props.page;
this.data = props.data; this.data = props.data;
} }
@ -24,7 +22,7 @@ export class Paginated<T> {
export type OrderBy = { field: string | true; param: 'asc' | 'desc' }; export type OrderBy = { field: string | true; param: 'asc' | 'desc' };
export type PaginatedQueryParams = { export type PaginatedQueryParams = {
limit: number; perPage: number;
page: number; page: number;
offset: number; offset: number;
orderBy: OrderBy; orderBy: OrderBy;
@ -32,7 +30,7 @@ export type PaginatedQueryParams = {
export interface RepositoryPort<Entity> { export interface RepositoryPort<Entity> {
insert(entity: Entity | Entity[]): Promise<void>; insert(entity: Entity | Entity[]): Promise<void>;
findOneById(id: string): Promise<Option<Entity>>; findOneById(id: string, include?: any): Promise<Entity>;
healthCheck(): Promise<boolean>; healthCheck(): Promise<boolean>;
// findAll(): Promise<Entity[]>; // findAll(): Promise<Entity[]>;
// findAllPaginated(params: PaginatedQueryParams): Promise<Paginated<Entity>>; // findAllPaginated(params: PaginatedQueryParams): Promise<Paginated<Entity>>;

View File

@ -62,49 +62,49 @@ export class AdMapper
fromDate: new Date(copy.fromDate), fromDate: new Date(copy.fromDate),
toDate: new Date(copy.toDate), toDate: new Date(copy.toDate),
monTime: copy.schedule.mon monTime: copy.schedule.mon
? this.timeConverter.dateTimeToUtc( ? this.timeConverter.localDateTimeToUtc(
copy.fromDate, copy.fromDate,
copy.schedule.mon, copy.schedule.mon,
timezone, timezone,
) )
: undefined, : undefined,
tueTime: copy.schedule.tue tueTime: copy.schedule.tue
? this.timeConverter.dateTimeToUtc( ? this.timeConverter.localDateTimeToUtc(
copy.fromDate, copy.fromDate,
copy.schedule.tue, copy.schedule.tue,
timezone, timezone,
) )
: undefined, : undefined,
wedTime: copy.schedule.wed wedTime: copy.schedule.wed
? this.timeConverter.dateTimeToUtc( ? this.timeConverter.localDateTimeToUtc(
copy.fromDate, copy.fromDate,
copy.schedule.wed, copy.schedule.wed,
timezone, timezone,
) )
: undefined, : undefined,
thuTime: copy.schedule.thu thuTime: copy.schedule.thu
? this.timeConverter.dateTimeToUtc( ? this.timeConverter.localDateTimeToUtc(
copy.fromDate, copy.fromDate,
copy.schedule.thu, copy.schedule.thu,
timezone, timezone,
) )
: undefined, : undefined,
friTime: copy.schedule.fri friTime: copy.schedule.fri
? this.timeConverter.dateTimeToUtc( ? this.timeConverter.localDateTimeToUtc(
copy.fromDate, copy.fromDate,
copy.schedule.fri, copy.schedule.fri,
timezone, timezone,
) )
: undefined, : undefined,
satTime: copy.schedule.sat satTime: copy.schedule.sat
? this.timeConverter.dateTimeToUtc( ? this.timeConverter.localDateTimeToUtc(
copy.fromDate, copy.fromDate,
copy.schedule.sat, copy.schedule.sat,
timezone, timezone,
) )
: undefined, : undefined,
sunTime: copy.schedule.sun sunTime: copy.schedule.sun
? this.timeConverter.dateTimeToUtc( ? this.timeConverter.localDateTimeToUtc(
copy.fromDate, copy.fromDate,
copy.schedule.sun, copy.schedule.sun,
timezone, timezone,
@ -143,6 +143,11 @@ export class AdMapper
}; };
toDomain = (record: AdReadModel): AdEntity => { toDomain = (record: AdReadModel): AdEntity => {
const timezone = this.timezoneFinder.timezones(
record.waypoints[0].lon,
record.waypoints[0].lat,
this.defaultParams.DEFAULT_TIMEZONE,
)[0];
const entity = new AdEntity({ const entity = new AdEntity({
id: record.uuid, id: record.uuid,
createdAt: new Date(record.createdAt), createdAt: new Date(record.createdAt),
@ -152,13 +157,23 @@ export class AdMapper
driver: record.driver, driver: record.driver,
passenger: record.passenger, passenger: record.passenger,
frequency: Frequency[record.frequency], frequency: Frequency[record.frequency],
fromDate: record.fromDate.toISOString(), fromDate: record.fromDate.toISOString().split('T')[0],
toDate: record.toDate.toISOString(), toDate: record.toDate.toISOString().split('T')[0],
schedule: { schedule: {
mon: record.monTime?.toISOString(), mon: record.monTime?.toISOString(),
tue: record.tueTime?.toISOString(), tue: record.tueTime?.toISOString(),
wed: record.wedTime?.toISOString(), wed: record.wedTime
thu: record.thuTime?.toISOString(), ? this.timeConverter.utcDatetimeToLocalTime(
record.wedTime.toISOString(),
timezone,
)
: undefined,
thu: record.thuTime
? this.timeConverter.utcDatetimeToLocalTime(
record.thuTime.toISOString(),
timezone,
)
: undefined,
fri: record.friTime?.toISOString(), fri: record.friTime?.toISOString(),
sat: record.satTime?.toISOString(), sat: record.satTime?.toISOString(),
sun: record.sunTime?.toISOString(), sun: record.sunTime?.toISOString(),
@ -198,7 +213,27 @@ export class AdMapper
toResponse = (entity: AdEntity): AdResponseDto => { toResponse = (entity: AdEntity): AdResponseDto => {
const props = entity.getProps(); const props = entity.getProps();
const response = new AdResponseDto(entity); const response = new AdResponseDto(entity);
response.uuid = props.id; response.userId = props.userId;
response.driver = props.driver;
response.passenger = props.passenger;
response.frequency = props.frequency;
response.fromDate = props.fromDate;
response.toDate = props.toDate;
response.schedule = { ...props.schedule };
response.marginDurations = { ...props.marginDurations };
response.seatsProposed = props.seatsProposed;
response.seatsRequested = props.seatsRequested;
response.waypoints = props.waypoints.map((waypoint: WaypointProps) => ({
position: waypoint.position,
name: waypoint.address.name,
houseNumber: waypoint.address.houseNumber,
street: waypoint.address.street,
postalCode: waypoint.address.postalCode,
locality: waypoint.address.locality,
country: waypoint.address.country,
lon: waypoint.address.coordinates.lon,
lat: waypoint.address.coordinates.lat,
}));
return response; return response;
}; };

View File

@ -20,12 +20,15 @@ import { CreateAdService } from './core/commands/create-ad/create-ad.service';
import { TimezoneFinder } from './infrastructure/timezone-finder'; import { TimezoneFinder } from './infrastructure/timezone-finder';
import { PrismaService } from '@libs/db/prisma.service'; import { PrismaService } from '@libs/db/prisma.service';
import { TimeConverter } from './infrastructure/time-converter'; import { TimeConverter } from './infrastructure/time-converter';
import { FindAdByIdGrpcController } from './interface/grpc-controllers/find-ad-by-id.grpc.controller';
import { FindAdByIdQueryHandler } from './core/queries/find-ad-by-id/find-ad-by-id.query-handler';
@Module({ @Module({
imports: [CqrsModule], imports: [CqrsModule],
controllers: [CreateAdGrpcController], controllers: [CreateAdGrpcController, FindAdByIdGrpcController],
providers: [ providers: [
CreateAdService, CreateAdService,
FindAdByIdQueryHandler,
PrismaService, PrismaService,
AdMapper, AdMapper,
{ {

View File

@ -1,8 +1,9 @@
export interface TimeConverterPort { export interface TimeConverterPort {
dateTimeToUtc( localDateTimeToUtc(
date: string, date: string,
time: string, time: string,
timezone: string, timezone: string,
dst?: boolean, dst?: boolean,
): Date; ): Date;
utcDatetimeToLocalTime(isoString: string, timezone: string): string;
} }

View File

@ -0,0 +1,17 @@
import { IQueryHandler, QueryHandler } from '@nestjs/cqrs';
import { FindAdByIdQuery } from './find-ad-by-id.query';
import { AD_REPOSITORY } from '@modules/ad/ad.di-tokens';
import { AdRepositoryPort } from '../../ports/ad.repository.port';
import { Inject } from '@nestjs/common';
import { AdEntity } from '../../ad.entity';
@QueryHandler(FindAdByIdQuery)
export class FindAdByIdQueryHandler implements IQueryHandler {
constructor(
@Inject(AD_REPOSITORY)
private readonly repository: AdRepositoryPort,
) {}
async execute(query: FindAdByIdQuery): Promise<AdEntity> {
return await this.repository.findOneById(query.id, { waypoints: true });
}
}

View File

@ -1,9 +1,10 @@
import { FindAdByIdRequestDTO } from '../../../interface/queries/find-ad-by-id/dtos/find-ad-by-id.request.dto'; import { QueryBase } from '@libs/ddd/query.base';
export class FindAdByIdQuery { export class FindAdByIdQuery extends QueryBase {
readonly id: string; readonly id: string;
constructor(findAdByIdRequestDTO: FindAdByIdRequestDTO) { constructor(id: string) {
this.id = findAdByIdRequestDTO.id; super();
this.id = id;
} }
} }

View File

@ -4,7 +4,7 @@ import { DateTime, TimeZone } from 'timezonecomplete';
@Injectable() @Injectable()
export class TimeConverter implements TimeConverterPort { export class TimeConverter implements TimeConverterPort {
dateTimeToUtc = ( localDateTimeToUtc = (
date: string, date: string,
time: string, time: string,
timezone: string, timezone: string,
@ -21,4 +21,16 @@ export class TimeConverter implements TimeConverterPort {
return undefined; return undefined;
} }
}; };
utcDatetimeToLocalTime = (isoString: string, timezone: string): string => {
try {
return new DateTime(isoString)
.convert(TimeZone.zone(timezone))
.toString()
.split('T')[1]
.substring(0, 5);
} catch (e) {
return undefined;
}
};
} }

View File

@ -1,6 +0,0 @@
import { AutoMap } from '@automapper/classes';
export class AdPresenter {
@AutoMap()
id: string;
}

View File

@ -1,5 +1,43 @@
import { ResponseBase } from '@libs/api/response.base'; import { ResponseBase } from '@libs/api/response.base';
import { Frequency } from '@modules/ad/core/ad.types';
export class AdResponseDto extends ResponseBase { export class AdResponseDto extends ResponseBase {
uuid: string; userId: string;
driver: boolean;
passenger: boolean;
frequency: Frequency;
fromDate: string;
toDate: string;
schedule: {
mon?: string;
tue?: string;
wed?: string;
thu?: string;
fri?: string;
sat?: string;
sun?: string;
};
marginDurations: {
mon?: number;
tue?: number;
wed?: number;
thu?: number;
fri?: number;
sat?: number;
sun?: number;
};
seatsProposed: number;
seatsRequested: number;
strict: boolean;
waypoints: {
position: number;
name?: string;
houseNumber?: string;
street?: string;
postalCode?: string;
locality?: string;
country: string;
lon: number;
lat: number;
}[];
} }

View File

@ -2,8 +2,7 @@ import { Controller, UsePipes } from '@nestjs/common';
import { CommandBus } from '@nestjs/cqrs'; import { CommandBus } from '@nestjs/cqrs';
import { GrpcMethod, RpcException } from '@nestjs/microservices'; import { GrpcMethod, RpcException } from '@nestjs/microservices';
import { RpcValidationPipe } from '../../../../utils/pipes/rpc.validation-pipe'; import { RpcValidationPipe } from '../../../../utils/pipes/rpc.validation-pipe';
import { AdPresenter } from '../ad.presenter'; import { CreateAdRequestDto } from './dtos/create-ad.request.dto';
import { CreateAdRequestDTO } from './dtos/create-ad.request.dto';
import { CreateAdCommand } from '../../core/commands/create-ad/create-ad.command'; import { CreateAdCommand } from '../../core/commands/create-ad/create-ad.command';
import { Result, match } from 'oxide.ts'; import { Result, match } from 'oxide.ts';
import { AggregateID } from '@libs/ddd'; import { AggregateID } from '@libs/ddd';
@ -21,7 +20,7 @@ export class CreateAdGrpcController {
constructor(private readonly commandBus: CommandBus) {} constructor(private readonly commandBus: CommandBus) {}
@GrpcMethod('AdsService', 'Create') @GrpcMethod('AdsService', 'Create')
async create(data: CreateAdRequestDTO): Promise<AdPresenter> { async create(data: CreateAdRequestDto): Promise<IdResponse> {
const result: Result<AggregateID, AdAlreadyExistsError> = const result: Result<AggregateID, AdAlreadyExistsError> =
await this.commandBus.execute(new CreateAdCommand(data)); await this.commandBus.execute(new CreateAdCommand(data));

View File

@ -1,8 +1,8 @@
import { AutoMap } from '@automapper/classes'; import { AutoMap } from '@automapper/classes';
import { IsOptional, IsString } from 'class-validator'; import { IsOptional, IsString } from 'class-validator';
import { CoordinatesDTO } from './coordinates.dto'; import { CoordinatesDto as CoordinatesDto } from './coordinates.dto';
export class AddressDTO extends CoordinatesDTO { export class AddressDto extends CoordinatesDto {
@IsOptional() @IsOptional()
@AutoMap() @AutoMap()
name?: string; name?: string;

View File

@ -1,11 +1,19 @@
import { AutoMap } from '@automapper/classes'; import { AutoMap } from '@automapper/classes';
import { Transform } from 'class-transformer';
import { IsLatitude, IsLongitude } from 'class-validator'; import { IsLatitude, IsLongitude } from 'class-validator';
import { toPrecision } from './validators/to-precision';
export class CoordinatesDTO { export class CoordinatesDto {
@Transform(({ value }) => toPrecision(value, 6), {
toClassOnly: true,
})
@IsLongitude() @IsLongitude()
@AutoMap() @AutoMap()
lon: number; lon: number;
@Transform(({ value }) => toPrecision(value, 6), {
toClassOnly: true,
})
@IsLatitude() @IsLatitude()
@AutoMap() @AutoMap()
lat: number; lat: number;

View File

@ -11,15 +11,15 @@ import {
IsISO8601, IsISO8601,
} from 'class-validator'; } from 'class-validator';
import { Transform, Type } from 'class-transformer'; import { Transform, Type } from 'class-transformer';
import { ScheduleDTO } from './schedule.dto'; import { ScheduleDto } from './schedule.dto';
import { MarginDurationsDTO } from './margin-durations.dto'; import { MarginDurationsDto } from './margin-durations.dto';
import { WaypointDTO } from './waypoint.dto'; import { WaypointDto } from './waypoint.dto';
import { intToFrequency } from './validators/frequency.mapping'; import { intToFrequency } from './validators/frequency.mapping';
import { IsSchedule } from './validators/decorators/is-schedule.validator'; import { IsSchedule } from './validators/decorators/is-schedule.validator';
import { HasValidPositionIndexes } from './validators/decorators/valid-position-indexes.validator'; import { HasValidPositionIndexes } from './validators/decorators/valid-position-indexes.validator';
import { Frequency } from '@modules/ad/core/ad.types'; import { Frequency } from '@modules/ad/core/ad.types';
export class CreateAdRequestDTO { export class CreateAdRequestDto {
@IsUUID(4) @IsUUID(4)
@AutoMap() @AutoMap()
userId: string; userId: string;
@ -55,17 +55,17 @@ export class CreateAdRequestDTO {
@AutoMap() @AutoMap()
toDate: string; toDate: string;
@Type(() => ScheduleDTO) @Type(() => ScheduleDto)
@IsSchedule() @IsSchedule()
@ValidateNested({ each: true }) @ValidateNested({ each: true })
@AutoMap() @AutoMap()
schedule: ScheduleDTO; schedule: ScheduleDto;
@IsOptional() @IsOptional()
@Type(() => MarginDurationsDTO) @Type(() => MarginDurationsDto)
@ValidateNested({ each: true }) @ValidateNested({ each: true })
@AutoMap() @AutoMap()
marginDurations?: MarginDurationsDTO; marginDurations?: MarginDurationsDto;
@IsOptional() @IsOptional()
@IsInt() @IsInt()
@ -82,10 +82,11 @@ export class CreateAdRequestDTO {
@AutoMap() @AutoMap()
strict?: boolean; strict?: boolean;
@Type(() => WaypointDto)
@IsArray() @IsArray()
@ArrayMinSize(2) @ArrayMinSize(2)
@HasValidPositionIndexes() @HasValidPositionIndexes()
@ValidateNested({ each: true }) @ValidateNested({ each: true })
@AutoMap() @AutoMap()
waypoints: WaypointDTO[]; waypoints: WaypointDto[];
} }

View File

@ -1,6 +1,6 @@
import { IsNotEmpty, IsString } from 'class-validator'; import { IsNotEmpty, IsString } from 'class-validator';
export class FindAdByIdRequestDTO { export class FindAdByIdRequestDto {
@IsString() @IsString()
@IsNotEmpty() @IsNotEmpty()
id: string; id: string;

View File

@ -1,7 +1,7 @@
import { AutoMap } from '@automapper/classes'; import { AutoMap } from '@automapper/classes';
import { IsInt, IsOptional } from 'class-validator'; import { IsInt, IsOptional } from 'class-validator';
export class MarginDurationsDTO { export class MarginDurationsDto {
@IsOptional() @IsOptional()
@IsInt() @IsInt()
@AutoMap() @AutoMap()

View File

@ -1,7 +1,7 @@
import { AutoMap } from '@automapper/classes'; import { AutoMap } from '@automapper/classes';
import { IsOptional, IsMilitaryTime } from 'class-validator'; import { IsOptional, IsMilitaryTime } from 'class-validator';
export class ScheduleDTO { export class ScheduleDto {
@IsOptional() @IsOptional()
@IsMilitaryTime() @IsMilitaryTime()
@AutoMap() @AutoMap()

View File

@ -1,6 +1,6 @@
import { ValidateBy, ValidationOptions, buildMessage } from 'class-validator'; import { ValidateBy, ValidationOptions, buildMessage } from 'class-validator';
import { hasValidPositionIndexes } from '../waypoint-position'; import { hasValidPositionIndexes } from '../waypoint-position';
import { WaypointDTO } from '../../waypoint.dto'; import { WaypointDto } from '../../waypoint.dto';
export const HasValidPositionIndexes = ( export const HasValidPositionIndexes = (
validationOptions?: ValidationOptions, validationOptions?: ValidationOptions,
@ -10,7 +10,7 @@ export const HasValidPositionIndexes = (
name: '', name: '',
constraints: [], constraints: [],
validator: { validator: {
validate: (waypoints: WaypointDTO[]): boolean => validate: (waypoints: WaypointDto[]): boolean =>
hasValidPositionIndexes(waypoints), hasValidPositionIndexes(waypoints),
defaultMessage: buildMessage( defaultMessage: buildMessage(
() => `invalid waypoints positions`, () => `invalid waypoints positions`,

View File

@ -0,0 +1,4 @@
export const toPrecision = (input: number, precision: number): number => {
const multiplier = 10 ** precision;
return Math.round((input + Number.EPSILON) * multiplier) / multiplier;
};

View File

@ -1,6 +1,6 @@
import { WaypointDTO } from '../waypoint.dto'; import { WaypointDto } from '../waypoint.dto';
export const hasValidPositionIndexes = (waypoints: WaypointDTO[]): boolean => { export const hasValidPositionIndexes = (waypoints: WaypointDto[]): boolean => {
if (!waypoints) return; if (!waypoints) return;
if (waypoints.every((waypoint) => waypoint.position === undefined)) if (waypoints.every((waypoint) => waypoint.position === undefined))
return true; return true;

View File

@ -1,8 +1,8 @@
import { AutoMap } from '@automapper/classes'; import { AutoMap } from '@automapper/classes';
import { IsInt, IsOptional } from 'class-validator'; import { IsInt, IsOptional } from 'class-validator';
import { AddressDTO } from './address.dto'; import { AddressDto } from './address.dto';
export class WaypointDTO extends AddressDTO { export class WaypointDto extends AddressDto {
@IsOptional() @IsOptional()
@IsInt() @IsInt()
@AutoMap() @AutoMap()

View File

@ -0,0 +1,38 @@
import { Controller, UsePipes } from '@nestjs/common';
import { QueryBus } from '@nestjs/cqrs';
import { GrpcMethod, RpcException } from '@nestjs/microservices';
import { RpcValidationPipe } from '@utils/pipes/rpc.validation-pipe';
import { FindAdByIdRequestDto } from './dtos/find-ad-by-id.request.dto';
import { FindAdByIdQuery } from '@modules/ad/core/queries/find-ad-by-id/find-ad-by-id.query';
import { AdResponseDto } from '../dtos/ad.response.dto';
import { AdEntity } from '@modules/ad/core/ad.entity';
import { AdMapper } from '@modules/ad/ad.mapper';
@UsePipes(
new RpcValidationPipe({
whitelist: false,
forbidUnknownValues: false,
}),
)
@Controller()
export class FindAdByIdGrpcController {
constructor(
protected readonly mapper: AdMapper,
private readonly queryBus: QueryBus,
) {}
@GrpcMethod('AdsService', 'FindOneById')
async findOnebyId(data: FindAdByIdRequestDto): Promise<AdResponseDto> {
try {
const ad: AdEntity = await this.queryBus.execute(
new FindAdByIdQuery(data.id),
);
return this.mapper.toResponse(ad);
} catch (e) {
throw new RpcException({
code: e.code,
message: e.message,
});
}
}
}

View File

@ -1,37 +0,0 @@
import { Mapper } from '@automapper/core';
import { InjectMapper } from '@automapper/nestjs';
import { Controller, UsePipes } from '@nestjs/common';
import { QueryBus } from '@nestjs/cqrs';
import { GrpcMethod, RpcException } from '@nestjs/microservices';
import { RpcValidationPipe } from '../../../../../utils/pipes/rpc.validation-pipe';
import { FindAdByIdRequestDTO } from './dtos/find-ad-by-id.request.dto';
import { AdPresenter } from '../../ad.presenter';
import { FindAdByIdQuery } from '../../../core/queries/find-ad-by-id/find-ad-by-id.query';
import { AdEntity } from '../../../core/ad.entity';
@UsePipes(
new RpcValidationPipe({
whitelist: false,
forbidUnknownValues: false,
}),
)
@Controller()
export class FindAdByIdGrpcController {
constructor(
private readonly queryBus: QueryBus,
@InjectMapper() private readonly mapper: Mapper,
) {}
@GrpcMethod('AdsService', 'FindOneById')
async findOnebyId(data: FindAdByIdRequestDTO): Promise<AdPresenter> {
try {
const ad = await this.queryBus.execute(new FindAdByIdQuery(data));
return this.mapper.map(ad, AdEntity, AdPresenter);
} catch (e) {
throw new RpcException({
code: e.code,
message: e.message,
});
}
}
}

View File

@ -3,10 +3,12 @@ import {
AD_REPOSITORY, AD_REPOSITORY,
PARAMS_PROVIDER, PARAMS_PROVIDER,
TIMEZONE_FINDER, TIMEZONE_FINDER,
TIME_CONVERTER,
} from '@modules/ad/ad.di-tokens'; } from '@modules/ad/ad.di-tokens';
import { AdMapper } from '@modules/ad/ad.mapper'; import { AdMapper } from '@modules/ad/ad.mapper';
import { AdRepository } from '@modules/ad/infrastructure/ad.repository'; import { AdRepository } from '@modules/ad/infrastructure/ad.repository';
import { DefaultParamsProvider } from '@modules/ad/infrastructure/default-params-provider'; import { DefaultParamsProvider } from '@modules/ad/infrastructure/default-params-provider';
import { TimeConverter } from '@modules/ad/infrastructure/time-converter';
import { TimezoneFinder } from '@modules/ad/infrastructure/timezone-finder'; import { TimezoneFinder } from '@modules/ad/infrastructure/timezone-finder';
import { ConfigModule } from '@nestjs/config'; import { ConfigModule } from '@nestjs/config';
import { EventEmitterModule } from '@nestjs/event-emitter'; import { EventEmitterModule } from '@nestjs/event-emitter';
@ -274,6 +276,10 @@ describe('Ad Repository', () => {
provide: TIMEZONE_FINDER, provide: TIMEZONE_FINDER,
useClass: TimezoneFinder, useClass: TimezoneFinder,
}, },
{
provide: TIME_CONVERTER,
useClass: TimeConverter,
},
], ],
}).compile(); }).compile();
prismaService = module.get<PrismaService>(PrismaService); prismaService = module.get<PrismaService>(PrismaService);
@ -416,7 +422,7 @@ describe('Ad Repository', () => {
waypoints: true, waypoints: true,
}); });
expect(result.unwrap().id).toBe(baseUuid.uuid); expect(result.id).toBe(baseUuid.uuid);
}); });
// it('should return null', async () => { // it('should return null', async () => {

View File

@ -45,8 +45,8 @@ const adEntity: AdEntity = new AdEntity({
postalCode: '54000', postalCode: '54000',
country: 'France', country: 'France',
coordinates: { coordinates: {
lat: 48.68944505415954, lat: 48.689445,
lon: 6.176510296462267, lon: 6.1765102,
}, },
}, },
}, },
@ -103,8 +103,8 @@ const adReadModel: AdReadModel = {
locality: 'Nancy', locality: 'Nancy',
postalCode: '54000', postalCode: '54000',
country: 'France', country: 'France',
lat: 48.68944505415954, lat: 48.689445,
lon: 6.176510296462267, lon: 6.1765102,
createdAt: now, createdAt: now,
updatedAt: now, updatedAt: now,
}, },
@ -163,12 +163,13 @@ const mockTimezoneFinder: TimezoneFinderPort = {
}; };
const mockTimeConverter: TimeConverterPort = { const mockTimeConverter: TimeConverterPort = {
dateTimeToUtc: jest localDateTimeToUtc: jest
.fn() .fn()
// eslint-disable-next-line @typescript-eslint/no-unused-vars // eslint-disable-next-line @typescript-eslint/no-unused-vars
.mockImplementation((datetime: Date, timezone: string, dst?: boolean) => { .mockImplementation((datetime: Date, timezone: string, dst?: boolean) => {
return datetime; return datetime;
}), }),
utcDatetimeToLocalTime: jest.fn(),
}; };
describe('Ad Mapper', () => { describe('Ad Mapper', () => {
@ -208,13 +209,13 @@ describe('Ad Mapper', () => {
it('should map persisted data to domain entity', async () => { it('should map persisted data to domain entity', async () => {
const mapped: AdEntity = adMapper.toDomain(adReadModel); const mapped: AdEntity = adMapper.toDomain(adReadModel);
expect(mapped.getProps().waypoints[0].address.coordinates.lat).toBe( expect(mapped.getProps().waypoints[0].address.coordinates.lat).toBe(
48.68944505415954, 48.689445,
); );
expect(mapped.getProps().waypoints[1].address.coordinates.lon).toBe(2.3522); expect(mapped.getProps().waypoints[1].address.coordinates.lon).toBe(2.3522);
}); });
it('should map domain entity to response', async () => { it('should map domain entity to response', async () => {
const mapped: AdResponseDto = adMapper.toResponse(adEntity); const mapped: AdResponseDto = adMapper.toResponse(adEntity);
expect(mapped.uuid).toBe('c160cf8c-f057-4962-841f-3ad68346df44'); expect(mapped.id).toBe('c160cf8c-f057-4962-841f-3ad68346df44');
}); });
}); });

View File

@ -2,8 +2,8 @@ import { Test, TestingModule } from '@nestjs/testing';
import { CreateAdService } from '@modules/ad/core/commands/create-ad/create-ad.service'; import { CreateAdService } from '@modules/ad/core/commands/create-ad/create-ad.service';
import { AD_REPOSITORY, PARAMS_PROVIDER } from '@modules/ad/ad.di-tokens'; import { AD_REPOSITORY, PARAMS_PROVIDER } from '@modules/ad/ad.di-tokens';
import { DefaultParamsProviderPort } from '@modules/ad/core/ports/default-params-provider.port'; import { DefaultParamsProviderPort } from '@modules/ad/core/ports/default-params-provider.port';
import { WaypointDTO } from '@modules/ad/interface/grpc-controllers/dtos/waypoint.dto'; import { WaypointDto } from '@modules/ad/interface/grpc-controllers/dtos/waypoint.dto';
import { CreateAdRequestDTO } from '@modules/ad/interface/grpc-controllers/dtos/create-ad.request.dto'; import { CreateAdRequestDto } from '@modules/ad/interface/grpc-controllers/dtos/create-ad.request.dto';
import { Frequency } from '@modules/ad/core/ad.types'; import { Frequency } from '@modules/ad/core/ad.types';
import { CreateAdCommand } from '@modules/ad/core/commands/create-ad/create-ad.command'; import { CreateAdCommand } from '@modules/ad/core/commands/create-ad/create-ad.command';
import { Result } from 'oxide.ts'; import { Result } from 'oxide.ts';
@ -12,7 +12,7 @@ import { AdAlreadyExistsError } from '@modules/ad/core/ad.errors';
import { AdEntity } from '@modules/ad/core/ad.entity'; import { AdEntity } from '@modules/ad/core/ad.entity';
import { ConflictException } from '@libs/exceptions'; import { ConflictException } from '@libs/exceptions';
const originWaypoint: WaypointDTO = { const originWaypoint: WaypointDto = {
position: 0, position: 0,
lon: 48.68944505415954, lon: 48.68944505415954,
lat: 6.176510296462267, lat: 6.176510296462267,
@ -22,7 +22,7 @@ const originWaypoint: WaypointDTO = {
postalCode: '54000', postalCode: '54000',
country: 'France', country: 'France',
}; };
const destinationWaypoint: WaypointDTO = { const destinationWaypoint: WaypointDto = {
position: 1, position: 1,
lon: 48.8566, lon: 48.8566,
lat: 2.3522, lat: 2.3522,
@ -30,7 +30,7 @@ const destinationWaypoint: WaypointDTO = {
postalCode: '75000', postalCode: '75000',
country: 'France', country: 'France',
}; };
const punctualCreateAdRequest: CreateAdRequestDTO = { const punctualCreateAdRequest: CreateAdRequestDto = {
userId: '4eb6a6af-ecfd-41c3-9118-473a507014d4', userId: '4eb6a6af-ecfd-41c3-9118-473a507014d4',
fromDate: '2023-12-21', fromDate: '2023-12-21',
toDate: '2023-12-21', toDate: '2023-12-21',

View File

@ -5,48 +5,81 @@ describe('Time Converter', () => {
const timeConverter: TimeConverter = new TimeConverter(); const timeConverter: TimeConverter = new TimeConverter();
expect(timeConverter).toBeDefined(); expect(timeConverter).toBeDefined();
}); });
it('should convert a paris datetime to utc', () => {
const timeConverter: TimeConverter = new TimeConverter(); describe('localDateTimeToUtc', () => {
const parisDate = '2023-06-22'; it('should convert a paris datetime to utc', () => {
const parisTime = '08:00'; const timeConverter: TimeConverter = new TimeConverter();
const utcDatetime = timeConverter.dateTimeToUtc( const parisDate = '2023-06-22';
parisDate, const parisTime = '08:00';
parisTime, const utcDatetime = timeConverter.localDateTimeToUtc(
'Europe/Paris', parisDate,
); parisTime,
expect(utcDatetime.toISOString()).toEqual('2023-06-22T06:00:00.000Z'); 'Europe/Paris',
);
expect(utcDatetime.toISOString()).toEqual('2023-06-22T06:00:00.000Z');
});
it('should return undefined if date is invalid', () => {
const timeConverter: TimeConverter = new TimeConverter();
const parisDate = '2023-16-22';
const parisTime = '08:00';
const utcDatetime = timeConverter.localDateTimeToUtc(
parisDate,
parisTime,
'Europe/Paris',
);
expect(utcDatetime).toBeUndefined();
});
it('should return undefined if time is invalid', () => {
const timeConverter: TimeConverter = new TimeConverter();
const parisDate = '2023-06-22';
const parisTime = '28:00';
const utcDatetime = timeConverter.localDateTimeToUtc(
parisDate,
parisTime,
'Europe/Paris',
);
expect(utcDatetime).toBeUndefined();
});
it('should return undefined if timezone is invalid', () => {
const timeConverter: TimeConverter = new TimeConverter();
const parisDate = '2023-06-22';
const parisTime = '08:00';
const utcDatetime = timeConverter.localDateTimeToUtc(
parisDate,
parisTime,
'Foo/Bar',
);
expect(utcDatetime).toBeUndefined();
});
}); });
it('should return undefined if date is invalid', () => {
const timeConverter: TimeConverter = new TimeConverter(); describe('utcDatetimeToLocalTime', () => {
const parisDate = '2023-16-22'; it('should convert an utc datetime isostring to a paris local time', () => {
const parisTime = '08:00'; const timeConverter: TimeConverter = new TimeConverter();
const utcDatetime = timeConverter.dateTimeToUtc( const utcDatetimeIsostring = '2023-06-22T06:25:00.000Z';
parisDate, const parisTime = timeConverter.utcDatetimeToLocalTime(
parisTime, utcDatetimeIsostring,
'Europe/Paris', 'Europe/Paris',
); );
expect(utcDatetime).toBeUndefined(); expect(parisTime).toBe('08:25');
}); });
it('should return undefined if time is invalid', () => { it('should return undefined if isostring input is invalid', () => {
const timeConverter: TimeConverter = new TimeConverter(); const timeConverter: TimeConverter = new TimeConverter();
const parisDate = '2023-06-22'; const utcDatetimeIsostring = 'not_an_isostring';
const parisTime = '28:00'; const parisTime = timeConverter.utcDatetimeToLocalTime(
const utcDatetime = timeConverter.dateTimeToUtc( utcDatetimeIsostring,
parisDate, 'Europe/Paris',
parisTime, );
'Europe/Paris', expect(parisTime).toBeUndefined();
); });
expect(utcDatetime).toBeUndefined(); it('should return undefined if timezone input is invalid', () => {
}); const timeConverter: TimeConverter = new TimeConverter();
it('should return undefined if timezone is invalid', () => { const utcDatetimeIsostring = '2023-06-22T06:25:00.000Z';
const timeConverter: TimeConverter = new TimeConverter(); const parisTime = timeConverter.utcDatetimeToLocalTime(
const parisDate = '2023-06-22'; utcDatetimeIsostring,
const parisTime = '08:00'; 'Foo/Bar',
const utcDatetime = timeConverter.dateTimeToUtc( );
parisDate, expect(parisTime).toBeUndefined();
parisTime, });
'Foo/Bar',
);
expect(utcDatetime).toBeUndefined();
}); });
}); });

View File

@ -1,8 +1,8 @@
import { hasValidPositionIndexes } from '@modules/ad/interface/grpc-controllers/dtos/validators/waypoint-position'; import { hasValidPositionIndexes } from '@modules/ad/interface/grpc-controllers/dtos/validators/waypoint-position';
import { WaypointDTO } from '@modules/ad/interface/grpc-controllers/dtos/waypoint.dto'; import { WaypointDto } from '@modules/ad/interface/grpc-controllers/dtos/waypoint.dto';
describe('addresses position validator', () => { describe('addresses position validator', () => {
const mockAddress1: WaypointDTO = { const mockAddress1: WaypointDto = {
lon: 48.68944505415954, lon: 48.68944505415954,
lat: 6.176510296462267, lat: 6.176510296462267,
houseNumber: '5', houseNumber: '5',
@ -11,14 +11,14 @@ describe('addresses position validator', () => {
postalCode: '54000', postalCode: '54000',
country: 'France', country: 'France',
}; };
const mockAddress2: WaypointDTO = { const mockAddress2: WaypointDto = {
lon: 48.8566, lon: 48.8566,
lat: 2.3522, lat: 2.3522,
locality: 'Paris', locality: 'Paris',
postalCode: '75000', postalCode: '75000',
country: 'France', country: 'France',
}; };
const mockAddress3: WaypointDTO = { const mockAddress3: WaypointDto = {
lon: 49.2628, lon: 49.2628,
lat: 4.0347, lat: 4.0347,
locality: 'Reims', locality: 'Reims',

View File

@ -1,6 +1,6 @@
import { ArgumentMetadata } from '@nestjs/common'; import { ArgumentMetadata } from '@nestjs/common';
import { RpcValidationPipe } from '../../pipes/rpc.validation-pipe'; import { RpcValidationPipe } from '../../pipes/rpc.validation-pipe';
import { FindAdByIdRequestDTO } from '../../../modules/ad/interface/queries/find-ad-by-id/dtos/find-ad-by-id.request.dto'; import { FindAdByIdRequestDto } from '../../../modules/ad/interface/grpc-controllers/dtos/find-ad-by-id.request.dto';
describe('RpcValidationPipe', () => { describe('RpcValidationPipe', () => {
it('should not validate request', async () => { it('should not validate request', async () => {
@ -10,10 +10,10 @@ describe('RpcValidationPipe', () => {
}); });
const metadata: ArgumentMetadata = { const metadata: ArgumentMetadata = {
type: 'body', type: 'body',
metatype: FindAdByIdRequestDTO, metatype: FindAdByIdRequestDto,
data: '', data: '',
}; };
await target.transform(<FindAdByIdRequestDTO>{}, metadata).catch((err) => { await target.transform(<FindAdByIdRequestDto>{}, metadata).catch((err) => {
expect(err.message).toEqual('Rpc Exception'); expect(err.message).toEqual('Rpc Exception');
}); });
}); });