basic match query

This commit is contained in:
sbriat 2023-08-25 09:53:41 +02:00
parent bca3374255
commit a98e5b3c83
24 changed files with 723 additions and 1 deletions

View File

@ -2,7 +2,6 @@ import { Frequency } from '@modules/ad/core/domain/ad.types';
export type Ad = {
id: string;
userId: string;
driver: boolean;
passenger: boolean;
frequency: Frequency;

View File

@ -0,0 +1,27 @@
import { QueryBase } from '@mobicoop/ddd-library';
import { Frequency } from '@modules/matcher/core/domain/match.types';
import { ScheduleItem } from '../../types/schedule-item';
import { Waypoint } from '../../types/waypoint';
export class MatchQuery extends QueryBase {
readonly driver?: boolean;
readonly passenger?: boolean;
readonly frequency?: Frequency;
readonly fromDate: string;
readonly toDate: string;
readonly schedule: ScheduleItem[];
readonly strict?: boolean;
readonly waypoints: Waypoint[];
constructor(props: MatchQuery) {
super();
this.driver = props.driver;
this.passenger = props.passenger;
this.frequency = props.frequency;
this.fromDate = props.fromDate;
this.toDate = props.toDate;
this.schedule = props.schedule;
this.strict = props.strict;
this.waypoints = props.waypoints;
}
}

View File

@ -0,0 +1,10 @@
import { Coordinates } from './coordinates';
export type Address = {
name?: string;
houseNumber?: string;
street?: string;
locality?: string;
postalCode?: string;
country: string;
} & Coordinates;

View File

@ -0,0 +1,4 @@
export type Coordinates = {
lon: number;
lat: number;
};

View File

@ -0,0 +1,5 @@
export type ScheduleItem = {
day?: number;
time: string;
margin?: number;
};

View File

@ -0,0 +1,5 @@
import { Address } from './address';
export type Waypoint = {
position?: number;
} & Address;

View File

@ -0,0 +1,8 @@
export enum Frequency {
PUNCTUAL = 'PUNCTUAL',
RECURRENT = 'RECURRENT',
}
export enum AlgorithmType {
CLASSIC = 'CLASSIC',
}

View File

@ -0,0 +1,6 @@
import { PaginatedResponseDto } from '@mobicoop/ddd-library';
import { MatchResponseDto } from './match.response.dto';
export class MatchPaginatedResponseDto extends PaginatedResponseDto<MatchResponseDto> {
readonly data: readonly MatchResponseDto[];
}

View File

@ -0,0 +1,3 @@
export class MatchResponseDto {
adId: string;
}

View File

@ -0,0 +1,26 @@
import { IsOptional, IsString } from 'class-validator';
import { CoordinatesDto as CoordinatesDto } from './coordinates.dto';
export class AddressDto extends CoordinatesDto {
@IsOptional()
name?: string;
@IsOptional()
@IsString()
houseNumber?: string;
@IsOptional()
@IsString()
street?: string;
@IsOptional()
@IsString()
locality?: string;
@IsOptional()
@IsString()
postalCode?: string;
@IsString()
country: string;
}

View File

@ -0,0 +1,9 @@
import { IsLatitude, IsLongitude } from 'class-validator';
export class CoordinatesDto {
@IsLongitude()
lon: number;
@IsLatitude()
lat: number;
}

View File

@ -0,0 +1,119 @@
import {
AlgorithmType,
Frequency,
} from '@modules/matcher/core/domain/match.types';
import {
ArrayMinSize,
IsArray,
IsBoolean,
IsDecimal,
IsEnum,
IsISO8601,
IsInt,
IsOptional,
Max,
Min,
ValidateNested,
} from 'class-validator';
import { Type } from 'class-transformer';
import { HasDay } from './validators/decorators/has-day.decorator';
import { IsAfterOrEqual } from './validators/decorators/is-after-or-equal.decorator';
import { ScheduleItemDto } from './schedule-item.dto';
import { WaypointDto } from './waypoint.dto';
import { HasValidPositionIndexes } from './validators/decorators/has-valid-position-indexes.decorator';
export class MatchRequestDto {
@IsOptional()
@IsBoolean()
driver?: boolean;
@IsOptional()
@IsBoolean()
passenger?: boolean;
@IsEnum(Frequency)
@HasDay('schedule', {
message: 'At least a day is required for a recurrent ad',
})
frequency: Frequency;
@IsISO8601({
strict: true,
strictSeparator: true,
})
fromDate: string;
@IsISO8601({
strict: true,
strictSeparator: true,
})
@IsAfterOrEqual('fromDate', {
message: 'toDate must be after or equal to fromDate',
})
toDate: string;
@Type(() => ScheduleItemDto)
@IsArray()
@ArrayMinSize(1)
@ValidateNested({ each: true })
schedule: ScheduleItemDto[];
@IsOptional()
@IsInt()
seatsProposed?: number;
@IsOptional()
@IsInt()
seatsRequested?: number;
@IsOptional()
@IsBoolean()
strict?: boolean;
@Type(() => WaypointDto)
@IsArray()
@ArrayMinSize(2)
@HasValidPositionIndexes()
@ValidateNested({ each: true })
waypoints: WaypointDto[];
@IsOptional()
@IsEnum(AlgorithmType)
algorithm?: AlgorithmType;
@IsOptional()
@IsInt()
remoteness?: number;
@IsOptional()
@IsBoolean()
useProportion?: boolean;
@IsOptional()
@IsDecimal()
@Min(0)
@Max(1)
proportion?: number;
@IsOptional()
@IsBoolean()
useAzimuth?: boolean;
@IsOptional()
@IsInt()
@Min(0)
@Max(359)
azimuthMargin?: number;
@IsOptional()
@IsDecimal()
@Min(0)
@Max(1)
maxDetourDistanceRatio?: number;
@IsOptional()
@IsDecimal()
@Min(0)
@Max(1)
maxDetourDurationRatio?: number;
}

View File

@ -0,0 +1,16 @@
import { IsOptional, IsMilitaryTime, IsInt, Min, Max } from 'class-validator';
export class ScheduleItemDto {
@IsOptional()
@IsInt()
@Min(0)
@Max(6)
day?: number;
@IsMilitaryTime()
time: string;
@IsOptional()
@IsInt()
margin?: number;
}

View File

@ -0,0 +1,34 @@
import { Frequency } from '@modules/ad/core/domain/ad.types';
import {
registerDecorator,
ValidationOptions,
ValidationArguments,
} from 'class-validator';
export function HasDay(
property: string,
validationOptions?: ValidationOptions,
) {
return function (object: object, propertyName: string) {
registerDecorator({
name: 'hasDay',
target: object.constructor,
propertyName: propertyName,
constraints: [property],
options: validationOptions,
validator: {
validate(value: any, args: ValidationArguments) {
const [relatedPropertyName] = args.constraints;
const relatedValue = (args.object as any)[relatedPropertyName];
return (
value == Frequency.PUNCTUAL ||
(Array.isArray(relatedValue) &&
relatedValue.some((scheduleItem) =>
scheduleItem.hasOwnProperty('day'),
))
);
},
},
});
};
}

View File

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

View File

@ -0,0 +1,43 @@
import {
registerDecorator,
ValidationOptions,
ValidationArguments,
isISO8601,
} from 'class-validator';
export function IsAfterOrEqual(
property: string,
validationOptions?: ValidationOptions,
) {
return function (object: object, propertyName: string) {
registerDecorator({
name: 'isAfterOrEqual',
target: object.constructor,
propertyName: propertyName,
constraints: [property],
options: validationOptions,
validator: {
validate(value: any, args: ValidationArguments) {
const [relatedPropertyName] = args.constraints;
const relatedValue = (args.object as any)[relatedPropertyName];
if (
!(
typeof value === 'string' &&
typeof relatedValue === 'string' &&
isISO8601(value, {
strict: true,
strictSeparator: true,
}) &&
isISO8601(relatedValue, {
strict: true,
strictSeparator: true,
})
)
)
return false;
return new Date(value) >= new Date(relatedValue);
},
},
});
};
}

View File

@ -0,0 +1,17 @@
import { WaypointDto } from '../waypoint.dto';
export const hasValidPositionIndexes = (waypoints: WaypointDto[]): boolean => {
if (!waypoints) return false;
if (waypoints.length == 0) return false;
if (waypoints.every((waypoint) => waypoint.position === undefined))
return false;
if (waypoints.every((waypoint) => typeof waypoint.position === 'number')) {
const positions = Array.from(waypoints, (waypoint) => waypoint.position);
positions.sort();
for (let i = 1; i < positions.length; i++)
if (positions[i] != positions[i - 1] + 1) return false;
return true;
}
return false;
};

View File

@ -0,0 +1,8 @@
import { IsInt, IsOptional } from 'class-validator';
import { AddressDto } from './address.dto';
export class WaypointDto extends AddressDto {
@IsOptional()
@IsInt()
position?: number;
}

View File

@ -0,0 +1,37 @@
import { Controller, UsePipes } from '@nestjs/common';
import { GrpcMethod, RpcException } from '@nestjs/microservices';
import { RpcValidationPipe } from '@mobicoop/ddd-library';
import { RpcExceptionCode } from '@mobicoop/ddd-library';
import { MatchPaginatedResponseDto } from '../dtos/match.paginated.response.dto';
import { QueryBus } from '@nestjs/cqrs';
import { MatchRequestDto } from './dtos/match.request.dto';
import { MatchQuery } from '@modules/matcher/core/application/queries/match/match.query';
@UsePipes(
new RpcValidationPipe({
whitelist: false,
forbidUnknownValues: false,
}),
)
@Controller()
export class MatchGrpcController {
constructor(private readonly queryBus: QueryBus) {}
@GrpcMethod('MatcherService', 'Match')
async match(data: MatchRequestDto): Promise<MatchPaginatedResponseDto> {
try {
const matches = await this.queryBus.execute(new MatchQuery(data));
return {
data: matches,
page: 1,
perPage: 5,
total: 1,
};
} catch (e) {
throw new RpcException({
code: RpcExceptionCode.UNKNOWN,
message: e.message,
});
}
}
}

View File

@ -0,0 +1,60 @@
import { Frequency } from '@modules/ad/core/domain/ad.types';
import { ScheduleItemDto } from '@modules/matcher/interface/grpc-controllers/dtos/schedule-item.dto';
import { HasDay } from '@modules/matcher/interface/grpc-controllers/dtos/validators/decorators/has-day.decorator';
import { Validator } from 'class-validator';
describe('Has day decorator', () => {
class MyClass {
@HasDay('schedule', {
message: 'At least a day is required for a recurrent ad',
})
frequency: Frequency;
schedule: ScheduleItemDto[];
}
it('should return a property decorator has a function', () => {
const hasDay = HasDay('someProperty');
expect(typeof hasDay).toBe('function');
});
it('should validate a punctual frequency associated with a valid schedule', async () => {
const myClassInstance = new MyClass();
myClassInstance.frequency = Frequency.PUNCTUAL;
myClassInstance.schedule = [
{
time: '07:15',
},
];
const validator = new Validator();
const validation = await validator.validate(myClassInstance);
expect(validation.length).toBe(0);
});
it('should validate a recurrent frequency associated with a valid schedule', async () => {
const myClassInstance = new MyClass();
myClassInstance.frequency = Frequency.RECURRENT;
myClassInstance.schedule = [
{
time: '07:15',
day: 1,
},
];
const validator = new Validator();
const validation = await validator.validate(myClassInstance);
expect(validation.length).toBe(0);
});
it('should not validate a recurrent frequency associated with an invalid schedule', async () => {
const myClassInstance = new MyClass();
myClassInstance.frequency = Frequency.RECURRENT;
myClassInstance.schedule = [
{
time: '07:15',
},
];
const validator = new Validator();
const validation = await validator.validate(myClassInstance);
expect(validation.length).toBe(1);
});
});

View File

@ -0,0 +1,62 @@
import { HasValidPositionIndexes } from '@modules/matcher/interface/grpc-controllers/dtos/validators/decorators/has-valid-position-indexes.decorator';
import { WaypointDto } from '@modules/matcher/interface/grpc-controllers/dtos/waypoint.dto';
import { Validator } from 'class-validator';
describe('valid position indexes decorator', () => {
class MyClass {
@HasValidPositionIndexes()
waypoints: WaypointDto[];
}
it('should return a property decorator has a function', () => {
const hasValidPositionIndexes = HasValidPositionIndexes();
expect(typeof hasValidPositionIndexes).toBe('function');
});
it('should validate an array of waypoints with valid positions', async () => {
const myClassInstance = new MyClass();
myClassInstance.waypoints = [
{
position: 0,
lon: 48.8566,
lat: 2.3522,
locality: 'Paris',
postalCode: '75000',
country: 'France',
},
{
position: 1,
lon: 49.2628,
lat: 4.0347,
locality: 'Reims',
postalCode: '51454',
country: 'France',
},
];
const validator = new Validator();
const validation = await validator.validate(myClassInstance);
expect(validation.length).toBe(0);
});
it('should not validate an array of waypoints with invalid positions', async () => {
const myClassInstance = new MyClass();
myClassInstance.waypoints = [
{
position: 1,
lon: 48.8566,
lat: 2.3522,
locality: 'Paris',
postalCode: '75000',
country: 'France',
},
{
position: 1,
lon: 49.2628,
lat: 4.0347,
locality: 'Reims',
postalCode: '51454',
country: 'France',
},
];
const validator = new Validator();
const validation = await validator.validate(myClassInstance);
expect(validation.length).toBe(1);
});
});

View File

@ -0,0 +1,75 @@
import { hasValidPositionIndexes } from '@modules/matcher/interface/grpc-controllers/dtos/validators/has-valid-position-indexes.validator';
import { WaypointDto } from '@modules/matcher/interface/grpc-controllers/dtos/waypoint.dto';
describe('addresses position validator', () => {
const mockAddress1: WaypointDto = {
lon: 48.689445,
lat: 6.17651,
houseNumber: '5',
street: 'Avenue Foch',
locality: 'Nancy',
postalCode: '54000',
country: 'France',
};
const mockAddress2: WaypointDto = {
lon: 48.8566,
lat: 2.3522,
locality: 'Paris',
postalCode: '75000',
country: 'France',
};
const mockAddress3: WaypointDto = {
lon: 49.2628,
lat: 4.0347,
locality: 'Reims',
postalCode: '51454',
country: 'France',
};
it('should not validate if no position is defined', () => {
expect(
hasValidPositionIndexes([mockAddress1, mockAddress2, mockAddress3]),
).toBeFalsy();
});
it('should not validate if only one position is defined', () => {
mockAddress1.position = 0;
expect(
hasValidPositionIndexes([mockAddress1, mockAddress2, mockAddress3]),
).toBeFalsy();
});
it('should not validate if positions are partially defined', () => {
mockAddress1.position = 0;
mockAddress2.position = null;
mockAddress3.position = undefined;
expect(
hasValidPositionIndexes([mockAddress1, mockAddress2, mockAddress3]),
).toBeFalsy();
});
it('should not validate if multiple positions have same value', () => {
mockAddress1.position = 0;
mockAddress2.position = 1;
mockAddress3.position = 1;
expect(
hasValidPositionIndexes([mockAddress1, mockAddress2, mockAddress3]),
).toBeFalsy();
});
it('should validate if all positions are ordered', () => {
mockAddress1.position = 0;
mockAddress2.position = 1;
mockAddress3.position = 2;
expect(
hasValidPositionIndexes([mockAddress1, mockAddress2, mockAddress3]),
).toBeTruthy();
mockAddress1.position = 1;
mockAddress2.position = 2;
mockAddress3.position = 3;
expect(
hasValidPositionIndexes([mockAddress1, mockAddress2, mockAddress3]),
).toBeTruthy();
});
it('should not validate if no waypoints are defined', () => {
expect(hasValidPositionIndexes(undefined)).toBeFalsy();
expect(hasValidPositionIndexes([])).toBeFalsy();
});
});

View File

@ -0,0 +1,45 @@
import { IsAfterOrEqual } from '@modules/matcher/interface/grpc-controllers/dtos/validators/decorators/is-after-or-equal.decorator';
import { Validator } from 'class-validator';
describe('Is after or equal decorator', () => {
class MyClass {
firstDate: string;
@IsAfterOrEqual('firstDate', {
message: 'secondDate must be after or equal to firstDate',
})
secondDate: string;
}
it('should return a property decorator has a function', () => {
const isAfterOrEqual = IsAfterOrEqual('someProperty');
expect(typeof isAfterOrEqual).toBe('function');
});
it('should validate a secondDate posterior to firstDate', async () => {
const myClassInstance = new MyClass();
myClassInstance.firstDate = '2023-07-20';
myClassInstance.secondDate = '2023-07-30';
const validator = new Validator();
const validation = await validator.validate(myClassInstance);
expect(validation.length).toBe(0);
});
it('should not validate a secondDate prior to firstDate', async () => {
const myClassInstance = new MyClass();
myClassInstance.firstDate = '2023-07-20';
myClassInstance.secondDate = '2023-07-19';
const validator = new Validator();
const validation = await validator.validate(myClassInstance);
expect(validation.length).toBe(1);
});
it('should not validate if dates are invalid', async () => {
const myClassInstance = new MyClass();
myClassInstance.firstDate = '2023-07-40';
myClassInstance.secondDate = '2023-07-19';
const validator = new Validator();
const validation = await validator.validate(myClassInstance);
expect(validation.length).toBe(1);
});
});

View File

@ -0,0 +1,82 @@
import { Frequency } from '@modules/matcher/core/domain/match.types';
import { MatchRequestDto } from '@modules/matcher/interface/grpc-controllers/dtos/match.request.dto';
import { WaypointDto } from '@modules/matcher/interface/grpc-controllers/dtos/waypoint.dto';
import { MatchGrpcController } from '@modules/matcher/interface/grpc-controllers/match.grpc-controller';
import { QueryBus } from '@nestjs/cqrs';
import { Test, TestingModule } from '@nestjs/testing';
const originWaypoint: WaypointDto = {
position: 0,
lat: 48.689445,
lon: 6.17651,
houseNumber: '5',
street: 'Avenue Foch',
locality: 'Nancy',
postalCode: '54000',
country: 'France',
};
const destinationWaypoint: WaypointDto = {
position: 1,
lat: 48.8566,
lon: 2.3522,
locality: 'Paris',
postalCode: '75000',
country: 'France',
};
const punctualMatchRequestDto: MatchRequestDto = {
passenger: true,
frequency: Frequency.PUNCTUAL,
fromDate: '2023-08-15',
toDate: '2023-08-15',
schedule: [
{
time: '07:00',
},
],
waypoints: [originWaypoint, destinationWaypoint],
};
const mockQueryBus = {
execute: jest.fn().mockImplementation(() => [
{
adId: 1,
},
{
adId: 2,
},
]),
};
describe('Match Grpc Controller', () => {
let matchGrpcController: MatchGrpcController;
beforeAll(async () => {
const module: TestingModule = await Test.createTestingModule({
providers: [
MatchGrpcController,
{
provide: QueryBus,
useValue: mockQueryBus,
},
],
}).compile();
matchGrpcController = module.get<MatchGrpcController>(MatchGrpcController);
});
afterEach(async () => {
jest.clearAllMocks();
});
it('should be defined', () => {
expect(matchGrpcController).toBeDefined();
});
it('should return matches', async () => {
const matchPaginatedResponseDto = await matchGrpcController.match(
punctualMatchRequestDto,
);
expect(matchPaginatedResponseDto.data).toHaveLength(2);
});
});