diff --git a/.env.dist b/.env.dist index ceae12f..5157f6c 100644 --- a/.env.dist +++ b/.env.dist @@ -15,14 +15,14 @@ MESSAGE_BROKER_EXCHANGE=mobicoop REDIS_HOST=v3-redis REDIS_PASSWORD=redis REDIS_PORT=6379 +REDIS_MATCHING_KEY=MATCHER:MATCHING +REDIS_MATCHING_TTL=900 # CACHE CACHE_TTL=5000 # DEFAULT CONFIGURATION -# default identifier used for match requests -DEFAULT_UUID=00000000-0000-0000-0000-000000000000 # algorithm type ALGORITHM=PASSENGER_ORIENTED # max distance in metres between driver diff --git a/package-lock.json b/package-lock.json index ed5a5e3..ea93558 100644 --- a/package-lock.json +++ b/package-lock.json @@ -13,7 +13,7 @@ "@grpc/proto-loader": "^0.7.6", "@liaoliaots/nestjs-redis": "^9.0.5", "@mobicoop/configuration-module": "^1.2.0", - "@mobicoop/ddd-library": "^1.3.0", + "@mobicoop/ddd-library": "^1.5.0", "@mobicoop/health-module": "^2.0.0", "@mobicoop/message-broker-module": "^1.2.0", "@nestjs/axios": "^2.0.0", @@ -1505,9 +1505,9 @@ } }, "node_modules/@mobicoop/ddd-library": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/@mobicoop/ddd-library/-/ddd-library-1.3.0.tgz", - "integrity": "sha512-WQTOIzGvsoh3o43Kukb9NIbJw18lsfSqu3k3cMZxc2mmgaYD7MtS4Yif/+KayQ6Ea4Ve3Hc6BVDls2X6svsoOg==", + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/@mobicoop/ddd-library/-/ddd-library-1.5.0.tgz", + "integrity": "sha512-CX/V2+vSXrGtKobsyBfVpMW323ZT8tHrgUl1qrvU1XjRKNShvwsKyC7739x7CNgkJ9sr3XV+75JrOXEnqU83zw==", "dependencies": { "@nestjs/event-emitter": "^1.4.2", "@nestjs/microservices": "^9.4.0", diff --git a/package.json b/package.json index 8fdf7af..94f9b03 100644 --- a/package.json +++ b/package.json @@ -34,7 +34,7 @@ "@grpc/proto-loader": "^0.7.6", "@liaoliaots/nestjs-redis": "^9.0.5", "@mobicoop/configuration-module": "^1.2.0", - "@mobicoop/ddd-library": "^1.3.0", + "@mobicoop/ddd-library": "^1.5.0", "@mobicoop/health-module": "^2.0.0", "@mobicoop/message-broker-module": "^1.2.0", "@nestjs/axios": "^2.0.0", diff --git a/src/modules/ad/ad.di-tokens.ts b/src/modules/ad/ad.di-tokens.ts index e225a20..ce89895 100644 --- a/src/modules/ad/ad.di-tokens.ts +++ b/src/modules/ad/ad.di-tokens.ts @@ -1,4 +1,5 @@ export const AD_REPOSITORY = Symbol('AD_REPOSITORY'); +export const MATCHING_REPOSITORY = Symbol('MATCHING_REPOSITORY'); export const AD_DIRECTION_ENCODER = Symbol('AD_DIRECTION_ENCODER'); export const AD_MESSAGE_PUBLISHER = Symbol('AD_MESSAGE_PUBLISHER'); export const AD_GET_BASIC_ROUTE_CONTROLLER = Symbol( diff --git a/src/modules/ad/ad.module.ts b/src/modules/ad/ad.module.ts index 90348ac..3756e70 100644 --- a/src/modules/ad/ad.module.ts +++ b/src/modules/ad/ad.module.ts @@ -12,6 +12,7 @@ import { INPUT_DATETIME_TRANSFORMER, AD_GET_DETAILED_ROUTE_CONTROLLER, OUTPUT_DATETIME_TRANSFORMER, + MATCHING_REPOSITORY, } from './ad.di-tokens'; import { MessageBrokerPublisher } from '@mobicoop/message-broker-module'; import { AdRepository } from './infrastructure/ad.repository'; @@ -32,6 +33,8 @@ import { InputDateTimeTransformer } from './infrastructure/input-datetime-transf import { GetDetailedRouteController } from '@modules/geography/interface/controllers/get-detailed-route.controller'; import { MatchMapper } from './match.mapper'; import { OutputDateTimeTransformer } from './infrastructure/output-datetime-transformer'; +import { MatchingRepository } from './infrastructure/matching.repository'; +import { MatchingMapper } from './matching.mapper'; const grpcControllers = [MatchGrpcController]; @@ -41,13 +44,17 @@ const commandHandlers: Provider[] = [CreateAdService]; const queryHandlers: Provider[] = [MatchQueryHandler]; -const mappers: Provider[] = [AdMapper, MatchMapper]; +const mappers: Provider[] = [AdMapper, MatchMapper, MatchingMapper]; const repositories: Provider[] = [ { provide: AD_REPOSITORY, useClass: AdRepository, }, + { + provide: MATCHING_REPOSITORY, + useClass: MatchingRepository, + }, ]; const messagePublishers: Provider[] = [ diff --git a/src/modules/ad/core/application/ports/matching.repository.port.ts b/src/modules/ad/core/application/ports/matching.repository.port.ts new file mode 100644 index 0000000..f715684 --- /dev/null +++ b/src/modules/ad/core/application/ports/matching.repository.port.ts @@ -0,0 +1,6 @@ +import { MatchingEntity } from '../../domain/matching.entity'; + +export type MatchingRepositoryPort = { + get(id: string): Promise; + save(matching: MatchingEntity): Promise; +}; diff --git a/src/modules/ad/core/application/queries/match/match.query-handler.ts b/src/modules/ad/core/application/queries/match/match.query-handler.ts index 17300d1..603b438 100644 --- a/src/modules/ad/core/application/queries/match/match.query-handler.ts +++ b/src/modules/ad/core/application/queries/match/match.query-handler.ts @@ -1,5 +1,5 @@ import { IQueryHandler, QueryHandler } from '@nestjs/cqrs'; -import { MatchQuery } from './match.query'; +import { MatchQuery, ScheduleItem } from './match.query'; import { Algorithm } from './algorithm.abstract'; import { PassengerOrientedAlgorithm } from './passenger-oriented-algorithm'; import { AlgorithmType } from '../../types/algorithm.types'; @@ -8,12 +8,16 @@ import { AdRepositoryPort } from '@modules/ad/core/application/ports/ad.reposito import { AD_REPOSITORY, INPUT_DATETIME_TRANSFORMER, + MATCHING_REPOSITORY, PARAMS_PROVIDER, } from '@modules/ad/ad.di-tokens'; import { MatchEntity } from '@modules/ad/core/domain/match.entity'; import { DefaultParamsProviderPort } from '../../ports/default-params-provider.port'; import { DefaultParams } from '../../ports/default-params.type'; import { DateTimeTransformerPort } from '../../ports/datetime-transformer.port'; +import { Paginator } from '@mobicoop/ddd-library'; +import { MatchingEntity } from '@modules/ad/core/domain/matching.entity'; +import { MatchingRepositoryPort } from '../../ports/matching.repository.port'; @QueryHandler(MatchQuery) export class MatchQueryHandler implements IQueryHandler { @@ -22,14 +26,16 @@ export class MatchQueryHandler implements IQueryHandler { constructor( @Inject(PARAMS_PROVIDER) private readonly defaultParamsProvider: DefaultParamsProviderPort, - @Inject(AD_REPOSITORY) private readonly repository: AdRepositoryPort, + @Inject(AD_REPOSITORY) private readonly adRepository: AdRepositoryPort, + @Inject(MATCHING_REPOSITORY) + private readonly matchingRepository: MatchingRepositoryPort, @Inject(INPUT_DATETIME_TRANSFORMER) private readonly datetimeTransformer: DateTimeTransformerPort, ) { this._defaultParams = defaultParamsProvider.getParams(); } - execute = async (query: MatchQuery): Promise => { + execute = async (query: MatchQuery): Promise => { query .setMissingMarginDurations(this._defaultParams.DEPARTURE_TIME_MARGIN) .setMissingStrict(this._defaultParams.STRICT) @@ -60,8 +66,74 @@ export class MatchQueryHandler implements IQueryHandler { switch (query.algorithmType) { case AlgorithmType.PASSENGER_ORIENTED: default: - algorithm = new PassengerOrientedAlgorithm(query, this.repository); + algorithm = new PassengerOrientedAlgorithm(query, this.adRepository); } - return algorithm.match(); + + const matches: MatchEntity[] = await algorithm.match(); + const perPage: number = query.perPage as number; + const page: number = Paginator.pageNumber( + matches.length, + perPage, + query.page as number, + ); + // create Matching Entity for persistence + const matchingEntity: MatchingEntity = MatchingEntity.create({ + matches: matches.map((matchEntity: MatchEntity) => ({ + adId: matchEntity.getProps().adId, + role: matchEntity.getProps().role, + frequency: matchEntity.getProps().frequency, + distance: matchEntity.getProps().distance, + duration: matchEntity.getProps().duration, + initialDistance: matchEntity.getProps().initialDistance, + initialDuration: matchEntity.getProps().initialDuration, + distanceDetour: matchEntity.getProps().distanceDetour, + durationDetour: matchEntity.getProps().durationDetour, + distanceDetourPercentage: + matchEntity.getProps().distanceDetourPercentage, + durationDetourPercentage: + matchEntity.getProps().durationDetourPercentage, + journeys: matchEntity.getProps().journeys, + })), + query: { + driver: query.driver as boolean, + passenger: query.passenger as boolean, + frequency: query.frequency, + fromDate: query.fromDate, + toDate: query.toDate, + schedule: query.schedule.map((scheduleItem: ScheduleItem) => ({ + day: scheduleItem.day as number, + time: scheduleItem.time, + margin: scheduleItem.margin as number, + })), + seatsProposed: query.seatsProposed as number, + seatsRequested: query.seatsRequested as number, + strict: query.strict as boolean, + waypoints: query.waypoints, + algorithmType: query.algorithmType as AlgorithmType, + remoteness: query.remoteness as number, + useProportion: query.useProportion as boolean, + proportion: query.proportion as number, + useAzimuth: query.useAzimuth as boolean, + azimuthMargin: query.azimuthMargin as number, + maxDetourDistanceRatio: query.maxDetourDistanceRatio as number, + maxDetourDurationRatio: query.maxDetourDurationRatio as number, + }, + }); + await this.matchingRepository.save(matchingEntity); + return { + id: matchingEntity.id, + matches: Paginator.pageItems(matches, page, perPage), + total: matches.length, + page, + perPage, + }; }; } + +export type MatchingResult = { + id: string; + matches: MatchEntity[]; + total: number; + page: number; + perPage: number; +}; diff --git a/src/modules/ad/core/domain/matching.entity.ts b/src/modules/ad/core/domain/matching.entity.ts new file mode 100644 index 0000000..619721b --- /dev/null +++ b/src/modules/ad/core/domain/matching.entity.ts @@ -0,0 +1,19 @@ +import { AggregateRoot, AggregateID } from '@mobicoop/ddd-library'; +import { v4 } from 'uuid'; +import { CreateMatchingProps, MatchingProps } from './matching.types'; + +export class MatchingEntity extends AggregateRoot { + protected readonly _id: AggregateID; + + static create = (create: CreateMatchingProps): MatchingEntity => { + const id = v4(); + const props: MatchingProps = { + ...create, + }; + return new MatchingEntity({ id, props }); + }; + + validate(): void { + // entity business rules validation to protect it's invariant before saving entity to a database + } +} diff --git a/src/modules/ad/core/domain/matching.errors.ts b/src/modules/ad/core/domain/matching.errors.ts new file mode 100644 index 0000000..b1fee32 --- /dev/null +++ b/src/modules/ad/core/domain/matching.errors.ts @@ -0,0 +1,11 @@ +import { ExceptionBase } from '@mobicoop/ddd-library'; + +export class MatchingNotFoundException extends ExceptionBase { + static readonly message = 'Matching error'; + + public readonly code = 'MATCHER.MATCHING_NOT_FOUND'; + + constructor(cause?: Error, metadata?: unknown) { + super(MatchingNotFoundException.message, cause, metadata); + } +} diff --git a/src/modules/ad/core/domain/matching.types.ts b/src/modules/ad/core/domain/matching.types.ts new file mode 100644 index 0000000..ddc67fa --- /dev/null +++ b/src/modules/ad/core/domain/matching.types.ts @@ -0,0 +1,14 @@ +import { MatchProps } from './match.types'; +import { MatchQueryProps } from './value-objects/match-query.value-object'; + +// All properties that a Matching has +export interface MatchingProps { + query: MatchQueryProps; // the query that induced the matches + matches: MatchProps[]; +} + +// Properties that are needed for a Matching creation +export interface CreateMatchingProps { + query: MatchQueryProps; + matches: MatchProps[]; +} diff --git a/src/modules/ad/core/domain/value-objects/match-query.value-object.ts b/src/modules/ad/core/domain/value-objects/match-query.value-object.ts new file mode 100644 index 0000000..3b1e42b --- /dev/null +++ b/src/modules/ad/core/domain/value-objects/match-query.value-object.ts @@ -0,0 +1,109 @@ +import { ValueObject } from '@mobicoop/ddd-library'; +import { Frequency } from '../ad.types'; +import { ScheduleItemProps } from './schedule-item.value-object'; +import { PointProps } from './point.value-object'; + +/** Note: + * Value Objects with multiple properties can contain + * other Value Objects inside if needed. + * */ + +export interface MatchQueryProps { + driver: boolean; + passenger: boolean; + frequency: Frequency; + fromDate: string; + toDate: string; + schedule: ScheduleItemProps[]; + seatsProposed: number; + seatsRequested: number; + strict: boolean; + waypoints: PointProps[]; + algorithmType: string; + remoteness: number; + useProportion: boolean; + proportion: number; + useAzimuth: boolean; + azimuthMargin: number; + maxDetourDistanceRatio: number; + maxDetourDurationRatio: number; +} + +export class MatchQuery extends ValueObject { + get driver(): boolean { + return this.props.driver; + } + + get passenger(): boolean { + return this.props.passenger; + } + + get frequency(): Frequency { + return this.props.frequency; + } + + get fromDate(): string { + return this.props.fromDate; + } + + get toDate(): string { + return this.props.toDate; + } + + get schedule(): ScheduleItemProps[] { + return this.props.schedule; + } + + get seatsProposed(): number { + return this.props.seatsProposed; + } + + get seatsRequested(): number { + return this.props.seatsRequested; + } + + get strict(): boolean { + return this.props.strict; + } + + get waypoints(): PointProps[] { + return this.props.waypoints; + } + + get algorithmType(): string { + return this.props.algorithmType; + } + + get remoteness(): number { + return this.props.remoteness; + } + + get useProportion(): boolean { + return this.props.useProportion; + } + + get proportion(): number { + return this.props.proportion; + } + + get useAzimuth(): boolean { + return this.props.useAzimuth; + } + + get azimuthMargin(): number { + return this.props.azimuthMargin; + } + + get maxDetourDistanceRatio(): number { + return this.props.maxDetourDistanceRatio; + } + + get maxDetourDurationRatio(): number { + return this.props.maxDetourDurationRatio; + } + + // eslint-disable-next-line @typescript-eslint/no-unused-vars + protected validate(props: MatchQueryProps): void { + return; + } +} diff --git a/src/modules/ad/infrastructure/matching.repository.ts b/src/modules/ad/infrastructure/matching.repository.ts new file mode 100644 index 0000000..c1c5e1c --- /dev/null +++ b/src/modules/ad/infrastructure/matching.repository.ts @@ -0,0 +1,46 @@ +import { InjectRedis } from '@liaoliaots/nestjs-redis'; +import { MatchingRepositoryPort } from '../core/application/ports/matching.repository.port'; +import { MatchingEntity } from '../core/domain/matching.entity'; +import { Redis } from 'ioredis'; +import { MatchingMapper } from '../matching.mapper'; +import { ConfigService } from '@nestjs/config'; +import { MatchingNotFoundException } from '../core/domain/matching.errors'; + +const REDIS_MATCHING_TTL = 900; +const REDIS_MATCHING_KEY = 'MATCHER:MATCHING'; + +export class MatchingRepository implements MatchingRepositoryPort { + private _redisKey: string; + private _redisTtl: number; + constructor( + @InjectRedis() private readonly redis: Redis, + private readonly configService: ConfigService, + private readonly mapper: MatchingMapper, + ) { + this._redisKey = + this.configService.get('REDIS_MATCHING_KEY') !== undefined + ? (this.configService.get('REDIS_MATCHING_KEY') as string) + : REDIS_MATCHING_KEY; + this._redisTtl = + this.configService.get('REDIS_MATCHING_TTL') !== undefined + ? (this.configService.get('REDIS_MATCHING_TTL') as number) + : REDIS_MATCHING_TTL; + } + + get = async (matchingId: string): Promise => { + const matching: string | null = await this.redis.get( + `${this._redisKey}:${matchingId}`, + ); + if (matching) return this.mapper.toDomain(matching); + throw new MatchingNotFoundException(new Error('Matching not found')); + }; + + save = async (matching: MatchingEntity): Promise => { + await this.redis.set( + `${this._redisKey}:${matching.id}`, + this.mapper.toPersistence(matching), + 'EX', + this._redisTtl, + ); + }; +} diff --git a/src/modules/ad/infrastructure/time-converter.ts b/src/modules/ad/infrastructure/time-converter.ts index fc3314f..462473c 100644 --- a/src/modules/ad/infrastructure/time-converter.ts +++ b/src/modules/ad/infrastructure/time-converter.ts @@ -33,7 +33,7 @@ export class TimeConverter implements TimeConverterPort { date: string, time: string, timezone: string, - dst?: boolean, + dst = false, ): string => new DateTime(`${date}T${time}`, TimeZone.zone('UTC')) .convert(TimeZone.zone(timezone, dst)) diff --git a/src/modules/ad/interface/dtos/id-paginated.reponse.dto.ts b/src/modules/ad/interface/dtos/id-paginated.reponse.dto.ts new file mode 100644 index 0000000..53c467b --- /dev/null +++ b/src/modules/ad/interface/dtos/id-paginated.reponse.dto.ts @@ -0,0 +1,7 @@ +import { PaginatedResponseDto } from '@mobicoop/ddd-library'; + +export abstract class IdPaginatedResponseDto< + T, +> extends PaginatedResponseDto { + readonly id: string; +} diff --git a/src/modules/ad/interface/dtos/match.paginated.response.dto.ts b/src/modules/ad/interface/dtos/match.paginated.response.dto.ts deleted file mode 100644 index 52f75c8..0000000 --- a/src/modules/ad/interface/dtos/match.paginated.response.dto.ts +++ /dev/null @@ -1,6 +0,0 @@ -import { PaginatedResponseDto } from '@mobicoop/ddd-library'; -import { MatchResponseDto } from './match.response.dto'; - -export class MatchPaginatedResponseDto extends PaginatedResponseDto { - readonly data: readonly MatchResponseDto[]; -} diff --git a/src/modules/ad/interface/dtos/matching.paginated.response.dto.ts b/src/modules/ad/interface/dtos/matching.paginated.response.dto.ts new file mode 100644 index 0000000..14d044e --- /dev/null +++ b/src/modules/ad/interface/dtos/matching.paginated.response.dto.ts @@ -0,0 +1,11 @@ +import { MatchResponseDto } from './match.response.dto'; +import { IdPaginatedResponseDto } from './id-paginated.reponse.dto'; + +export class MatchingPaginatedResponseDto extends IdPaginatedResponseDto { + readonly id: string; + readonly data: readonly MatchResponseDto[]; + constructor(props: IdPaginatedResponseDto) { + super(props); + this.id = props.id; + } +} diff --git a/src/modules/ad/interface/grpc-controllers/match.grpc-controller.ts b/src/modules/ad/interface/grpc-controllers/match.grpc-controller.ts index cbdf93e..930eaa9 100644 --- a/src/modules/ad/interface/grpc-controllers/match.grpc-controller.ts +++ b/src/modules/ad/interface/grpc-controllers/match.grpc-controller.ts @@ -2,7 +2,7 @@ import { Controller, Inject, 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 { MatchingPaginatedResponseDto } from '../dtos/matching.paginated.response.dto'; import { QueryBus } from '@nestjs/cqrs'; import { MatchRequestDto } from './dtos/match.request.dto'; import { MatchQuery } from '@modules/ad/core/application/queries/match/match.query'; @@ -10,6 +10,7 @@ import { MatchEntity } from '@modules/ad/core/domain/match.entity'; import { AD_ROUTE_PROVIDER } from '@modules/ad/ad.di-tokens'; import { RouteProviderPort } from '@modules/ad/core/application/ports/route-provider.port'; import { MatchMapper } from '@modules/ad/match.mapper'; +import { MatchingResult } from '@modules/ad/core/application/queries/match/match.query-handler'; @UsePipes( new RpcValidationPipe({ @@ -27,18 +28,19 @@ export class MatchGrpcController { ) {} @GrpcMethod('MatcherService', 'Match') - async match(data: MatchRequestDto): Promise { + async match(data: MatchRequestDto): Promise { try { - const matches: MatchEntity[] = await this.queryBus.execute( + const matchingResult: MatchingResult = await this.queryBus.execute( new MatchQuery(data, this.routeProvider), ); - return new MatchPaginatedResponseDto({ - data: matches.map((match: MatchEntity) => + return new MatchingPaginatedResponseDto({ + id: matchingResult.id, + data: matchingResult.matches.map((match: MatchEntity) => this.matchMapper.toResponse(match), ), - page: 1, - perPage: 5, - total: matches.length, + page: matchingResult.page, + perPage: matchingResult.perPage, + total: matchingResult.total, }); } catch (e) { throw new RpcException({ diff --git a/src/modules/ad/interface/grpc-controllers/matcher.proto b/src/modules/ad/interface/grpc-controllers/matcher.proto index 44dae76..9e2447e 100644 --- a/src/modules/ad/interface/grpc-controllers/matcher.proto +++ b/src/modules/ad/interface/grpc-controllers/matcher.proto @@ -24,6 +24,8 @@ message MatchRequest { float maxDetourDistanceRatio = 15; float maxDetourDurationRatio = 16; int32 identifier = 22; + optional int32 page = 23; + optional int32 perPage = 24; } message ScheduleItem { @@ -90,6 +92,9 @@ message Actor { } message Matches { - repeated Match data = 1; - int32 total = 2; + string id = 1; + repeated Match data = 2; + int32 total = 3; + int32 page = 4; + int32 perPage = 5; } diff --git a/src/modules/ad/matching.mapper.ts b/src/modules/ad/matching.mapper.ts new file mode 100644 index 0000000..e470f44 --- /dev/null +++ b/src/modules/ad/matching.mapper.ts @@ -0,0 +1,13 @@ +import { Injectable } from '@nestjs/common'; +import { Mapper } from '@mobicoop/ddd-library'; +import { MatchingEntity } from './core/domain/matching.entity'; + +@Injectable() +export class MatchingMapper + implements Mapper +{ + toPersistence = (entity: MatchingEntity): string => JSON.stringify(entity); + + toDomain = (record: string): MatchingEntity => + new MatchingEntity(JSON.parse(record)); +} diff --git a/src/modules/ad/tests/unit/core/match-query.value-object.spec.ts b/src/modules/ad/tests/unit/core/match-query.value-object.spec.ts new file mode 100644 index 0000000..c774714 --- /dev/null +++ b/src/modules/ad/tests/unit/core/match-query.value-object.spec.ts @@ -0,0 +1,61 @@ +import { AlgorithmType } from '@modules/ad/core/application/types/algorithm.types'; +import { Frequency } from '@modules/ad/core/domain/ad.types'; +import { MatchQuery } from '@modules/ad/core/domain/value-objects/match-query.value-object'; + +describe('Match Query value object', () => { + it('should create a match query value object', () => { + const matchQueryVO = new MatchQuery({ + driver: false, + passenger: true, + frequency: Frequency.PUNCTUAL, + fromDate: '2023-09-01', + toDate: '2023-09-01', + schedule: [ + { + day: 5, + time: '07:10', + margin: 900, + }, + ], + seatsProposed: 3, + seatsRequested: 1, + strict: false, + waypoints: [ + { + lat: 48.689445, + lon: 6.17651, + }, + { + lat: 48.21548, + lon: 5.65874, + }, + ], + algorithmType: AlgorithmType.PASSENGER_ORIENTED, + remoteness: 15000, + useProportion: true, + proportion: 0.3, + useAzimuth: true, + azimuthMargin: 10, + maxDetourDistanceRatio: 0.3, + maxDetourDurationRatio: 0.3, + }); + expect(matchQueryVO.driver).toBe(false); + expect(matchQueryVO.passenger).toBe(true); + expect(matchQueryVO.frequency).toBe(Frequency.PUNCTUAL); + expect(matchQueryVO.fromDate).toBe('2023-09-01'); + expect(matchQueryVO.toDate).toBe('2023-09-01'); + expect(matchQueryVO.schedule.length).toBe(1); + expect(matchQueryVO.seatsProposed).toBe(3); + expect(matchQueryVO.seatsRequested).toBe(1); + expect(matchQueryVO.strict).toBe(false); + expect(matchQueryVO.waypoints.length).toBe(2); + expect(matchQueryVO.algorithmType).toBe(AlgorithmType.PASSENGER_ORIENTED); + expect(matchQueryVO.remoteness).toBe(15000); + expect(matchQueryVO.useProportion).toBe(true); + expect(matchQueryVO.proportion).toBe(0.3); + expect(matchQueryVO.useAzimuth).toBe(true); + expect(matchQueryVO.azimuthMargin).toBe(10); + expect(matchQueryVO.maxDetourDistanceRatio).toBe(0.3); + expect(matchQueryVO.maxDetourDurationRatio).toBe(0.3); + }); +}); diff --git a/src/modules/ad/tests/unit/core/match.query-handler.spec.ts b/src/modules/ad/tests/unit/core/match.query-handler.spec.ts index 1f308f2..7aeed8f 100644 --- a/src/modules/ad/tests/unit/core/match.query-handler.spec.ts +++ b/src/modules/ad/tests/unit/core/match.query-handler.spec.ts @@ -1,17 +1,22 @@ import { AD_REPOSITORY, INPUT_DATETIME_TRANSFORMER, + MATCHING_REPOSITORY, PARAMS_PROVIDER, } from '@modules/ad/ad.di-tokens'; import { DateTimeTransformerPort } from '@modules/ad/core/application/ports/datetime-transformer.port'; import { DefaultParamsProviderPort } from '@modules/ad/core/application/ports/default-params-provider.port'; +import { MatchingRepositoryPort } from '@modules/ad/core/application/ports/matching.repository.port'; import { RouteProviderPort } from '@modules/ad/core/application/ports/route-provider.port'; import { MatchQuery } from '@modules/ad/core/application/queries/match/match.query'; -import { MatchQueryHandler } from '@modules/ad/core/application/queries/match/match.query-handler'; +import { + MatchQueryHandler, + MatchingResult, +} from '@modules/ad/core/application/queries/match/match.query-handler'; import { AlgorithmType } from '@modules/ad/core/application/types/algorithm.types'; import { Waypoint } from '@modules/ad/core/application/types/waypoint.type'; import { Frequency, Role } from '@modules/ad/core/domain/ad.types'; -import { MatchEntity } from '@modules/ad/core/domain/match.entity'; +import { MatchingEntity } from '@modules/ad/core/domain/matching.entity'; import { Test, TestingModule } from '@nestjs/testing'; const originWaypoint: Waypoint = { @@ -51,9 +56,30 @@ const mockAdRepository = { ], })), }, + { + id: '4431adea-2e10-4032-a743-01d537058914', + getProps: jest.fn().mockImplementation(() => ({ + role: Role.DRIVER, + waypoints: [ + { + lat: 48.698754, + lon: 6.159874, + }, + { + lat: 48.969874, + lon: 2.449875, + }, + ], + })), + }, ]), }; +const mockMatchingRepository: MatchingRepositoryPort = { + get: jest.fn(), + save: jest.fn(), +}; + const mockDefaultParamsProvider: DefaultParamsProviderPort = { getParams: () => { return { @@ -107,6 +133,10 @@ describe('Match Query Handler', () => { provide: AD_REPOSITORY, useValue: mockAdRepository, }, + { + provide: MATCHING_REPOSITORY, + useValue: mockMatchingRepository, + }, { provide: PARAMS_PROVIDER, useValue: mockDefaultParamsProvider, @@ -125,7 +155,8 @@ describe('Match Query Handler', () => { expect(matchQueryHandler).toBeDefined(); }); - it('should return a Match entity', async () => { + it('should return a Matching', async () => { + jest.spyOn(MatchingEntity, 'create'); const matchQuery = new MatchQuery( { algorithmType: AlgorithmType.PASSENGER_ORIENTED, @@ -146,7 +177,10 @@ describe('Match Query Handler', () => { }, mockRouteProvider, ); - const matches: MatchEntity[] = await matchQueryHandler.execute(matchQuery); - expect(matches.length).toBeGreaterThanOrEqual(0); + const matching: MatchingResult = await matchQueryHandler.execute( + matchQuery, + ); + expect(matching.id).toHaveLength(36); + expect(MatchingEntity.create).toHaveBeenCalledTimes(1); }); }); diff --git a/src/modules/ad/tests/unit/infrastructure/matching.repository.spec.ts b/src/modules/ad/tests/unit/infrastructure/matching.repository.spec.ts new file mode 100644 index 0000000..67ae1ee --- /dev/null +++ b/src/modules/ad/tests/unit/infrastructure/matching.repository.spec.ts @@ -0,0 +1,228 @@ +import { getRedisToken } from '@liaoliaots/nestjs-redis'; +import { Frequency, Role } from '@modules/ad/core/domain/ad.types'; +import { Target } from '@modules/ad/core/domain/candidate.types'; +import { MatchingEntity } from '@modules/ad/core/domain/matching.entity'; +import { MatchingNotFoundException } from '@modules/ad/core/domain/matching.errors'; +import { MatchingRepository } from '@modules/ad/infrastructure/matching.repository'; +import { MatchingMapper } from '@modules/ad/matching.mapper'; +import { ConfigService } from '@nestjs/config'; +import { Test, TestingModule } from '@nestjs/testing'; + +const mockConfigService = { + get: jest.fn().mockImplementation((value: string) => { + switch (value) { + case 'REDIS_MATCHING_KEY': + return 'MATCHER:MATCHING'; + case 'REDIS_MATCHING_TTL': + return 900; + default: + return 'some_default_value'; + } + }), +}; + +const mockEmptyConfigService = { + get: jest.fn().mockImplementation(() => ({})), +}; + +const mockRedis = { + get: jest + .fn() + .mockImplementationOnce(() => null) + .mockImplementation( + () => + '{"_id":"644a7cb3-6436-4db5-850d-b4c7421d4b97","_createdAt":"2023-09-27T15:19:36.487Z","_updatedAt":"2023-09-27T15:19:36.487Z","props":{"matches":[{"adId":"2dfed880-28ad-4a2f-83d9-b8b45677387b","role":"DRIVER","frequency":"RECURRENT","distance":509967,"duration":17404,"initialDistance":495197,"initialDuration":16589,"distanceDetour":14770,"durationDetour":815,"distanceDetourPercentage":2.98,"durationDetourPercentage":4.91,"journeys":[{"props":{"firstDate":"2024-01-07T00:00:00.000Z","lastDate":"2024-06-30T00:00:00.000Z","journeyItems":[{"props":{"lon":0.683083,"lat":47.503445,"duration":17,"distance":0,"actorTimes":[{"props":{"role":"DRIVER","target":"START","firstDatetime":"2024-01-07T20:00:00.000Z","firstMinDatetime":"2024-01-07T19:45:00.000Z","firstMaxDatetime":"2024-01-07T20:15:00.000Z","lastDatetime":"2024-06-30T20:00:00.000Z","lastMinDatetime":"2024-06-30T19:45:00.000Z","lastMaxDatetime":"2024-06-30T20:15:00.000Z"}}]}},{"props":{"lon":0.364394,"lat":46.607501,"duration":4199,"distance":112695,"actorTimes":[{"props":{"role":"PASSENGER","target":"START","firstDatetime":"2024-01-07T21:30:00.000Z","firstMinDatetime":"2024-01-07T21:15:00.000Z","firstMaxDatetime":"2024-01-07T21:45:00.000Z","lastDatetime":"2024-06-30T21:30:00.000Z","lastMinDatetime":"2024-06-30T21:15:00.000Z","lastMaxDatetime":"2024-06-30T21:45:00.000Z"}},{"props":{"role":"DRIVER","target":"NEUTRAL","firstDatetime":"2024-01-07T21:09:59.000Z","firstMinDatetime":"2024-01-07T20:54:59.000Z","firstMaxDatetime":"2024-01-07T21:24:59.000Z","lastDatetime":"2024-06-30T21:09:59.000Z","lastMinDatetime":"2024-06-30T20:54:59.000Z","lastMaxDatetime":"2024-06-30T21:24:59.000Z"}}]}},{"props":{"lon":0.559606,"lat":44.175994,"duration":16975,"distance":503502,"actorTimes":[{"props":{"role":"PASSENGER","target":"FINISH","firstDatetime":"2024-01-08T00:42:55.000Z","firstMinDatetime":"2024-01-08T00:27:55.000Z","firstMaxDatetime":"2024-01-08T00:57:55.000Z","lastDatetime":"2024-07-01T00:42:55.000Z","lastMinDatetime":"2024-07-01T00:27:55.000Z","lastMaxDatetime":"2024-07-01T00:57:55.000Z"}},{"props":{"role":"DRIVER","target":"NEUTRAL","firstDatetime":"2024-01-08T00:42:55.000Z","firstMinDatetime":"2024-01-08T00:27:55.000Z","firstMaxDatetime":"2024-01-08T00:57:55.000Z","lastDatetime":"2024-07-01T00:42:55.000Z","lastMinDatetime":"2024-07-01T00:27:55.000Z","lastMaxDatetime":"2024-07-01T00:57:55.000Z"}}]}},{"props":{"lon":0.610873,"lat":44.204195,"duration":17395,"distance":509967,"actorTimes":[{"props":{"role":"DRIVER","target":"FINISH","firstDatetime":"2024-01-08T00:49:55.000Z","firstMinDatetime":"2024-01-08T00:34:55.000Z","firstMaxDatetime":"2024-01-08T01:04:55.000Z","lastDatetime":"2024-07-01T00:49:55.000Z","lastMinDatetime":"2024-07-01T00:34:55.000Z","lastMaxDatetime":"2024-07-01T01:04:55.000Z"}}]}}]}}]},{"adId":"57bc4da9-1ac2-4c63-acc7-5ff1fe6bc380","role":"DRIVER","frequency":"RECURRENT","distance":491989,"duration":18170,"initialDistance":477219,"initialDuration":17355,"distanceDetour":14770,"durationDetour":815,"distanceDetourPercentage":3.1,"durationDetourPercentage":4.7,"journeys":[{"props":{"firstDate":"2024-01-07T00:00:00.000Z","lastDate":"2024-06-30T00:00:00.000Z","journeyItems":[{"props":{"lon":0.683083,"lat":47.503445,"duration":17,"distance":0,"actorTimes":[{"props":{"role":"DRIVER","target":"START","firstDatetime":"2024-01-07T20:10:00.000Z","firstMinDatetime":"2024-01-07T19:55:00.000Z","firstMaxDatetime":"2024-01-07T20:25:00.000Z","lastDatetime":"2024-06-30T20:10:00.000Z","lastMinDatetime":"2024-06-30T19:55:00.000Z","lastMaxDatetime":"2024-06-30T20:25:00.000Z"}}]}},{"props":{"lon":0.364394,"lat":46.607501,"duration":4199,"distance":112695,"actorTimes":[{"props":{"role":"PASSENGER","target":"START","firstDatetime":"2024-01-07T21:30:00.000Z","firstMinDatetime":"2024-01-07T21:15:00.000Z","firstMaxDatetime":"2024-01-07T21:45:00.000Z","lastDatetime":"2024-06-30T21:30:00.000Z","lastMinDatetime":"2024-06-30T21:15:00.000Z","lastMaxDatetime":"2024-06-30T21:45:00.000Z"}},{"props":{"role":"DRIVER","target":"NEUTRAL","firstDatetime":"2024-01-07T21:19:59.000Z","firstMinDatetime":"2024-01-07T21:04:59.000Z","firstMaxDatetime":"2024-01-07T21:34:59.000Z","lastDatetime":"2024-06-30T21:19:59.000Z","lastMinDatetime":"2024-06-30T21:04:59.000Z","lastMaxDatetime":"2024-06-30T21:34:59.000Z"}}]}},{"props":{"lon":0.192701,"lat":46.029224,"duration":7195,"distance":190046,"actorTimes":[{"props":{"role":"DRIVER","target":"INTERMEDIATE","firstDatetime":"2024-01-07T22:09:55.000Z","firstMinDatetime":"2024-01-07T21:54:55.000Z","firstMaxDatetime":"2024-01-07T22:24:55.000Z","lastDatetime":"2024-06-30T22:09:55.000Z","lastMinDatetime":"2024-06-30T21:54:55.000Z","lastMaxDatetime":"2024-06-30T22:24:55.000Z"}}]}},{"props":{"lon":0.559606,"lat":44.175994,"duration":17741,"distance":485523,"actorTimes":[{"props":{"role":"PASSENGER","target":"FINISH","firstDatetime":"2024-01-08T01:05:41.000Z","firstMinDatetime":"2024-01-08T00:50:41.000Z","firstMaxDatetime":"2024-01-08T01:20:41.000Z","lastDatetime":"2024-07-01T01:05:41.000Z","lastMinDatetime":"2024-07-01T00:50:41.000Z","lastMaxDatetime":"2024-07-01T01:20:41.000Z"}},{"props":{"role":"DRIVER","target":"NEUTRAL","firstDatetime":"2024-01-08T01:05:41.000Z","firstMinDatetime":"2024-01-08T00:50:41.000Z","firstMaxDatetime":"2024-01-08T01:20:41.000Z","lastDatetime":"2024-07-01T01:05:41.000Z","lastMinDatetime":"2024-07-01T00:50:41.000Z","lastMaxDatetime":"2024-07-01T01:20:41.000Z"}}]}},{"props":{"lon":0.610873,"lat":44.204195,"duration":18161,"distance":491989,"actorTimes":[{"props":{"role":"DRIVER","target":"FINISH","firstDatetime":"2024-01-08T01:12:41.000Z","firstMinDatetime":"2024-01-08T00:57:41.000Z","firstMaxDatetime":"2024-01-08T01:27:41.000Z","lastDatetime":"2024-07-01T01:12:41.000Z","lastMinDatetime":"2024-07-01T00:57:41.000Z","lastMaxDatetime":"2024-07-01T01:27:41.000Z"}}]}}]}},{"props":{"firstDate":"2024-01-04T00:00:00.000Z","lastDate":"2024-06-27T00:00:00.000Z","journeyItems":[{"props":{"lon":0.683083,"lat":47.503445,"duration":17,"distance":0,"actorTimes":[{"props":{"role":"DRIVER","target":"START","firstDatetime":"2024-01-04T21:00:00.000Z","firstMinDatetime":"2024-01-04T20:45:00.000Z","firstMaxDatetime":"2024-01-04T21:15:00.000Z","lastDatetime":"2024-06-27T21:00:00.000Z","lastMinDatetime":"2024-06-27T20:45:00.000Z","lastMaxDatetime":"2024-06-27T21:15:00.000Z"}}]}},{"props":{"lon":0.364394,"lat":46.607501,"duration":4199,"distance":112695,"actorTimes":[{"props":{"role":"PASSENGER","target":"START","firstDatetime":"2024-01-04T22:20:00.000Z","firstMinDatetime":"2024-01-04T22:05:00.000Z","firstMaxDatetime":"2024-01-04T22:35:00.000Z","lastDatetime":"2024-06-27T22:20:00.000Z","lastMinDatetime":"2024-06-27T22:05:00.000Z","lastMaxDatetime":"2024-06-27T22:35:00.000Z"}},{"props":{"role":"DRIVER","target":"NEUTRAL","firstDatetime":"2024-01-04T22:09:59.000Z","firstMinDatetime":"2024-01-04T21:54:59.000Z","firstMaxDatetime":"2024-01-04T22:24:59.000Z","lastDatetime":"2024-06-27T22:09:59.000Z","lastMinDatetime":"2024-06-27T21:54:59.000Z","lastMaxDatetime":"2024-06-27T22:24:59.000Z"}}]}},{"props":{"lon":0.192701,"lat":46.029224,"duration":7195,"distance":190046,"actorTimes":[{"props":{"role":"DRIVER","target":"INTERMEDIATE","firstDatetime":"2024-01-04T22:59:55.000Z","firstMinDatetime":"2024-01-04T22:44:55.000Z","firstMaxDatetime":"2024-01-04T23:14:55.000Z","lastDatetime":"2024-06-27T22:59:55.000Z","lastMinDatetime":"2024-06-27T22:44:55.000Z","lastMaxDatetime":"2024-06-27T23:14:55.000Z"}}]}},{"props":{"lon":0.559606,"lat":44.175994,"duration":17741,"distance":485523,"actorTimes":[{"props":{"role":"PASSENGER","target":"FINISH","firstDatetime":"2024-01-05T01:55:41.000Z","firstMinDatetime":"2024-01-05T01:40:41.000Z","firstMaxDatetime":"2024-01-05T02:10:41.000Z","lastDatetime":"2024-06-28T01:55:41.000Z","lastMinDatetime":"2024-06-28T01:40:41.000Z","lastMaxDatetime":"2024-06-28T02:10:41.000Z"}},{"props":{"role":"DRIVER","target":"NEUTRAL","firstDatetime":"2024-01-05T01:55:41.000Z","firstMinDatetime":"2024-01-05T01:40:41.000Z","firstMaxDatetime":"2024-01-05T02:10:41.000Z","lastDatetime":"2024-06-28T01:55:41.000Z","lastMinDatetime":"2024-06-28T01:40:41.000Z","lastMaxDatetime":"2024-06-28T02:10:41.000Z"}}]}},{"props":{"lon":0.610873,"lat":44.204195,"duration":18161,"distance":491989,"actorTimes":[{"props":{"role":"DRIVER","target":"FINISH","firstDatetime":"2024-01-05T02:02:41.000Z","firstMinDatetime":"2024-01-05T01:47:41.000Z","firstMaxDatetime":"2024-01-05T02:17:41.000Z","lastDatetime":"2024-06-28T02:02:41.000Z","lastMinDatetime":"2024-06-28T01:47:41.000Z","lastMaxDatetime":"2024-06-28T02:17:41.000Z"}}]}}]}}]}],"query":{"driver":false,"passenger":true,"frequency":"RECURRENT","fromDate":"2024-01-02","toDate":"2024-06-30","schedule":[{"day":0,"time":"21:30","margin":900},{"day":4,"time":"22:20","margin":900}],"seatsProposed":3,"seatsRequested":1,"strict":true,"waypoints":[{"position":0,"lon":0.364394,"lat":46.607501,"houseNumber":"298","street":"Aveue de la liberté","locality":"Buxerolles","postalCode":"86180","country":"France"},{"position":1,"lon":0.559606,"lat":44.175994,"houseNumber":"1","street":"place du 8 mai 1945","locality":"Roquefort","postalCode":"47310","country":"France"}],"algorithmType":"PASSENGER_ORIENTED","remoteness":15000,"useProportion":true,"proportion":0.3,"useAzimuth":true,"azimuthMargin":10,"maxDetourDistanceRatio":0.3,"maxDetourDurationRatio":0.3}},"_domainEvents":[]}', + ), + set: jest.fn(), +}; + +const mockMapper = { + toDomain: jest.fn().mockImplementation(() => ({ + id: '644a7cb3-6436-4db5-850d-b4c7421d4b97', + createdAt: '2023-09-27T15:19:36.487Z', + updatedAt: '2023-09-27T15:19:36.487Z', + props: [], + })), + toPersistence: jest.fn(), +}; + +const matchingEntity: MatchingEntity = new MatchingEntity({ + id: '644a7cb3-6436-4db5-850d-b4c7421d4b97', + createdAt: new Date(), + updatedAt: new Date(), + props: { + matches: [ + { + adId: 'dd937edf-1264-4868-b073-d1952abe30b1', + role: Role.DRIVER, + frequency: Frequency.PUNCTUAL, + distance: 356041, + duration: 12647, + initialDistance: 348745, + initialDuration: 12105, + distanceDetour: 7296, + durationDetour: 542, + distanceDetourPercentage: 4.1, + durationDetourPercentage: 3.8, + journeys: [ + { + firstDate: new Date('2023-09-01'), + lastDate: new Date('2023-09-01'), + journeyItems: [ + { + lon: 6.35484, + lat: 48.26587, + duration: 0, + distance: 0, + actorTimes: [ + { + role: Role.DRIVER, + target: Target.START, + firstDatetime: new Date('2023-09-01T07:00:00Z'), + firstMinDatetime: new Date('2023-09-01T06:45:00Z'), + firstMaxDatetime: new Date('2023-09-01T07:15:00Z'), + lastDatetime: new Date('2023-09-01T07:00:00Z'), + lastMinDatetime: new Date('2023-09-01T06:45:00Z'), + lastMaxDatetime: new Date('2023-09-01T07:15:00Z'), + }, + ], + }, + ], + }, + ], + // ... + }, + ], + query: { + driver: false, + passenger: true, + frequency: Frequency.PUNCTUAL, + fromDate: '2023-09-01', + toDate: '2023-09-01', + schedule: [ + { + day: 5, + time: '06:40', + margin: 900, + }, + ], + seatsProposed: 3, + seatsRequested: 1, + strict: true, + waypoints: [ + { + lon: 6.389745, + lat: 48.32644, + }, + { + lon: 6.984567, + lat: 48.021548, + }, + ], + algorithmType: 'PASSENGER_ORIENTED', + remoteness: 15000, + useProportion: true, + proportion: 0.3, + useAzimuth: true, + azimuthMargin: 10, + maxDetourDistanceRatio: 0.3, + maxDetourDurationRatio: 0.3, + }, + }, +}); + +describe('Matching repository', () => { + let matchingRepository: MatchingRepository; + + beforeAll(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [ + MatchingRepository, + { + provide: getRedisToken('default'), + useValue: mockRedis, + }, + { + provide: ConfigService, + useValue: mockConfigService, + }, + { + provide: MatchingMapper, + useValue: mockMapper, + }, + ], + }).compile(); + + matchingRepository = module.get(MatchingRepository); + }); + + afterEach(async () => { + jest.clearAllMocks(); + }); + + it('should be defined', () => { + expect(matchingRepository).toBeDefined(); + }); + it('should throw an exception if a matching is not found', async () => { + await expect( + matchingRepository.get('644a7cb3-6436-4db5-850d-b4c7421d4b98'), + ).rejects.toBeInstanceOf(MatchingNotFoundException); + }); + it('should get a matching', async () => { + const matching: MatchingEntity = await matchingRepository.get( + '644a7cb3-6436-4db5-850d-b4c7421d4b97', + ); + expect(matching.id).toBe('644a7cb3-6436-4db5-850d-b4c7421d4b97'); + }); + it('should save a matching', async () => { + jest.spyOn(mockRedis, 'set'); + await matchingRepository.save(matchingEntity); + expect(mockRedis.set).toHaveBeenCalledTimes(1); + }); +}); + +describe('Matching repository without env vars', () => { + let secondMatchingRepository: MatchingRepository; + + beforeAll(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [ + MatchingRepository, + { + provide: getRedisToken('default'), + useValue: mockRedis, + }, + { + provide: ConfigService, + useValue: mockEmptyConfigService, + }, + { + provide: MatchingMapper, + useValue: mockMapper, + }, + ], + }).compile(); + + secondMatchingRepository = + module.get(MatchingRepository); + }); + + afterEach(async () => { + jest.clearAllMocks(); + }); + + it('should be defined', () => { + expect(secondMatchingRepository).toBeDefined(); + }); + it('should get a matching', async () => { + const matching: MatchingEntity = await secondMatchingRepository.get( + '644a7cb3-6436-4db5-850d-b4c7421d4b97', + ); + expect(matching.id).toBe('644a7cb3-6436-4db5-850d-b4c7421d4b97'); + }); + it('should save a matching', async () => { + jest.spyOn(mockRedis, 'set'); + await secondMatchingRepository.save(matchingEntity); + expect(mockRedis.set).toHaveBeenCalledTimes(1); + }); +}); diff --git a/src/modules/ad/tests/unit/infrastructure/time-converter.spec.ts b/src/modules/ad/tests/unit/infrastructure/time-converter.spec.ts index ce8ca75..45941f1 100644 --- a/src/modules/ad/tests/unit/infrastructure/time-converter.spec.ts +++ b/src/modules/ad/tests/unit/infrastructure/time-converter.spec.ts @@ -52,7 +52,7 @@ describe('Time Converter', () => { }); describe('localStringDateTimeToUtcDate', () => { - it('should convert a summer paris date and time to a utc date', () => { + it('should convert a summer paris date and time to a utc date with dst', () => { const timeConverter: TimeConverter = new TimeConverter(); const parisDate = '2023-06-22'; const parisTime = '12:00'; @@ -64,7 +64,7 @@ describe('Time Converter', () => { ); expect(utcDate.toISOString()).toBe('2023-06-22T10:00:00.000Z'); }); - it('should convert a winter paris date and time to a utc date', () => { + it('should convert a winter paris date and time to a utc date with dst', () => { const timeConverter: TimeConverter = new TimeConverter(); const parisDate = '2023-02-02'; const parisTime = '12:00'; @@ -72,6 +72,7 @@ describe('Time Converter', () => { parisDate, parisTime, 'Europe/Paris', + true, ); expect(utcDate.toISOString()).toBe('2023-02-02T11:00:00.000Z'); }); @@ -83,7 +84,6 @@ describe('Time Converter', () => { parisDate, parisTime, 'Europe/Paris', - false, ); expect(utcDate.toISOString()).toBe('2023-06-22T11:00:00.000Z'); }); @@ -148,6 +148,30 @@ describe('Time Converter', () => { }); describe('utcStringDateTimeToLocalIsoString', () => { + it('should convert a utc string date and time to a summer paris date isostring with dst', () => { + const timeConverter: TimeConverter = new TimeConverter(); + const utcDate = '2023-06-22'; + const utcTime = '10:00'; + const localIsoString = timeConverter.utcStringDateTimeToLocalIsoString( + utcDate, + utcTime, + 'Europe/Paris', + true, + ); + expect(localIsoString).toBe('2023-06-22T12:00:00.000+02:00'); + }); + it('should convert a utc string date and time to a winter paris date isostring with dst', () => { + const timeConverter: TimeConverter = new TimeConverter(); + const utcDate = '2023-02-02'; + const utcTime = '10:00'; + const localIsoString = timeConverter.utcStringDateTimeToLocalIsoString( + utcDate, + utcTime, + 'Europe/Paris', + true, + ); + expect(localIsoString).toBe('2023-02-02T11:00:00.000+01:00'); + }); it('should convert a utc string date and time to a summer paris date isostring', () => { const timeConverter: TimeConverter = new TimeConverter(); const utcDate = '2023-06-22'; @@ -157,29 +181,6 @@ describe('Time Converter', () => { utcTime, 'Europe/Paris', ); - expect(localIsoString).toBe('2023-06-22T12:00:00.000+02:00'); - }); - it('should convert a utc string date and time to a winter paris date isostring', () => { - const timeConverter: TimeConverter = new TimeConverter(); - const utcDate = '2023-02-02'; - const utcTime = '10:00'; - const localIsoString = timeConverter.utcStringDateTimeToLocalIsoString( - utcDate, - utcTime, - 'Europe/Paris', - ); - expect(localIsoString).toBe('2023-02-02T11:00:00.000+01:00'); - }); - it('should convert a utc string date and time to a summer paris date isostring without dst', () => { - const timeConverter: TimeConverter = new TimeConverter(); - const utcDate = '2023-06-22'; - const utcTime = '10:00'; - const localIsoString = timeConverter.utcStringDateTimeToLocalIsoString( - utcDate, - utcTime, - 'Europe/Paris', - false, - ); expect(localIsoString).toBe('2023-06-22T11:00:00.000+01:00'); }); it('should convert a utc date to a tonga date isostring', () => { diff --git a/src/modules/ad/tests/unit/interface/match.grpc.controller.spec.ts b/src/modules/ad/tests/unit/interface/match.grpc.controller.spec.ts index 44203aa..a138f04 100644 --- a/src/modules/ad/tests/unit/interface/match.grpc.controller.spec.ts +++ b/src/modules/ad/tests/unit/interface/match.grpc.controller.spec.ts @@ -1,12 +1,14 @@ import { RpcExceptionCode } from '@mobicoop/ddd-library'; import { AD_ROUTE_PROVIDER } from '@modules/ad/ad.di-tokens'; import { RouteProviderPort } from '@modules/ad/core/application/ports/route-provider.port'; +import { MatchingResult } from '@modules/ad/core/application/queries/match/match.query-handler'; import { AlgorithmType } from '@modules/ad/core/application/types/algorithm.types'; import { Frequency, Role } from '@modules/ad/core/domain/ad.types'; import { Target } from '@modules/ad/core/domain/candidate.types'; import { MatchEntity } from '@modules/ad/core/domain/match.entity'; import { ActorTime } from '@modules/ad/core/domain/value-objects/actor-time.value-object'; import { JourneyItem } from '@modules/ad/core/domain/value-objects/journey-item.value-object'; +import { MatchingPaginatedResponseDto } from '@modules/ad/interface/dtos/matching.paginated.response.dto'; import { MatchRequestDto } from '@modules/ad/interface/grpc-controllers/dtos/match.request.dto'; import { WaypointDto } from '@modules/ad/interface/grpc-controllers/dtos/waypoint.dto'; import { MatchGrpcController } from '@modules/ad/interface/grpc-controllers/match.grpc-controller'; @@ -55,117 +57,126 @@ const recurrentMatchRequestDto: MatchRequestDto = { const mockQueryBus = { execute: jest .fn() - .mockImplementationOnce(() => [ - MatchEntity.create({ - adId: '53a0bf71-4132-4f7b-a4cc-88c796b6bdf1', - role: Role.DRIVER, - frequency: Frequency.RECURRENT, - distance: 356041, - duration: 12647, - initialDistance: 349251, - initialDuration: 12103, - journeys: [ - { - firstDate: new Date('2023-09-01'), - lastDate: new Date('2024-08-30'), - journeyItems: [ - new JourneyItem({ - lat: 48.689445, - lon: 6.17651, - duration: 0, - distance: 0, - actorTimes: [ - new ActorTime({ - role: Role.DRIVER, - target: Target.START, - firstDatetime: new Date('2023-09-01 07:00'), - firstMinDatetime: new Date('2023-09-01 06:45'), - firstMaxDatetime: new Date('2023-09-01 07:15'), - lastDatetime: new Date('2024-08-30 07:00'), - lastMinDatetime: new Date('2024-08-30 06:45'), - lastMaxDatetime: new Date('2024-08-30 07:15'), - }), - ], - }), - new JourneyItem({ - lat: 48.369445, - lon: 6.67487, - duration: 2100, - distance: 56878, - actorTimes: [ - new ActorTime({ - role: Role.DRIVER, - target: Target.NEUTRAL, - firstDatetime: new Date('2023-09-01 07:35'), - firstMinDatetime: new Date('2023-09-01 07:20'), - firstMaxDatetime: new Date('2023-09-01 07:50'), - lastDatetime: new Date('2024-08-30 07:35'), - lastMinDatetime: new Date('2024-08-30 07:20'), - lastMaxDatetime: new Date('2024-08-30 07:50'), - }), - new ActorTime({ - role: Role.PASSENGER, - target: Target.START, - firstDatetime: new Date('2023-09-01 07:32'), - firstMinDatetime: new Date('2023-09-01 07:17'), - firstMaxDatetime: new Date('2023-09-01 07:47'), - lastDatetime: new Date('2024-08-30 07:32'), - lastMinDatetime: new Date('2024-08-30 07:17'), - lastMaxDatetime: new Date('2024-08-30 07:47'), - }), - ], - }), - new JourneyItem({ - lat: 47.98487, - lon: 6.9427, - duration: 3840, - distance: 76491, - actorTimes: [ - new ActorTime({ - role: Role.DRIVER, - target: Target.NEUTRAL, - firstDatetime: new Date('2023-09-01 08:04'), - firstMinDatetime: new Date('2023-09-01 07:51'), - firstMaxDatetime: new Date('2023-09-01 08:19'), - lastDatetime: new Date('2024-08-30 08:04'), - lastMinDatetime: new Date('2024-08-30 07:51'), - lastMaxDatetime: new Date('2024-08-30 08:19'), - }), - new ActorTime({ - role: Role.PASSENGER, - target: Target.FINISH, - firstDatetime: new Date('2023-09-01 08:01'), - firstMinDatetime: new Date('2023-09-01 07:46'), - firstMaxDatetime: new Date('2023-09-01 08:16'), - lastDatetime: new Date('2024-08-30 08:01'), - lastMinDatetime: new Date('2024-08-30 07:46'), - lastMaxDatetime: new Date('2024-08-30 08:16'), - }), - ], - }), - new JourneyItem({ - lat: 47.365987, - lon: 7.02154, - duration: 4980, - distance: 96475, - actorTimes: [ - new ActorTime({ - role: Role.DRIVER, - target: Target.FINISH, - firstDatetime: new Date('2023-09-01 08:23'), - firstMinDatetime: new Date('2023-09-01 08:08'), - firstMaxDatetime: new Date('2023-09-01 08:38'), - lastDatetime: new Date('2024-08-30 08:23'), - lastMinDatetime: new Date('2024-08-30 08:08'), - lastMaxDatetime: new Date('2024-08-30 08:38'), - }), - ], - }), - ], - }, - ], - }), - ]) + .mockImplementationOnce( + () => + { + id: '43c83ae2-f4b0-4ac6-b8bf-8071801924d4', + page: 1, + perPage: 10, + matches: [ + MatchEntity.create({ + adId: '53a0bf71-4132-4f7b-a4cc-88c796b6bdf1', + role: Role.DRIVER, + frequency: Frequency.RECURRENT, + distance: 356041, + duration: 12647, + initialDistance: 349251, + initialDuration: 12103, + journeys: [ + { + firstDate: new Date('2023-09-01'), + lastDate: new Date('2024-08-30'), + journeyItems: [ + new JourneyItem({ + lat: 48.689445, + lon: 6.17651, + duration: 0, + distance: 0, + actorTimes: [ + new ActorTime({ + role: Role.DRIVER, + target: Target.START, + firstDatetime: new Date('2023-09-01 07:00'), + firstMinDatetime: new Date('2023-09-01 06:45'), + firstMaxDatetime: new Date('2023-09-01 07:15'), + lastDatetime: new Date('2024-08-30 07:00'), + lastMinDatetime: new Date('2024-08-30 06:45'), + lastMaxDatetime: new Date('2024-08-30 07:15'), + }), + ], + }), + new JourneyItem({ + lat: 48.369445, + lon: 6.67487, + duration: 2100, + distance: 56878, + actorTimes: [ + new ActorTime({ + role: Role.DRIVER, + target: Target.NEUTRAL, + firstDatetime: new Date('2023-09-01 07:35'), + firstMinDatetime: new Date('2023-09-01 07:20'), + firstMaxDatetime: new Date('2023-09-01 07:50'), + lastDatetime: new Date('2024-08-30 07:35'), + lastMinDatetime: new Date('2024-08-30 07:20'), + lastMaxDatetime: new Date('2024-08-30 07:50'), + }), + new ActorTime({ + role: Role.PASSENGER, + target: Target.START, + firstDatetime: new Date('2023-09-01 07:32'), + firstMinDatetime: new Date('2023-09-01 07:17'), + firstMaxDatetime: new Date('2023-09-01 07:47'), + lastDatetime: new Date('2024-08-30 07:32'), + lastMinDatetime: new Date('2024-08-30 07:17'), + lastMaxDatetime: new Date('2024-08-30 07:47'), + }), + ], + }), + new JourneyItem({ + lat: 47.98487, + lon: 6.9427, + duration: 3840, + distance: 76491, + actorTimes: [ + new ActorTime({ + role: Role.DRIVER, + target: Target.NEUTRAL, + firstDatetime: new Date('2023-09-01 08:04'), + firstMinDatetime: new Date('2023-09-01 07:51'), + firstMaxDatetime: new Date('2023-09-01 08:19'), + lastDatetime: new Date('2024-08-30 08:04'), + lastMinDatetime: new Date('2024-08-30 07:51'), + lastMaxDatetime: new Date('2024-08-30 08:19'), + }), + new ActorTime({ + role: Role.PASSENGER, + target: Target.FINISH, + firstDatetime: new Date('2023-09-01 08:01'), + firstMinDatetime: new Date('2023-09-01 07:46'), + firstMaxDatetime: new Date('2023-09-01 08:16'), + lastDatetime: new Date('2024-08-30 08:01'), + lastMinDatetime: new Date('2024-08-30 07:46'), + lastMaxDatetime: new Date('2024-08-30 08:16'), + }), + ], + }), + new JourneyItem({ + lat: 47.365987, + lon: 7.02154, + duration: 4980, + distance: 96475, + actorTimes: [ + new ActorTime({ + role: Role.DRIVER, + target: Target.FINISH, + firstDatetime: new Date('2023-09-01 08:23'), + firstMinDatetime: new Date('2023-09-01 08:08'), + firstMaxDatetime: new Date('2023-09-01 08:38'), + lastDatetime: new Date('2024-08-30 08:23'), + lastMinDatetime: new Date('2024-08-30 08:08'), + lastMaxDatetime: new Date('2024-08-30 08:38'), + }), + ], + }), + ], + }, + ], + }), + ], + total: 1, + }, + ) .mockImplementationOnce(() => { throw new Error(); }), @@ -319,12 +330,16 @@ describe('Match Grpc Controller', () => { expect(matchGrpcController).toBeDefined(); }); - it('should return matches', async () => { + it('should return a matching', async () => { jest.spyOn(mockQueryBus, 'execute'); - const matchPaginatedResponseDto = await matchGrpcController.match( - recurrentMatchRequestDto, + const matchingPaginatedResponseDto: MatchingPaginatedResponseDto = + await matchGrpcController.match(recurrentMatchRequestDto); + expect(matchingPaginatedResponseDto.id).toBe( + '43c83ae2-f4b0-4ac6-b8bf-8071801924d4', ); - expect(matchPaginatedResponseDto.data).toHaveLength(1); + expect(matchingPaginatedResponseDto.data).toHaveLength(1); + expect(matchingPaginatedResponseDto.page).toBe(1); + expect(matchingPaginatedResponseDto.perPage).toBe(10); expect(mockQueryBus.execute).toHaveBeenCalledTimes(1); }); diff --git a/src/modules/ad/tests/unit/matching.mapper.spec.ts b/src/modules/ad/tests/unit/matching.mapper.spec.ts new file mode 100644 index 0000000..fdc4f2e --- /dev/null +++ b/src/modules/ad/tests/unit/matching.mapper.spec.ts @@ -0,0 +1,118 @@ +import { Frequency, Role } from '@modules/ad/core/domain/ad.types'; +import { Target } from '@modules/ad/core/domain/candidate.types'; +import { MatchingEntity } from '@modules/ad/core/domain/matching.entity'; +import { MatchingMapper } from '@modules/ad/matching.mapper'; +import { Test } from '@nestjs/testing'; + +describe('Matching Mapper', () => { + let matchingMapper: MatchingMapper; + + beforeAll(async () => { + const module = await Test.createTestingModule({ + providers: [MatchingMapper], + }).compile(); + matchingMapper = module.get(MatchingMapper); + }); + + it('should be defined', () => { + expect(matchingMapper).toBeDefined(); + }); + + it('should map domain entity to persistence', async () => { + const matchingEntity: MatchingEntity = new MatchingEntity({ + id: '644a7cb3-6436-4db5-850d-b4c7421d4b97', + createdAt: new Date('2023-08-20T09:48:00Z'), + updatedAt: new Date('2023-08-20T09:48:00Z'), + props: { + matches: [ + { + adId: 'dd937edf-1264-4868-b073-d1952abe30b1', + role: Role.DRIVER, + frequency: Frequency.PUNCTUAL, + distance: 356041, + duration: 12647, + initialDistance: 348745, + initialDuration: 12105, + distanceDetour: 7296, + durationDetour: 542, + distanceDetourPercentage: 4.1, + durationDetourPercentage: 3.8, + journeys: [ + { + firstDate: new Date('2023-09-01'), + lastDate: new Date('2023-09-01'), + journeyItems: [ + { + lon: 6.35484, + lat: 48.26587, + duration: 0, + distance: 0, + actorTimes: [ + { + role: Role.DRIVER, + target: Target.START, + firstDatetime: new Date('2023-09-01T07:00:00Z'), + firstMinDatetime: new Date('2023-09-01T06:45:00Z'), + firstMaxDatetime: new Date('2023-09-01T07:15:00Z'), + lastDatetime: new Date('2023-09-01T07:00:00Z'), + lastMinDatetime: new Date('2023-09-01T06:45:00Z'), + lastMaxDatetime: new Date('2023-09-01T07:15:00Z'), + }, + ], + }, + ], + }, + ], + // ... + }, + ], + query: { + driver: false, + passenger: true, + frequency: Frequency.PUNCTUAL, + fromDate: '2023-09-01', + toDate: '2023-09-01', + schedule: [ + { + day: 5, + time: '06:40', + margin: 900, + }, + ], + seatsProposed: 3, + seatsRequested: 1, + strict: true, + waypoints: [ + { + lon: 6.389745, + lat: 48.32644, + }, + { + lon: 6.984567, + lat: 48.021548, + }, + ], + algorithmType: 'PASSENGER_ORIENTED', + remoteness: 15000, + useProportion: true, + proportion: 0.3, + useAzimuth: true, + azimuthMargin: 10, + maxDetourDistanceRatio: 0.3, + maxDetourDurationRatio: 0.3, + }, + }, + }); + const mapped: string = matchingMapper.toPersistence(matchingEntity); + expect(mapped).toBe( + '{"_id":"644a7cb3-6436-4db5-850d-b4c7421d4b97","_createdAt":"2023-08-20T09:48:00.000Z","_updatedAt":"2023-08-20T09:48:00.000Z","props":{"matches":[{"adId":"dd937edf-1264-4868-b073-d1952abe30b1","role":"DRIVER","frequency":"PUNCTUAL","distance":356041,"duration":12647,"initialDistance":348745,"initialDuration":12105,"distanceDetour":7296,"durationDetour":542,"distanceDetourPercentage":4.1,"durationDetourPercentage":3.8,"journeys":[{"firstDate":"2023-09-01T00:00:00.000Z","lastDate":"2023-09-01T00:00:00.000Z","journeyItems":[{"lon":6.35484,"lat":48.26587,"duration":0,"distance":0,"actorTimes":[{"role":"DRIVER","target":"START","firstDatetime":"2023-09-01T07:00:00.000Z","firstMinDatetime":"2023-09-01T06:45:00.000Z","firstMaxDatetime":"2023-09-01T07:15:00.000Z","lastDatetime":"2023-09-01T07:00:00.000Z","lastMinDatetime":"2023-09-01T06:45:00.000Z","lastMaxDatetime":"2023-09-01T07:15:00.000Z"}]}]}]}],"query":{"driver":false,"passenger":true,"frequency":"PUNCTUAL","fromDate":"2023-09-01","toDate":"2023-09-01","schedule":[{"day":5,"time":"06:40","margin":900}],"seatsProposed":3,"seatsRequested":1,"strict":true,"waypoints":[{"lon":6.389745,"lat":48.32644},{"lon":6.984567,"lat":48.021548}],"algorithmType":"PASSENGER_ORIENTED","remoteness":15000,"useProportion":true,"proportion":0.3,"useAzimuth":true,"azimuthMargin":10,"maxDetourDistanceRatio":0.3,"maxDetourDurationRatio":0.3}},"_domainEvents":[]}', + ); + }); + + it('should map persisted string to domain entity', async () => { + const matchingEntity: MatchingEntity = matchingMapper.toDomain( + '{"_id":"644a7cb3-6436-4db5-850d-b4c7421d4b97","_createdAt":"2023-08-20T09:48:00.000Z","_updatedAt":"2023-08-20T09:48:00.000Z","props":{"matches":[{"adId":"dd937edf-1264-4868-b073-d1952abe30b1","role":"DRIVER","frequency":"PUNCTUAL","distance":356041,"duration":12647,"initialDistance":348745,"initialDuration":12105,"distanceDetour":7296,"durationDetour":542,"distanceDetourPercentage":4.1,"durationDetourPercentage":3.8,"journeys":[{"firstDate":"2023-09-01T00:00:00.000Z","lastDate":"2023-09-01T00:00:00.000Z","journeyItems":[{"lon":6.35484,"lat":48.26587,"duration":0,"distance":0,"actorTimes":[{"role":"DRIVER","target":"START","firstDatetime":"2023-09-01T07:00:00.000Z","firstMinDatetime":"2023-09-01T06:45:00.000Z","firstMaxDatetime":"2023-09-01T07:15:00.000Z","lastDatetime":"2023-09-01T07:00:00.000Z","lastMinDatetime":"2023-09-01T06:45:00.000Z","lastMaxDatetime":"2023-09-01T07:15:00.000Z"}]}]}]}],"query":{"driver":false,"passenger":true,"frequency":"PUNCTUAL","fromDate":"2023-09-01","toDate":"2023-09-01","schedule":[{"day":5,"time":"06:40","margin":900}],"seatsProposed":3,"seatsRequested":1,"strict":true,"waypoints":[{"lon":6.389745,"lat":48.32644},{"lon":6.984567,"lat":48.021548}],"algorithmType":"PASSENGER_ORIENTED","remoteness":15000,"useProportion":true,"proportion":0.3,"useAzimuth":true,"azimuthMargin":10,"maxDetourDistanceRatio":0.3,"maxDetourDurationRatio":0.3}},"_domainEvents":[]}', + ); + expect(matchingEntity.getProps().query.fromDate).toBe('2023-09-01'); + }); +});