diff --git a/.env.dist b/.env.dist index fa384ef..5157f6c 100644 --- a/.env.dist +++ b/.env.dist @@ -15,32 +15,25 @@ 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 -# default number of seats proposed as driver -DEFAULT_SEATS=3 # algorithm type -ALGORITHM=CLASSIC -# strict algorithm (if relevant with the algorithm type) -# if set to true, matches are made so that -# punctual ads match only with punctual ads and -# recurrent ads match only with recurrent ads -STRICT_ALGORITHM=0 +ALGORITHM=PASSENGER_ORIENTED # max distance in metres between driver # route and passenger pick-up / drop-off REMOTENESS=15000 # use passenger proportion -USE_PROPORTION=1 +USE_PROPORTION=true # minimal driver proportion PROPORTION=0.3 # use azimuth calculation -USE_AZIMUTH=1 +USE_AZIMUTH=true # azimuth margin AZIMUTH_MARGIN=10 # margin duration in seconds @@ -54,3 +47,16 @@ MAX_DETOUR_DURATION_RATIO=0.3 GEOROUTER_TYPE=graphhopper # georouter url GEOROUTER_URL=http://localhost:8989 +# default carpool departure time margin (in seconds) +DEPARTURE_TIME_MARGIN=900 +# default role +ROLE=passenger +# seats proposes as driver / requested as passenger +SEATS_PROPOSED=3 +SEATS_REQUESTED=1 +# accept only same frequency requests +STRICT_FREQUENCY=false +# default timezone +TIMEZONE=Europe/Paris +# number of matching results per page +PER_PAGE=10 diff --git a/README.md b/README.md index 31944d4..7d655f8 100644 --- a/README.md +++ b/README.md @@ -9,3 +9,232 @@ You need [Docker](https://docs.docker.com/engine/) and its [compose](https://doc You also need NodeJS installed locally : we **strongly** advise to install [Node Version Manager](https://github.com/nvm-sh/nvm) and use the latest LTS version of Node (check that your local version matches with the one used in the Dockerfile). The API will run inside a docker container, **but** the install itself is made outside the container, because during development we need tools that need to be available locally (eg. ESLint or Prettier with fix-on-save). + +A RabbitMQ instance is also required to send / receive messages when data has been inserted/updated/deleted. + +# Installation + +- copy `.env.dist` to `.env` : + + ```bash + cp .env.dist .env + ``` + + Modify it if needed. + +- install the dependencies : + + ```bash + npm install + ``` + +- start the containers : + + ```bash + docker compose up -d + ``` + + The app runs automatically on port **5005**. + +## Database migration + +Before using the app, you need to launch the database migration (it will be launched inside the container) : + +```bash +npm run migrate +``` + +## Usage + +The app exposes the following [gRPC](https://grpc.io/) services : + +- **Match** : find matching ads corresponding to the given criteria + + For example, as a passenger, to search for drivers for a punctual carpool : + + ```json + { + "driver": false, + "passenger": true, + "frequency": "PUNCTUAL", + "algorithmType": "PASSENGER_ORIENTED", + "fromDate": "2024-06-05", + "toDate": "2024-06-05", + "schedule": [ + { + "time": "07:30" + } + ], + "waypoints": [ + { + "houseNumber": "23", + "street": "rue de viller", + "postalCode": "54300", + "locality": "Lunéville", + "lon": 6.490527, + "lat": 48.590119, + "country": "France", + "position": 0 + }, + { + "houseNumber": "3", + "street": "rue du passage", + "postalCode": "67117", + "locality": "Ittenheim", + "lon": 7.594361, + "lat": 48.603004, + "country": "France", + "position": 1 + } + ] + } + ``` + + As a passenger, to search for drivers for a recurrent carpool : + + ```json + { + "driver": false, + "passenger": true, + "frequency": "RECURRENT", + "algorithmType": "PASSENGER_ORIENTED", + "fromDate": "2024-01-02", + "toDate": "2024-06-30", + "strict": true, + "page": 1, + "perPage": 5, + "schedule": [ + { + "day": 1, + "time": "07:30" + }, + { + "day": 2, + "time": "07:45" + }, + { + "day": 4, + "time": "07:30" + }, + , + { + "day": 5, + "time": "07:30" + } + ], + "waypoints": [ + { + "houseNumber": "298", + "street": "Aveue de la liberté", + "postalCode": "86180", + "locality": "Buxerolles", + "lon": 0.364394, + "lat": 46.607501, + "country": "France", + "position": 0 + }, + { + "houseNumber": "1", + "street": "place du 8 mai 1945", + "postalCode": "47310", + "locality": "Roquefort", + "lon": 0.559606, + "lat": 44.175994, + "country": "France", + "position": 1 + } + ] + } + ``` + + The list of possible criteria : + + - **id** (optional): the id of a previous matching result (as a uuid) + - **driver** (boolean, optional): to search for passengers (_default : false_) + - **passenger** (boolean, optional): to search fo drivers (_default : true_) + - **frequency**: the frequency of the search (`PUNCTUAL` or `RECURRENT`) + - **strict** (boolean, optional): if set to true, allow matching only with similar frequency ads (_default : false_) + - **fromDate**: start date for recurrent ad, carpool date for punctual ad + - **toDate**: end date for recurrent ad, same as fromDate for punctual ad + - **schedule**: an array of schedule items, a schedule item containing : + - the week day as a number, from 0 (sunday) to 6 (saturday) if the ad is recurrent (default to fromDate day for punctual search) + - the departure time (as HH:MM) + - the margin around the departure time in seconds (optional) (_default : 900_) + - **seatsProposed** (integer, optional): number of seats proposed as driver (_default : 3_) + - **seatsRequested** (integer, optional): number of seats requested as passenger (_default : 1_) + - **waypoints**: an array of addresses that represent the waypoints of the journey (only first and last waypoints are used for passenger ads). Note that positions are **required** and **must** be consecutives + - **algorithmType** (optional): the type of algorithm to use (as of 2023-09-28, only the default `PASSENGER_ORIENTED` is accepted) + - **remoteness** (integer, optional): an integer to indicate the maximum flying distance (in metres) between the driver route and the passenger pick-up / drop-off points (_default : 15000_) + - **useProportion** (boolean, optional): a boolean to indicate if the matching algorithm will compare the distance of the passenger route against the distance of the driver route (_default : 1_). Works in combination with **proportion** parameter + - **proportion** (float, optional): a fraction (float between 0 and 1) to indicate minimum proportion of the distance of the passenger route against the distance of the driver route (_default : 0.3_). Works in combination with **use_proportion** parameter + - **useAzimuth** (boolean, optional): a boolean to indicate if the matching algorithm will use the azimuth of the driver and passenger routes (_default : 1_) + - **azimuthMargin** (integer, optional): an integer (representing the number of degrees) to indicate the range around the opposite azimuth to consider the candidate route excluded (_default : 10_) + - **maxDetourDistanceRatio** (float, optional): a fraction (float between 0 and 1) of the driver route distance to indicate the maximum detour distance acceptable for a passenger (_default : 0.3_) + - **maxDetourDurationRatio** (float, optional): a fraction (float between 0 and 1) of the driver route duration to indicate the maximum detour duration acceptable for a passenger (_default : 0.3_) + - **page** (integer, optional): the page of results to display (_default : 1_) + - **perPage** (integer, optional): the number of results to display per page (_default : 10_) + +If the matching is successful, you will get a result, containing : + +- **id**: the id of the matching; as matching is a time-consuming process, results are cached and thus accessible later using this id (pagination works as well !) +- **total**: the total number of results +- **page**: the number of the page that is returned (may be different than the number of the required page, if number of results does not match with perPage parameter) +- **perPage**: the number of results per page (as it may not be specified in the request) +- **data**: an array of the results themselves, each including: + - **id**: an id for the result + - **adId**: the id of the ad that matches + - **role**: the role of the ad owner in that match + - **distance**: the distance in metres of the resulting carpool + - **duration**: the duration in seconds of the resulting carpool + - **initialDistance**: the initial distance in metres for the driver + - **initialDuration**: the initial duration in seconds for the driver + - **distanceDetour**: the detour distance in metres + - **durationDetour**: the detour duration in seconds + - **distanceDetourPercentage**: the detour distance in percentage of the original distance + - **durationDetourPercentage**: the detour duration in percentage of the original duration + - **journeys**: the possible journeys for the carpool (one journey for punctual carpools, one or more journeys for recurrent carpools), each including: + - **day**: the week day for the journey, as a number, from 0 (sunday) to 6 (saturday) + - **firstDate**: the first possible date for the journey + - **lastDate**: the last possible date for the journey + - **steps**: the steps of the journey (coordinates with distance, duration and actors implied), each including: + - **distance**: the distance to reach the step in metres + - **duration**: the duration to reach the step in seconds + - **lon**: the longitude of the point for the step + - **lat**: the longitude of the point for the step + - **time**: the driver time at that step + - **actors**: the actors for that step: + - **role**: the role of the actor (`DRIVER` or `PASSENGER`) + - **target**: the meaning of the step for the actor: + - _START_ for the first point of the actor + - _FINISH_ for the last point of the actor + - _INTERMEDIATE_ for a driver intermediate point + - _NEUTRAL_ for a passenger point from the point of view of a driver + +## Tests / ESLint / Prettier + +Tests are run outside the container for ease of use (switching between different environments inside containers using prisma is complicated and error prone). +The integration tests use a dedicated database (see _db-test_ section of _docker-compose.yml_). + +```bash +# run all tests (unit + integration) +npm run test + +# unit tests only +npm run test:unit + +# integration tests only +npm run test:integration + +# coverage +npm run test:cov + +# ESLint +npm run lint + +# Prettier +npm run pretty +``` + +## License + +Mobicoop V3 - Matcher Service is [AGPL licensed](LICENSE). diff --git a/package-lock.json b/package-lock.json index 27bd203..ce905e2 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,19 +1,19 @@ { "name": "@mobicoop/matcher", - "version": "0.0.2", + "version": "1.0.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@mobicoop/matcher", - "version": "0.0.2", + "version": "1.0.0", "license": "AGPL", "dependencies": { "@grpc/grpc-js": "^1.8.14", "@grpc/proto-loader": "^0.7.6", "@liaoliaots/nestjs-redis": "^9.0.5", "@mobicoop/configuration-module": "^1.2.0", - "@mobicoop/ddd-library": "^1.1.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.1.0", - "resolved": "https://registry.npmjs.org/@mobicoop/ddd-library/-/ddd-library-1.1.0.tgz", - "integrity": "sha512-x4X7j2CJYZQPDZgLuZP5TFk59fle1wTPdX++Z2YyD7VwwV+yOmVvMIRfTyLRFUTzLObrd6FKs8mh+g59n9jUlA==", + "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 ed61f84..32d120f 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@mobicoop/matcher", - "version": "0.0.2", + "version": "1.0.0", "description": "Mobicoop V3 Matcher", "author": "sbriat", "private": true, @@ -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.1.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/app.module.ts b/src/app.module.ts index 4fcc186..196ba9f 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -26,15 +26,19 @@ import { GeographyModule } from '@modules/geography/geography.module'; useFactory: async ( configService: ConfigService, ): Promise => ({ - domain: configService.get('SERVICE_CONFIGURATION_DOMAIN'), + domain: configService.get( + 'SERVICE_CONFIGURATION_DOMAIN', + ) as string, messageBroker: { - uri: configService.get('MESSAGE_BROKER_URI'), - exchange: configService.get('MESSAGE_BROKER_EXCHANGE'), + uri: configService.get('MESSAGE_BROKER_URI') as string, + exchange: configService.get( + 'MESSAGE_BROKER_EXCHANGE', + ) as string, }, redis: { - host: configService.get('REDIS_HOST'), + host: configService.get('REDIS_HOST') as string, password: configService.get('REDIS_PASSWORD'), - port: configService.get('REDIS_PORT'), + port: configService.get('REDIS_PORT') as number, }, setConfigurationBrokerQueue: 'matcher-configuration-create-update', deleteConfigurationQueue: 'matcher-configuration-delete', diff --git a/src/main.ts b/src/main.ts index 8db905d..2c26622 100644 --- a/src/main.ts +++ b/src/main.ts @@ -11,14 +11,17 @@ async function bootstrap() { app.connectMicroservice({ transport: Transport.GRPC, options: { - package: ['health'], - protoPath: [join(__dirname, 'health.proto')], + package: ['matcher', 'health'], + protoPath: [ + join(__dirname, 'modules/ad/interface/grpc-controllers/matcher.proto'), + join(__dirname, 'health.proto'), + ], url: process.env.SERVICE_URL + ':' + process.env.SERVICE_PORT, - loader: { keepCase: true }, + loader: { keepCase: true, enums: String }, }, }); await app.startAllMicroservices(); - await app.listen(process.env.HEALTH_SERVICE_PORT); + await app.listen(process.env.HEALTH_SERVICE_PORT as string); } bootstrap(); diff --git a/src/modules/ad/ad.di-tokens.ts b/src/modules/ad/ad.di-tokens.ts index 8690c8d..ce89895 100644 --- a/src/modules/ad/ad.di-tokens.ts +++ b/src/modules/ad/ad.di-tokens.ts @@ -1,7 +1,18 @@ 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( 'AD_GET_BASIC_ROUTE_CONTROLLER', ); +export const AD_GET_DETAILED_ROUTE_CONTROLLER = Symbol( + 'AD_GET_DETAILED_ROUTE_CONTROLLER', +); export const AD_ROUTE_PROVIDER = Symbol('AD_ROUTE_PROVIDER'); +export const PARAMS_PROVIDER = Symbol('PARAMS_PROVIDER'); +export const TIMEZONE_FINDER = Symbol('TIMEZONE_FINDER'); +export const TIME_CONVERTER = Symbol('TIME_CONVERTER'); +export const INPUT_DATETIME_TRANSFORMER = Symbol('INPUT_DATETIME_TRANSFORMER'); +export const OUTPUT_DATETIME_TRANSFORMER = Symbol( + 'OUTPUT_DATETIME_TRANSFORMER', +); diff --git a/src/modules/ad/ad.mapper.ts b/src/modules/ad/ad.mapper.ts index 81a94c1..f31f7e6 100644 --- a/src/modules/ad/ad.mapper.ts +++ b/src/modules/ad/ad.mapper.ts @@ -4,11 +4,13 @@ import { AdWriteModel, AdReadModel, ScheduleItemModel, - AdUnsupportedWriteModel, + AdWriteExtraModel, } from './infrastructure/ad.repository'; -import { Frequency } from './core/domain/ad.types'; import { v4 } from 'uuid'; -import { ScheduleItemProps } from './core/domain/value-objects/schedule-item.value-object'; +import { + ScheduleItem, + ScheduleItemProps, +} from './core/domain/value-objects/schedule-item.value-object'; import { DirectionEncoderPort } from '@modules/geography/core/application/ports/direction-encoder.port'; import { AD_DIRECTION_ENCODER } from './ad.di-tokens'; import { ExtendedMapper } from '@mobicoop/ddd-library'; @@ -27,7 +29,7 @@ export class AdMapper AdEntity, AdReadModel, AdWriteModel, - AdUnsupportedWriteModel, + AdWriteExtraModel, undefined > { @@ -77,28 +79,12 @@ export class AdMapper return record; }; - toDomain = (record: AdReadModel): AdEntity => { - const entity = new AdEntity({ + toDomain = (record: AdReadModel): AdEntity => + new AdEntity({ id: record.uuid, createdAt: new Date(record.createdAt), updatedAt: new Date(record.updatedAt), props: { - driver: record.driver, - passenger: record.passenger, - frequency: Frequency[record.frequency], - fromDate: record.fromDate.toISOString().split('T')[0], - toDate: record.toDate.toISOString().split('T')[0], - schedule: record.schedule.map((scheduleItem: ScheduleItemModel) => ({ - day: scheduleItem.day, - time: `${scheduleItem.time - .getUTCHours() - .toString() - .padStart(2, '0')}:${scheduleItem.time - .getUTCMinutes() - .toString() - .padStart(2, '0')}`, - margin: scheduleItem.margin, - })), seatsProposed: record.seatsProposed, seatsRequested: record.seatsRequested, strict: record.strict, @@ -106,6 +92,25 @@ export class AdMapper driverDistance: record.driverDistance, passengerDuration: record.passengerDuration, passengerDistance: record.passengerDistance, + driver: record.driver, + passenger: record.passenger, + frequency: record.frequency, + fromDate: record.fromDate.toISOString().split('T')[0], + toDate: record.toDate.toISOString().split('T')[0], + schedule: record.schedule.map( + (scheduleItem: ScheduleItemModel) => + new ScheduleItem({ + day: scheduleItem.day, + time: `${scheduleItem.time + .getUTCHours() + .toString() + .padStart(2, '0')}:${scheduleItem.time + .getUTCMinutes() + .toString() + .padStart(2, '0')}`, + margin: scheduleItem.margin, + }), + ), waypoints: this.directionEncoder .decode(record.waypoints) .map((coordinates, index) => ({ @@ -117,15 +122,8 @@ export class AdMapper points: [], }, }); - return entity; - }; - // eslint-disable-next-line @typescript-eslint/no-unused-vars - toResponse = (entity: AdEntity): undefined => { - return undefined; - }; - - toUnsupportedPersistence = (entity: AdEntity): AdUnsupportedWriteModel => ({ + toPersistenceExtra = (entity: AdEntity): AdWriteExtraModel => ({ waypoints: this.directionEncoder.encode(entity.getProps().waypoints), direction: this.directionEncoder.encode(entity.getProps().points), }); diff --git a/src/modules/ad/ad.module.ts b/src/modules/ad/ad.module.ts index 53788ec..3756e70 100644 --- a/src/modules/ad/ad.module.ts +++ b/src/modules/ad/ad.module.ts @@ -6,6 +6,13 @@ import { AD_DIRECTION_ENCODER, AD_ROUTE_PROVIDER, AD_GET_BASIC_ROUTE_CONTROLLER, + PARAMS_PROVIDER, + TIMEZONE_FINDER, + TIME_CONVERTER, + 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'; @@ -17,18 +24,37 @@ import { GetBasicRouteController } from '@modules/geography/interface/controller import { RouteProvider } from './infrastructure/route-provider'; import { GeographyModule } from '@modules/geography/geography.module'; import { CreateAdService } from './core/application/commands/create-ad/create-ad.service'; +import { MatchGrpcController } from './interface/grpc-controllers/match.grpc-controller'; +import { MatchQueryHandler } from './core/application/queries/match/match.query-handler'; +import { DefaultParamsProvider } from './infrastructure/default-params-provider'; +import { TimezoneFinder } from './infrastructure/timezone-finder'; +import { TimeConverter } from './infrastructure/time-converter'; +import { InputDateTimeTransformer } from './infrastructure/input-datetime-transformer'; +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]; const messageHandlers = [AdCreatedMessageHandler]; const commandHandlers: Provider[] = [CreateAdService]; -const mappers: Provider[] = [AdMapper]; +const queryHandlers: Provider[] = [MatchQueryHandler]; + +const mappers: Provider[] = [AdMapper, MatchMapper, MatchingMapper]; const repositories: Provider[] = [ { provide: AD_REPOSITORY, useClass: AdRepository, }, + { + provide: MATCHING_REPOSITORY, + useClass: MatchingRepository, + }, ]; const messagePublishers: Provider[] = [ @@ -53,19 +79,51 @@ const adapters: Provider[] = [ provide: AD_GET_BASIC_ROUTE_CONTROLLER, useClass: GetBasicRouteController, }, + { + provide: AD_GET_DETAILED_ROUTE_CONTROLLER, + useClass: GetDetailedRouteController, + }, + { + provide: PARAMS_PROVIDER, + useClass: DefaultParamsProvider, + }, + { + provide: TIMEZONE_FINDER, + useClass: TimezoneFinder, + }, + { + provide: TIME_CONVERTER, + useClass: TimeConverter, + }, + { + provide: INPUT_DATETIME_TRANSFORMER, + useClass: InputDateTimeTransformer, + }, + { + provide: OUTPUT_DATETIME_TRANSFORMER, + useClass: OutputDateTimeTransformer, + }, ]; @Module({ imports: [CqrsModule, GeographyModule], + controllers: [...grpcControllers], providers: [ ...messageHandlers, ...commandHandlers, + ...queryHandlers, ...mappers, ...repositories, ...messagePublishers, ...orms, ...adapters, ], - exports: [PrismaService, AdMapper, AD_REPOSITORY, AD_DIRECTION_ENCODER], + exports: [ + PrismaService, + AdMapper, + AD_REPOSITORY, + AD_DIRECTION_ENCODER, + AD_MESSAGE_PUBLISHER, + ], }) export class AdModule {} diff --git a/src/modules/ad/core/application/commands/create-ad/create-ad.command.ts b/src/modules/ad/core/application/commands/create-ad/create-ad.command.ts index 3b1e695..a3a6d9a 100644 --- a/src/modules/ad/core/application/commands/create-ad/create-ad.command.ts +++ b/src/modules/ad/core/application/commands/create-ad/create-ad.command.ts @@ -1,7 +1,7 @@ import { Frequency } from '@modules/ad/core/domain/ad.types'; import { Command, CommandProps } from '@mobicoop/ddd-library'; import { ScheduleItem } from '../../types/schedule-item.type'; -import { Waypoint } from '../../types/waypoint.type'; +import { Address } from '../../types/address.type'; export class CreateAdCommand extends Command { readonly id: string; @@ -14,11 +14,11 @@ export class CreateAdCommand extends Command { readonly seatsProposed: number; readonly seatsRequested: number; readonly strict: boolean; - readonly waypoints: Waypoint[]; + readonly waypoints: Address[]; constructor(props: CommandProps) { super(props); - this.id = props.id; + this.id = props.id as string; this.driver = props.driver; this.passenger = props.passenger; this.frequency = props.frequency; diff --git a/src/modules/ad/core/application/commands/create-ad/create-ad.service.ts b/src/modules/ad/core/application/commands/create-ad/create-ad.service.ts index 2ec8f5a..92c2e72 100644 --- a/src/modules/ad/core/application/commands/create-ad/create-ad.service.ts +++ b/src/modules/ad/core/application/commands/create-ad/create-ad.service.ts @@ -8,7 +8,15 @@ import { AggregateID, ConflictException } from '@mobicoop/ddd-library'; import { AdAlreadyExistsException } from '@modules/ad/core/domain/ad.errors'; import { RouteProviderPort } from '../../ports/route-provider.port'; import { Role } from '@modules/ad/core/domain/ad.types'; -import { Route } from '../../types/route.type'; +import { + Path, + PathCreator, + PathType, + TypedRoute, +} from '@modules/ad/core/domain/path-creator.service'; +import { Waypoint } from '../../types/waypoint.type'; +import { Point as PointValueObject } from '@modules/ad/core/domain/value-objects/point.value-object'; +import { Point } from '@modules/geography/core/domain/route.types'; @CommandHandler(CreateAdCommand) export class CreateAdService implements ICommandHandler { @@ -23,10 +31,70 @@ export class CreateAdService implements ICommandHandler { const roles: Role[] = []; if (command.driver) roles.push(Role.DRIVER); if (command.passenger) roles.push(Role.PASSENGER); - const route: Route = await this.routeProvider.getBasic( + + const pathCreator: PathCreator = new PathCreator( roles, - command.waypoints, + command.waypoints.map( + (waypoint: Waypoint) => + new PointValueObject({ + lon: waypoint.lon, + lat: waypoint.lat, + }), + ), ); + + let typedRoutes: TypedRoute[]; + try { + typedRoutes = await Promise.all( + pathCreator.getBasePaths().map(async (path: Path) => ({ + type: path.type, + route: await this.routeProvider.getBasic(path.waypoints), + })), + ); + } catch (e: any) { + throw new Error('Unable to find a route for given waypoints'); + } + + let driverDistance: number | undefined; + let driverDuration: number | undefined; + let passengerDistance: number | undefined; + let passengerDuration: number | undefined; + let points: PointValueObject[] | undefined; + let fwdAzimuth: number | undefined; + let backAzimuth: number | undefined; + try { + typedRoutes.forEach((typedRoute: TypedRoute) => { + if ([PathType.DRIVER, PathType.GENERIC].includes(typedRoute.type)) { + driverDistance = typedRoute.route.distance; + driverDuration = typedRoute.route.duration; + points = typedRoute.route.points.map( + (point: Point) => + new PointValueObject({ + lon: point.lon, + lat: point.lat, + }), + ); + fwdAzimuth = typedRoute.route.fwdAzimuth; + backAzimuth = typedRoute.route.backAzimuth; + } + if ([PathType.PASSENGER, PathType.GENERIC].includes(typedRoute.type)) { + passengerDistance = typedRoute.route.distance; + passengerDuration = typedRoute.route.duration; + if (!points) + points = typedRoute.route.points.map( + (point: Point) => + new PointValueObject({ + lon: point.lon, + lat: point.lat, + }), + ); + if (!fwdAzimuth) fwdAzimuth = typedRoute.route.fwdAzimuth; + if (!backAzimuth) backAzimuth = typedRoute.route.backAzimuth; + } + }); + } catch (error: any) { + throw new Error('Invalid route'); + } const ad = AdEntity.create({ id: command.id, driver: command.driver, @@ -39,17 +107,16 @@ export class CreateAdService implements ICommandHandler { seatsRequested: command.seatsRequested, strict: command.strict, waypoints: command.waypoints, - points: route.points, - driverDistance: route.driverDistance, - driverDuration: route.driverDuration, - passengerDistance: route.passengerDistance, - passengerDuration: route.passengerDuration, - fwdAzimuth: route.fwdAzimuth, - backAzimuth: route.backAzimuth, + points: points as PointValueObject[], + driverDistance, + driverDuration, + passengerDistance, + passengerDuration, + fwdAzimuth: fwdAzimuth as number, + backAzimuth: backAzimuth as number, }); - try { - await this.repository.insertWithUnsupportedFields(ad, 'ad'); + await this.repository.insertExtra(ad, 'ad'); return ad.id; } catch (error: any) { if (error instanceof ConflictException) { diff --git a/src/modules/ad/core/application/ports/ad.repository.port.ts b/src/modules/ad/core/application/ports/ad.repository.port.ts index 91b5294..20eb6a3 100644 --- a/src/modules/ad/core/application/ports/ad.repository.port.ts +++ b/src/modules/ad/core/application/ports/ad.repository.port.ts @@ -1,4 +1,6 @@ import { ExtendedRepositoryPort } from '@mobicoop/ddd-library'; import { AdEntity } from '../../domain/ad.entity'; -export type AdRepositoryPort = ExtendedRepositoryPort; +export type AdRepositoryPort = ExtendedRepositoryPort & { + getCandidateAds(queryString: string): Promise; +}; diff --git a/src/modules/ad/core/application/ports/datetime-transformer.port.ts b/src/modules/ad/core/application/ports/datetime-transformer.port.ts new file mode 100644 index 0000000..4b651c0 --- /dev/null +++ b/src/modules/ad/core/application/ports/datetime-transformer.port.ts @@ -0,0 +1,26 @@ +export interface DateTimeTransformerPort { + fromDate(geoFromDate: GeoDateTime, frequency: Frequency): string; + toDate( + toDate: string, + geoFromDate: GeoDateTime, + frequency: Frequency, + ): string; + day(day: number, geoFromDate: GeoDateTime, frequency: Frequency): number; + time(geoFromDate: GeoDateTime, frequency: Frequency): string; +} + +export type GeoDateTime = { + date: string; + time: string; + coordinates: Coordinates; +}; + +export type Coordinates = { + lon: number; + lat: number; +}; + +export enum Frequency { + PUNCTUAL = 'PUNCTUAL', + RECURRENT = 'RECURRENT', +} diff --git a/src/modules/ad/core/application/ports/default-params-provider.port.ts b/src/modules/ad/core/application/ports/default-params-provider.port.ts new file mode 100644 index 0000000..e316b77 --- /dev/null +++ b/src/modules/ad/core/application/ports/default-params-provider.port.ts @@ -0,0 +1,5 @@ +import { DefaultParams } from './default-params.type'; + +export interface DefaultParamsProviderPort { + getParams(): DefaultParams; +} diff --git a/src/modules/ad/core/application/ports/default-params.type.ts b/src/modules/ad/core/application/ports/default-params.type.ts new file mode 100644 index 0000000..9b4c2ca --- /dev/null +++ b/src/modules/ad/core/application/ports/default-params.type.ts @@ -0,0 +1,20 @@ +import { AlgorithmType } from '../types/algorithm.types'; + +export type DefaultParams = { + DRIVER: boolean; + PASSENGER: boolean; + SEATS_PROPOSED: number; + SEATS_REQUESTED: number; + DEPARTURE_TIME_MARGIN: number; + STRICT: boolean; + TIMEZONE: string; + ALGORITHM_TYPE: AlgorithmType; + REMOTENESS: number; + USE_PROPORTION: boolean; + PROPORTION: number; + USE_AZIMUTH: boolean; + AZIMUTH_MARGIN: number; + MAX_DETOUR_DISTANCE_RATIO: number; + MAX_DETOUR_DURATION_RATIO: number; + PER_PAGE: number; +}; 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/ports/route-provider.port.ts b/src/modules/ad/core/application/ports/route-provider.port.ts index 4ce43b0..2087fba 100644 --- a/src/modules/ad/core/application/ports/route-provider.port.ts +++ b/src/modules/ad/core/application/ports/route-provider.port.ts @@ -1,10 +1,19 @@ -import { Role } from '../../domain/ad.types'; -import { Waypoint } from '../types/waypoint.type'; +import { Point } from '../types/point.type'; import { Route } from '../types/route.type'; export interface RouteProviderPort { /** - * Get a basic route with points and overall duration / distance + * Get a basic route : + * - simple points (coordinates only) + * - overall duration + * - overall distance */ - getBasic(roles: Role[], waypoints: Waypoint[]): Promise; + getBasic(waypoints: Point[]): Promise; + /** + * Get a detailed route : + * - detailed points (coordinates and time / distance to reach the point) + * - overall duration + * - overall distance + */ + getDetailed(waypoints: Point[]): Promise; } diff --git a/src/modules/ad/core/application/ports/time-converter.port.ts b/src/modules/ad/core/application/ports/time-converter.port.ts new file mode 100644 index 0000000..112340f --- /dev/null +++ b/src/modules/ad/core/application/ports/time-converter.port.ts @@ -0,0 +1,18 @@ +export interface TimeConverterPort { + localStringTimeToUtcStringTime(time: string, timezone: string): string; + utcStringTimeToLocalStringTime(time: string, timezone: string): string; + localStringDateTimeToUtcDate( + date: string, + time: string, + timezone: string, + dst?: boolean, + ): Date; + utcStringDateTimeToLocalIsoString( + date: string, + time: string, + timezone: string, + dst?: boolean, + ): string; + utcUnixEpochDayFromTime(time: string, timezone: string): number; + localUnixEpochDayFromTime(time: string, timezone: string): number; +} diff --git a/src/modules/ad/core/application/ports/timezone-finder.port.ts b/src/modules/ad/core/application/ports/timezone-finder.port.ts new file mode 100644 index 0000000..72ba115 --- /dev/null +++ b/src/modules/ad/core/application/ports/timezone-finder.port.ts @@ -0,0 +1,3 @@ +export interface TimezoneFinderPort { + timezones(lon: number, lat: number, defaultTimezone?: string): string[]; +} diff --git a/src/modules/ad/core/application/queries/match/algorithm.abstract.ts b/src/modules/ad/core/application/queries/match/algorithm.abstract.ts new file mode 100644 index 0000000..ac902fc --- /dev/null +++ b/src/modules/ad/core/application/queries/match/algorithm.abstract.ts @@ -0,0 +1,59 @@ +import { CandidateEntity } from '@modules/ad/core/domain/candidate.entity'; +import { MatchEntity } from '../../../domain/match.entity'; +import { MatchQuery } from './match.query'; +import { AdRepositoryPort } from '@modules/ad/core/application/ports/ad.repository.port'; +import { Journey } from '@modules/ad/core/domain/value-objects/journey.value-object'; + +export abstract class Algorithm { + protected candidates: CandidateEntity[]; + protected selector: Selector; + protected processors: Processor[]; + constructor( + protected readonly query: MatchQuery, + protected readonly repository: AdRepositoryPort, + ) {} + + /** + * Return Ads that matches the query as Matches + */ + match = async (): Promise => { + this.candidates = await this.selector.select(); + for (const processor of this.processors) { + this.candidates = await processor.execute(this.candidates); + } + // console.log(JSON.stringify(this.candidates, null, 2)); + return this.candidates.map((candidate: CandidateEntity) => + MatchEntity.create({ + adId: candidate.id, + role: candidate.getProps().role, + frequency: candidate.getProps().frequency, + distance: candidate.getProps().distance as number, + duration: candidate.getProps().duration as number, + initialDistance: candidate.getProps().driverDistance, + initialDuration: candidate.getProps().driverDuration, + journeys: candidate.getProps().journeys as Journey[], + }), + ); + }; +} + +/** + * A selector queries potential candidates in a repository + */ +export abstract class Selector { + protected readonly query: MatchQuery; + protected readonly repository: AdRepositoryPort; + constructor(query: MatchQuery, repository: AdRepositoryPort) { + this.query = query; + this.repository = repository; + } + abstract select(): Promise; +} + +/** + * A processor processes candidates information + */ +export abstract class Processor { + constructor(protected readonly query: MatchQuery) {} + abstract execute(candidates: CandidateEntity[]): Promise; +} diff --git a/src/modules/ad/core/application/queries/match/completer/completer.abstract.ts b/src/modules/ad/core/application/queries/match/completer/completer.abstract.ts new file mode 100644 index 0000000..ee155b3 --- /dev/null +++ b/src/modules/ad/core/application/queries/match/completer/completer.abstract.ts @@ -0,0 +1,9 @@ +import { CandidateEntity } from '@modules/ad/core/domain/candidate.entity'; +import { Processor } from '../algorithm.abstract'; + +export abstract class Completer extends Processor { + execute = async (candidates: CandidateEntity[]): Promise => + this.complete(candidates); + + abstract complete(candidates: CandidateEntity[]): Promise; +} diff --git a/src/modules/ad/core/application/queries/match/completer/journey.completer.ts b/src/modules/ad/core/application/queries/match/completer/journey.completer.ts new file mode 100644 index 0000000..71469bc --- /dev/null +++ b/src/modules/ad/core/application/queries/match/completer/journey.completer.ts @@ -0,0 +1,9 @@ +import { CandidateEntity } from '@modules/ad/core/domain/candidate.entity'; +import { Completer } from './completer.abstract'; + +export class JourneyCompleter extends Completer { + complete = async ( + candidates: CandidateEntity[], + ): Promise => + candidates.map((candidate: CandidateEntity) => candidate.createJourneys()); +} diff --git a/src/modules/ad/core/application/queries/match/completer/passenger-oriented-carpool-path.completer.ts b/src/modules/ad/core/application/queries/match/completer/passenger-oriented-carpool-path.completer.ts new file mode 100644 index 0000000..f5232ab --- /dev/null +++ b/src/modules/ad/core/application/queries/match/completer/passenger-oriented-carpool-path.completer.ts @@ -0,0 +1,55 @@ +import { CandidateEntity } from '@modules/ad/core/domain/candidate.entity'; +import { Completer } from './completer.abstract'; +import { Role } from '@modules/ad/core/domain/ad.types'; +import { Waypoint } from '../../../types/waypoint.type'; +import { CarpoolPathCreator } from '@modules/ad/core/domain/carpool-path-creator.service'; +import { + Point, + PointProps, +} from '@modules/ad/core/domain/value-objects/point.value-object'; + +/** + * Complete candidates with crew carpool path + */ +export class PassengerOrientedCarpoolPathCompleter extends Completer { + complete = async ( + candidates: CandidateEntity[], + ): Promise => { + candidates.forEach((candidate: CandidateEntity) => { + const carpoolPathCreator = new CarpoolPathCreator( + candidate.getProps().role == Role.DRIVER + ? candidate.getProps().driverWaypoints.map( + (waypoint: PointProps) => + new Point({ + lon: waypoint.lon, + lat: waypoint.lat, + }), + ) + : this.query.waypoints.map( + (waypoint: Waypoint) => + new Point({ + lon: waypoint.lon, + lat: waypoint.lat, + }), + ), + candidate.getProps().role == Role.PASSENGER + ? candidate.getProps().driverWaypoints.map( + (waypoint: PointProps) => + new Point({ + lon: waypoint.lon, + lat: waypoint.lat, + }), + ) + : this.query.waypoints.map( + (waypoint: Waypoint) => + new Point({ + lon: waypoint.lon, + lat: waypoint.lat, + }), + ), + ); + candidate.setCarpoolPath(carpoolPathCreator.carpoolPath()); + }); + return candidates; + }; +} diff --git a/src/modules/ad/core/application/queries/match/completer/route.completer.ts b/src/modules/ad/core/application/queries/match/completer/route.completer.ts new file mode 100644 index 0000000..5a07e10 --- /dev/null +++ b/src/modules/ad/core/application/queries/match/completer/route.completer.ts @@ -0,0 +1,51 @@ +import { CandidateEntity } from '@modules/ad/core/domain/candidate.entity'; +import { Completer } from './completer.abstract'; +import { MatchQuery } from '../match.query'; +import { Step } from '../../../types/step.type'; +import { CarpoolPathItem } from '@modules/ad/core/domain/value-objects/carpool-path-item.value-object'; + +export class RouteCompleter extends Completer { + protected readonly type: RouteCompleterType; + constructor(query: MatchQuery, type: RouteCompleterType) { + super(query); + this.type = type; + } + + complete = async ( + candidates: CandidateEntity[], + ): Promise => { + await Promise.all( + candidates.map(async (candidate: CandidateEntity) => { + switch (this.type) { + case RouteCompleterType.BASIC: + const basicCandidateRoute = await this.query.routeProvider.getBasic( + (candidate.getProps().carpoolPath as CarpoolPathItem[]).map( + (carpoolPathItem: CarpoolPathItem) => carpoolPathItem, + ), + ); + candidate.setMetrics( + basicCandidateRoute.distance, + basicCandidateRoute.duration, + ); + break; + case RouteCompleterType.DETAILED: + const detailedCandidateRoute = + await this.query.routeProvider.getDetailed( + (candidate.getProps().carpoolPath as CarpoolPathItem[]).map( + (carpoolPathItem: CarpoolPathItem) => carpoolPathItem, + ), + ); + candidate.setSteps(detailedCandidateRoute.steps as Step[]); + break; + } + return candidate; + }), + ); + return candidates; + }; +} + +export enum RouteCompleterType { + BASIC = 'basic', + DETAILED = 'detailed', +} diff --git a/src/modules/ad/core/application/queries/match/filter/filter.abstract.ts b/src/modules/ad/core/application/queries/match/filter/filter.abstract.ts new file mode 100644 index 0000000..f4522b9 --- /dev/null +++ b/src/modules/ad/core/application/queries/match/filter/filter.abstract.ts @@ -0,0 +1,9 @@ +import { CandidateEntity } from '@modules/ad/core/domain/candidate.entity'; +import { Processor } from '../algorithm.abstract'; + +export abstract class Filter extends Processor { + execute = async (candidates: CandidateEntity[]): Promise => + this.filter(candidates); + + abstract filter(candidates: CandidateEntity[]): Promise; +} diff --git a/src/modules/ad/core/application/queries/match/filter/journey.filter.ts b/src/modules/ad/core/application/queries/match/filter/journey.filter.ts new file mode 100644 index 0000000..bcb9c09 --- /dev/null +++ b/src/modules/ad/core/application/queries/match/filter/journey.filter.ts @@ -0,0 +1,10 @@ +import { CandidateEntity } from '@modules/ad/core/domain/candidate.entity'; +import { Filter } from './filter.abstract'; + +/** + * Filter candidates with empty journeys + */ +export class JourneyFilter extends Filter { + filter = async (candidates: CandidateEntity[]): Promise => + candidates.filter((candidate: CandidateEntity) => candidate.hasJourneys()); +} diff --git a/src/modules/ad/core/application/queries/match/filter/passenger-oriented-geo.filter.ts b/src/modules/ad/core/application/queries/match/filter/passenger-oriented-geo.filter.ts new file mode 100644 index 0000000..7061ee0 --- /dev/null +++ b/src/modules/ad/core/application/queries/match/filter/passenger-oriented-geo.filter.ts @@ -0,0 +1,12 @@ +import { CandidateEntity } from '@modules/ad/core/domain/candidate.entity'; +import { Filter } from './filter.abstract'; + +/** + * Filter candidates with unacceptable detour + */ +export class PassengerOrientedGeoFilter extends Filter { + filter = async (candidates: CandidateEntity[]): Promise => + candidates.filter((candidate: CandidateEntity) => + candidate.isDetourValid(), + ); +} 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 new file mode 100644 index 0000000..141bbad --- /dev/null +++ b/src/modules/ad/core/application/queries/match/match.query-handler.ts @@ -0,0 +1,149 @@ +import { IQueryHandler, QueryHandler } from '@nestjs/cqrs'; +import { MatchQuery, ScheduleItem } from './match.query'; +import { Algorithm } from './algorithm.abstract'; +import { PassengerOrientedAlgorithm } from './passenger-oriented-algorithm'; +import { AlgorithmType } from '../../types/algorithm.types'; +import { Inject } from '@nestjs/common'; +import { AdRepositoryPort } from '@modules/ad/core/application/ports/ad.repository.port'; +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 { + private readonly _defaultParams: DefaultParams; + + constructor( + @Inject(PARAMS_PROVIDER) + private readonly defaultParamsProvider: DefaultParamsProviderPort, + @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 => { + query + .setMissingMarginDurations(this._defaultParams.DEPARTURE_TIME_MARGIN) + .setMissingStrict(this._defaultParams.STRICT) + .setDefaultDriverAndPassengerParameters({ + driver: this._defaultParams.DRIVER, + passenger: this._defaultParams.PASSENGER, + seatsProposed: this._defaultParams.SEATS_PROPOSED, + seatsRequested: this._defaultParams.SEATS_REQUESTED, + }) + .setDefaultAlgorithmParameters({ + algorithmType: this._defaultParams.ALGORITHM_TYPE, + remoteness: this._defaultParams.REMOTENESS, + useProportion: this._defaultParams.USE_PROPORTION, + proportion: this._defaultParams.PROPORTION, + useAzimuth: this._defaultParams.USE_AZIMUTH, + azimuthMargin: this._defaultParams.AZIMUTH_MARGIN, + maxDetourDistanceRatio: this._defaultParams.MAX_DETOUR_DISTANCE_RATIO, + maxDetourDurationRatio: this._defaultParams.MAX_DETOUR_DURATION_RATIO, + }) + .setDefaultPagination({ + page: 1, + perPage: this._defaultParams.PER_PAGE, + }) + .setDatesAndSchedule(this.datetimeTransformer); + let matchingEntity: MatchingEntity | undefined = await this._cachedMatching( + query.id, + ); + if (!matchingEntity) + matchingEntity = (await this._createMatching(query)) as MatchingEntity; + const perPage: number = query.perPage as number; + const page: number = Paginator.pageNumber( + matchingEntity.getProps().matches.length, + perPage, + query.page as number, + ); + return { + id: matchingEntity.id, + matches: Paginator.pageItems( + matchingEntity.getProps().matches, + page, + perPage, + ), + total: matchingEntity.getProps().matches.length, + page, + perPage, + }; + }; + + private _cachedMatching = async ( + id?: string, + ): Promise => { + if (!id) return undefined; + try { + return await this.matchingRepository.get(id); + } catch (e: any) { + return undefined; + } + }; + + private _createMatching = async ( + query: MatchQuery, + ): Promise => { + await query.setRoutes(); + + let algorithm: Algorithm; + switch (query.algorithmType) { + case AlgorithmType.PASSENGER_ORIENTED: + default: + algorithm = new PassengerOrientedAlgorithm(query, this.adRepository); + } + + const matches: MatchEntity[] = await algorithm.match(); + const matchingEntity = MatchingEntity.create({ + matches, + 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 matchingEntity; + }; +} + +export type MatchingResult = { + id: string; + matches: MatchEntity[]; + total: number; + page: number; + perPage: number; +}; diff --git a/src/modules/ad/core/application/queries/match/match.query.ts b/src/modules/ad/core/application/queries/match/match.query.ts new file mode 100644 index 0000000..937ed4c --- /dev/null +++ b/src/modules/ad/core/application/queries/match/match.query.ts @@ -0,0 +1,258 @@ +import { QueryBase } from '@mobicoop/ddd-library'; +import { AlgorithmType } from '../../types/algorithm.types'; +import { Waypoint } from '../../types/waypoint.type'; +import { Frequency, Role } from '@modules/ad/core/domain/ad.types'; +import { MatchRequestDto } from '@modules/ad/interface/grpc-controllers/dtos/match.request.dto'; +import { DateTimeTransformerPort } from '../../ports/datetime-transformer.port'; +import { RouteProviderPort } from '../../ports/route-provider.port'; +import { + Path, + PathCreator, + PathType, + TypedRoute, +} from '@modules/ad/core/domain/path-creator.service'; +import { Point } from '@modules/ad/core/domain/value-objects/point.value-object'; +import { Route } from '../../types/route.type'; + +export class MatchQuery extends QueryBase { + id?: string; + driver?: boolean; + passenger?: boolean; + readonly frequency: Frequency; + fromDate: string; + toDate: string; + schedule: ScheduleItem[]; + seatsProposed?: number; + seatsRequested?: number; + strict?: boolean; + readonly waypoints: Waypoint[]; + algorithmType?: AlgorithmType; + remoteness?: number; + useProportion?: boolean; + proportion?: number; + useAzimuth?: boolean; + azimuthMargin?: number; + maxDetourDistanceRatio?: number; + maxDetourDurationRatio?: number; + page?: number; + perPage?: number; + driverRoute?: Route; + passengerRoute?: Route; + backAzimuth?: number; + private readonly originWaypoint: Waypoint; + routeProvider: RouteProviderPort; + + constructor(props: MatchRequestDto, routeProvider: RouteProviderPort) { + super(); + this.id = props.id; + 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.seatsProposed = props.seatsProposed; + this.seatsRequested = props.seatsRequested; + this.strict = props.strict; + this.waypoints = props.waypoints; + this.algorithmType = props.algorithmType; + this.remoteness = props.remoteness; + this.useProportion = props.useProportion; + this.proportion = props.proportion; + this.useAzimuth = props.useAzimuth; + this.azimuthMargin = props.azimuthMargin; + this.maxDetourDistanceRatio = props.maxDetourDistanceRatio; + this.maxDetourDurationRatio = props.maxDetourDurationRatio; + this.page = props.page; + this.perPage = props.perPage; + this.originWaypoint = this.waypoints.filter( + (waypoint: Waypoint) => waypoint.position == 0, + )[0]; + this.routeProvider = routeProvider; + } + + setMissingMarginDurations = (defaultMarginDuration: number): MatchQuery => { + this.schedule.forEach((day: ScheduleItem) => { + if (day.margin === undefined) day.margin = defaultMarginDuration; + }); + return this; + }; + + setMissingStrict = (strict: boolean): MatchQuery => { + if (this.strict === undefined) this.strict = strict; + return this; + }; + + setDefaultDriverAndPassengerParameters = ( + defaultDriverAndPassengerParameters: DefaultDriverAndPassengerParameters, + ): MatchQuery => { + this.driver = !!this.driver; + this.passenger = !!this.passenger; + if (!this.driver && !this.passenger) { + this.driver = defaultDriverAndPassengerParameters.driver; + this.seatsProposed = defaultDriverAndPassengerParameters.seatsProposed; + this.passenger = defaultDriverAndPassengerParameters.passenger; + this.seatsRequested = defaultDriverAndPassengerParameters.seatsRequested; + return this; + } + if (!this.seatsProposed || this.seatsProposed <= 0) + this.seatsProposed = defaultDriverAndPassengerParameters.seatsProposed; + if (!this.seatsRequested || this.seatsRequested <= 0) + this.seatsRequested = defaultDriverAndPassengerParameters.seatsRequested; + return this; + }; + + setDefaultAlgorithmParameters = ( + defaultAlgorithmParameters: DefaultAlgorithmParameters, + ): MatchQuery => { + if (!this.algorithmType) + this.algorithmType = defaultAlgorithmParameters.algorithmType; + if (!this.remoteness) + this.remoteness = defaultAlgorithmParameters.remoteness; + if (this.useProportion == undefined) + this.useProportion = defaultAlgorithmParameters.useProportion; + if (!this.proportion) + this.proportion = defaultAlgorithmParameters.proportion; + if (this.useAzimuth == undefined) + this.useAzimuth = defaultAlgorithmParameters.useAzimuth; + if (!this.azimuthMargin) + this.azimuthMargin = defaultAlgorithmParameters.azimuthMargin; + if (!this.maxDetourDistanceRatio) + this.maxDetourDistanceRatio = + defaultAlgorithmParameters.maxDetourDistanceRatio; + if (!this.maxDetourDurationRatio) + this.maxDetourDurationRatio = + defaultAlgorithmParameters.maxDetourDurationRatio; + return this; + }; + + setDefaultPagination = (defaultPagination: DefaultPagination): MatchQuery => { + if (!this.page) this.page = defaultPagination.page; + if (!this.perPage) this.perPage = defaultPagination.perPage; + return this; + }; + + setDatesAndSchedule = ( + datetimeTransformer: DateTimeTransformerPort, + ): MatchQuery => { + const initialFromDate: string = this.fromDate; + this.fromDate = datetimeTransformer.fromDate( + { + date: initialFromDate, + time: this.schedule[0].time, + coordinates: { + lon: this.originWaypoint.lon, + lat: this.originWaypoint.lat, + }, + }, + this.frequency, + ); + this.toDate = datetimeTransformer.toDate( + this.toDate, + { + date: initialFromDate, + time: this.schedule[0].time, + coordinates: { + lon: this.originWaypoint.lon, + lat: this.originWaypoint.lat, + }, + }, + this.frequency, + ); + this.schedule = this.schedule.map((scheduleItem: ScheduleItem) => ({ + day: datetimeTransformer.day( + scheduleItem.day ?? new Date(this.fromDate).getUTCDay(), + { + date: this.fromDate, + time: scheduleItem.time, + coordinates: { + lon: this.originWaypoint.lon, + lat: this.originWaypoint.lat, + }, + }, + this.frequency, + ), + time: datetimeTransformer.time( + { + date: this.fromDate, + time: scheduleItem.time, + coordinates: { + lon: this.originWaypoint.lon, + lat: this.originWaypoint.lat, + }, + }, + this.frequency, + ), + margin: scheduleItem.margin, + })); + return this; + }; + + setRoutes = async (): Promise => { + const roles: Role[] = []; + if (this.driver) roles.push(Role.DRIVER); + if (this.passenger) roles.push(Role.PASSENGER); + const pathCreator: PathCreator = new PathCreator( + roles, + this.waypoints.map( + (waypoint: Waypoint) => + new Point({ + lon: waypoint.lon, + lat: waypoint.lat, + }), + ), + ); + try { + ( + await Promise.all( + pathCreator.getBasePaths().map(async (path: Path) => ({ + type: path.type, + route: await this.routeProvider.getBasic(path.waypoints), + })), + ) + ).forEach((typedRoute: TypedRoute) => { + if (typedRoute.type !== PathType.PASSENGER) { + this.driverRoute = typedRoute.route; + this.backAzimuth = typedRoute.route.backAzimuth; + } + if (typedRoute.type !== PathType.DRIVER) { + this.passengerRoute = typedRoute.route; + if (!this.backAzimuth) + this.backAzimuth = typedRoute.route.backAzimuth; + } + }); + } catch (e: any) { + throw new Error('Unable to find a route for given waypoints'); + } + return this; + }; +} + +export type ScheduleItem = { + day?: number; + time: string; + margin?: number; +}; + +interface DefaultDriverAndPassengerParameters { + driver: boolean; + passenger: boolean; + seatsProposed: number; + seatsRequested: number; +} + +interface DefaultAlgorithmParameters { + algorithmType: AlgorithmType; + remoteness: number; + useProportion: boolean; + proportion: number; + useAzimuth: boolean; + azimuthMargin: number; + maxDetourDistanceRatio: number; + maxDetourDurationRatio: number; +} + +interface DefaultPagination { + page: number; + perPage: number; +} diff --git a/src/modules/ad/core/application/queries/match/passenger-oriented-algorithm.ts b/src/modules/ad/core/application/queries/match/passenger-oriented-algorithm.ts new file mode 100644 index 0000000..8621d6f --- /dev/null +++ b/src/modules/ad/core/application/queries/match/passenger-oriented-algorithm.ts @@ -0,0 +1,30 @@ +import { Algorithm } from './algorithm.abstract'; +import { MatchQuery } from './match.query'; +import { PassengerOrientedCarpoolPathCompleter } from './completer/passenger-oriented-carpool-path.completer'; +import { PassengerOrientedGeoFilter } from './filter/passenger-oriented-geo.filter'; +import { AdRepositoryPort } from '../../ports/ad.repository.port'; +import { PassengerOrientedSelector } from './selector/passenger-oriented.selector'; +import { + RouteCompleter, + RouteCompleterType, +} from './completer/route.completer'; +import { JourneyCompleter } from './completer/journey.completer'; +import { JourneyFilter } from './filter/journey.filter'; + +export class PassengerOrientedAlgorithm extends Algorithm { + constructor( + protected readonly query: MatchQuery, + protected readonly repository: AdRepositoryPort, + ) { + super(query, repository); + this.selector = new PassengerOrientedSelector(query, repository); + this.processors = [ + new PassengerOrientedCarpoolPathCompleter(query), + new RouteCompleter(query, RouteCompleterType.BASIC), + new PassengerOrientedGeoFilter(query), + new RouteCompleter(query, RouteCompleterType.DETAILED), + new JourneyCompleter(query), + new JourneyFilter(query), + ]; + } +} diff --git a/src/modules/ad/core/application/queries/match/selector/passenger-oriented.selector.ts b/src/modules/ad/core/application/queries/match/selector/passenger-oriented.selector.ts new file mode 100644 index 0000000..013dd7e --- /dev/null +++ b/src/modules/ad/core/application/queries/match/selector/passenger-oriented.selector.ts @@ -0,0 +1,364 @@ +import { Frequency, Role } from '@modules/ad/core/domain/ad.types'; +import { Selector } from '../algorithm.abstract'; +import { Waypoint } from '../../../types/waypoint.type'; +import { Point } from '../../../types/point.type'; +import { CandidateEntity } from '@modules/ad/core/domain/candidate.entity'; +import { AdEntity } from '@modules/ad/core/domain/ad.entity'; +import { ScheduleItem } from '@modules/ad/core/domain/value-objects/schedule-item.value-object'; + +export class PassengerOrientedSelector extends Selector { + select = async (): Promise => { + const queryStringRoles: QueryStringRole[] = []; + if (this.query.driver) + queryStringRoles.push({ + query: this._createQueryString(Role.DRIVER), + role: Role.DRIVER, + }); + if (this.query.passenger) + queryStringRoles.push({ + query: this._createQueryString(Role.PASSENGER), + role: Role.PASSENGER, + }); + + return ( + await Promise.all( + queryStringRoles.map>( + async (queryStringRole: QueryStringRole) => + { + ads: await this.repository.getCandidateAds(queryStringRole.query), + role: queryStringRole.role, + }, + ), + ) + ) + .map((adsRole: AdsRole) => + adsRole.ads.map((adEntity: AdEntity) => + CandidateEntity.create({ + id: adEntity.id, + role: adsRole.role == Role.DRIVER ? Role.PASSENGER : Role.DRIVER, + frequency: adEntity.getProps().frequency, + dateInterval: { + lowerDate: this._maxDateString( + this.query.fromDate, + adEntity.getProps().fromDate, + ), + higherDate: this._minDateString( + this.query.toDate, + adEntity.getProps().toDate, + ), + }, + driverWaypoints: + adsRole.role == Role.PASSENGER + ? adEntity.getProps().waypoints + : this.query.waypoints.map((waypoint: Waypoint) => ({ + position: waypoint.position, + lon: waypoint.lon, + lat: waypoint.lat, + })), + passengerWaypoints: + adsRole.role == Role.DRIVER + ? adEntity.getProps().waypoints + : this.query.waypoints.map((waypoint: Waypoint) => ({ + position: waypoint.position, + lon: waypoint.lon, + lat: waypoint.lat, + })), + driverDistance: + adsRole.role == Role.PASSENGER + ? (adEntity.getProps().driverDistance as number) + : (this.query.driverRoute?.distance as number), + driverDuration: + adsRole.role == Role.PASSENGER + ? (adEntity.getProps().driverDuration as number) + : (this.query.driverRoute?.duration as number), + driverSchedule: + adsRole.role == Role.PASSENGER + ? adEntity.getProps().schedule + : this.query.schedule.map((scheduleItem: ScheduleItem) => ({ + day: scheduleItem.day as number, + time: scheduleItem.time, + margin: scheduleItem.margin as number, + })), + passengerSchedule: + adsRole.role == Role.DRIVER + ? adEntity.getProps().schedule + : this.query.schedule.map((scheduleItem: ScheduleItem) => ({ + day: scheduleItem.day as number, + time: scheduleItem.time, + margin: scheduleItem.margin as number, + })), + spacetimeDetourRatio: { + maxDistanceDetourRatio: this.query + .maxDetourDistanceRatio as number, + maxDurationDetourRatio: this.query + .maxDetourDurationRatio as number, + }, + }), + ), + ) + .flat(); + }; + + private _createQueryString = (role: Role): string => + [ + this._createSelect(role), + this._createFrom(), + 'WHERE', + this._createWhere(role), + ] + .join(' ') + .replace(/\s+/g, ' '); // remove duplicate spaces for easy debug ! + + private _createSelect = (role: Role): string => + [ + `SELECT \ + ad.uuid,driver,passenger,frequency,public.st_astext(ad.waypoints) as waypoints,\ + "fromDate","toDate",\ + "seatsProposed","seatsRequested",\ + strict,\ + "fwdAzimuth","backAzimuth",\ + ad."createdAt",ad."updatedAt",\ + si.uuid as "scheduleItemUuid",si.day,si.time,si.margin,si."createdAt" as "scheduleItemCreatedAt",si."updatedAt" as "scheduleItemUpdatedAt"`, + role == Role.DRIVER ? this._selectAsDriver() : this._selectAsPassenger(), + ].join(); + + private _selectAsDriver = (): string => + `${this.query.driverRoute?.duration} as driverDuration,${this.query.driverRoute?.distance} as driverDistance`; + + private _selectAsPassenger = (): string => + `"driverDuration","driverDistance"`; + + private _createFrom = (): string => + 'FROM ad LEFT JOIN schedule_item si ON ad.uuid = si."adUuid"'; + + private _createWhere = (role: Role): string => + [ + this._whereRole(role), + this._whereStrict(), + this._whereDate(), + this._whereSchedule(role), + this._whereAzimuth(), + this._whereProportion(role), + this._whereRemoteness(role), + ] + .filter((where: string) => where != '') + .join(' AND '); + + private _whereRole = (role: Role): string => + role == Role.PASSENGER ? 'driver=True' : 'passenger=True'; + + private _whereStrict = (): string => + this.query.strict + ? this.query.frequency == Frequency.PUNCTUAL + ? `frequency='${Frequency.PUNCTUAL}'` + : `frequency='${Frequency.RECURRENT}'` + : ''; + + private _whereDate = (): string => + `(\ + (\ + "fromDate" <= '${this.query.fromDate}' AND "fromDate" <= '${this.query.toDate}' AND\ + "toDate" >= '${this.query.toDate}' AND "toDate" >= '${this.query.fromDate}'\ + ) OR (\ + "fromDate" >= '${this.query.fromDate}' AND "fromDate" <= '${this.query.toDate}' AND\ + "toDate" <= '${this.query.toDate}' AND "toDate" >= '${this.query.fromDate}'\ + ) OR (\ + "fromDate" <= '${this.query.fromDate}' AND "fromDate" <= '${this.query.toDate}' AND\ + "toDate" <= '${this.query.toDate}' AND "toDate" >= '${this.query.fromDate}'\ + ) OR (\ + "fromDate" >= '${this.query.fromDate}' AND "fromDate" <= '${this.query.toDate}' AND\ + "toDate" >= '${this.query.toDate}' AND "toDate" >= '${this.query.fromDate}'\ + )\ + )`; + + private _whereSchedule = (role: Role): string => { + const schedule: string[] = []; + // we need full dates to compare times, because margins can lead to compare on previous or next day + // -first we establish a base calendar (up to a week) + const scheduleDates: Date[] = this._datesBetweenBoundaries( + this.query.fromDate, + this.query.toDate, + ); + // - then we compare each resulting day of the schedule with each day of calendar, + // adding / removing margin depending on the role + scheduleDates.map((date: Date) => { + this.query.schedule + .filter( + (scheduleItem: ScheduleItem) => date.getUTCDay() == scheduleItem.day, + ) + .map((scheduleItem: ScheduleItem) => { + switch (role) { + case Role.PASSENGER: + schedule.push(this._wherePassengerSchedule(date, scheduleItem)); + break; + case Role.DRIVER: + schedule.push(this._whereDriverSchedule(date, scheduleItem)); + break; + } + }); + }); + if (schedule.length > 0) { + return ['(', schedule.join(' OR '), ')'].join(''); + } + return ''; + }; + + private _wherePassengerSchedule = ( + date: Date, + scheduleItem: ScheduleItem, + ): string => { + let maxDepartureDatetime: Date = new Date(date); + maxDepartureDatetime.setHours(parseInt(scheduleItem.time.split(':')[0])); + maxDepartureDatetime.setMinutes(parseInt(scheduleItem.time.split(':')[1])); + maxDepartureDatetime = this._addMargin( + maxDepartureDatetime, + scheduleItem.margin as number, + ); + // we want the min departure time of the driver to be before the max departure time of the passenger + return `make_timestamp(\ + ${maxDepartureDatetime.getUTCFullYear()},\ + ${maxDepartureDatetime.getUTCMonth() + 1},\ + ${maxDepartureDatetime.getUTCDate()},\ + CAST(EXTRACT(hour from time) as integer),\ + CAST(EXTRACT(minute from time) as integer),0) - interval '1 second' * margin <=\ + make_timestamp(\ + ${maxDepartureDatetime.getUTCFullYear()},\ + ${maxDepartureDatetime.getUTCMonth() + 1},\ + ${maxDepartureDatetime.getUTCDate()},${maxDepartureDatetime.getUTCHours()},${maxDepartureDatetime.getUTCMinutes()},0)`; + }; + + private _whereDriverSchedule = ( + date: Date, + scheduleItem: ScheduleItem, + ): string => { + let minDepartureDatetime: Date = new Date(date); + minDepartureDatetime.setHours(parseInt(scheduleItem.time.split(':')[0])); + minDepartureDatetime.setMinutes(parseInt(scheduleItem.time.split(':')[1])); + minDepartureDatetime = this._addMargin( + minDepartureDatetime, + -(scheduleItem.margin as number), + ); + // we want the max departure time of the passenger to be after the min departure time of the driver + return `make_timestamp(\ + ${minDepartureDatetime.getUTCFullYear()}, + ${minDepartureDatetime.getUTCMonth() + 1}, + ${minDepartureDatetime.getUTCDate()},\ + CAST(EXTRACT(hour from time) as integer),\ + CAST(EXTRACT(minute from time) as integer),0) + interval '1 second' * margin >=\ + make_timestamp(\ + ${minDepartureDatetime.getUTCFullYear()}, + ${minDepartureDatetime.getUTCMonth() + 1}, + ${minDepartureDatetime.getUTCDate()},${minDepartureDatetime.getUTCHours()},${minDepartureDatetime.getUTCMinutes()},0)`; + }; + + private _whereAzimuth = (): string => { + if (!this.query.useAzimuth) return ''; + const { minAzimuth, maxAzimuth } = this._azimuthRange( + this.query.backAzimuth as number, + this.query.azimuthMargin as number, + ); + if (minAzimuth <= maxAzimuth) + return `("fwdAzimuth" <= ${minAzimuth} OR "fwdAzimuth" >= ${maxAzimuth})`; + return `("fwdAzimuth" <= ${minAzimuth} AND "fwdAzimuth" >= ${maxAzimuth})`; + }; + + private _whereProportion = (role: Role): string => { + if (!this.query.useProportion) return ''; + switch (role) { + case Role.PASSENGER: + return `(${this.query.passengerRoute?.distance}>(${this.query.proportion}*"driverDistance"))`; + case Role.DRIVER: + return `("passengerDistance">(${this.query.proportion}*${this.query.driverRoute?.distance}))`; + } + }; + + private _whereRemoteness = (role: Role): string => { + this.query.waypoints.sort( + (firstWaypoint: Waypoint, secondWaypoint: Waypoint) => + firstWaypoint.position - secondWaypoint.position, + ); + switch (role) { + case Role.PASSENGER: + return `\ + public.st_distance('POINT(${this.query.waypoints[0].lon} ${ + this.query.waypoints[0].lat + })'::public.geography,direction)<\ + ${this.query.remoteness} AND \ + public.st_distance('POINT(${ + this.query.waypoints[this.query.waypoints.length - 1].lon + } ${ + this.query.waypoints[this.query.waypoints.length - 1].lat + })'::public.geography,direction)<\ + ${this.query.remoteness}`; + case Role.DRIVER: + const lineStringPoints: string[] = []; + this.query.driverRoute?.points.forEach((point: Point) => + lineStringPoints.push( + `public.st_makepoint(${point.lon},${point.lat})`, + ), + ); + const lineString = [ + 'public.st_makeline( ARRAY[ ', + lineStringPoints.join(','), + '] )::public.geography', + ].join(''); + return `\ + public.st_distance( public.st_startpoint(waypoints::public.geometry), ${lineString})<\ + ${this.query.remoteness} AND \ + public.st_distance( public.st_endpoint(waypoints::public.geometry), ${lineString})<\ + ${this.query.remoteness}`; + } + }; + + private _datesBetweenBoundaries = ( + firstDate: string, + lastDate: string, + max = 7, + ): Date[] => { + const fromDate: Date = new Date(firstDate); + const toDate: Date = new Date(lastDate); + const dates: Date[] = []; + let count = 0; + for ( + let date = fromDate; + date <= toDate; + date.setUTCDate(date.getUTCDate() + 1) + ) { + dates.push(new Date(date)); + count++; + if (count == max) break; + } + return dates; + }; + + private _addMargin = (date: Date, marginInSeconds: number): Date => { + date.setUTCSeconds(marginInSeconds); + return date; + }; + + private _azimuthRange = ( + azimuth: number, + margin: number, + ): { minAzimuth: number; maxAzimuth: number } => ({ + minAzimuth: + azimuth - margin < 0 ? azimuth - margin + 360 : azimuth - margin, + maxAzimuth: + azimuth + margin > 360 ? azimuth + margin - 360 : azimuth + margin, + }); + + private _maxDateString = (date1: string, date2: string): string => + new Date(date1) > new Date(date2) ? date1 : date2; + + private _minDateString = (date1: string, date2: string): string => + new Date(date1) < new Date(date2) ? date1 : date2; +} + +export type QueryStringRole = { + query: string; + role: Role; +}; + +type AdsRole = { + ads: AdEntity[]; + role: Role; +}; diff --git a/src/modules/ad/core/application/types/address.type.ts b/src/modules/ad/core/application/types/address.type.ts new file mode 100644 index 0000000..af02842 --- /dev/null +++ b/src/modules/ad/core/application/types/address.type.ts @@ -0,0 +1,10 @@ +import { Point } from './point.type'; + +export type Address = { + name?: string; + houseNumber?: string; + street?: string; + locality?: string; + postalCode?: string; + country?: string; +} & Point; diff --git a/src/modules/ad/core/application/types/algorithm.types.ts b/src/modules/ad/core/application/types/algorithm.types.ts new file mode 100644 index 0000000..dd5809d --- /dev/null +++ b/src/modules/ad/core/application/types/algorithm.types.ts @@ -0,0 +1,20 @@ +import { Role } from '../../domain/ad.types'; +import { Point } from '../../domain/value-objects/point.value-object'; + +export enum AlgorithmType { + PASSENGER_ORIENTED = 'PASSENGER_ORIENTED', +} + +/** + * A candidate is a potential match + */ +export type Candidate = { + ad: Ad; + role: Role; + driverWaypoints: Point[]; + crewWaypoints: Point[]; +}; + +export type Ad = { + id: string; +}; diff --git a/src/modules/ad/core/application/types/coordinates.type.ts b/src/modules/ad/core/application/types/point.type.ts similarity index 54% rename from src/modules/ad/core/application/types/coordinates.type.ts rename to src/modules/ad/core/application/types/point.type.ts index 8e149ed..9bb160e 100644 --- a/src/modules/ad/core/application/types/coordinates.type.ts +++ b/src/modules/ad/core/application/types/point.type.ts @@ -1,4 +1,4 @@ -export type Coordinates = { +export type Point = { lon: number; lat: number; }; diff --git a/src/modules/ad/core/application/types/route.type.ts b/src/modules/ad/core/application/types/route.type.ts index 971e2a9..297bc71 100644 --- a/src/modules/ad/core/application/types/route.type.ts +++ b/src/modules/ad/core/application/types/route.type.ts @@ -1,11 +1,12 @@ -import { Coordinates } from './coordinates.type'; +import { Point } from './point.type'; +import { Step } from './step.type'; export type Route = { - driverDistance?: number; - driverDuration?: number; - passengerDistance?: number; - passengerDuration?: number; + distance: number; + duration: number; fwdAzimuth: number; backAzimuth: number; - points: Coordinates[]; + distanceAzimuth: number; + points: Point[]; + steps?: Step[]; }; diff --git a/src/modules/ad/core/application/types/step.type.ts b/src/modules/ad/core/application/types/step.type.ts new file mode 100644 index 0000000..c9e9b7b --- /dev/null +++ b/src/modules/ad/core/application/types/step.type.ts @@ -0,0 +1,6 @@ +import { Point } from './point.type'; + +export type Step = Point & { + duration: number; + distance?: number; +}; diff --git a/src/modules/ad/core/application/types/waypoint.type.ts b/src/modules/ad/core/application/types/waypoint.type.ts index ba91158..b08efad 100644 --- a/src/modules/ad/core/application/types/waypoint.type.ts +++ b/src/modules/ad/core/application/types/waypoint.type.ts @@ -1,5 +1,5 @@ -import { Coordinates } from './coordinates.type'; +import { Address } from './address.type'; export type Waypoint = { position: number; -} & Coordinates; +} & Address; diff --git a/src/modules/ad/core/domain/ad.types.ts b/src/modules/ad/core/domain/ad.types.ts index ba60073..10f906b 100644 --- a/src/modules/ad/core/domain/ad.types.ts +++ b/src/modules/ad/core/domain/ad.types.ts @@ -1,6 +1,5 @@ import { PointProps } from './value-objects/point.value-object'; import { ScheduleItemProps } from './value-objects/schedule-item.value-object'; -import { WaypointProps } from './value-objects/waypoint.value-object'; // All properties that an Ad has export interface AdProps { @@ -17,7 +16,7 @@ export interface AdProps { driverDistance?: number; passengerDuration?: number; passengerDistance?: number; - waypoints: WaypointProps[]; + waypoints: PointProps[]; points: PointProps[]; fwdAzimuth: number; backAzimuth: number; @@ -35,7 +34,7 @@ export interface CreateAdProps { seatsProposed: number; seatsRequested: number; strict: boolean; - waypoints: WaypointProps[]; + waypoints: PointProps[]; driverDuration?: number; driverDistance?: number; passengerDuration?: number; diff --git a/src/modules/ad/core/domain/calendar-tools.service.ts b/src/modules/ad/core/domain/calendar-tools.service.ts new file mode 100644 index 0000000..933b628 --- /dev/null +++ b/src/modules/ad/core/domain/calendar-tools.service.ts @@ -0,0 +1,125 @@ +import { ExceptionBase } from '@mobicoop/ddd-library'; +import { DateInterval } from './candidate.types'; + +export class CalendarTools { + /** + * Returns the first date corresponding to a week day (0 based monday) + * within a date range + */ + static firstDate = (weekDay: number, dateInterval: DateInterval): Date => { + if (weekDay < 0 || weekDay > 6) + throw new CalendarToolsException( + new Error('weekDay must be between 0 and 6'), + ); + const lowerDateAsDate: Date = new Date(dateInterval.lowerDate); + const higherDateAsDate: Date = new Date(dateInterval.higherDate); + if (lowerDateAsDate.getUTCDay() == weekDay) return lowerDateAsDate; + const nextDate: Date = new Date(lowerDateAsDate); + nextDate.setUTCDate( + lowerDateAsDate.getUTCDate() + + (7 - (lowerDateAsDate.getUTCDay() - weekDay)), + ); + if (lowerDateAsDate.getUTCDay() < weekDay) { + nextDate.setUTCMonth(lowerDateAsDate.getUTCMonth()); + nextDate.setUTCFullYear(lowerDateAsDate.getUTCFullYear()); + nextDate.setUTCDate( + lowerDateAsDate.getUTCDate() + (weekDay - lowerDateAsDate.getUTCDay()), + ); + } + if (nextDate <= higherDateAsDate) return nextDate; + throw new CalendarToolsException( + new Error('no available day for the given date range'), + ); + }; + + /** + * Returns the last date corresponding to a week day (0 based monday) + * within a date range + */ + static lastDate = (weekDay: number, dateInterval: DateInterval): Date => { + if (weekDay < 0 || weekDay > 6) + throw new CalendarToolsException( + new Error('weekDay must be between 0 and 6'), + ); + const lowerDateAsDate: Date = new Date(dateInterval.lowerDate); + const higherDateAsDate: Date = new Date(dateInterval.higherDate); + if (higherDateAsDate.getUTCDay() == weekDay) return higherDateAsDate; + const previousDate: Date = new Date(higherDateAsDate); + previousDate.setUTCDate( + higherDateAsDate.getUTCDate() - (higherDateAsDate.getUTCDay() - weekDay), + ); + if (higherDateAsDate.getUTCDay() < weekDay) { + previousDate.setUTCMonth(higherDateAsDate.getUTCMonth()); + previousDate.setUTCFullYear(higherDateAsDate.getUTCFullYear()); + previousDate.setUTCDate( + higherDateAsDate.getUTCDate() - + (7 + (higherDateAsDate.getUTCDay() - weekDay)), + ); + } + if (previousDate >= lowerDateAsDate) return previousDate; + throw new CalendarToolsException( + new Error('no available day for the given date range'), + ); + }; + + /** + * Returns a date from a date (as a date) and a time (as a string), adding optional seconds + */ + static datetimeWithSeconds = ( + date: Date, + time: string, + additionalSeconds = 0, + ): Date => { + const datetime: Date = new Date(date); + datetime.setUTCHours(parseInt(time.split(':')[0])); + datetime.setUTCMinutes(parseInt(time.split(':')[1])); + datetime.setUTCSeconds(additionalSeconds); + return datetime; + }; + + /** + * Returns dates from a day and time based on unix epoch day + * (1970-01-01 is day 4) + * The method returns an array of dates because for edges (day 0 and 6) + * we need to return 2 possibilities : one for the previous week, one for the next week + */ + static epochDaysFromTime = (weekDay: number, time: string): Date[] => { + if (weekDay < 0 || weekDay > 6) + throw new CalendarToolsException( + new Error('weekDay must be between 0 and 6'), + ); + switch (weekDay) { + case 0: + return [ + new Date(`1969-12-28T${time}:00Z`), + new Date(`1970-01-04T${time}:00Z`), + ]; + case 1: + return [new Date(`1969-12-29T${time}:00Z`)]; + case 2: + return [new Date(`1969-12-30T${time}:00Z`)]; + case 3: + return [new Date(`1969-12-31T${time}:00Z`)]; + case 5: + return [new Date(`1970-01-02T${time}:00Z`)]; + case 6: + return [ + new Date(`1969-12-27T${time}:00Z`), + new Date(`1970-01-03T${time}:00Z`), + ]; + case 4: + default: + return [new Date(`1970-01-01T${time}:00Z`)]; + } + }; +} + +export class CalendarToolsException extends ExceptionBase { + static readonly message = 'Calendar tools error'; + + public readonly code = 'CALENDAR.TOOLS'; + + constructor(cause?: Error, metadata?: unknown) { + super(CalendarToolsException.message, cause, metadata); + } +} diff --git a/src/modules/ad/core/domain/candidate.entity.ts b/src/modules/ad/core/domain/candidate.entity.ts new file mode 100644 index 0000000..ec3a7c9 --- /dev/null +++ b/src/modules/ad/core/domain/candidate.entity.ts @@ -0,0 +1,264 @@ +import { AggregateRoot, AggregateID } from '@mobicoop/ddd-library'; +import { + CandidateProps, + CreateCandidateProps, + Target, +} from './candidate.types'; +import { + CarpoolPathItem, + CarpoolPathItemProps, +} from './value-objects/carpool-path-item.value-object'; +import { Step, StepProps } from './value-objects/step.value-object'; +import { ScheduleItem } from './value-objects/schedule-item.value-object'; +import { Journey } from './value-objects/journey.value-object'; +import { CalendarTools } from './calendar-tools.service'; +import { JourneyItem } from './value-objects/journey-item.value-object'; +import { Actor } from './value-objects/actor.value-object'; +import { ActorTime } from './value-objects/actor-time.value-object'; +import { Role } from './ad.types'; + +export class CandidateEntity extends AggregateRoot { + protected readonly _id: AggregateID; + + static create = (create: CreateCandidateProps): CandidateEntity => { + const props: CandidateProps = { ...create }; + return new CandidateEntity({ id: create.id, props }); + }; + + setCarpoolPath = (carpoolPath: CarpoolPathItemProps[]): CandidateEntity => { + this.props.carpoolPath = carpoolPath; + return this; + }; + + setMetrics = (distance: number, duration: number): CandidateEntity => { + this.props.distance = distance; + this.props.duration = duration; + return this; + }; + + setSteps = (steps: StepProps[]): CandidateEntity => { + this.props.steps = steps; + return this; + }; + + isDetourValid = (): boolean => + this._validateDistanceDetour() && this._validateDurationDetour(); + + hasJourneys = (): boolean => + this.getProps().journeys !== undefined && + (this.getProps().journeys as Journey[]).length > 0; + + /** + * Create the journeys based on the driver schedule (the driver 'drives' the carpool !) + * This is a tedious process : additional information can be found in deeper methods ! + */ + createJourneys = (): CandidateEntity => { + this.props.journeys = this.props.driverSchedule + // first we create the journeys + .map((driverScheduleItem: ScheduleItem) => + this._createJourney(driverScheduleItem), + ) + // then we filter the ones with invalid pickups + .filter((journey: Journey) => journey.hasValidPickUp()); + return this; + }; + + private _validateDurationDetour = (): boolean => + this.props.duration + ? this.props.duration <= + this.props.driverDuration * + (1 + this.props.spacetimeDetourRatio.maxDurationDetourRatio) + : false; + + private _validateDistanceDetour = (): boolean => + this.props.distance + ? this.props.distance <= + this.props.driverDistance * + (1 + this.props.spacetimeDetourRatio.maxDistanceDetourRatio) + : false; + + private _createJourney = (driverScheduleItem: ScheduleItem): Journey => + new Journey({ + firstDate: CalendarTools.firstDate( + driverScheduleItem.day, + this.props.dateInterval, + ), + lastDate: CalendarTools.lastDate( + driverScheduleItem.day, + this.props.dateInterval, + ), + journeyItems: this._createJourneyItems(driverScheduleItem), + }); + + private _createJourneyItems = ( + driverScheduleItem: ScheduleItem, + ): JourneyItem[] => + this.props.carpoolPath?.map( + (carpoolPathItem: CarpoolPathItem, index: number) => + this._createJourneyItem(carpoolPathItem, index, driverScheduleItem), + ) as JourneyItem[]; + + /** + * Create a journey item based on a carpool path item and driver schedule item + * The stepIndex is used to get the duration to reach the carpool path item + * from the steps prop (computed previously by a georouter) + * There MUST be a one/one relation between the carpool path items indexes + * and the steps indexes. + */ + private _createJourneyItem = ( + carpoolPathItem: CarpoolPathItem, + stepIndex: number, + driverScheduleItem: ScheduleItem, + ): JourneyItem => + new JourneyItem({ + lon: carpoolPathItem.lon, + lat: carpoolPathItem.lat, + duration: ((this.props.steps as Step[])[stepIndex] as Step).duration, + distance: ((this.props.steps as Step[])[stepIndex] as Step).distance, + actorTimes: carpoolPathItem.actors.map((actor: Actor) => + this._createActorTime( + actor, + driverScheduleItem, + ((this.props.steps as Step[])[stepIndex] as Step).duration, + ), + ), + }); + + private _createActorTime = ( + actor: Actor, + driverScheduleItem: ScheduleItem, + duration: number, + ): ActorTime => { + const scheduleItem: ScheduleItem = + actor.role == Role.PASSENGER && actor.target == Target.START + ? this._closestPassengerScheduleItem(driverScheduleItem) + : driverScheduleItem; + const effectiveDuration = + (actor.role == Role.PASSENGER && actor.target == Target.START) || + actor.target == Target.START + ? 0 + : duration; + const firstDate: Date = CalendarTools.firstDate( + scheduleItem.day, + this.props.dateInterval, + ); + const lastDate: Date = CalendarTools.lastDate( + scheduleItem.day, + this.props.dateInterval, + ); + return new ActorTime({ + role: actor.role, + target: actor.target, + firstDatetime: CalendarTools.datetimeWithSeconds( + firstDate, + scheduleItem.time, + effectiveDuration, + ), + firstMinDatetime: CalendarTools.datetimeWithSeconds( + firstDate, + scheduleItem.time, + -scheduleItem.margin + effectiveDuration, + ), + firstMaxDatetime: CalendarTools.datetimeWithSeconds( + firstDate, + scheduleItem.time, + scheduleItem.margin + effectiveDuration, + ), + lastDatetime: CalendarTools.datetimeWithSeconds( + lastDate, + scheduleItem.time, + effectiveDuration, + ), + lastMinDatetime: CalendarTools.datetimeWithSeconds( + lastDate, + scheduleItem.time, + -scheduleItem.margin + effectiveDuration, + ), + lastMaxDatetime: CalendarTools.datetimeWithSeconds( + lastDate, + scheduleItem.time, + scheduleItem.margin + effectiveDuration, + ), + }); + }; + + /** + * Get the closest (in time) passenger schedule item for a given driver schedule item + * This is mandatory as we can't rely only on the day of the schedule item : + * items on different days can match when playing with margins around midnight + */ + private _closestPassengerScheduleItem = ( + driverScheduleItem: ScheduleItem, + ): ScheduleItem => + CalendarTools.epochDaysFromTime( + driverScheduleItem.day, + driverScheduleItem.time, + ) + .map((driverDate: Date) => + this._minPassengerScheduleItemGapForDate(driverDate), + ) + .reduce( + ( + previousScheduleItemGap: ScheduleItemGap, + currentScheduleItemGap: ScheduleItemGap, + ) => + previousScheduleItemGap.gap < currentScheduleItemGap.gap + ? previousScheduleItemGap + : currentScheduleItemGap, + ).scheduleItem; + + /** + * Find the passenger schedule item with the minimum duration between a given date and the dates of the passenger schedule + */ + private _minPassengerScheduleItemGapForDate = (date: Date): ScheduleItemGap => + this.props.passengerSchedule + // first map the passenger schedule to "real" dates (we use unix epoch date as base) + .map( + (scheduleItem: ScheduleItem) => + { + scheduleItem, + range: CalendarTools.epochDaysFromTime( + scheduleItem.day, + scheduleItem.time, + ), + }, + ) + // then compute the duration in seconds to the given date + // for each "real" date computed in step 1 + .map((scheduleItemRange: ScheduleItemRange) => ({ + scheduleItem: scheduleItemRange.scheduleItem, + gap: scheduleItemRange.range + // compute the duration + .map((scheduleDate: Date) => + Math.round(Math.abs(scheduleDate.getTime() - date.getTime())), + ) + // keep the lowest duration + .reduce((previousGap: number, currentGap: number) => + previousGap < currentGap ? previousGap : currentGap, + ), + })) + // finally, keep the passenger schedule item with the lowest duration + .reduce( + ( + previousScheduleItemGap: ScheduleItemGap, + currentScheduleItemGap: ScheduleItemGap, + ) => + previousScheduleItemGap.gap < currentScheduleItemGap.gap + ? previousScheduleItemGap + : currentScheduleItemGap, + ); + + validate(): void { + // entity business rules validation to protect it's invariant before saving entity to a database + } +} + +type ScheduleItemRange = { + scheduleItem: ScheduleItem; + range: Date[]; +}; + +type ScheduleItemGap = { + scheduleItem: ScheduleItem; + gap: number; +}; diff --git a/src/modules/ad/core/domain/candidate.types.ts b/src/modules/ad/core/domain/candidate.types.ts new file mode 100644 index 0000000..a7d82cf --- /dev/null +++ b/src/modules/ad/core/domain/candidate.types.ts @@ -0,0 +1,61 @@ +import { Frequency, Role } from './ad.types'; +import { PointProps } from './value-objects/point.value-object'; +import { ScheduleItemProps } from './value-objects/schedule-item.value-object'; +import { CarpoolPathItemProps } from './value-objects/carpool-path-item.value-object'; +import { JourneyProps } from './value-objects/journey.value-object'; +import { StepProps } from './value-objects/step.value-object'; + +// All properties that a Candidate has +export interface CandidateProps { + role: Role; + frequency: Frequency; + driverWaypoints: PointProps[]; + passengerWaypoints: PointProps[]; + driverSchedule: ScheduleItemProps[]; + passengerSchedule: ScheduleItemProps[]; + driverDistance: number; + driverDuration: number; + dateInterval: DateInterval; + carpoolPath?: CarpoolPathItemProps[]; + distance?: number; + duration?: number; + steps?: StepProps[]; + journeys?: JourneyProps[]; + spacetimeDetourRatio: SpacetimeDetourRatio; +} + +// Properties that are needed for a Candidate creation +export interface CreateCandidateProps { + id: string; + role: Role; + frequency: Frequency; + driverDistance: number; + driverDuration: number; + driverWaypoints: PointProps[]; + passengerWaypoints: PointProps[]; + driverSchedule: ScheduleItemProps[]; + passengerSchedule: ScheduleItemProps[]; + spacetimeDetourRatio: SpacetimeDetourRatio; + dateInterval: DateInterval; +} + +export enum Target { + START = 'START', + INTERMEDIATE = 'INTERMEDIATE', + FINISH = 'FINISH', + NEUTRAL = 'NEUTRAL', +} + +export abstract class Validator { + abstract validate(): boolean; +} + +export type SpacetimeDetourRatio = { + maxDistanceDetourRatio: number; + maxDurationDetourRatio: number; +}; + +export type DateInterval = { + lowerDate: string; + higherDate: string; +}; diff --git a/src/modules/ad/core/domain/carpool-path-creator.service.ts b/src/modules/ad/core/domain/carpool-path-creator.service.ts new file mode 100644 index 0000000..5448ae2 --- /dev/null +++ b/src/modules/ad/core/domain/carpool-path-creator.service.ts @@ -0,0 +1,283 @@ +import { Role } from './ad.types'; +import { Target } from './candidate.types'; +import { CarpoolPathCreatorException } from './match.errors'; +import { Actor } from './value-objects/actor.value-object'; +import { Point } from './value-objects/point.value-object'; +import { CarpoolPathItem } from './value-objects/carpool-path-item.value-object'; + +export class CarpoolPathCreator { + private PRECISION = 5; + + constructor( + private readonly driverWaypoints: Point[], + private readonly passengerWaypoints: Point[], + ) { + if (driverWaypoints.length < 2) + throw new CarpoolPathCreatorException( + new Error('At least 2 driver waypoints must be defined'), + ); + if (passengerWaypoints.length < 2) + throw new CarpoolPathCreatorException( + new Error('At least 2 passenger waypoints must be defined'), + ); + } + + /** + * Creates a path (a list of carpoolPathItem) between driver waypoints + and passenger waypoints respecting the order + of the driver waypoints + Inspired by : + https://stackoverflow.com/questions/849211/shortest-distance-between-a-point-and-a-line-segment + */ + public carpoolPath = (): CarpoolPathItem[] => + this._consolidate( + this._mixedCarpoolPath( + this._driverCarpoolPath(), + this._passengerCarpoolPath(), + ), + ); + + private _mixedCarpoolPath = ( + driverCarpoolPath: CarpoolPathItem[], + passengerCarpoolPath: CarpoolPathItem[], + ): CarpoolPathItem[] => + driverCarpoolPath.length == 2 + ? this._simpleMixedCarpoolPath(driverCarpoolPath, passengerCarpoolPath) + : this._complexMixedCarpoolPath(driverCarpoolPath, passengerCarpoolPath); + + private _driverCarpoolPath = (): CarpoolPathItem[] => + this.driverWaypoints.map( + (waypoint: Point, index: number) => + new CarpoolPathItem({ + lon: waypoint.lon, + lat: waypoint.lat, + actors: [ + new Actor({ + role: Role.DRIVER, + target: this._getTarget(index, this.driverWaypoints), + }), + ], + }), + ); + + /** + * Creates the passenger carpoolPath with original passenger waypoints, adding driver waypoints that are the same + */ + private _passengerCarpoolPath = (): CarpoolPathItem[] => { + const carpoolPath: CarpoolPathItem[] = []; + this.passengerWaypoints.forEach( + (passengerWaypoint: Point, index: number) => { + const carpoolPathItem: CarpoolPathItem = new CarpoolPathItem({ + lon: passengerWaypoint.lon, + lat: passengerWaypoint.lat, + actors: [ + new Actor({ + role: Role.PASSENGER, + target: this._getTarget(index, this.passengerWaypoints), + }), + ], + }); + if ( + this.driverWaypoints.filter((driverWaypoint: Point) => + passengerWaypoint.equals(driverWaypoint), + ).length == 0 + ) { + carpoolPathItem.actors.push( + new Actor({ + role: Role.DRIVER, + target: Target.NEUTRAL, + }), + ); + } + carpoolPath.push(carpoolPathItem); + }, + ); + return carpoolPath; + }; + + private _simpleMixedCarpoolPath = ( + driverCarpoolPath: CarpoolPathItem[], + passengerCarpoolPath: CarpoolPathItem[], + ): CarpoolPathItem[] => [ + driverCarpoolPath[0], + ...passengerCarpoolPath, + driverCarpoolPath[1], + ]; + + private _complexMixedCarpoolPath = ( + driverCarpoolPath: CarpoolPathItem[], + passengerCarpoolPath: CarpoolPathItem[], + ): CarpoolPathItem[] => { + let mixedCarpoolPath: CarpoolPathItem[] = [...driverCarpoolPath]; + const originInsertIndex: number = this._insertIndex( + passengerCarpoolPath[0], + driverCarpoolPath, + ); + mixedCarpoolPath = [ + ...mixedCarpoolPath.slice(0, originInsertIndex), + passengerCarpoolPath[0], + ...mixedCarpoolPath.slice(originInsertIndex), + ]; + const destinationInsertIndex: number = + this._insertIndex( + passengerCarpoolPath[passengerCarpoolPath.length - 1], + driverCarpoolPath, + ) + 1; + mixedCarpoolPath = [ + ...mixedCarpoolPath.slice(0, destinationInsertIndex), + passengerCarpoolPath[passengerCarpoolPath.length - 1], + ...mixedCarpoolPath.slice(destinationInsertIndex), + ]; + return mixedCarpoolPath; + }; + + private _insertIndex = ( + targetCarpoolPathItem: CarpoolPathItem, + carpoolPath: CarpoolPathItem[], + ): number => + this._closestSegmentIndex( + targetCarpoolPathItem, + this._segments(carpoolPath), + ) + 1; + + private _segments = (carpoolPath: CarpoolPathItem[]): CarpoolPathItem[][] => { + const segments: CarpoolPathItem[][] = []; + carpoolPath.forEach((carpoolPathItem: CarpoolPathItem, index: number) => { + if (index < carpoolPath.length - 1) + segments.push([carpoolPathItem, carpoolPath[index + 1]]); + }); + return segments; + }; + + private _closestSegmentIndex = ( + carpoolPathItem: CarpoolPathItem, + segments: CarpoolPathItem[][], + ): number => { + const distances: Map = new Map(); + segments.forEach((segment: CarpoolPathItem[], index: number) => { + distances.set(index, this._distanceToSegment(carpoolPathItem, segment)); + }); + const sortedDistances: Map = new Map( + [...distances.entries()].sort((a, b) => a[1] - b[1]), + ); + const [closestSegmentIndex] = sortedDistances.keys(); + return closestSegmentIndex; + }; + + private _distanceToSegment = ( + carpoolPathItem: CarpoolPathItem, + segment: CarpoolPathItem[], + ): number => + parseFloat( + Math.sqrt( + this._distanceToSegmentSquared(carpoolPathItem, segment), + ).toFixed(this.PRECISION), + ); + + private _distanceToSegmentSquared = ( + carpoolPathItem: CarpoolPathItem, + segment: CarpoolPathItem[], + ): number => { + const length2: number = this._distanceSquared( + new Point({ + lon: segment[0].lon, + lat: segment[0].lat, + }), + new Point({ + lon: segment[1].lon, + lat: segment[1].lat, + }), + ); + if (length2 == 0) + return this._distanceSquared( + new Point({ + lon: carpoolPathItem.lon, + lat: carpoolPathItem.lat, + }), + new Point({ + lon: segment[0].lon, + lat: segment[0].lat, + }), + ); + const length: number = Math.max( + 0, + Math.min( + 1, + ((carpoolPathItem.lon - segment[0].lon) * + (segment[1].lon - segment[0].lon) + + (carpoolPathItem.lat - segment[0].lat) * + (segment[1].lat - segment[0].lat)) / + length2, + ), + ); + const newPoint: Point = new Point({ + lon: segment[0].lon + length * (segment[1].lon - segment[0].lon), + lat: segment[0].lat + length * (segment[1].lat - segment[0].lat), + }); + return this._distanceSquared( + new Point({ + lon: carpoolPathItem.lon, + lat: carpoolPathItem.lat, + }), + newPoint, + ); + }; + + private _distanceSquared = (point1: Point, point2: Point): number => + parseFloat( + ( + Math.pow(point1.lon - point2.lon, 2) + + Math.pow(point1.lat - point2.lat, 2) + ).toFixed(this.PRECISION), + ); + + private _getTarget = (index: number, waypoints: Point[]): Target => + index == 0 + ? Target.START + : index == waypoints.length - 1 + ? Target.FINISH + : Target.INTERMEDIATE; + + /** + * Consolidate carpoolPath by removing duplicate actors (eg. driver with neutral and start or finish target) + */ + private _consolidate = ( + carpoolPath: CarpoolPathItem[], + ): CarpoolPathItem[] => { + const uniquePoints: Point[] = []; + carpoolPath.forEach((carpoolPathItem: CarpoolPathItem) => { + if ( + uniquePoints.find((point: Point) => + point.equals( + new Point({ + lon: carpoolPathItem.lon, + lat: carpoolPathItem.lat, + }), + ), + ) === undefined + ) + uniquePoints.push( + new Point({ + lon: carpoolPathItem.lon, + lat: carpoolPathItem.lat, + }), + ); + }); + return uniquePoints.map( + (point: Point) => + new CarpoolPathItem({ + lon: point.lon, + lat: point.lat, + actors: carpoolPath + .filter((carpoolPathItem: CarpoolPathItem) => + new Point({ + lon: carpoolPathItem.lon, + lat: carpoolPathItem.lat, + }).equals(point), + ) + .map((carpoolPathItem: CarpoolPathItem) => carpoolPathItem.actors) + .flat(), + }), + ); + }; +} diff --git a/src/modules/ad/core/domain/match.entity.ts b/src/modules/ad/core/domain/match.entity.ts new file mode 100644 index 0000000..1a1e5ea --- /dev/null +++ b/src/modules/ad/core/domain/match.entity.ts @@ -0,0 +1,27 @@ +import { AggregateRoot, AggregateID } from '@mobicoop/ddd-library'; +import { v4 } from 'uuid'; +import { CreateMatchProps, MatchProps } from './match.types'; + +export class MatchEntity extends AggregateRoot { + protected readonly _id: AggregateID; + + static create = (create: CreateMatchProps): MatchEntity => { + const id = v4(); + const props: MatchProps = { + ...create, + distanceDetour: create.distance - create.initialDistance, + durationDetour: create.duration - create.initialDuration, + distanceDetourPercentage: parseFloat( + ((100 * create.distance) / create.initialDistance - 100).toFixed(2), + ), + durationDetourPercentage: parseFloat( + ((100 * create.duration) / create.initialDuration - 100).toFixed(2), + ), + }; + return new MatchEntity({ 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/match.errors.ts b/src/modules/ad/core/domain/match.errors.ts new file mode 100644 index 0000000..91484bf --- /dev/null +++ b/src/modules/ad/core/domain/match.errors.ts @@ -0,0 +1,21 @@ +import { ExceptionBase } from '@mobicoop/ddd-library'; + +export class PathCreatorException extends ExceptionBase { + static readonly message = 'Path creator error'; + + public readonly code = 'MATCHER.PATH_CREATOR'; + + constructor(cause?: Error, metadata?: unknown) { + super(PathCreatorException.message, cause, metadata); + } +} + +export class CarpoolPathCreatorException extends ExceptionBase { + static readonly message = 'Carpool path creator error'; + + public readonly code = 'MATCHER.CARPOOL_PATH_CREATOR'; + + constructor(cause?: Error, metadata?: unknown) { + super(CarpoolPathCreatorException.message, cause, metadata); + } +} diff --git a/src/modules/ad/core/domain/match.types.ts b/src/modules/ad/core/domain/match.types.ts new file mode 100644 index 0000000..66539fb --- /dev/null +++ b/src/modules/ad/core/domain/match.types.ts @@ -0,0 +1,48 @@ +import { AlgorithmType } from '../application/types/algorithm.types'; +import { Frequency, Role } from './ad.types'; +import { JourneyProps } from './value-objects/journey.value-object'; + +// All properties that a Match has +export interface MatchProps { + adId: string; + role: Role; + frequency: Frequency; + distance: number; + duration: number; + initialDistance: number; + initialDuration: number; + distanceDetour: number; + durationDetour: number; + distanceDetourPercentage: number; + durationDetourPercentage: number; + journeys: JourneyProps[]; +} + +// Properties that are needed for a Match creation +export interface CreateMatchProps { + adId: string; + role: Role; + frequency: Frequency; + distance: number; + duration: number; + initialDistance: number; + initialDuration: number; + journeys: JourneyProps[]; +} + +export interface DefaultMatchQueryProps { + driver: boolean; + passenger: boolean; + marginDuration: number; + strict: boolean; + seatsProposed: number; + seatsRequested: number; + algorithmType?: AlgorithmType; + remoteness?: number; + useProportion?: boolean; + proportion?: number; + useAzimuth?: boolean; + azimuthMargin?: number; + maxDetourDistanceRatio?: number; + maxDetourDurationRatio?: 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..fa41021 --- /dev/null +++ b/src/modules/ad/core/domain/matching.types.ts @@ -0,0 +1,14 @@ +import { MatchEntity } from './match.entity'; +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: MatchEntity[]; +} + +// Properties that are needed for a Matching creation +export interface CreateMatchingProps { + query: MatchQueryProps; + matches: MatchEntity[]; +} diff --git a/src/modules/ad/core/domain/path-creator.service.ts b/src/modules/ad/core/domain/path-creator.service.ts new file mode 100644 index 0000000..ed34c0c --- /dev/null +++ b/src/modules/ad/core/domain/path-creator.service.ts @@ -0,0 +1,87 @@ +import { Role } from './ad.types'; +import { Point } from './value-objects/point.value-object'; +import { PathCreatorException } from './match.errors'; +import { Route } from '../application/types/route.type'; + +export class PathCreator { + constructor( + private readonly roles: Role[], + private readonly waypoints: Point[], + ) { + if (roles.length == 0) + throw new PathCreatorException( + new Error('At least a role must be defined'), + ); + if (waypoints.length < 2) + throw new PathCreatorException( + new Error('At least 2 waypoints must be defined'), + ); + } + + public getBasePaths = (): Path[] => { + const paths: Path[] = []; + if ( + this.roles.includes(Role.DRIVER) && + this.roles.includes(Role.PASSENGER) + ) { + if (this.waypoints.length == 2) { + // 2 points => same route for driver and passenger + paths.push(this._createGenericPath()); + } else { + paths.push(this._createDriverPath(), this._createPassengerPath()); + } + } else if (this.roles.includes(Role.DRIVER)) { + paths.push(this._createDriverPath()); + } else if (this.roles.includes(Role.PASSENGER)) { + paths.push(this._createPassengerPath()); + } + return paths; + }; + + private _createGenericPath = (): Path => + this._createPath(this.waypoints, PathType.GENERIC); + + private _createDriverPath = (): Path => + this._createPath(this.waypoints, PathType.DRIVER); + + private _createPassengerPath = (): Path => + this._createPath( + [this._firstWaypoint(), this._lastWaypoint()], + PathType.PASSENGER, + ); + + private _firstWaypoint = (): Point => this.waypoints[0]; + + private _lastWaypoint = (): Point => + this.waypoints[this.waypoints.length - 1]; + + private _createPath = (waypoints: Point[], type: PathType): Path => ({ + type, + waypoints, + }); +} + +export type Path = { + type: PathType; + waypoints: Point[]; +}; + +export type TypedRoute = { + type: PathType; + route: Route; +}; + +/** + * PathType id used for route calculation, to reduce the number of routes to compute : + * - a single route for a driver only + * - a single route for a passenger only + * - a single route for a driver and passenger with 2 waypoints given + * - two routes for a driver and passenger with more than 2 waypoints given + * (all the waypoints as driver, only origin and destination as passenger as + * intermediate waypoints doesn't matter in that case) + */ +export enum PathType { + GENERIC = 'generic', + DRIVER = 'driver', + PASSENGER = 'passenger', +} diff --git a/src/modules/ad/core/domain/value-objects/actor-time.value-object.ts b/src/modules/ad/core/domain/value-objects/actor-time.value-object.ts new file mode 100644 index 0000000..41b0bfa --- /dev/null +++ b/src/modules/ad/core/domain/value-objects/actor-time.value-object.ts @@ -0,0 +1,84 @@ +import { ArgumentInvalidException, ValueObject } from '@mobicoop/ddd-library'; +import { Role } from '../ad.types'; +import { Target } from '../candidate.types'; +import { Actor, ActorProps } from './actor.value-object'; + +/** Note: + * Value Objects with multiple properties can contain + * other Value Objects inside if needed. + * */ + +export interface ActorTimeProps extends ActorProps { + firstDatetime: Date; + firstMinDatetime: Date; + firstMaxDatetime: Date; + lastDatetime: Date; + lastMinDatetime: Date; + lastMaxDatetime: Date; +} + +export class ActorTime extends ValueObject { + get role(): Role { + return this.props.role; + } + + get target(): Target { + return this.props.target; + } + + get firstDatetime(): Date { + return this.props.firstDatetime; + } + + get firstMinDatetime(): Date { + return this.props.firstMinDatetime; + } + + get firstMaxDatetime(): Date { + return this.props.firstMaxDatetime; + } + + get lastDatetime(): Date { + return this.props.lastDatetime; + } + + get lastMinDatetime(): Date { + return this.props.lastMinDatetime; + } + + get lastMaxDatetime(): Date { + return this.props.lastMaxDatetime; + } + + protected validate(props: ActorTimeProps): void { + // validate actor props + new Actor({ + role: props.role, + target: props.target, + }); + if (props.firstDatetime.getUTCDay() != props.lastDatetime.getUTCDay()) + throw new ArgumentInvalidException( + 'firstDatetime week day must be equal to lastDatetime week day', + ); + if (props.firstDatetime > props.lastDatetime) + throw new ArgumentInvalidException( + 'firstDatetime must be before or equal to lastDatetime', + ); + if (props.firstMinDatetime > props.firstDatetime) + throw new ArgumentInvalidException( + 'firstMinDatetime must be before or equal to firstDatetime', + ); + if (props.firstDatetime > props.firstMaxDatetime) + throw new ArgumentInvalidException( + 'firstDatetime must be before or equal to firstMaxDatetime', + ); + if (props.lastMinDatetime > props.lastDatetime) + throw new ArgumentInvalidException( + 'lastMinDatetime must be before or equal to lastDatetime', + ); + if (props.lastDatetime > props.lastMaxDatetime) + throw new ArgumentInvalidException( + 'lastDatetime must be before or equal to lastMaxDatetime', + ); + } +} diff --git a/src/modules/ad/core/domain/value-objects/actor.value-object.ts b/src/modules/ad/core/domain/value-objects/actor.value-object.ts new file mode 100644 index 0000000..d73c260 --- /dev/null +++ b/src/modules/ad/core/domain/value-objects/actor.value-object.ts @@ -0,0 +1,27 @@ +import { ValueObject } from '@mobicoop/ddd-library'; +import { Role } from '../ad.types'; +import { Target } from '../candidate.types'; + +/** Note: + * Value Objects with multiple properties can contain + * other Value Objects inside if needed. + * */ + +export interface ActorProps { + role: Role; + target: Target; +} + +export class Actor extends ValueObject { + get role(): Role { + return this.props.role; + } + + get target(): Target { + return this.props.target; + } + + protected validate(): void { + return; + } +} diff --git a/src/modules/ad/core/domain/value-objects/carpool-path-item.value-object.ts b/src/modules/ad/core/domain/value-objects/carpool-path-item.value-object.ts new file mode 100644 index 0000000..377000a --- /dev/null +++ b/src/modules/ad/core/domain/value-objects/carpool-path-item.value-object.ts @@ -0,0 +1,47 @@ +import { + ArgumentOutOfRangeException, + ValueObject, +} from '@mobicoop/ddd-library'; +import { Actor, ActorProps } from './actor.value-object'; +import { Role } from '../ad.types'; +import { Point, PointProps } from './point.value-object'; + +/** Note: + * Value Objects with multiple properties can contain + * other Value Objects inside if needed. + * */ + +export interface CarpoolPathItemProps extends PointProps { + actors: ActorProps[]; +} + +export class CarpoolPathItem extends ValueObject { + get lon(): number { + return this.props.lon; + } + + get lat(): number { + return this.props.lat; + } + + get actors(): ActorProps[] { + return this.props.actors; + } + + protected validate(props: CarpoolPathItemProps): void { + // validate point props + new Point({ + lon: props.lon, + lat: props.lat, + }); + if (props.actors.length <= 0) + throw new ArgumentOutOfRangeException('at least one actor is required'); + if ( + props.actors.filter((actor: Actor) => actor.role == Role.DRIVER).length > + 1 + ) + throw new ArgumentOutOfRangeException( + 'a carpoolStep can contain only one driver', + ); + } +} diff --git a/src/modules/ad/core/domain/value-objects/journey-item.value-object.ts b/src/modules/ad/core/domain/value-objects/journey-item.value-object.ts new file mode 100644 index 0000000..ef0c9fd --- /dev/null +++ b/src/modules/ad/core/domain/value-objects/journey-item.value-object.ts @@ -0,0 +1,64 @@ +import { + ArgumentOutOfRangeException, + ValueObject, +} from '@mobicoop/ddd-library'; +import { ActorTime, ActorTimeProps } from './actor-time.value-object'; +import { Step, StepProps } from './step.value-object'; +import { Role } from '../ad.types'; + +/** Note: + * Value Objects with multiple properties can contain + * other Value Objects inside if needed. + * */ + +export interface JourneyItemProps extends StepProps { + actorTimes: ActorTimeProps[]; +} + +export class JourneyItem extends ValueObject { + get duration(): number { + return this.props.duration; + } + + get distance(): number | undefined { + return this.props.distance; + } + + get lon(): number { + return this.props.lon; + } + + get lat(): number { + return this.props.lat; + } + + get actorTimes(): ActorTimeProps[] { + return this.props.actorTimes; + } + + driverTime = (): string => { + const driverTime: Date = ( + this.actorTimes.find( + (actorTime: ActorTime) => actorTime.role == Role.DRIVER, + ) as ActorTime + ).firstDatetime; + return `${driverTime.getHours().toString().padStart(2, '0')}:${driverTime + .getMinutes() + .toString() + .padStart(2, '0')}`; + }; + + protected validate(props: JourneyItemProps): void { + // validate step props + new Step({ + lon: props.lon, + lat: props.lat, + distance: props.distance, + duration: props.duration, + }); + if (props.actorTimes.length == 0) + throw new ArgumentOutOfRangeException( + 'at least one actorTime is required', + ); + } +} diff --git a/src/modules/ad/core/domain/value-objects/journey.value-object.ts b/src/modules/ad/core/domain/value-objects/journey.value-object.ts new file mode 100644 index 0000000..d36bc9e --- /dev/null +++ b/src/modules/ad/core/domain/value-objects/journey.value-object.ts @@ -0,0 +1,106 @@ +import { ArgumentInvalidException, ValueObject } from '@mobicoop/ddd-library'; +import { JourneyItem, JourneyItemProps } from './journey-item.value-object'; +import { ActorTime } from './actor-time.value-object'; +import { Role } from '../ad.types'; +import { Target } from '../candidate.types'; +import { Point } from './point.value-object'; + +/** Note: + * Value Objects with multiple properties can contain + * other Value Objects inside if needed. + * */ + +export interface JourneyProps { + firstDate: Date; + lastDate: Date; + journeyItems: JourneyItemProps[]; +} + +export class Journey extends ValueObject { + get firstDate(): Date { + return this.props.firstDate; + } + + get lastDate(): Date { + return this.props.lastDate; + } + + get journeyItems(): JourneyItemProps[] { + return this.props.journeyItems; + } + + hasValidPickUp = (): boolean => { + const passengerDepartureJourneyItem: JourneyItem = this.journeyItems.find( + (journeyItem: JourneyItem) => + journeyItem.actorTimes.find( + (actorTime: ActorTime) => + actorTime.role == Role.PASSENGER && + actorTime.target == Target.START, + ) as ActorTime, + ) as JourneyItem; + const passengerDepartureActorTime = + passengerDepartureJourneyItem.actorTimes.find( + (actorTime: ActorTime) => + actorTime.role == Role.PASSENGER && actorTime.target == Target.START, + ) as ActorTime; + const driverNeutralActorTime = + passengerDepartureJourneyItem.actorTimes.find( + (actorTime: ActorTime) => + actorTime.role == Role.DRIVER && actorTime.target == Target.NEUTRAL, + ) as ActorTime; + return ( + (passengerDepartureActorTime.firstMinDatetime <= + driverNeutralActorTime.firstMaxDatetime && + driverNeutralActorTime.firstMaxDatetime <= + passengerDepartureActorTime.firstMaxDatetime) || + (passengerDepartureActorTime.firstMinDatetime <= + driverNeutralActorTime.firstMinDatetime && + driverNeutralActorTime.firstMinDatetime <= + passengerDepartureActorTime.firstMaxDatetime) + ); + }; + + firstDriverDepartureTime = (): string => { + const firstDriverDepartureDatetime: Date = ( + this._driverDepartureJourneyItem().actorTimes.find( + (actorTime: ActorTime) => + actorTime.role == Role.DRIVER && actorTime.target == Target.START, + ) as ActorTime + ).firstDatetime; + return `${firstDriverDepartureDatetime + .getUTCHours() + .toString() + .padStart(2, '0')}:${firstDriverDepartureDatetime + .getUTCMinutes() + .toString() + .padStart(2, '0')}`; + }; + + driverOrigin = (): Point => + new Point({ + lon: this._driverDepartureJourneyItem().lon, + lat: this._driverDepartureJourneyItem().lat, + }); + + private _driverDepartureJourneyItem = (): JourneyItem => + this.journeyItems.find( + (journeyItem: JourneyItem) => + journeyItem.actorTimes.find( + (actorTime: ActorTime) => + actorTime.role == Role.DRIVER && actorTime.target == Target.START, + ) as ActorTime, + ) as JourneyItem; + + protected validate(props: JourneyProps): void { + if (props.firstDate.getUTCDay() != props.lastDate.getUTCDay()) + throw new ArgumentInvalidException( + 'firstDate week day must be equal to lastDate week day', + ); + if (props.firstDate > props.lastDate) + throw new ArgumentInvalidException('firstDate must be before lastDate'); + if (props.journeyItems.length < 2) + throw new ArgumentInvalidException( + 'at least 2 journey items are required', + ); + } +} 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/core/domain/value-objects/schedule-item.value-object.ts b/src/modules/ad/core/domain/value-objects/schedule-item.value-object.ts index 5f2d66b..eb32016 100644 --- a/src/modules/ad/core/domain/value-objects/schedule-item.value-object.ts +++ b/src/modules/ad/core/domain/value-objects/schedule-item.value-object.ts @@ -16,7 +16,7 @@ export interface ScheduleItemProps { } export class ScheduleItem extends ValueObject { - get day(): number | undefined { + get day(): number { return this.props.day; } @@ -24,11 +24,10 @@ export class ScheduleItem extends ValueObject { return this.props.time; } - get margin(): number | undefined { + get margin(): number { return this.props.margin; } - // eslint-disable-next-line @typescript-eslint/no-unused-vars protected validate(props: ScheduleItemProps): void { if (props.day < 0 || props.day > 6) throw new ArgumentOutOfRangeException('day must be between 0 and 6'); diff --git a/src/modules/ad/core/domain/value-objects/step.value-object.ts b/src/modules/ad/core/domain/value-objects/step.value-object.ts new file mode 100644 index 0000000..fbcc410 --- /dev/null +++ b/src/modules/ad/core/domain/value-objects/step.value-object.ts @@ -0,0 +1,46 @@ +import { ArgumentInvalidException, ValueObject } from '@mobicoop/ddd-library'; +import { Point, PointProps } from './point.value-object'; + +/** Note: + * Value Objects with multiple properties can contain + * other Value Objects inside if needed. + * */ + +export interface StepProps extends PointProps { + duration: number; + distance?: number; +} + +export class Step extends ValueObject { + get duration(): number { + return this.props.duration; + } + + get distance(): number | undefined { + return this.props.distance; + } + + get lon(): number { + return this.props.lon; + } + + get lat(): number { + return this.props.lat; + } + + protected validate(props: StepProps): void { + // validate point props + new Point({ + lon: props.lon, + lat: props.lat, + }); + if (props.duration < 0) + throw new ArgumentInvalidException( + 'duration must be greater than or equal to 0', + ); + if (props.distance !== undefined && props.distance < 0) + throw new ArgumentInvalidException( + 'distance must be greater than or equal to 0', + ); + } +} diff --git a/src/modules/ad/core/domain/value-objects/waypoint.value-object.ts b/src/modules/ad/core/domain/value-objects/waypoint.value-object.ts deleted file mode 100644 index 353f51d..0000000 --- a/src/modules/ad/core/domain/value-objects/waypoint.value-object.ts +++ /dev/null @@ -1,41 +0,0 @@ -import { - ArgumentInvalidException, - ArgumentOutOfRangeException, - ValueObject, -} from '@mobicoop/ddd-library'; - -/** Note: - * Value Objects with multiple properties can contain - * other Value Objects inside if needed. - * */ - -export interface WaypointProps { - position: number; - lon: number; - lat: number; -} - -export class Waypoint extends ValueObject { - get position(): number { - return this.props.position; - } - - get lon(): number { - return this.props.lon; - } - - get lat(): number { - return this.props.lat; - } - - protected validate(props: WaypointProps): void { - if (props.position < 0) - throw new ArgumentInvalidException( - 'position must be greater than or equal to 0', - ); - if (props.lon > 180 || props.lon < -180) - throw new ArgumentOutOfRangeException('lon must be between -180 and 180'); - if (props.lat > 90 || props.lat < -90) - throw new ArgumentOutOfRangeException('lat must be between -90 and 90'); - } -} diff --git a/src/modules/ad/infrastructure/ad.repository.ts b/src/modules/ad/infrastructure/ad.repository.ts index ae07fd7..5f45287 100644 --- a/src/modules/ad/infrastructure/ad.repository.ts +++ b/src/modules/ad/infrastructure/ad.repository.ts @@ -7,53 +7,75 @@ import { AD_MESSAGE_PUBLISHER } from '../ad.di-tokens'; import { AdEntity } from '../core/domain/ad.entity'; import { AdMapper } from '../ad.mapper'; import { ExtendedPrismaRepositoryBase } from '@mobicoop/ddd-library/dist/db/prisma-repository.base'; +import { Frequency } from '../core/domain/ad.types'; -export type AdBaseModel = { +export type AdModel = { uuid: string; driver: boolean; passenger: boolean; - frequency: string; + frequency: Frequency; fromDate: Date; toDate: Date; seatsProposed: number; seatsRequested: number; strict: boolean; - driverDuration: number; - driverDistance: number; - passengerDuration: number; - passengerDistance: number; + driverDuration?: number; + driverDistance?: number; + passengerDuration?: number; + passengerDistance?: number; fwdAzimuth: number; backAzimuth: number; createdAt: Date; updatedAt: Date; }; -export type AdReadModel = AdBaseModel & { +/** + * The record as returned by the persistence system + */ +export type AdReadModel = AdModel & { waypoints: string; - direction: string; schedule: ScheduleItemModel[]; }; -export type AdWriteModel = AdBaseModel & { +/** + * The record ready to be sent to the peristence system + */ +export type AdWriteModel = AdModel & { schedule: { create: ScheduleItemModel[]; }; }; -export type AdUnsupportedWriteModel = { +export type AdWriteExtraModel = { waypoints: string; direction: string; }; -export type ScheduleItemModel = { - uuid: string; +export type ScheduleItem = { day: number; time: Date; margin: number; +}; + +export type ScheduleItemModel = ScheduleItem & { + uuid: string; createdAt: Date; updatedAt: Date; }; +export type UngroupedAdModel = AdModel & + ScheduleItem & { + scheduleItemUuid: string; + scheduleItemCreatedAt: Date; + scheduleItemUpdatedAt: Date; + waypoints: string; + }; + +export type GroupedAdModel = AdModel & { + schedule: ScheduleItemModel[]; + waypoints: string; +}; + /** * Repository is used for retrieving/saving domain entities * */ @@ -63,7 +85,7 @@ export class AdRepository AdEntity, AdReadModel, AdWriteModel, - AdUnsupportedWriteModel + AdWriteExtraModel > implements AdRepositoryPort { @@ -86,4 +108,64 @@ export class AdRepository }), ); } + + getCandidateAds = async (queryString: string): Promise => + this._toAdReadModels( + (await this.prismaRaw.$queryRawUnsafe(queryString)) as UngroupedAdModel[], + ) + .map((adReadModel: AdReadModel) => { + if (this.mapper.toDomain) return this.mapper.toDomain(adReadModel); + }) + .filter( + (adEntity: AdEntity | undefined) => adEntity !== undefined, + ) as AdEntity[]; + + private _toAdReadModels = ( + ungroupedAds: UngroupedAdModel[], + ): AdReadModel[] => { + const groupedAdModels: GroupedAdModel[] = ungroupedAds.map( + (ungroupedAd: UngroupedAdModel) => ({ + uuid: ungroupedAd.uuid, + driver: ungroupedAd.driver, + passenger: ungroupedAd.passenger, + frequency: ungroupedAd.frequency, + fromDate: ungroupedAd.fromDate, + toDate: ungroupedAd.toDate, + schedule: [ + { + uuid: ungroupedAd.scheduleItemUuid, + day: ungroupedAd.day, + time: ungroupedAd.time, + margin: ungroupedAd.margin, + createdAt: ungroupedAd.scheduleItemCreatedAt, + updatedAt: ungroupedAd.scheduleItemUpdatedAt, + }, + ], + seatsProposed: ungroupedAd.seatsProposed, + seatsRequested: ungroupedAd.seatsRequested, + strict: ungroupedAd.strict, + driverDuration: ungroupedAd.driverDuration, + driverDistance: ungroupedAd.driverDistance, + passengerDuration: ungroupedAd.passengerDuration, + passengerDistance: ungroupedAd.passengerDistance, + fwdAzimuth: ungroupedAd.fwdAzimuth, + backAzimuth: ungroupedAd.backAzimuth, + waypoints: ungroupedAd.waypoints, + createdAt: ungroupedAd.createdAt, + updatedAt: ungroupedAd.updatedAt, + }), + ); + const adReadModels: AdReadModel[] = []; + groupedAdModels.forEach((groupdeAdModel: GroupedAdModel) => { + const adReadModel: AdReadModel | undefined = adReadModels.find( + (arm: AdReadModel) => arm.uuid == groupdeAdModel.uuid, + ); + if (adReadModel) { + adReadModel.schedule.push(...groupdeAdModel.schedule); + } else { + adReadModels.push(groupdeAdModel); + } + }); + return adReadModels; + }; } diff --git a/src/modules/ad/infrastructure/default-params-provider.ts b/src/modules/ad/infrastructure/default-params-provider.ts new file mode 100644 index 0000000..10d7f13 --- /dev/null +++ b/src/modules/ad/infrastructure/default-params-provider.ts @@ -0,0 +1,90 @@ +import { Injectable } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import { DefaultParamsProviderPort } from '../core/application/ports/default-params-provider.port'; +import { DefaultParams } from '../core/application/ports/default-params.type'; +import { AlgorithmType } from '../core/application/types/algorithm.types'; + +const DRIVER = false; +const PASSENGER = true; +const SEATS_PROPOSED = 3; +const SEATS_REQUESTED = 1; +const DEPARTURE_TIME_MARGIN = 900; +const TIMEZONE = 'Europe/Paris'; +const ALGORITHM_TYPE = 'PASSENGER_ORIENTED'; +const REMOTENESS = 15000; +const USE_PROPORTION = true; +const PROPORTION = 0.3; +const USE_AZIMUTH = true; +const AZIMUTH_MARGIN = 10; +const MAX_DETOUR_DISTANCE_RATIO = 0.3; +const MAX_DETOUR_DURATION_RATIO = 0.3; +const PER_PAGE = 10; + +@Injectable() +export class DefaultParamsProvider implements DefaultParamsProviderPort { + constructor(private readonly _configService: ConfigService) {} + getParams = (): DefaultParams => ({ + DRIVER: + this._configService.get('ROLE') !== undefined + ? this._configService.get('ROLE') == 'driver' + : DRIVER, + SEATS_PROPOSED: + this._configService.get('SEATS_PROPOSED') !== undefined + ? parseInt(this._configService.get('SEATS_PROPOSED') as string) + : SEATS_PROPOSED, + PASSENGER: + this._configService.get('ROLE') !== undefined + ? this._configService.get('ROLE') == 'passenger' + : PASSENGER, + SEATS_REQUESTED: + this._configService.get('SEATS_REQUESTED') !== undefined + ? parseInt(this._configService.get('SEATS_REQUESTED') as string) + : SEATS_REQUESTED, + DEPARTURE_TIME_MARGIN: + this._configService.get('DEPARTURE_TIME_MARGIN') !== undefined + ? parseInt(this._configService.get('DEPARTURE_TIME_MARGIN') as string) + : DEPARTURE_TIME_MARGIN, + STRICT: this._configService.get('STRICT_FREQUENCY') == 'true', + TIMEZONE: this._configService.get('TIMEZONE') ?? TIMEZONE, + ALGORITHM_TYPE: + AlgorithmType[ + this._configService.get('ALGORITHM_TYPE') as AlgorithmType + ] ?? AlgorithmType[ALGORITHM_TYPE], + REMOTENESS: + this._configService.get('REMOTENESS') !== undefined + ? parseInt(this._configService.get('REMOTENESS') as string) + : REMOTENESS, + USE_PROPORTION: + this._configService.get('USE_PROPORTION') !== undefined + ? this._configService.get('USE_PROPORTION') == 'true' + : USE_PROPORTION, + PROPORTION: + this._configService.get('PROPORTION') !== undefined + ? parseFloat(this._configService.get('PROPORTION') as string) + : PROPORTION, + USE_AZIMUTH: + this._configService.get('USE_AZIMUTH') !== undefined + ? this._configService.get('USE_AZIMUTH') == 'true' + : USE_AZIMUTH, + AZIMUTH_MARGIN: + this._configService.get('AZIMUTH_MARGIN') !== undefined + ? parseInt(this._configService.get('AZIMUTH_MARGIN') as string) + : AZIMUTH_MARGIN, + MAX_DETOUR_DISTANCE_RATIO: + this._configService.get('MAX_DETOUR_DISTANCE_RATIO') !== undefined + ? parseFloat( + this._configService.get('MAX_DETOUR_DISTANCE_RATIO') as string, + ) + : MAX_DETOUR_DISTANCE_RATIO, + MAX_DETOUR_DURATION_RATIO: + this._configService.get('MAX_DETOUR_DURATION_RATIO') !== undefined + ? parseFloat( + this._configService.get('MAX_DETOUR_DURATION_RATIO') as string, + ) + : MAX_DETOUR_DURATION_RATIO, + PER_PAGE: + this._configService.get('PER_PAGE') !== undefined + ? parseInt(this._configService.get('PER_PAGE') as string) + : PER_PAGE, + }); +} diff --git a/src/modules/ad/infrastructure/input-datetime-transformer.ts b/src/modules/ad/infrastructure/input-datetime-transformer.ts new file mode 100644 index 0000000..5181a0a --- /dev/null +++ b/src/modules/ad/infrastructure/input-datetime-transformer.ts @@ -0,0 +1,132 @@ +import { Inject, Injectable } from '@nestjs/common'; +import { + DateTimeTransformerPort, + Frequency, + GeoDateTime, +} from '../core/application/ports/datetime-transformer.port'; +import { TimeConverterPort } from '../core/application/ports/time-converter.port'; +import { + PARAMS_PROVIDER, + TIMEZONE_FINDER, + TIME_CONVERTER, +} from '../ad.di-tokens'; +import { TimezoneFinderPort } from '../core/application/ports/timezone-finder.port'; +import { DefaultParamsProviderPort } from '../core/application/ports/default-params-provider.port'; + +@Injectable() +export class InputDateTimeTransformer implements DateTimeTransformerPort { + private readonly _defaultTimezone: string; + constructor( + @Inject(PARAMS_PROVIDER) + private readonly defaultParamsProvider: DefaultParamsProviderPort, + @Inject(TIMEZONE_FINDER) + private readonly timezoneFinder: TimezoneFinderPort, + @Inject(TIME_CONVERTER) private readonly timeConverter: TimeConverterPort, + ) { + this._defaultTimezone = defaultParamsProvider.getParams().TIMEZONE; + } + + /** + * Compute the fromDate : if an ad is punctual, the departure date + * is converted to UTC with the time and timezone + */ + fromDate = (geoFromDate: GeoDateTime, frequency: Frequency): string => { + if (frequency === Frequency.RECURRENT) return geoFromDate.date; + return this.timeConverter + .localStringDateTimeToUtcDate( + geoFromDate.date, + geoFromDate.time, + this.timezoneFinder.timezones( + geoFromDate.coordinates.lon, + geoFromDate.coordinates.lat, + this._defaultTimezone, + )[0], + ) + .toISOString() + .split('T')[0]; + }; + + /** + * Get the toDate depending on frequency, time and timezone : + * if the ad is punctual, the toDate is equal to the fromDate + */ + toDate = ( + toDate: string, + geoFromDate: GeoDateTime, + frequency: Frequency, + ): string => { + if (frequency === Frequency.RECURRENT) return toDate; + return this.fromDate(geoFromDate, frequency); + }; + + /** + * Get the day for a schedule item : + * - if the ad is punctual, the day is infered from fromDate + * - if the ad is recurrent, the day is computed by converting the time to utc + */ + day = ( + day: number, + geoFromDate: GeoDateTime, + frequency: Frequency, + ): number => { + if (frequency === Frequency.RECURRENT) + return this.recurrentDay( + day, + geoFromDate.time, + this.timezoneFinder.timezones( + geoFromDate.coordinates.lon, + geoFromDate.coordinates.lat, + this._defaultTimezone, + )[0], + ); + return new Date(this.fromDate(geoFromDate, frequency)).getUTCDay(); + }; + + /** + * Get the utc time + */ + time = (geoFromDate: GeoDateTime, frequency: Frequency): string => { + if (frequency === Frequency.RECURRENT) + return this.timeConverter.localStringTimeToUtcStringTime( + geoFromDate.time, + this.timezoneFinder.timezones( + geoFromDate.coordinates.lon, + geoFromDate.coordinates.lat, + this._defaultTimezone, + )[0], + ); + return this.timeConverter + .localStringDateTimeToUtcDate( + geoFromDate.date, + geoFromDate.time, + this.timezoneFinder.timezones( + geoFromDate.coordinates.lon, + geoFromDate.coordinates.lat, + this._defaultTimezone, + )[0], + ) + .toISOString() + .split('T')[1] + .split(':', 2) + .join(':'); + }; + + /** + * Get the day for a schedule item for a recurrent ad + * The day may change when transforming from local timezone to utc + */ + private recurrentDay = ( + day: number, + time: string, + timezone: string, + ): number => { + const unixEpochDay = 4; // 1970-01-01 is a thursday ! + const utcBaseDay = this.timeConverter.utcUnixEpochDayFromTime( + time, + timezone, + ); + if (unixEpochDay == utcBaseDay) return day; + if (unixEpochDay > utcBaseDay) return day > 0 ? day - 1 : 6; + return day < 6 ? day + 1 : 0; + }; +} 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/output-datetime-transformer.ts b/src/modules/ad/infrastructure/output-datetime-transformer.ts new file mode 100644 index 0000000..d2d44be --- /dev/null +++ b/src/modules/ad/infrastructure/output-datetime-transformer.ts @@ -0,0 +1,116 @@ +import { Inject, Injectable } from '@nestjs/common'; +import { + DateTimeTransformerPort, + Frequency, + GeoDateTime, +} from '../core/application/ports/datetime-transformer.port'; +import { TimeConverterPort } from '../core/application/ports/time-converter.port'; +import { TIMEZONE_FINDER, TIME_CONVERTER } from '../ad.di-tokens'; +import { TimezoneFinderPort } from '../core/application/ports/timezone-finder.port'; + +@Injectable() +export class OutputDateTimeTransformer implements DateTimeTransformerPort { + constructor( + @Inject(TIMEZONE_FINDER) + private readonly timezoneFinder: TimezoneFinderPort, + @Inject(TIME_CONVERTER) private readonly timeConverter: TimeConverterPort, + ) {} + + /** + * Compute the fromDate : if an ad is punctual, the departure date + * is converted from UTC to the local date with the time and timezone + */ + fromDate = (geoFromDate: GeoDateTime, frequency: Frequency): string => { + if (frequency === Frequency.RECURRENT) return geoFromDate.date; + return this.timeConverter + .utcStringDateTimeToLocalIsoString( + geoFromDate.date, + geoFromDate.time, + this.timezoneFinder.timezones( + geoFromDate.coordinates.lon, + geoFromDate.coordinates.lat, + )[0], + ) + .split('T')[0]; + }; + + /** + * Get the toDate depending on frequency, time and timezone : + * if the ad is punctual, the toDate is equal to the fromDate + */ + toDate = ( + toDate: string, + geoFromDate: GeoDateTime, + frequency: Frequency, + ): string => { + if (frequency === Frequency.RECURRENT) return toDate; + return this.fromDate(geoFromDate, frequency); + }; + + /** + * Get the day for a schedule item : + * - if the ad is punctual, the day is infered from fromDate + * - if the ad is recurrent, the day is computed by converting the time from utc to local time + */ + day = ( + day: number, + geoFromDate: GeoDateTime, + frequency: Frequency, + ): number => { + if (frequency === Frequency.RECURRENT) + return this.recurrentDay( + day, + geoFromDate.time, + this.timezoneFinder.timezones( + geoFromDate.coordinates.lon, + geoFromDate.coordinates.lat, + )[0], + ); + return new Date(this.fromDate(geoFromDate, frequency)).getDay(); + }; + + /** + * Get the utc time + */ + time = (geoFromDate: GeoDateTime, frequency: Frequency): string => { + if (frequency === Frequency.RECURRENT) + return this.timeConverter.utcStringTimeToLocalStringTime( + geoFromDate.time, + this.timezoneFinder.timezones( + geoFromDate.coordinates.lon, + geoFromDate.coordinates.lat, + )[0], + ); + return this.timeConverter + .utcStringDateTimeToLocalIsoString( + geoFromDate.date, + geoFromDate.time, + this.timezoneFinder.timezones( + geoFromDate.coordinates.lon, + geoFromDate.coordinates.lat, + )[0], + ) + .split('T')[1] + .split(':', 2) + .join(':'); + }; + + /** + * Get the day for a schedule item for a recurrent ad + * The day may change when transforming from utc to local timezone + */ + private recurrentDay = ( + day: number, + time: string, + timezone: string, + ): number => { + const unixEpochDay = 4; // 1970-01-01 is a thursday ! + const localBaseDay = this.timeConverter.localUnixEpochDayFromTime( + time, + timezone, + ); + if (unixEpochDay == localBaseDay) return day; + if (unixEpochDay > localBaseDay) return day > 0 ? day - 1 : 6; + return day < 6 ? day + 1 : 0; + }; +} diff --git a/src/modules/ad/infrastructure/route-provider.ts b/src/modules/ad/infrastructure/route-provider.ts index cd57ec2..ada7160 100644 --- a/src/modules/ad/infrastructure/route-provider.ts +++ b/src/modules/ad/infrastructure/route-provider.ts @@ -1,21 +1,28 @@ import { Inject, Injectable } from '@nestjs/common'; import { RouteProviderPort } from '../core/application/ports/route-provider.port'; -import { Route } from '../core/application/types/route.type'; -import { Waypoint } from '../core/application/types/waypoint.type'; -import { Role } from '../core/domain/ad.types'; -import { GetBasicRouteControllerPort } from '@modules/geography/core/application/ports/get-basic-route-controller.port'; -import { AD_GET_BASIC_ROUTE_CONTROLLER } from '../ad.di-tokens'; +import { GetRouteControllerPort } from '@modules/geography/core/application/ports/get-route-controller.port'; +import { + AD_GET_BASIC_ROUTE_CONTROLLER, + AD_GET_DETAILED_ROUTE_CONTROLLER, +} from '../ad.di-tokens'; +import { Point, Route } from '@modules/geography/core/domain/route.types'; @Injectable() export class RouteProvider implements RouteProviderPort { constructor( @Inject(AD_GET_BASIC_ROUTE_CONTROLLER) - private readonly getBasicRouteController: GetBasicRouteControllerPort, + private readonly getBasicRouteController: GetRouteControllerPort, + @Inject(AD_GET_DETAILED_ROUTE_CONTROLLER) + private readonly getDetailedRouteController: GetRouteControllerPort, ) {} - getBasic = async (roles: Role[], waypoints: Waypoint[]): Promise => + getBasic = async (waypoints: Point[]): Promise => await this.getBasicRouteController.get({ - roles, + waypoints, + }); + + getDetailed = async (waypoints: Point[]): Promise => + await this.getDetailedRouteController.get({ waypoints, }); } diff --git a/src/modules/ad/infrastructure/time-converter.ts b/src/modules/ad/infrastructure/time-converter.ts new file mode 100644 index 0000000..462473c --- /dev/null +++ b/src/modules/ad/infrastructure/time-converter.ts @@ -0,0 +1,57 @@ +import { Injectable } from '@nestjs/common'; +import { DateTime, TimeZone } from 'timezonecomplete'; +import { TimeConverterPort } from '../core/application/ports/time-converter.port'; + +@Injectable() +export class TimeConverter implements TimeConverterPort { + private readonly UNIX_EPOCH = '1970-01-01'; + + localStringTimeToUtcStringTime = (time: string, timezone: string): string => + new DateTime(`${this.UNIX_EPOCH}T${time}`, TimeZone.zone(timezone)) + .convert(TimeZone.zone('UTC')) + .format('HH:mm'); + + utcStringTimeToLocalStringTime = (time: string, timezone: string): string => + new DateTime(`${this.UNIX_EPOCH}T${time}`, TimeZone.zone('UTC')) + .convert(TimeZone.zone(timezone)) + .format('HH:mm'); + + localStringDateTimeToUtcDate = ( + date: string, + time: string, + timezone: string, + dst = false, + ): Date => + new Date( + new DateTime( + `${date}T${time}`, + TimeZone.zone(timezone, dst), + ).toIsoString(), + ); + + utcStringDateTimeToLocalIsoString = ( + date: string, + time: string, + timezone: string, + dst = false, + ): string => + new DateTime(`${date}T${time}`, TimeZone.zone('UTC')) + .convert(TimeZone.zone(timezone, dst)) + .toIsoString(); + + utcUnixEpochDayFromTime = (time: string, timezone: string): number => + new Date( + new DateTime(`${this.UNIX_EPOCH}T${time}`, TimeZone.zone(timezone, false)) + .convert(TimeZone.zone('UTC')) + .toIsoString() + .split('T')[0], + ).getUTCDay(); + + localUnixEpochDayFromTime = (time: string, timezone: string): number => + new Date( + new DateTime(`${this.UNIX_EPOCH}T${time}`, TimeZone.zone('UTC')) + .convert(TimeZone.zone(timezone)) + .toIsoString() + .split('T')[0], + ).getUTCDay(); +} diff --git a/src/modules/ad/infrastructure/timezone-finder.ts b/src/modules/ad/infrastructure/timezone-finder.ts new file mode 100644 index 0000000..feb0b5a --- /dev/null +++ b/src/modules/ad/infrastructure/timezone-finder.ts @@ -0,0 +1,16 @@ +import { Injectable } from '@nestjs/common'; +import { find } from 'geo-tz'; +import { TimezoneFinderPort } from '../core/application/ports/timezone-finder.port'; + +@Injectable() +export class TimezoneFinder implements TimezoneFinderPort { + timezones = ( + lon: number, + lat: number, + defaultTimezone?: string, + ): string[] => { + const foundTimezones = find(lat, lon); + if (defaultTimezone && foundTimezones.length == 0) return [defaultTimezone]; + return foundTimezones; + }; +} diff --git a/src/modules/ad/interface/dtos/actor.response.dto.ts b/src/modules/ad/interface/dtos/actor.response.dto.ts new file mode 100644 index 0000000..ed701e4 --- /dev/null +++ b/src/modules/ad/interface/dtos/actor.response.dto.ts @@ -0,0 +1,4 @@ +export class ActorResponseDto { + role: string; + target: string; +} 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/journey.response.dto.ts b/src/modules/ad/interface/dtos/journey.response.dto.ts new file mode 100644 index 0000000..f01599b --- /dev/null +++ b/src/modules/ad/interface/dtos/journey.response.dto.ts @@ -0,0 +1,8 @@ +import { StepResponseDto } from './step.response.dto'; + +export class JourneyResponseDto { + day: number; + firstDate: string; + lastDate: string; + steps: StepResponseDto[]; +} diff --git a/src/modules/ad/interface/dtos/match.response.dto.ts b/src/modules/ad/interface/dtos/match.response.dto.ts new file mode 100644 index 0000000..8797252 --- /dev/null +++ b/src/modules/ad/interface/dtos/match.response.dto.ts @@ -0,0 +1,17 @@ +import { ResponseBase } from '@mobicoop/ddd-library'; +import { JourneyResponseDto } from './journey.response.dto'; + +export class MatchResponseDto extends ResponseBase { + adId: string; + role: string; + frequency: string; + distance: number; + duration: number; + initialDistance: number; + initialDuration: number; + distanceDetour: number; + durationDetour: number; + distanceDetourPercentage: number; + durationDetourPercentage: number; + journeys: JourneyResponseDto[]; +} 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/dtos/step.response.dto.ts b/src/modules/ad/interface/dtos/step.response.dto.ts new file mode 100644 index 0000000..f7083b5 --- /dev/null +++ b/src/modules/ad/interface/dtos/step.response.dto.ts @@ -0,0 +1,10 @@ +import { ActorResponseDto } from './actor.response.dto'; + +export class StepResponseDto { + distance: number; + duration: number; + lon: number; + lat: number; + time: string; + actors: ActorResponseDto[]; +} diff --git a/src/modules/ad/interface/grpc-controllers/dtos/address.dto.ts b/src/modules/ad/interface/grpc-controllers/dtos/address.dto.ts new file mode 100644 index 0000000..d37dfa7 --- /dev/null +++ b/src/modules/ad/interface/grpc-controllers/dtos/address.dto.ts @@ -0,0 +1,28 @@ +import { IsOptional, IsString } from 'class-validator'; +import { CoordinatesDto as CoordinatesDto } from './coordinates.dto'; + +export class AddressDto extends CoordinatesDto { + @IsOptional() + @IsString() + name?: string; + + @IsOptional() + @IsString() + houseNumber?: string; + + @IsOptional() + @IsString() + street?: string; + + @IsOptional() + @IsString() + locality?: string; + + @IsOptional() + @IsString() + postalCode?: string; + + @IsOptional() + @IsString() + country?: string; +} diff --git a/src/modules/ad/interface/grpc-controllers/dtos/coordinates.dto.ts b/src/modules/ad/interface/grpc-controllers/dtos/coordinates.dto.ts new file mode 100644 index 0000000..cb636ae --- /dev/null +++ b/src/modules/ad/interface/grpc-controllers/dtos/coordinates.dto.ts @@ -0,0 +1,9 @@ +import { IsLatitude, IsLongitude } from 'class-validator'; + +export class CoordinatesDto { + @IsLongitude() + lon: number; + + @IsLatitude() + lat: number; +} diff --git a/src/modules/ad/interface/grpc-controllers/dtos/match.request.dto.ts b/src/modules/ad/interface/grpc-controllers/dtos/match.request.dto.ts new file mode 100644 index 0000000..70131e2 --- /dev/null +++ b/src/modules/ad/interface/grpc-controllers/dtos/match.request.dto.ts @@ -0,0 +1,130 @@ +import { + ArrayMinSize, + IsArray, + IsBoolean, + IsDecimal, + IsEnum, + IsISO8601, + IsInt, + IsOptional, + IsUUID, + 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'; +import { Frequency } from '@modules/ad/core/domain/ad.types'; +import { AlgorithmType } from '@modules/ad/core/application/types/algorithm.types'; + +export class MatchRequestDto { + @IsUUID() + @IsOptional() + id?: string; + + @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) + algorithmType?: 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; + + @IsOptional() + @IsInt() + page?: number; + + @IsOptional() + @IsInt() + perPage?: number; +} diff --git a/src/modules/ad/interface/grpc-controllers/dtos/schedule-item.dto.ts b/src/modules/ad/interface/grpc-controllers/dtos/schedule-item.dto.ts new file mode 100644 index 0000000..9c0f734 --- /dev/null +++ b/src/modules/ad/interface/grpc-controllers/dtos/schedule-item.dto.ts @@ -0,0 +1,16 @@ +import { IsMilitaryTime, IsInt, Min, Max, IsOptional } from 'class-validator'; + +export class ScheduleItemDto { + @IsOptional() + @IsInt() + @Min(0) + @Max(6) + day?: number; + + @IsMilitaryTime() + time: string; + + @IsOptional() + @IsInt() + margin?: number; +} diff --git a/src/modules/ad/interface/grpc-controllers/dtos/validators/decorators/has-day.decorator.ts b/src/modules/ad/interface/grpc-controllers/dtos/validators/decorators/has-day.decorator.ts new file mode 100644 index 0000000..ed3cf0f --- /dev/null +++ b/src/modules/ad/interface/grpc-controllers/dtos/validators/decorators/has-day.decorator.ts @@ -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'), + )) + ); + }, + }, + }); + }; +} diff --git a/src/modules/ad/interface/grpc-controllers/dtos/validators/decorators/has-valid-position-indexes.decorator.ts b/src/modules/ad/interface/grpc-controllers/dtos/validators/decorators/has-valid-position-indexes.decorator.ts new file mode 100644 index 0000000..87e3a36 --- /dev/null +++ b/src/modules/ad/interface/grpc-controllers/dtos/validators/decorators/has-valid-position-indexes.decorator.ts @@ -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, + ); diff --git a/src/modules/ad/interface/grpc-controllers/dtos/validators/decorators/is-after-or-equal.decorator.ts b/src/modules/ad/interface/grpc-controllers/dtos/validators/decorators/is-after-or-equal.decorator.ts new file mode 100644 index 0000000..fb6e734 --- /dev/null +++ b/src/modules/ad/interface/grpc-controllers/dtos/validators/decorators/is-after-or-equal.decorator.ts @@ -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); + }, + }, + }); + }; +} diff --git a/src/modules/ad/interface/grpc-controllers/dtos/validators/has-valid-position-indexes.validator.ts b/src/modules/ad/interface/grpc-controllers/dtos/validators/has-valid-position-indexes.validator.ts new file mode 100644 index 0000000..302de1c --- /dev/null +++ b/src/modules/ad/interface/grpc-controllers/dtos/validators/has-valid-position-indexes.validator.ts @@ -0,0 +1,11 @@ +import { WaypointDto } from '../waypoint.dto'; + +export const hasValidPositionIndexes = (waypoints: WaypointDto[]): boolean => { + if (waypoints.length == 0) return false; + 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; +}; diff --git a/src/modules/ad/interface/grpc-controllers/dtos/waypoint.dto.ts b/src/modules/ad/interface/grpc-controllers/dtos/waypoint.dto.ts new file mode 100644 index 0000000..1d6ebd6 --- /dev/null +++ b/src/modules/ad/interface/grpc-controllers/dtos/waypoint.dto.ts @@ -0,0 +1,7 @@ +import { IsInt } from 'class-validator'; +import { AddressDto } from './address.dto'; + +export class WaypointDto extends AddressDto { + @IsInt() + position: number; +} diff --git a/src/modules/ad/interface/grpc-controllers/match.grpc-controller.ts b/src/modules/ad/interface/grpc-controllers/match.grpc-controller.ts new file mode 100644 index 0000000..930eaa9 --- /dev/null +++ b/src/modules/ad/interface/grpc-controllers/match.grpc-controller.ts @@ -0,0 +1,52 @@ +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 { 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'; +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({ + whitelist: false, + forbidUnknownValues: false, + }), +) +@Controller() +export class MatchGrpcController { + constructor( + private readonly queryBus: QueryBus, + @Inject(AD_ROUTE_PROVIDER) + private readonly routeProvider: RouteProviderPort, + private readonly matchMapper: MatchMapper, + ) {} + + @GrpcMethod('MatcherService', 'Match') + async match(data: MatchRequestDto): Promise { + try { + const matchingResult: MatchingResult = await this.queryBus.execute( + new MatchQuery(data, this.routeProvider), + ); + return new MatchingPaginatedResponseDto({ + id: matchingResult.id, + data: matchingResult.matches.map((match: MatchEntity) => + this.matchMapper.toResponse(match), + ), + page: matchingResult.page, + perPage: matchingResult.perPage, + total: matchingResult.total, + }); + } catch (e) { + throw new RpcException({ + code: RpcExceptionCode.UNKNOWN, + message: e.message, + }); + } + } +} diff --git a/src/modules/ad/interface/grpc-controllers/matcher.proto b/src/modules/ad/interface/grpc-controllers/matcher.proto new file mode 100644 index 0000000..2144731 --- /dev/null +++ b/src/modules/ad/interface/grpc-controllers/matcher.proto @@ -0,0 +1,101 @@ +syntax = "proto3"; + +package matcher; + +service MatcherService { + rpc Match(MatchRequest) returns (Matches); +} + +message MatchRequest { + string id = 1; + bool driver = 2; + bool passenger = 3; + Frequency frequency = 4; + string fromDate = 5; + string toDate = 6; + repeated ScheduleItem schedule = 7; + bool strict = 8; + repeated Waypoint waypoints = 9; + AlgorithmType algorithmType = 10; + int32 remoteness = 11; + bool useProportion = 12; + int32 proportion = 13; + bool useAzimuth = 14; + int32 azimuthMargin = 15; + float maxDetourDistanceRatio = 16; + float maxDetourDurationRatio = 17; + int32 identifier = 18; + optional int32 page = 19; + optional int32 perPage = 20; +} + +message ScheduleItem { + int32 day = 1; + string time = 2; + int32 margin = 3; +} + +message Waypoint { + int32 position = 1; + double lon = 2; + double lat = 3; + string name = 4; + string houseNumber = 5; + string street = 6; + string locality = 7; + string postalCode = 8; + string country = 9; +} + +enum Frequency { + PUNCTUAL = 1; + RECURRENT = 2; +} + +enum AlgorithmType { + PASSENGER_ORIENTED = 0; +} + +message Match { + string id = 1; + string adId = 2; + string role = 3; + int32 distance = 4; + int32 duration = 5; + int32 initialDistance = 6; + int32 initialDuration = 7; + int32 distanceDetour = 8; + int32 durationDetour = 9; + double distanceDetourPercentage = 10; + double durationDetourPercentage = 11; + repeated Journey journeys = 12; +} + +message Journey { + int32 day = 1; + string firstDate = 2; + string lastDate = 3; + repeated Step steps = 4; +} + +message Step { + int32 distance = 1; + int32 duration = 2; + double lon = 3; + double lat = 4; + string time = 5; + repeated Actor actors = 6; +} + +message Actor { + string role = 1; + string target = 2; +} + +message Matches { + string id = 1; + repeated Match data = 2; + int32 total = 3; + int32 page = 4; + int32 perPage = 5; +} diff --git a/src/modules/ad/interface/message-handlers/ad-created.message-handler.ts b/src/modules/ad/interface/message-handlers/ad-created.message-handler.ts index 621c6a1..9e7aa88 100644 --- a/src/modules/ad/interface/message-handlers/ad-created.message-handler.ts +++ b/src/modules/ad/interface/message-handlers/ad-created.message-handler.ts @@ -16,7 +16,7 @@ export class AdCreatedMessageHandler { const createdAd: Ad = JSON.parse(message); await this.commandBus.execute( new CreateAdCommand({ - id: createdAd.id, + id: createdAd.aggregateId, driver: createdAd.driver, passenger: createdAd.passenger, frequency: createdAd.frequency, diff --git a/src/modules/ad/interface/message-handlers/ad.types.ts b/src/modules/ad/interface/message-handlers/ad.types.ts index 1deb105..45eb53b 100644 --- a/src/modules/ad/interface/message-handlers/ad.types.ts +++ b/src/modules/ad/interface/message-handlers/ad.types.ts @@ -1,8 +1,7 @@ import { Frequency } from '@modules/ad/core/domain/ad.types'; export type Ad = { - id: string; - userId: string; + aggregateId: string; driver: boolean; passenger: boolean; frequency: Frequency; diff --git a/src/modules/ad/match.mapper.ts b/src/modules/ad/match.mapper.ts new file mode 100644 index 0000000..f0e47fb --- /dev/null +++ b/src/modules/ad/match.mapper.ts @@ -0,0 +1,80 @@ +import { Inject, Injectable } from '@nestjs/common'; +import { MatchEntity } from './core/domain/match.entity'; +import { MatchResponseDto } from './interface/dtos/match.response.dto'; +import { ResponseBase } from '@mobicoop/ddd-library'; +import { Journey } from './core/domain/value-objects/journey.value-object'; +import { JourneyItem } from './core/domain/value-objects/journey-item.value-object'; +import { ActorTime } from './core/domain/value-objects/actor-time.value-object'; +import { OUTPUT_DATETIME_TRANSFORMER } from './ad.di-tokens'; +import { DateTimeTransformerPort } from './core/application/ports/datetime-transformer.port'; + +@Injectable() +export class MatchMapper { + constructor( + @Inject(OUTPUT_DATETIME_TRANSFORMER) + private readonly outputDatetimeTransformer: DateTimeTransformerPort, + ) {} + + toResponse = (match: MatchEntity): MatchResponseDto => { + return { + ...new ResponseBase(match), + adId: match.getProps().adId, + role: match.getProps().role, + frequency: match.getProps().frequency, + distance: match.getProps().distance, + duration: match.getProps().duration, + initialDistance: match.getProps().initialDistance, + initialDuration: match.getProps().initialDuration, + distanceDetour: match.getProps().distanceDetour, + durationDetour: match.getProps().durationDetour, + distanceDetourPercentage: match.getProps().distanceDetourPercentage, + durationDetourPercentage: match.getProps().durationDetourPercentage, + journeys: match.getProps().journeys.map((journey: Journey) => ({ + day: new Date( + this.outputDatetimeTransformer.fromDate( + { + date: journey.firstDate.toISOString().split('T')[0], + time: journey.firstDriverDepartureTime(), + coordinates: journey.driverOrigin(), + }, + match.getProps().frequency, + ), + ).getDay(), + firstDate: this.outputDatetimeTransformer.fromDate( + { + date: journey.firstDate.toISOString().split('T')[0], + time: journey.firstDriverDepartureTime(), + coordinates: journey.driverOrigin(), + }, + match.getProps().frequency, + ), + lastDate: this.outputDatetimeTransformer.fromDate( + { + date: journey.lastDate.toISOString().split('T')[0], + time: journey.firstDriverDepartureTime(), + coordinates: journey.driverOrigin(), + }, + match.getProps().frequency, + ), + steps: journey.journeyItems.map((journeyItem: JourneyItem) => ({ + duration: journeyItem.duration, + distance: journeyItem.distance as number, + lon: journeyItem.lon, + lat: journeyItem.lat, + time: this.outputDatetimeTransformer.time( + { + date: journey.firstDate.toISOString().split('T')[0], + time: journeyItem.driverTime(), + coordinates: journey.driverOrigin(), + }, + match.getProps().frequency, + ), + actors: journeyItem.actorTimes.map((actorTime: ActorTime) => ({ + role: actorTime.role, + target: actorTime.target, + })), + })), + })), + }; + }; +} diff --git a/src/modules/ad/matching.mapper.ts b/src/modules/ad/matching.mapper.ts new file mode 100644 index 0000000..700d260 --- /dev/null +++ b/src/modules/ad/matching.mapper.ts @@ -0,0 +1,234 @@ +import { Injectable } from '@nestjs/common'; +import { Mapper } from '@mobicoop/ddd-library'; +import { MatchingEntity } from './core/domain/matching.entity'; +import { Frequency, Role } from './core/domain/ad.types'; +import { MatchEntity } from './core/domain/match.entity'; +import { Target } from './core/domain/candidate.types'; +import { Waypoint } from './core/application/types/waypoint.type'; +import { ScheduleItem } from './core/application/types/schedule-item.type'; +import { Journey } from './core/domain/value-objects/journey.value-object'; +import { JourneyItem } from './core/domain/value-objects/journey-item.value-object'; +import { ActorTime } from './core/domain/value-objects/actor-time.value-object'; + +@Injectable() +export class MatchingMapper + implements Mapper +{ + toPersistence = (entity: MatchingEntity): string => + JSON.stringify({ + id: entity.id, + createdAt: entity.createdAt.toISOString(), + updatedAt: entity.updatedAt.toISOString(), + matches: entity.getProps().matches.map((match: MatchEntity) => ({ + adId: match.getProps().adId, + role: match.getProps().role, + frequency: match.getProps().frequency, + distance: match.getProps().distance, + duration: match.getProps().duration, + initialDistance: match.getProps().initialDistance, + initialDuration: match.getProps().initialDuration, + distanceDetour: match.getProps().distanceDetour, + durationDetour: match.getProps().durationDetour, + distanceDetourPercentage: match.getProps().distanceDetourPercentage, + durationDetourPercentage: match.getProps().durationDetourPercentage, + journeys: match.getProps().journeys.map((journey: Journey) => ({ + firstDate: journey.firstDate.toISOString(), + lastDate: journey.lastDate.toISOString(), + journeyItems: journey.journeyItems.map( + (journeyItem: JourneyItem) => ({ + lon: journeyItem.lon, + lat: journeyItem.lat, + duration: journeyItem.duration, + distance: journeyItem.distance, + actorTimes: journeyItem.actorTimes.map( + (actorTime: ActorTime) => ({ + role: actorTime.role, + target: actorTime.target, + firstDatetime: actorTime.firstDatetime.toISOString(), + firstMinDatetime: actorTime.firstMinDatetime.toISOString(), + firstMaxDatetime: actorTime.firstMaxDatetime.toISOString(), + lastDatetime: actorTime.lastDatetime.toISOString(), + lastMinDatetime: actorTime.lastMinDatetime.toISOString(), + lastMaxDatetime: actorTime.lastMaxDatetime.toISOString(), + }), + ), + }), + ), + })), + })), + query: { + driver: entity.getProps().query.driver, + passenger: entity.getProps().query.passenger, + frequency: entity.getProps().query.frequency, + fromDate: entity.getProps().query.fromDate, + toDate: entity.getProps().query.toDate, + schedule: entity + .getProps() + .query.schedule.map((scheduleItem: ScheduleItem) => ({ + day: scheduleItem.day, + time: scheduleItem.time, + margin: scheduleItem.margin, + })), + seatsProposed: entity.getProps().query.seatsProposed, + seatsRequested: entity.getProps().query.seatsRequested, + strict: entity.getProps().query.strict, + waypoints: entity + .getProps() + .query.waypoints.map((waypoint: Waypoint) => ({ + lon: waypoint.lon, + lat: waypoint.lat, + position: waypoint.position, + houseNumber: waypoint.houseNumber, + street: waypoint.street, + postalCode: waypoint.postalCode, + locality: waypoint.locality, + country: waypoint.country, + })), + algorithmType: entity.getProps().query.algorithmType, + remoteness: entity.getProps().query.remoteness, + useProportion: entity.getProps().query.useProportion, + proportion: entity.getProps().query.proportion, + useAzimuth: entity.getProps().query.useAzimuth, + azimuthMargin: entity.getProps().query.azimuthMargin, + maxDetourDistanceRatio: entity.getProps().query.maxDetourDistanceRatio, + maxDetourDurationRatio: entity.getProps().query.maxDetourDurationRatio, + }, + }); + + toDomain = (record: string): MatchingEntity => { + const parsedRecord: PersistedMatching = JSON.parse(record); + const matchingEntity: MatchingEntity = new MatchingEntity({ + id: parsedRecord.id, + createdAt: new Date(parsedRecord.createdAt), + updatedAt: new Date(parsedRecord.updatedAt), + props: { + query: parsedRecord.query, + matches: parsedRecord.matches.map((match: PersistedMatch) => + MatchEntity.create({ + adId: match.adId, + role: match.role, + frequency: match.frequency, + distance: match.distance, + duration: match.duration, + initialDistance: match.initialDistance, + initialDuration: match.initialDuration, + journeys: match.journeys.map( + (journey: PersistedJourney) => + new Journey({ + firstDate: new Date(journey.firstDate), + lastDate: new Date(journey.lastDate), + journeyItems: journey.journeyItems.map( + (journeyItem: PersistedJourneyItem) => + new JourneyItem({ + lon: journeyItem.lon, + lat: journeyItem.lat, + duration: journeyItem.duration, + distance: journeyItem.distance, + actorTimes: journeyItem.actorTimes.map( + (actorTime: PersistedActorTime) => + new ActorTime({ + role: actorTime.role, + target: actorTime.target, + firstDatetime: new Date(actorTime.firstDatetime), + firstMinDatetime: new Date( + actorTime.firstMinDatetime, + ), + firstMaxDatetime: new Date( + actorTime.firstMaxDatetime, + ), + lastDatetime: new Date(actorTime.lastDatetime), + lastMinDatetime: new Date( + actorTime.lastMinDatetime, + ), + lastMaxDatetime: new Date( + actorTime.lastMaxDatetime, + ), + }), + ), + }), + ), + }), + ), + }), + ), + }, + }); + return matchingEntity; + }; +} + +type PersistedMatching = { + id: string; + createdAt: string; + updatedAt: string; + matches: PersistedMatch[]; + query: { + driver: boolean; + passenger: boolean; + frequency: Frequency; + fromDate: string; + toDate: string; + schedule: { + day: number; + time: string; + margin: number; + }[]; + seatsProposed: number; + seatsRequested: number; + strict: boolean; + waypoints: { + houseNumber: string; + street: string; + postalCode: string; + locality: string; + lon: number; + lat: number; + country: string; + position: number; + }[]; + algorithmType: string; + remoteness: number; + useProportion: boolean; + proportion: number; + useAzimuth: boolean; + azimuthMargin: number; + maxDetourDistanceRatio: number; + maxDetourDurationRatio: number; + }; +}; + +type PersistedMatch = { + adId: string; + role: Role; + frequency: Frequency; + distance: number; + duration: number; + initialDistance: number; + initialDuration: number; + journeys: PersistedJourney[]; +}; + +type PersistedJourney = { + firstDate: string; + lastDate: string; + journeyItems: PersistedJourneyItem[]; +}; + +type PersistedJourneyItem = { + lon: number; + lat: number; + duration: number; + distance: number; + actorTimes: PersistedActorTime[]; +}; + +type PersistedActorTime = { + role: Role; + target: Target; + firstDatetime: string; + firstMinDatetime: string; + firstMaxDatetime: string; + lastDatetime: string; + lastMinDatetime: string; + lastMaxDatetime: string; +}; diff --git a/src/modules/ad/tests/integration/ad.repository.spec.ts b/src/modules/ad/tests/integration/ad.repository.spec.ts index c74557c..2b29066 100644 --- a/src/modules/ad/tests/integration/ad.repository.spec.ts +++ b/src/modules/ad/tests/integration/ad.repository.spec.ts @@ -89,12 +89,10 @@ describe('Ad Repository', () => { strict: false, waypoints: [ { - position: 0, lon: 43.7102, lat: 7.262, }, { - position: 1, lon: 43.2965, lat: 5.3698, }, @@ -126,7 +124,7 @@ describe('Ad Repository', () => { }; const adToCreate: AdEntity = AdEntity.create(createAdProps); - await adRepository.insertWithUnsupportedFields(adToCreate, 'ad'); + await adRepository.insertExtra(adToCreate, 'ad'); const afterCount = await prismaService.ad.count(); @@ -175,12 +173,10 @@ describe('Ad Repository', () => { strict: false, waypoints: [ { - position: 0, lon: 43.7102, lat: 7.262, }, { - position: 1, lon: 43.2965, lat: 5.3698, }, @@ -212,7 +208,7 @@ describe('Ad Repository', () => { }; const adToCreate: AdEntity = AdEntity.create(createAdProps); - await adRepository.insertWithUnsupportedFields(adToCreate, 'ad'); + await adRepository.insertExtra(adToCreate, 'ad'); const afterCount = await prismaService.ad.count(); diff --git a/src/modules/ad/tests/unit/ad.mapper.spec.ts b/src/modules/ad/tests/unit/ad.mapper.spec.ts index ae0bb6d..6c2a662 100644 --- a/src/modules/ad/tests/unit/ad.mapper.spec.ts +++ b/src/modules/ad/tests/unit/ad.mapper.spec.ts @@ -4,7 +4,7 @@ import { AdEntity } from '@modules/ad/core/domain/ad.entity'; import { Frequency } from '@modules/ad/core/domain/ad.types'; import { AdReadModel, - AdUnsupportedWriteModel, + AdWriteExtraModel, AdWriteModel, } from '@modules/ad/infrastructure/ad.repository'; import { DirectionEncoderPort } from '@modules/geography/core/application/ports/direction-encoder.port'; @@ -28,12 +28,10 @@ const adEntity: AdEntity = new AdEntity({ ], waypoints: [ { - position: 0, lat: 48.689445, lon: 6.1765102, }, { - position: 1, lat: 48.8566, lon: 2.3522, }, @@ -84,8 +82,6 @@ const adReadModel: AdReadModel = { }, ], waypoints: "'LINESTRING(6.1765102 48.689445,2.3522 48.8566)'", - direction: - "'LINESTRING(6.1765102 48.689445,5.12345 48.76543,2.3522 48.8566)'", driverDistance: 350000, driverDuration: 14400, passengerDistance: 350000, @@ -149,8 +145,7 @@ describe('Ad Mapper', () => { }); it('should map domain entity to unsupported db persistence data', async () => { - const mapped: AdUnsupportedWriteModel = - adMapper.toUnsupportedPersistence(adEntity); + const mapped: AdWriteExtraModel = adMapper.toPersistenceExtra(adEntity); expect(mapped.waypoints).toBe( "'LINESTRING(6.1765102 48.689445,2.3522 48.8566)'", ); @@ -165,8 +160,4 @@ describe('Ad Mapper', () => { expect(mapped.getProps().schedule[0].time).toBe('07:05'); expect(mapped.getProps().waypoints.length).toBe(2); }); - - it('should map domain entity to response', async () => { - expect(adMapper.toResponse(adEntity)).toBeUndefined(); - }); }); diff --git a/src/modules/ad/tests/unit/core/actor-time.value-object.spec.ts b/src/modules/ad/tests/unit/core/actor-time.value-object.spec.ts new file mode 100644 index 0000000..f41ccba --- /dev/null +++ b/src/modules/ad/tests/unit/core/actor-time.value-object.spec.ts @@ -0,0 +1,107 @@ +import { ArgumentInvalidException } from '@mobicoop/ddd-library'; +import { Role } from '@modules/ad/core/domain/ad.types'; +import { Target } from '@modules/ad/core/domain/candidate.types'; +import { ActorTime } from '@modules/ad/core/domain/value-objects/actor-time.value-object'; + +describe('Actor time value object', () => { + it('should create an actor time value object', () => { + const actorTimeVO = new ActorTime({ + role: Role.DRIVER, + target: Target.START, + firstDatetime: new Date('2023-09-01T07:00Z'), + firstMinDatetime: new Date('2023-09-01T06:45Z'), + firstMaxDatetime: new Date('2023-09-01T07:15Z'), + lastDatetime: new Date('2024-08-30T07:00Z'), + lastMinDatetime: new Date('2024-08-30T06:45Z'), + lastMaxDatetime: new Date('2024-08-30T07:15Z'), + }); + expect(actorTimeVO.role).toBe(Role.DRIVER); + expect(actorTimeVO.target).toBe(Target.START); + expect(actorTimeVO.firstDatetime.getUTCHours()).toBe(7); + expect(actorTimeVO.firstMinDatetime.getUTCMinutes()).toBe(45); + expect(actorTimeVO.firstMaxDatetime.getUTCMinutes()).toBe(15); + expect(actorTimeVO.lastDatetime.getUTCHours()).toBe(7); + expect(actorTimeVO.lastMinDatetime.getUTCMinutes()).toBe(45); + expect(actorTimeVO.lastMaxDatetime.getUTCMinutes()).toBe(15); + }); + it('should throw an error if dates are inconsistent', () => { + expect( + () => + new ActorTime({ + role: Role.DRIVER, + target: Target.START, + firstDatetime: new Date('2023-09-01T07:00Z'), + firstMinDatetime: new Date('2023-09-01T07:05Z'), + firstMaxDatetime: new Date('2023-09-01T07:15Z'), + lastDatetime: new Date('2024-08-30T07:00Z'), + lastMinDatetime: new Date('2024-08-30T06:45Z'), + lastMaxDatetime: new Date('2024-08-30T07:15Z'), + }), + ).toThrow(ArgumentInvalidException); + expect( + () => + new ActorTime({ + role: Role.DRIVER, + target: Target.START, + firstDatetime: new Date('2023-09-01T07:00Z'), + firstMinDatetime: new Date('2023-09-01T06:45Z'), + firstMaxDatetime: new Date('2023-09-01T06:55Z'), + lastDatetime: new Date('2024-08-30T07:00Z'), + lastMinDatetime: new Date('2024-08-30T06:45Z'), + lastMaxDatetime: new Date('2024-08-30T07:15Z'), + }), + ).toThrow(ArgumentInvalidException); + expect( + () => + new ActorTime({ + role: Role.DRIVER, + target: Target.START, + firstDatetime: new Date('2023-09-01T07:00Z'), + firstMinDatetime: new Date('2023-09-01T06:45Z'), + firstMaxDatetime: new Date('2023-09-01T07:15Z'), + lastDatetime: new Date('2024-08-30T07:00Z'), + lastMinDatetime: new Date('2024-08-30T07:05Z'), + lastMaxDatetime: new Date('2024-08-30T07:15Z'), + }), + ).toThrow(ArgumentInvalidException); + expect( + () => + new ActorTime({ + role: Role.DRIVER, + target: Target.START, + firstDatetime: new Date('2023-09-01T07:00Z'), + firstMinDatetime: new Date('2023-09-01T06:45Z'), + firstMaxDatetime: new Date('2023-09-01T07:15Z'), + lastDatetime: new Date('2024-08-30T07:00Z'), + lastMinDatetime: new Date('2024-08-30T06:45Z'), + lastMaxDatetime: new Date('2024-08-30T06:35Z'), + }), + ).toThrow(ArgumentInvalidException); + expect( + () => + new ActorTime({ + role: Role.DRIVER, + target: Target.START, + firstDatetime: new Date('2024-08-30T07:00Z'), + firstMinDatetime: new Date('2024-08-30T06:45Z'), + firstMaxDatetime: new Date('2024-08-30T07:15Z'), + lastDatetime: new Date('2023-09-01T07:00Z'), + lastMinDatetime: new Date('2023-09-01T06:45Z'), + lastMaxDatetime: new Date('2023-09-01T07:15Z'), + }), + ).toThrow(ArgumentInvalidException); + expect( + () => + new ActorTime({ + role: Role.DRIVER, + target: Target.START, + firstDatetime: new Date('2023-09-01T07:00Z'), + firstMinDatetime: new Date('2023-09-01T06:45Z'), + firstMaxDatetime: new Date('2023-09-01T07:15Z'), + lastDatetime: new Date('2024-08-31T07:00Z'), + lastMinDatetime: new Date('2024-08-31T06:45Z'), + lastMaxDatetime: new Date('2024-08-31T06:35Z'), + }), + ).toThrow(ArgumentInvalidException); + }); +}); diff --git a/src/modules/ad/tests/unit/core/actor.value-object.spec.ts b/src/modules/ad/tests/unit/core/actor.value-object.spec.ts new file mode 100644 index 0000000..9e4c473 --- /dev/null +++ b/src/modules/ad/tests/unit/core/actor.value-object.spec.ts @@ -0,0 +1,14 @@ +import { Role } from '@modules/ad/core/domain/ad.types'; +import { Target } from '@modules/ad/core/domain/candidate.types'; +import { Actor } from '@modules/ad/core/domain/value-objects/actor.value-object'; + +describe('Actor value object', () => { + it('should create an actor value object', () => { + const actorVO = new Actor({ + role: Role.DRIVER, + target: Target.START, + }); + expect(actorVO.role).toBe(Role.DRIVER); + expect(actorVO.target).toBe(Target.START); + }); +}); diff --git a/src/modules/ad/tests/unit/core/ad.entity.spec.ts b/src/modules/ad/tests/unit/core/ad.entity.spec.ts index 8b04836..6a8fe97 100644 --- a/src/modules/ad/tests/unit/core/ad.entity.spec.ts +++ b/src/modules/ad/tests/unit/core/ad.entity.spec.ts @@ -1,14 +1,12 @@ import { AdEntity } from '@modules/ad/core/domain/ad.entity'; import { CreateAdProps, Frequency } from '@modules/ad/core/domain/ad.types'; -import { WaypointProps } from '@modules/ad/core/domain/value-objects/waypoint.value-object'; +import { PointProps } from '@modules/ad/core/domain/value-objects/point.value-object'; -const originWaypointProps: WaypointProps = { - position: 0, +const originPointProps: PointProps = { lat: 48.689445, lon: 6.17651, }; -const destinationWaypointProps: WaypointProps = { - position: 1, +const destinationPointProps: PointProps = { lat: 48.8566, lon: 2.3522, }; @@ -30,7 +28,7 @@ const createAdProps: CreateAdProps = { seatsProposed: 3, seatsRequested: 1, strict: false, - waypoints: [originWaypointProps, destinationWaypointProps], + waypoints: [originPointProps, destinationPointProps], driverDistance: 23000, driverDuration: 900, passengerDistance: 23000, diff --git a/src/modules/ad/tests/unit/core/algorithm.abstract.spec.ts b/src/modules/ad/tests/unit/core/algorithm.abstract.spec.ts new file mode 100644 index 0000000..277a2af --- /dev/null +++ b/src/modules/ad/tests/unit/core/algorithm.abstract.spec.ts @@ -0,0 +1,136 @@ +import { AdRepositoryPort } from '@modules/ad/core/application/ports/ad.repository.port'; +import { RouteProviderPort } from '@modules/ad/core/application/ports/route-provider.port'; +import { + Algorithm, + Selector, +} from '@modules/ad/core/application/queries/match/algorithm.abstract'; +import { MatchQuery } from '@modules/ad/core/application/queries/match/match.query'; +import { Waypoint } from '@modules/ad/core/application/types/waypoint.type'; +import { Frequency, Role } from '@modules/ad/core/domain/ad.types'; +import { CandidateEntity } from '@modules/ad/core/domain/candidate.entity'; +import { MatchEntity } from '@modules/ad/core/domain/match.entity'; + +const originWaypoint: Waypoint = { + position: 0, + lat: 48.689445, + lon: 6.17651, + houseNumber: '5', + street: 'Avenue Foch', + locality: 'Nancy', + postalCode: '54000', + country: 'France', +}; +const destinationWaypoint: Waypoint = { + position: 1, + lat: 48.8566, + lon: 2.3522, + locality: 'Paris', + postalCode: '75000', + country: 'France', +}; + +const mockRouteProvider: RouteProviderPort = { + getBasic: jest.fn(), + getDetailed: jest.fn(), +}; + +const matchQuery = new MatchQuery( + { + frequency: Frequency.PUNCTUAL, + fromDate: '2023-08-28', + toDate: '2023-08-28', + schedule: [ + { + time: '01:05', + }, + ], + waypoints: [originWaypoint, destinationWaypoint], + }, + mockRouteProvider, +); + +const mockAdRepository: AdRepositoryPort = { + insertExtra: jest.fn(), + findOneById: jest.fn(), + findOne: jest.fn(), + insert: jest.fn(), + update: jest.fn(), + updateWhere: jest.fn(), + delete: jest.fn(), + count: jest.fn(), + healthCheck: jest.fn(), + getCandidateAds: jest.fn(), +}; + +class SomeSelector extends Selector { + select = async (): Promise => [ + CandidateEntity.create({ + id: 'cc260669-1c6d-441f-80a5-19cd59afb777', + role: Role.DRIVER, + frequency: Frequency.PUNCTUAL, + dateInterval: { + lowerDate: '2023-08-28', + higherDate: '2023-08-28', + }, + driverWaypoints: [ + { + lat: 48.678454, + lon: 6.189745, + }, + { + lat: 48.84877, + lon: 2.398457, + }, + ], + passengerWaypoints: [ + { + lat: 48.849445, + lon: 6.68651, + }, + { + lat: 47.18746, + lon: 2.89742, + }, + ], + driverDistance: 350145, + driverDuration: 13548, + driverSchedule: [ + { + day: 0, + time: '07:00', + margin: 900, + }, + ], + passengerSchedule: [ + { + day: 0, + time: '07:10', + margin: 900, + }, + ], + spacetimeDetourRatio: { + maxDistanceDetourRatio: 0.3, + maxDurationDetourRatio: 0.3, + }, + }), + ]; +} + +class SomeAlgorithm extends Algorithm { + constructor( + protected readonly query: MatchQuery, + protected readonly repository: AdRepositoryPort, + ) { + super(query, repository); + this.selector = new SomeSelector(query, repository); + this.processors = []; + } +} + +describe('Abstract Algorithm', () => { + it('should return matches', async () => { + const someAlgorithm = new SomeAlgorithm(matchQuery, mockAdRepository); + const matches: MatchEntity[] = await someAlgorithm.match(); + expect(matches).toHaveLength(1); + }); +}); diff --git a/src/modules/ad/tests/unit/core/calendar-tools.service.spec.ts b/src/modules/ad/tests/unit/core/calendar-tools.service.spec.ts new file mode 100644 index 0000000..67fcf7b --- /dev/null +++ b/src/modules/ad/tests/unit/core/calendar-tools.service.spec.ts @@ -0,0 +1,180 @@ +import { + CalendarTools, + CalendarToolsException, +} from '@modules/ad/core/domain/calendar-tools.service'; + +describe('Calendar tools service', () => { + describe('First date', () => { + it('should return the first date for a given week day within a date range', () => { + const firstDate: Date = CalendarTools.firstDate(1, { + lowerDate: '2023-08-31', + higherDate: '2023-09-07', + }); + expect(firstDate.getUTCDay()).toBe(1); + expect(firstDate.getUTCDate()).toBe(4); + expect(firstDate.getUTCMonth()).toBe(8); + const secondDate: Date = CalendarTools.firstDate(5, { + lowerDate: '2023-08-31', + higherDate: '2023-09-07', + }); + expect(secondDate.getUTCDay()).toBe(5); + expect(secondDate.getUTCDate()).toBe(1); + expect(secondDate.getUTCMonth()).toBe(8); + const thirdDate: Date = CalendarTools.firstDate(4, { + lowerDate: '2023-08-31', + higherDate: '2023-09-07', + }); + expect(thirdDate.getUTCDay()).toBe(4); + expect(thirdDate.getUTCDate()).toBe(31); + expect(thirdDate.getUTCMonth()).toBe(7); + }); + it('should throw an exception if a given week day is not within a date range', () => { + expect(() => { + CalendarTools.firstDate(1, { + lowerDate: '2023-09-05', + higherDate: '2023-09-07', + }); + }).toThrow(CalendarToolsException); + }); + it('should throw an exception if a given week day is invalid', () => { + expect(() => { + CalendarTools.firstDate(8, { + lowerDate: '2023-09-05', + higherDate: '2023-09-07', + }); + }).toThrow(CalendarToolsException); + }); + }); + + describe('Second date', () => { + it('should return the last date for a given week day within a date range', () => { + const firstDate: Date = CalendarTools.lastDate(0, { + lowerDate: '2023-09-30', + higherDate: '2024-09-30', + }); + expect(firstDate.getUTCDay()).toBe(0); + expect(firstDate.getUTCDate()).toBe(29); + expect(firstDate.getUTCMonth()).toBe(8); + const secondDate: Date = CalendarTools.lastDate(5, { + lowerDate: '2023-09-30', + higherDate: '2024-09-30', + }); + expect(secondDate.getUTCDay()).toBe(5); + expect(secondDate.getUTCDate()).toBe(27); + expect(secondDate.getUTCMonth()).toBe(8); + const thirdDate: Date = CalendarTools.lastDate(1, { + lowerDate: '2023-09-30', + higherDate: '2024-09-30', + }); + expect(thirdDate.getUTCDay()).toBe(1); + expect(thirdDate.getUTCDate()).toBe(30); + expect(thirdDate.getUTCMonth()).toBe(8); + }); + it('should throw an exception if a given week day is not within a date range', () => { + expect(() => { + CalendarTools.lastDate(2, { + lowerDate: '2024-09-27', + higherDate: '2024-09-30', + }); + }).toThrow(CalendarToolsException); + }); + it('should throw an exception if a given week day is invalid', () => { + expect(() => { + CalendarTools.lastDate(8, { + lowerDate: '2023-09-30', + higherDate: '2024-09-30', + }); + }).toThrow(CalendarToolsException); + }); + }); + + describe('Datetime from string', () => { + it('should return a date with time from a string without additional seconds', () => { + const datetime: Date = CalendarTools.datetimeWithSeconds( + new Date('2023-09-01'), + '07:12', + ); + expect(datetime.getUTCMinutes()).toBe(12); + }); + it('should return a date with time from a string with additional seconds', () => { + const datetime: Date = CalendarTools.datetimeWithSeconds( + new Date('2023-09-01'), + '07:12', + 60, + ); + expect(datetime.getUTCMinutes()).toBe(13); + }); + it('should return a date with time from a string with negative additional seconds', () => { + const datetime: Date = CalendarTools.datetimeWithSeconds( + new Date('2023-09-01'), + '07:00', + -60, + ); + expect(datetime.getUTCHours()).toBe(6); + expect(datetime.getUTCMinutes()).toBe(59); + }); + }); + + describe('epochDaysFromTime', () => { + it('should return the epoch day for day 1', () => { + const days: Date[] = CalendarTools.epochDaysFromTime(1, '07:00'); + expect(days).toHaveLength(1); + expect(days[0].getUTCFullYear()).toBe(1969); + expect(days[0].getUTCMonth()).toBe(11); + expect(days[0].getUTCDate()).toBe(29); + }); + it('should return the epoch day for day 2', () => { + const days: Date[] = CalendarTools.epochDaysFromTime(2, '07:00'); + expect(days).toHaveLength(1); + expect(days[0].getUTCFullYear()).toBe(1969); + expect(days[0].getUTCMonth()).toBe(11); + expect(days[0].getUTCDate()).toBe(30); + }); + it('should return the epoch day for day 3', () => { + const days: Date[] = CalendarTools.epochDaysFromTime(3, '07:00'); + expect(days).toHaveLength(1); + expect(days[0].getUTCFullYear()).toBe(1969); + expect(days[0].getUTCMonth()).toBe(11); + expect(days[0].getUTCDate()).toBe(31); + }); + it('should return the epoch day for day 4', () => { + const days: Date[] = CalendarTools.epochDaysFromTime(4, '07:00'); + expect(days).toHaveLength(1); + expect(days[0].getUTCFullYear()).toBe(1970); + expect(days[0].getUTCMonth()).toBe(0); + expect(days[0].getUTCDate()).toBe(1); + }); + it('should return the epoch day for day 5', () => { + const days: Date[] = CalendarTools.epochDaysFromTime(5, '07:00'); + expect(days).toHaveLength(1); + expect(days[0].getUTCFullYear()).toBe(1970); + expect(days[0].getUTCMonth()).toBe(0); + expect(days[0].getUTCDate()).toBe(2); + }); + it('should return the epoch days for day 0', () => { + const days: Date[] = CalendarTools.epochDaysFromTime(0, '07:00'); + expect(days).toHaveLength(2); + expect(days[0].getUTCFullYear()).toBe(1969); + expect(days[0].getUTCMonth()).toBe(11); + expect(days[0].getUTCDate()).toBe(28); + expect(days[1].getUTCFullYear()).toBe(1970); + expect(days[1].getUTCMonth()).toBe(0); + expect(days[1].getUTCDate()).toBe(4); + }); + it('should return the epoch days for day 6', () => { + const days: Date[] = CalendarTools.epochDaysFromTime(6, '07:00'); + expect(days).toHaveLength(2); + expect(days[0].getUTCFullYear()).toBe(1969); + expect(days[0].getUTCMonth()).toBe(11); + expect(days[0].getUTCDate()).toBe(27); + expect(days[1].getUTCFullYear()).toBe(1970); + expect(days[1].getUTCMonth()).toBe(0); + expect(days[1].getUTCDate()).toBe(3); + }); + it('should throw an exception if a given week day is invalid', () => { + expect(() => { + CalendarTools.epochDaysFromTime(8, '07:00'); + }).toThrow(CalendarToolsException); + }); + }); +}); diff --git a/src/modules/ad/tests/unit/core/candidate.entity.spec.ts b/src/modules/ad/tests/unit/core/candidate.entity.spec.ts new file mode 100644 index 0000000..10bb27b --- /dev/null +++ b/src/modules/ad/tests/unit/core/candidate.entity.spec.ts @@ -0,0 +1,507 @@ +import { Frequency, Role } from '@modules/ad/core/domain/ad.types'; +import { CandidateEntity } from '@modules/ad/core/domain/candidate.entity'; +import { + SpacetimeDetourRatio, + Target, +} from '@modules/ad/core/domain/candidate.types'; +import { Actor } from '@modules/ad/core/domain/value-objects/actor.value-object'; +import { CarpoolPathItemProps } from '@modules/ad/core/domain/value-objects/carpool-path-item.value-object'; +import { Journey } from '@modules/ad/core/domain/value-objects/journey.value-object'; +import { PointProps } from '@modules/ad/core/domain/value-objects/point.value-object'; +import { ScheduleItemProps } from '@modules/ad/core/domain/value-objects/schedule-item.value-object'; +import { StepProps } from '@modules/ad/core/domain/value-objects/step.value-object'; + +const waypointsSet1: PointProps[] = [ + { + lat: 48.678454, + lon: 6.189745, + }, + { + lat: 48.84877, + lon: 2.398457, + }, +]; + +const waypointsSet2: PointProps[] = [ + { + lat: 48.689445, + lon: 6.17651, + }, + { + lat: 48.8566, + lon: 2.3522, + }, +]; + +const schedule1: ScheduleItemProps[] = [ + { + day: 1, + time: '07:00', + margin: 900, + }, +]; + +const schedule2: ScheduleItemProps[] = [ + { + day: 1, + time: '07:10', + margin: 900, + }, +]; + +const schedule3: ScheduleItemProps[] = [ + { + day: 1, + time: '06:30', + margin: 900, + }, + { + day: 2, + time: '06:30', + margin: 900, + }, + { + day: 3, + time: '06:00', + margin: 900, + }, + { + day: 4, + time: '06:30', + margin: 900, + }, + { + day: 5, + time: '06:30', + margin: 900, + }, +]; + +const schedule4: ScheduleItemProps[] = [ + { + day: 1, + time: '06:50', + margin: 900, + }, + { + day: 2, + time: '06:50', + margin: 900, + }, + { + day: 4, + time: '06:50', + margin: 900, + }, + { + day: 5, + time: '06:50', + margin: 900, + }, +]; + +const schedule5: ScheduleItemProps[] = [ + { + day: 0, + time: '00:02', + margin: 900, + }, + { + day: 1, + time: '07:05', + margin: 900, + }, +]; + +const schedule6: ScheduleItemProps[] = [ + { + day: 1, + time: '23:10', + margin: 900, + }, + { + day: 6, + time: '23:57', + margin: 900, + }, +]; + +const schedule7: ScheduleItemProps[] = [ + { + day: 4, + time: '19:00', + margin: 900, + }, +]; + +const spacetimeDetourRatio: SpacetimeDetourRatio = { + maxDistanceDetourRatio: 0.3, + maxDurationDetourRatio: 0.3, +}; + +const carpoolPath1: CarpoolPathItemProps[] = [ + { + lat: 48.689445, + lon: 6.17651, + actors: [ + new Actor({ + role: Role.DRIVER, + target: Target.START, + }), + new Actor({ + role: Role.PASSENGER, + target: Target.START, + }), + ], + }, + { + lat: 48.8566, + lon: 2.3522, + actors: [ + new Actor({ + role: Role.DRIVER, + target: Target.FINISH, + }), + new Actor({ + role: Role.PASSENGER, + target: Target.FINISH, + }), + ], + }, +]; + +const carpoolPath2: CarpoolPathItemProps[] = [ + { + lat: 48.689445, + lon: 6.17651, + actors: [ + new Actor({ + role: Role.DRIVER, + target: Target.START, + }), + ], + }, + { + lat: 48.678451, + lon: 6.168784, + actors: [ + new Actor({ + role: Role.DRIVER, + target: Target.NEUTRAL, + }), + new Actor({ + role: Role.PASSENGER, + target: Target.START, + }), + ], + }, + { + lat: 48.848715, + lon: 2.36985, + actors: [ + new Actor({ + role: Role.DRIVER, + target: Target.NEUTRAL, + }), + new Actor({ + role: Role.PASSENGER, + target: Target.FINISH, + }), + ], + }, + { + lat: 48.8566, + lon: 2.3522, + actors: [ + new Actor({ + role: Role.DRIVER, + target: Target.FINISH, + }), + ], + }, +]; + +const steps: StepProps[] = [ + { + lat: 48.689445, + lon: 6.17651, + duration: 0, + distance: 0, + }, + { + lat: 48.678451, + lon: 6.168784, + duration: 1254, + distance: 33462, + }, + { + lat: 48.848715, + lon: 2.36985, + duration: 12477, + distance: 343654, + }, + { + lat: 48.8566, + lon: 2.3522, + duration: 13548, + distance: 350145, + }, +]; + +describe('Candidate entity', () => { + it('should create a new candidate entity', () => { + const candidateEntity: CandidateEntity = CandidateEntity.create({ + id: 'cc260669-1c6d-441f-80a5-19cd59afb777', + role: Role.DRIVER, + frequency: Frequency.PUNCTUAL, + dateInterval: { + lowerDate: '2023-08-28', + higherDate: '2023-08-28', + }, + driverWaypoints: waypointsSet1, + passengerWaypoints: waypointsSet2, + driverDistance: 350145, + driverDuration: 13548, + driverSchedule: schedule1, + passengerSchedule: schedule2, + spacetimeDetourRatio, + }); + expect(candidateEntity.id.length).toBe(36); + }); + + it('should set a candidate entity carpool path', () => { + const candidateEntity: CandidateEntity = CandidateEntity.create({ + id: '5600ccfb-ab69-4d03-aa30-0fbe84fcedc0', + role: Role.PASSENGER, + frequency: Frequency.PUNCTUAL, + dateInterval: { + lowerDate: '2023-08-28', + higherDate: '2023-08-28', + }, + driverWaypoints: waypointsSet2, + passengerWaypoints: waypointsSet2, + driverDistance: 350145, + driverDuration: 13548, + driverSchedule: schedule1, + passengerSchedule: schedule2, + spacetimeDetourRatio, + }).setCarpoolPath(carpoolPath1); + expect(candidateEntity.getProps().carpoolPath).toHaveLength(2); + }); + + it('should create a new candidate entity with spacetime metrics', () => { + const candidateEntity: CandidateEntity = CandidateEntity.create({ + id: 'cc260669-1c6d-441f-80a5-19cd59afb777', + role: Role.DRIVER, + frequency: Frequency.PUNCTUAL, + dateInterval: { + lowerDate: '2023-08-28', + higherDate: '2023-08-28', + }, + driverWaypoints: waypointsSet1, + passengerWaypoints: waypointsSet2, + driverDistance: 350145, + driverDuration: 13548, + driverSchedule: schedule1, + passengerSchedule: schedule2, + spacetimeDetourRatio, + }).setMetrics(352688, 14587); + expect(candidateEntity.getProps().distance).toBe(352688); + expect(candidateEntity.getProps().duration).toBe(14587); + }); + + describe('detour validation', () => { + it('should not validate a candidate entity with exceeding distance detour', () => { + const candidateEntity: CandidateEntity = CandidateEntity.create({ + id: 'cc260669-1c6d-441f-80a5-19cd59afb777', + role: Role.DRIVER, + frequency: Frequency.PUNCTUAL, + dateInterval: { + lowerDate: '2023-08-28', + higherDate: '2023-08-28', + }, + driverWaypoints: waypointsSet1, + passengerWaypoints: waypointsSet2, + driverDistance: 350145, + driverDuration: 13548, + driverSchedule: schedule1, + passengerSchedule: schedule2, + spacetimeDetourRatio, + }).setMetrics(458690, 13980); + expect(candidateEntity.isDetourValid()).toBeFalsy(); + }); + it('should not validate a candidate entity with exceeding duration detour', () => { + const candidateEntity: CandidateEntity = CandidateEntity.create({ + id: 'cc260669-1c6d-441f-80a5-19cd59afb777', + role: Role.DRIVER, + frequency: Frequency.PUNCTUAL, + dateInterval: { + lowerDate: '2023-08-28', + higherDate: '2023-08-28', + }, + driverWaypoints: waypointsSet1, + passengerWaypoints: waypointsSet2, + driverDistance: 350145, + driverDuration: 13548, + driverSchedule: schedule1, + passengerSchedule: schedule2, + spacetimeDetourRatio, + }).setMetrics(352368, 18314); + expect(candidateEntity.isDetourValid()).toBeFalsy(); + }); + }); + + describe('Journeys', () => { + it('should create journeys for a single date', () => { + const candidateEntity: CandidateEntity = CandidateEntity.create({ + id: '5600ccfb-ab69-4d03-aa30-0fbe84fcedc0', + role: Role.PASSENGER, + frequency: Frequency.PUNCTUAL, + dateInterval: { + lowerDate: '2023-08-28', + higherDate: '2023-08-28', + }, + driverWaypoints: waypointsSet1, + passengerWaypoints: waypointsSet2, + driverDistance: 350145, + driverDuration: 13548, + driverSchedule: schedule1, + passengerSchedule: schedule2, + spacetimeDetourRatio, + }) + .setCarpoolPath(carpoolPath2) + .setSteps(steps) + .createJourneys(); + expect(candidateEntity.getProps().journeys).toHaveLength(1); + }); + it('should create journeys for multiple dates', () => { + const candidateEntity: CandidateEntity = CandidateEntity.create({ + id: '5600ccfb-ab69-4d03-aa30-0fbe84fcedc0', + role: Role.PASSENGER, + frequency: Frequency.RECURRENT, + dateInterval: { + lowerDate: '2023-09-01', + higherDate: '2024-09-01', + }, + driverWaypoints: waypointsSet1, + passengerWaypoints: waypointsSet2, + driverDistance: 350145, + driverDuration: 13548, + driverSchedule: schedule3, + passengerSchedule: schedule4, + spacetimeDetourRatio, + }) + .setCarpoolPath(carpoolPath2) + .setSteps(steps) + .createJourneys(); + expect(candidateEntity.getProps().journeys).toHaveLength(4); + expect( + ( + candidateEntity.getProps().journeys as Journey[] + )[0].firstDate.getDate(), + ).toBe(4); + expect( + ( + candidateEntity.getProps().journeys as Journey[] + )[0].journeyItems[1].actorTimes[1].firstMinDatetime.getDate(), + ).toBe(4); + expect( + ( + candidateEntity.getProps().journeys as Journey[] + )[1].journeyItems[1].actorTimes[1].firstMinDatetime.getDate(), + ).toBe(5); + }); + it('should create journeys for multiple dates, including week edges (saturday/sunday)', () => { + const candidateEntity: CandidateEntity = CandidateEntity.create({ + id: '5600ccfb-ab69-4d03-aa30-0fbe84fcedc0', + role: Role.PASSENGER, + frequency: Frequency.RECURRENT, + dateInterval: { + lowerDate: '2023-09-01', + higherDate: '2024-09-01', + }, + driverWaypoints: waypointsSet1, + passengerWaypoints: waypointsSet2, + driverDistance: 350145, + driverDuration: 13548, + driverSchedule: schedule5, + passengerSchedule: schedule6, + spacetimeDetourRatio, + }) + .setCarpoolPath(carpoolPath2) + .setSteps(steps) + .createJourneys(); + expect(candidateEntity.getProps().journeys).toHaveLength(1); + expect( + (candidateEntity.getProps().journeys as Journey[])[0].journeyItems[1] + .actorTimes[0].target, + ).toBe(Target.NEUTRAL); + expect( + ( + candidateEntity.getProps().journeys as Journey[] + )[0].journeyItems[1].actorTimes[0].firstDatetime.getUTCHours(), + ).toBe(0); + expect( + ( + candidateEntity.getProps().journeys as Journey[] + )[0].journeyItems[1].actorTimes[0].firstDatetime.getUTCMinutes(), + ).toBe(22); + expect( + ( + candidateEntity.getProps().journeys as Journey[] + )[0].journeyItems[1].actorTimes[1].firstMinDatetime.getUTCHours(), + ).toBe(23); + expect( + ( + candidateEntity.getProps().journeys as Journey[] + )[0].journeyItems[1].actorTimes[1].firstMinDatetime.getUTCMinutes(), + ).toBe(42); + }); + + it('should not create journeys if dates does not match', () => { + const candidateEntity: CandidateEntity = CandidateEntity.create({ + id: '5600ccfb-ab69-4d03-aa30-0fbe84fcedc0', + role: Role.PASSENGER, + frequency: Frequency.RECURRENT, + dateInterval: { + lowerDate: '2023-09-01', + higherDate: '2024-09-01', + }, + driverWaypoints: waypointsSet1, + passengerWaypoints: waypointsSet2, + driverDistance: 350145, + driverDuration: 13548, + driverSchedule: schedule1, + passengerSchedule: schedule7, + spacetimeDetourRatio, + }) + .setCarpoolPath(carpoolPath2) + .setSteps(steps) + .createJourneys(); + expect(candidateEntity.getProps().journeys).toHaveLength(0); + expect(candidateEntity.hasJourneys()).toBeFalsy(); + }); + + it('should not verify journeys if journeys is undefined', () => { + const candidateEntity: CandidateEntity = CandidateEntity.create({ + id: '5600ccfb-ab69-4d03-aa30-0fbe84fcedc0', + role: Role.PASSENGER, + frequency: Frequency.RECURRENT, + dateInterval: { + lowerDate: '2023-09-01', + higherDate: '2024-09-01', + }, + driverWaypoints: waypointsSet1, + passengerWaypoints: waypointsSet2, + driverDistance: 350145, + driverDuration: 13548, + driverSchedule: schedule1, + passengerSchedule: schedule7, + spacetimeDetourRatio, + }) + .setCarpoolPath(carpoolPath2) + .setSteps(steps); + expect(candidateEntity.hasJourneys()).toBeFalsy(); + }); + }); +}); diff --git a/src/modules/ad/tests/unit/core/carpool-path-creator.service.spec.ts b/src/modules/ad/tests/unit/core/carpool-path-creator.service.spec.ts new file mode 100644 index 0000000..709c2bf --- /dev/null +++ b/src/modules/ad/tests/unit/core/carpool-path-creator.service.spec.ts @@ -0,0 +1,113 @@ +import { CarpoolPathCreator } from '@modules/ad/core/domain/carpool-path-creator.service'; +import { CarpoolPathCreatorException } from '@modules/ad/core/domain/match.errors'; +import { Point } from '@modules/ad/core/domain/value-objects/point.value-object'; +import { CarpoolPathItem } from '@modules/ad/core/domain/value-objects/carpool-path-item.value-object'; + +const waypoint1: Point = new Point({ + lat: 0, + lon: 0, +}); +const waypoint2: Point = new Point({ + lat: 2, + lon: 2, +}); +const waypoint3: Point = new Point({ + lat: 5, + lon: 5, +}); +const waypoint4: Point = new Point({ + lat: 6, + lon: 6, +}); +const waypoint5: Point = new Point({ + lat: 8, + lon: 8, +}); +const waypoint6: Point = new Point({ + lat: 10, + lon: 10, +}); + +describe('Carpool Path Creator Service', () => { + it('should create a simple carpool path', () => { + const carpoolPathCreator: CarpoolPathCreator = new CarpoolPathCreator( + [waypoint1, waypoint6], + [waypoint2, waypoint5], + ); + const carpoolPath: CarpoolPathItem[] = carpoolPathCreator.carpoolPath(); + expect(carpoolPath).toHaveLength(4); + expect(carpoolPath[0].actors.length).toBe(1); + }); + it('should create a simple carpool path with same destination for driver and passenger', () => { + const carpoolPathCreator: CarpoolPathCreator = new CarpoolPathCreator( + [waypoint1, waypoint6], + [waypoint2, waypoint6], + ); + const carpoolPath: CarpoolPathItem[] = carpoolPathCreator.carpoolPath(); + expect(carpoolPath).toHaveLength(3); + expect(carpoolPath[0].actors.length).toBe(1); + expect(carpoolPath[1].actors.length).toBe(2); + expect(carpoolPath[2].actors.length).toBe(2); + }); + it('should create a simple carpool path with same waypoints for driver and passenger', () => { + const carpoolPathCreator: CarpoolPathCreator = new CarpoolPathCreator( + [waypoint1, waypoint6], + [waypoint1, waypoint6], + ); + const carpoolPath: CarpoolPathItem[] = carpoolPathCreator.carpoolPath(); + expect(carpoolPath).toHaveLength(2); + expect(carpoolPath[0].actors.length).toBe(2); + expect(carpoolPath[1].actors.length).toBe(2); + }); + it('should create a complex carpool path with 3 driver waypoints', () => { + const carpoolPathCreator: CarpoolPathCreator = new CarpoolPathCreator( + [waypoint1, waypoint3, waypoint6], + [waypoint2, waypoint5], + ); + const carpoolPath: CarpoolPathItem[] = carpoolPathCreator.carpoolPath(); + expect(carpoolPath).toHaveLength(5); + expect(carpoolPath[0].actors.length).toBe(1); + expect(carpoolPath[1].actors.length).toBe(2); + expect(carpoolPath[2].actors.length).toBe(1); + expect(carpoolPath[3].actors.length).toBe(2); + expect(carpoolPath[4].actors.length).toBe(1); + }); + it('should create a complex carpool path with 4 driver waypoints', () => { + const carpoolPathCreator: CarpoolPathCreator = new CarpoolPathCreator( + [waypoint1, waypoint3, waypoint4, waypoint6], + [waypoint2, waypoint5], + ); + const carpoolPath: CarpoolPathItem[] = carpoolPathCreator.carpoolPath(); + expect(carpoolPath).toHaveLength(6); + expect(carpoolPath[0].actors.length).toBe(1); + expect(carpoolPath[1].actors.length).toBe(2); + expect(carpoolPath[2].actors.length).toBe(1); + expect(carpoolPath[3].actors.length).toBe(1); + expect(carpoolPath[4].actors.length).toBe(2); + expect(carpoolPath[5].actors.length).toBe(1); + }); + it('should create a alternate complex carpool path with 4 driver waypoints', () => { + const carpoolPathCreator: CarpoolPathCreator = new CarpoolPathCreator( + [waypoint1, waypoint2, waypoint5, waypoint6], + [waypoint3, waypoint4], + ); + const carpoolPath: CarpoolPathItem[] = carpoolPathCreator.carpoolPath(); + expect(carpoolPath).toHaveLength(6); + expect(carpoolPath[0].actors.length).toBe(1); + expect(carpoolPath[1].actors.length).toBe(1); + expect(carpoolPath[2].actors.length).toBe(2); + expect(carpoolPath[3].actors.length).toBe(2); + expect(carpoolPath[4].actors.length).toBe(1); + expect(carpoolPath[5].actors.length).toBe(1); + }); + it('should throw an exception if less than 2 driver waypoints are given', () => { + expect(() => { + new CarpoolPathCreator([waypoint1], [waypoint3, waypoint4]); + }).toThrow(CarpoolPathCreatorException); + }); + it('should throw an exception if less than 2 passenger waypoints are given', () => { + expect(() => { + new CarpoolPathCreator([waypoint1, waypoint6], [waypoint3]); + }).toThrow(CarpoolPathCreatorException); + }); +}); diff --git a/src/modules/ad/tests/unit/core/carpool-path-item.value-object.spec.ts b/src/modules/ad/tests/unit/core/carpool-path-item.value-object.spec.ts new file mode 100644 index 0000000..9da4378 --- /dev/null +++ b/src/modules/ad/tests/unit/core/carpool-path-item.value-object.spec.ts @@ -0,0 +1,54 @@ +import { ArgumentOutOfRangeException } from '@mobicoop/ddd-library'; +import { Role } from '@modules/ad/core/domain/ad.types'; +import { Target } from '@modules/ad/core/domain/candidate.types'; +import { Actor } from '@modules/ad/core/domain/value-objects/actor.value-object'; +import { CarpoolPathItem } from '@modules/ad/core/domain/value-objects/carpool-path-item.value-object'; + +describe('Carpool Path Item value object', () => { + it('should create a path item value object', () => { + const carpoolPathItemVO = new CarpoolPathItem({ + lat: 48.689445, + lon: 6.17651, + actors: [ + new Actor({ + role: Role.DRIVER, + target: Target.NEUTRAL, + }), + new Actor({ + role: Role.PASSENGER, + target: Target.START, + }), + ], + }); + expect(carpoolPathItemVO.lon).toBe(6.17651); + expect(carpoolPathItemVO.lat).toBe(48.689445); + expect(carpoolPathItemVO.actors).toHaveLength(2); + }); + it('should throw an exception if actors is empty', () => { + expect(() => { + new CarpoolPathItem({ + lat: 48.689445, + lon: 6.17651, + actors: [], + }); + }).toThrow(ArgumentOutOfRangeException); + }); + it('should throw an exception if actors contains more than one driver', () => { + expect(() => { + new CarpoolPathItem({ + lat: 48.689445, + lon: 6.17651, + actors: [ + new Actor({ + role: Role.DRIVER, + target: Target.NEUTRAL, + }), + new Actor({ + role: Role.DRIVER, + target: Target.START, + }), + ], + }); + }).toThrow(ArgumentOutOfRangeException); + }); +}); diff --git a/src/modules/ad/tests/unit/core/create-ad.service.spec.ts b/src/modules/ad/tests/unit/core/create-ad.service.spec.ts index 29f7e2c..220bd18 100644 --- a/src/modules/ad/tests/unit/core/create-ad.service.spec.ts +++ b/src/modules/ad/tests/unit/core/create-ad.service.spec.ts @@ -7,16 +7,14 @@ import { CreateAdProps, Frequency } from '@modules/ad/core/domain/ad.types'; import { CreateAdService } from '@modules/ad/core/application/commands/create-ad/create-ad.service'; import { CreateAdCommand } from '@modules/ad/core/application/commands/create-ad/create-ad.command'; import { AdAlreadyExistsException } from '@modules/ad/core/domain/ad.errors'; -import { WaypointProps } from '@modules/ad/core/domain/value-objects/waypoint.value-object'; import { RouteProviderPort } from '@modules/ad/core/application/ports/route-provider.port'; +import { PointProps } from '@modules/ad/core/domain/value-objects/point.value-object'; -const originWaypoint: WaypointProps = { - position: 0, +const originWaypoint: PointProps = { lat: 48.689445, lon: 6.17651, }; -const destinationWaypoint: WaypointProps = { - position: 1, +const destinationWaypoint: PointProps = { lat: 48.8566, lon: 2.3522, }; @@ -48,9 +46,10 @@ const createAdProps: CreateAdProps = { }; const mockAdRepository = { - insertWithUnsupportedFields: jest + insertExtra: jest .fn() .mockImplementationOnce(() => ({})) + .mockImplementationOnce(() => ({})) .mockImplementationOnce(() => { throw new Error(); }) @@ -60,29 +59,41 @@ const mockAdRepository = { }; const mockRouteProvider: RouteProviderPort = { - getBasic: jest.fn().mockImplementation(() => ({ - driverDistance: 350101, - driverDuration: 14422, - passengerDistance: 350101, - passengerDuration: 14422, - fwdAzimuth: 273, - backAzimuth: 93, - distanceAzimuth: 336544, - points: [ - { - lon: 6.1765102, - lat: 48.689445, - }, - { - lon: 4.984578, - lat: 48.725687, - }, - { - lon: 2.3522, - lat: 48.8566, - }, - ], - })), + getBasic: jest + .fn() + .mockImplementationOnce(() => { + throw new Error(); + }) + .mockImplementationOnce(() => ({ + distance: 350101, + duration: 14422, + fwdAzimuth: 273, + backAzimuth: 93, + distanceAzimuth: 336544, + points: undefined, + })) + .mockImplementation(() => ({ + distance: 350101, + duration: 14422, + fwdAzimuth: 273, + backAzimuth: 93, + distanceAzimuth: 336544, + points: [ + { + lon: 6.1765102, + lat: 48.689445, + }, + { + lon: 4.984578, + lat: 48.725687, + }, + { + lon: 2.3522, + lat: 48.8566, + }, + ], + })), + getDetailed: jest.fn(), }; describe('create-ad.service', () => { @@ -112,7 +123,17 @@ describe('create-ad.service', () => { describe('execution', () => { const createAdCommand = new CreateAdCommand(createAdProps); - it('should create a new ad', async () => { + it('should throw an error if route cant be computed', async () => { + await expect( + createAdService.execute(createAdCommand), + ).rejects.toBeInstanceOf(Error); + }); + it('should throw an error if route is corrupted', async () => { + await expect( + createAdService.execute(createAdCommand), + ).rejects.toBeInstanceOf(Error); + }); + it('should create a new ad as driver and passenger', async () => { AdEntity.create = jest.fn().mockReturnValue({ id: '047a6ecf-23d4-4d3e-877c-3225d560a8da', }); @@ -121,18 +142,22 @@ describe('create-ad.service', () => { ); expect(result).toBe('047a6ecf-23d4-4d3e-877c-3225d560a8da'); }); - it('should throw an error if something bad happens', async () => { + it('should create a new ad as passenger', async () => { AdEntity.create = jest.fn().mockReturnValue({ id: '047a6ecf-23d4-4d3e-877c-3225d560a8da', }); + const result: AggregateID = await createAdService.execute({ + ...createAdCommand, + driver: false, + }); + expect(result).toBe('047a6ecf-23d4-4d3e-877c-3225d560a8da'); + }); + it('should throw an error if something bad happens', async () => { await expect( createAdService.execute(createAdCommand), ).rejects.toBeInstanceOf(Error); }); it('should throw an exception if Ad already exists', async () => { - AdEntity.create = jest.fn().mockReturnValue({ - id: '047a6ecf-23d4-4d3e-877c-3225d560a8da', - }); await expect( createAdService.execute(createAdCommand), ).rejects.toBeInstanceOf(AdAlreadyExistsException); diff --git a/src/modules/ad/tests/unit/core/journey-item.value-object.spec.ts b/src/modules/ad/tests/unit/core/journey-item.value-object.spec.ts new file mode 100644 index 0000000..5c4322e --- /dev/null +++ b/src/modules/ad/tests/unit/core/journey-item.value-object.spec.ts @@ -0,0 +1,47 @@ +import { ArgumentOutOfRangeException } from '@mobicoop/ddd-library'; +import { Role } from '@modules/ad/core/domain/ad.types'; +import { Target } from '@modules/ad/core/domain/candidate.types'; +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'; + +describe('Journey item value object', () => { + it('should create a journey item value object', () => { + const journeyItemVO: JourneyItem = new JourneyItem({ + lat: 48.689445, + lon: 6.17651, + duration: 1545, + distance: 48754, + actorTimes: [ + new ActorTime({ + role: Role.DRIVER, + target: Target.NEUTRAL, + 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'), + }), + ], + }); + expect(journeyItemVO.duration).toBe(1545); + expect(journeyItemVO.distance).toBe(48754); + expect(journeyItemVO.lon).toBe(6.17651); + expect(journeyItemVO.lat).toBe(48.689445); + expect(journeyItemVO.actorTimes[0].firstMaxDatetime.getUTCMinutes()).toBe( + 15, + ); + }); + it('should throw an error if actorTimes is too short', () => { + expect( + () => + new JourneyItem({ + lat: 48.689445, + lon: 6.17651, + duration: 1545, + distance: 48754, + actorTimes: [], + }), + ).toThrow(ArgumentOutOfRangeException); + }); +}); diff --git a/src/modules/ad/tests/unit/core/journey.completer.spec.ts b/src/modules/ad/tests/unit/core/journey.completer.spec.ts new file mode 100644 index 0000000..b084679 --- /dev/null +++ b/src/modules/ad/tests/unit/core/journey.completer.spec.ts @@ -0,0 +1,152 @@ +import { JourneyCompleter } from '@modules/ad/core/application/queries/match/completer/journey.completer'; +import { MatchQuery } from '@modules/ad/core/application/queries/match/match.query'; +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 { CandidateEntity } from '@modules/ad/core/domain/candidate.entity'; +import { Target } from '@modules/ad/core/domain/candidate.types'; +import { Actor } from '@modules/ad/core/domain/value-objects/actor.value-object'; + +const originWaypoint: Waypoint = { + position: 0, + lat: 48.689445, + lon: 6.17651, + houseNumber: '5', + street: 'Avenue Foch', + locality: 'Nancy', + postalCode: '54000', + country: 'France', +}; +const destinationWaypoint: Waypoint = { + position: 1, + lat: 48.8566, + lon: 2.3522, + locality: 'Paris', + postalCode: '75000', + country: 'France', +}; + +const matchQuery = new MatchQuery( + { + algorithmType: AlgorithmType.PASSENGER_ORIENTED, + driver: true, + passenger: true, + frequency: Frequency.PUNCTUAL, + fromDate: '2023-08-28', + toDate: '2023-08-28', + schedule: [ + { + time: '07:05', + }, + ], + strict: false, + waypoints: [originWaypoint, destinationWaypoint], + }, + { + getBasic: jest.fn().mockImplementation(() => ({ + distance: 350101, + duration: 14422, + fwdAzimuth: 273, + backAzimuth: 93, + distanceAzimuth: 336544, + points: [], + })), + getDetailed: jest.fn().mockImplementation(() => ({ + distance: 350102, + duration: 14423, + fwdAzimuth: 273, + backAzimuth: 93, + distanceAzimuth: 336544, + points: [], + })), + }, +); + +const candidate: CandidateEntity = CandidateEntity.create({ + id: 'cc260669-1c6d-441f-80a5-19cd59afb777', + role: Role.DRIVER, + frequency: Frequency.PUNCTUAL, + dateInterval: { + lowerDate: '2023-08-28', + higherDate: '2023-08-28', + }, + driverWaypoints: [ + { + lat: 48.678454, + lon: 6.189745, + }, + { + lat: 48.84877, + lon: 2.398457, + }, + ], + passengerWaypoints: [ + { + lat: 48.689445, + lon: 6.17651, + }, + { + lat: 48.8566, + lon: 2.3522, + }, + ], + driverDistance: 350145, + driverDuration: 13548, + driverSchedule: [ + { + day: 1, + time: '07:00', + margin: 900, + }, + ], + passengerSchedule: [ + { + day: 1, + time: '07:10', + margin: 900, + }, + ], + spacetimeDetourRatio: { + maxDistanceDetourRatio: 0.3, + maxDurationDetourRatio: 0.3, + }, +}).setCarpoolPath([ + { + lat: 48.689445, + lon: 6.17651, + actors: [ + new Actor({ + role: Role.DRIVER, + target: Target.START, + }), + new Actor({ + role: Role.PASSENGER, + target: Target.START, + }), + ], + }, + { + lat: 48.8566, + lon: 2.3522, + actors: [ + new Actor({ + role: Role.DRIVER, + target: Target.FINISH, + }), + new Actor({ + role: Role.PASSENGER, + target: Target.FINISH, + }), + ], + }, +]); +candidate.createJourneys = jest.fn().mockImplementation(() => candidate); + +describe('Journey completer', () => { + it('should complete candidates with their journey', async () => { + const journeyCompleter: JourneyCompleter = new JourneyCompleter(matchQuery); + const completedCandidates: CandidateEntity[] = + await journeyCompleter.complete([candidate]); + expect(completedCandidates.length).toBe(1); + }); +}); diff --git a/src/modules/ad/tests/unit/core/journey.filter.spec.ts b/src/modules/ad/tests/unit/core/journey.filter.spec.ts new file mode 100644 index 0000000..239f0f4 --- /dev/null +++ b/src/modules/ad/tests/unit/core/journey.filter.spec.ts @@ -0,0 +1,118 @@ +import { JourneyFilter } from '@modules/ad/core/application/queries/match/filter/journey.filter'; +import { MatchQuery } from '@modules/ad/core/application/queries/match/match.query'; +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 { CandidateEntity } from '@modules/ad/core/domain/candidate.entity'; + +const originWaypoint: Waypoint = { + position: 0, + lat: 48.689445, + lon: 6.17651, + houseNumber: '5', + street: 'Avenue Foch', + locality: 'Nancy', + postalCode: '54000', + country: 'France', +}; +const destinationWaypoint: Waypoint = { + position: 1, + lat: 48.8566, + lon: 2.3522, + locality: 'Paris', + postalCode: '75000', + country: 'France', +}; + +const matchQuery = new MatchQuery( + { + algorithmType: AlgorithmType.PASSENGER_ORIENTED, + driver: true, + passenger: true, + frequency: Frequency.PUNCTUAL, + fromDate: '2023-08-28', + toDate: '2023-08-28', + schedule: [ + { + time: '07:05', + }, + ], + strict: false, + waypoints: [originWaypoint, destinationWaypoint], + }, + { + getBasic: jest.fn(), + getDetailed: jest.fn(), + }, +); + +const candidate: CandidateEntity = CandidateEntity.create({ + id: 'cc260669-1c6d-441f-80a5-19cd59afb777', + role: Role.DRIVER, + frequency: Frequency.PUNCTUAL, + dateInterval: { + lowerDate: '2023-08-28', + higherDate: '2023-08-28', + }, + driverWaypoints: [ + { + lat: 48.678454, + lon: 6.189745, + }, + { + lat: 48.84877, + lon: 2.398457, + }, + ], + passengerWaypoints: [ + { + lat: 48.689445, + lon: 6.17651, + }, + { + lat: 48.8566, + lon: 2.3522, + }, + ], + driverDistance: 350145, + driverDuration: 13548, + driverSchedule: [ + { + day: 0, + time: '07:00', + margin: 900, + }, + ], + passengerSchedule: [ + { + day: 0, + time: '07:10', + margin: 900, + }, + ], + spacetimeDetourRatio: { + maxDistanceDetourRatio: 0.3, + maxDurationDetourRatio: 0.3, + }, +}); + +describe('Passenger oriented time filter', () => { + it('should not filter valid candidates', async () => { + const passengerOrientedTimeFilter: JourneyFilter = new JourneyFilter( + matchQuery, + ); + candidate.hasJourneys = () => true; + const filteredCandidates: CandidateEntity[] = + await passengerOrientedTimeFilter.filter([candidate]); + expect(filteredCandidates.length).toBe(1); + }); + it('should filter invalid candidates', async () => { + const passengerOrientedTimeFilter: JourneyFilter = new JourneyFilter( + matchQuery, + ); + candidate.hasJourneys = () => false; + const filteredCandidates: CandidateEntity[] = + await passengerOrientedTimeFilter.filter([candidate]); + expect(filteredCandidates.length).toBe(0); + }); +}); diff --git a/src/modules/ad/tests/unit/core/journey.value-object.spec.ts b/src/modules/ad/tests/unit/core/journey.value-object.spec.ts new file mode 100644 index 0000000..6bc1c5e --- /dev/null +++ b/src/modules/ad/tests/unit/core/journey.value-object.spec.ts @@ -0,0 +1,345 @@ +import { ArgumentInvalidException } from '@mobicoop/ddd-library'; +import { Role } from '@modules/ad/core/domain/ad.types'; +import { Target } from '@modules/ad/core/domain/candidate.types'; +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 { Journey } from '@modules/ad/core/domain/value-objects/journey.value-object'; + +describe('Journey value object', () => { + it('should create a journey value object', () => { + const journeyVO = new Journey({ + 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'), + }), + ], + }), + ], + }); + expect(journeyVO.journeyItems).toHaveLength(4); + expect(journeyVO.firstDate.getUTCDate()).toBe(1); + expect(journeyVO.lastDate.getUTCMonth()).toBe(7); + }); + it('should throw an error if dates are inconsistent', () => { + expect( + () => + new Journey({ + firstDate: new Date('2023-09-01'), + lastDate: new Date('2024-08-31'), + 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'), + }), + ], + }), + ], + }), + ).toThrow(ArgumentInvalidException); + expect( + () => + new Journey({ + firstDate: new Date('2024-08-30'), + lastDate: new Date('2023-09-01'), + 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'), + }), + ], + }), + ], + }), + ).toThrow(ArgumentInvalidException); + }); + it('should throw an error if journeyItems is too short', () => { + expect( + () => + new Journey({ + 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'), + }), + ], + }), + ], + }), + ).toThrow(ArgumentInvalidException); + }); +}); 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.entity.spec.ts b/src/modules/ad/tests/unit/core/match.entity.spec.ts new file mode 100644 index 0000000..b2c0990 --- /dev/null +++ b/src/modules/ad/tests/unit/core/match.entity.spec.ts @@ -0,0 +1,120 @@ +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'; + +describe('Match entity create', () => { + it('should create a new match entity', async () => { + const match: MatchEntity = MatchEntity.create({ + adId: '53a0bf71-4132-4f7b-a4cc-88c796b6bdf1', + role: Role.DRIVER, + frequency: Frequency.RECURRENT, + distance: 356041, + duration: 12647, + initialDistance: 315478, + initialDuration: 12105, + 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'), + }), + ], + }), + ], + }, + ], + }); + expect(match.id.length).toBe(36); + }); +}); 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 new file mode 100644 index 0000000..bc60516 --- /dev/null +++ b/src/modules/ad/tests/unit/core/match.query-handler.spec.ts @@ -0,0 +1,401 @@ +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, + 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 { Target } from '@modules/ad/core/domain/candidate.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 = { + position: 0, + lat: 48.689445, + lon: 6.17651, + houseNumber: '5', + street: 'Avenue Foch', + locality: 'Nancy', + postalCode: '54000', + country: 'France', +}; +const destinationWaypoint: Waypoint = { + position: 1, + lat: 48.8566, + lon: 2.3522, + locality: 'Paris', + postalCode: '75000', + country: 'France', +}; + +const mockAdRepository = { + getCandidateAds: jest.fn().mockImplementation(() => [ + { + id: 'cc260669-1c6d-441f-80a5-19cd59afb777', + getProps: jest.fn().mockImplementation(() => ({ + role: Role.DRIVER, + waypoints: [ + { + lat: 48.68787, + lon: 6.165871, + }, + { + lat: 48.97878, + lon: 2.45787, + }, + ], + })), + }, + { + 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() + .mockImplementationOnce( + () => + new MatchingEntity({ + id: 'a3b10efb-121e-4d08-9198-9f57afdb5e2d', + createdAt: new Date('2023-08-20T09:48:00Z'), + updatedAt: new Date('2023-08-20T09:48:00Z'), + props: { + matches: [ + new MatchEntity({ + id: '4bd4e90b-ffba-4f5f-b904-48ad0667a1d7', + createdAt: new Date('2023-08-30T08:45:00Z'), + updatedAt: new Date('2023-08-30T08:45:00Z'), + props: { + 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-08-28'), + lastDate: new Date('2023-08-28'), + journeyItems: [ + { + lon: 6.389745, + lat: 48.32644, + duration: 0, + distance: 0, + actorTimes: [ + { + role: Role.DRIVER, + target: Target.START, + firstDatetime: new Date('2023-08-28T07:00:00Z'), + firstMinDatetime: new Date( + '2023-08-28T06:45:00Z', + ), + firstMaxDatetime: new Date( + '2023-08-28T07:15:00Z', + ), + lastDatetime: new Date('2023-08-28T07:00:00Z'), + lastMinDatetime: new Date('2023-08-28T06:45:00Z'), + lastMaxDatetime: new Date('2023-08-28T07:15:00Z'), + }, + { + role: Role.PASSENGER, + target: Target.START, + firstDatetime: new Date('2023-08-28T07:00:00Z'), + firstMinDatetime: new Date( + '2023-08-28T06:45:00Z', + ), + firstMaxDatetime: new Date( + '2023-08-28T07:15:00Z', + ), + lastDatetime: new Date('2023-08-28T07:00:00Z'), + lastMinDatetime: new Date('2023-08-28T06:45:00Z'), + lastMaxDatetime: new Date('2023-08-28T07:15:00Z'), + }, + ], + }, + { + lon: 6.984567, + lat: 48.021548, + distance: 356041, + duration: 12647, + actorTimes: [ + { + role: Role.DRIVER, + target: Target.FINISH, + firstDatetime: new Date('2023-08-28T07:00:00Z'), + firstMinDatetime: new Date( + '2023-08-28T06:45:00Z', + ), + firstMaxDatetime: new Date( + '2023-08-28T07:15:00Z', + ), + lastDatetime: new Date('2023-08-28T07:00:00Z'), + lastMinDatetime: new Date('2023-08-28T06:45:00Z'), + lastMaxDatetime: new Date('2023-08-28T07:15:00Z'), + }, + { + role: Role.PASSENGER, + target: Target.FINISH, + firstDatetime: new Date('2023-08-28T07:00:00Z'), + firstMinDatetime: new Date( + '2023-08-28T06:45:00Z', + ), + firstMaxDatetime: new Date( + '2023-08-28T07:15:00Z', + ), + lastDatetime: new Date('2023-08-28T07:00:00Z'), + lastMinDatetime: new Date('2023-08-28T06:45:00Z'), + lastMaxDatetime: new Date('2023-08-28T07:15:00Z'), + }, + ], + }, + ], + }, + ], + }, + }), + ], + query: { + driver: false, + passenger: true, + frequency: Frequency.PUNCTUAL, + fromDate: '2023-08-28', + toDate: '2023-08-28', + schedule: [ + { + day: 1, + 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, + }, + }, + }), + ) + .mockImplementationOnce(() => { + throw new Error(); + }), + save: jest.fn(), +}; + +const mockDefaultParamsProvider: DefaultParamsProviderPort = { + getParams: () => { + return { + DEPARTURE_TIME_MARGIN: 900, + DRIVER: false, + SEATS_PROPOSED: 3, + PASSENGER: true, + SEATS_REQUESTED: 1, + STRICT: false, + TIMEZONE: 'Europe/Paris', + ALGORITHM_TYPE: AlgorithmType.PASSENGER_ORIENTED, + REMOTENESS: 15000, + USE_PROPORTION: true, + PROPORTION: 0.3, + USE_AZIMUTH: true, + AZIMUTH_MARGIN: 10, + MAX_DETOUR_DISTANCE_RATIO: 0.3, + MAX_DETOUR_DURATION_RATIO: 0.3, + PER_PAGE: 10, + }; + }, +}; + +const mockInputDateTimeTransformer: DateTimeTransformerPort = { + fromDate: jest.fn(), + toDate: jest.fn(), + day: jest.fn(), + time: jest.fn(), +}; + +const mockRouteProvider: RouteProviderPort = { + getBasic: jest.fn().mockImplementation(() => ({ + distance: 350101, + duration: 14422, + fwdAzimuth: 273, + backAzimuth: 93, + distanceAzimuth: 336544, + points: [], + })), + getDetailed: jest.fn(), +}; + +describe('Match Query Handler', () => { + let matchQueryHandler: MatchQueryHandler; + + beforeAll(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [ + MatchQueryHandler, + { + provide: AD_REPOSITORY, + useValue: mockAdRepository, + }, + { + provide: MATCHING_REPOSITORY, + useValue: mockMatchingRepository, + }, + { + provide: PARAMS_PROVIDER, + useValue: mockDefaultParamsProvider, + }, + { + provide: INPUT_DATETIME_TRANSFORMER, + useValue: mockInputDateTimeTransformer, + }, + ], + }).compile(); + + matchQueryHandler = module.get(MatchQueryHandler); + }); + + afterEach(async () => { + jest.clearAllMocks(); + }); + + it('should be defined', () => { + expect(matchQueryHandler).toBeDefined(); + }); + + it('should return a Matching', async () => { + jest.spyOn(MatchingEntity, 'create'); + const matchQuery = new MatchQuery( + { + algorithmType: AlgorithmType.PASSENGER_ORIENTED, + driver: false, + passenger: true, + frequency: Frequency.PUNCTUAL, + fromDate: '2023-08-28', + toDate: '2023-08-28', + schedule: [ + { + time: '07:05', + day: 1, + margin: 900, + }, + ], + strict: false, + waypoints: [originWaypoint, destinationWaypoint], + }, + mockRouteProvider, + ); + const matching: MatchingResult = await matchQueryHandler.execute( + matchQuery, + ); + expect(matching.id).toHaveLength(36); + expect(MatchingEntity.create).toHaveBeenCalledTimes(1); + }); + + it('should return a valid saved Matching', async () => { + jest.spyOn(MatchingEntity, 'create'); + const matchQuery = new MatchQuery( + { + id: 'a3b10efb-121e-4d08-9198-9f57afdb5e2d', + algorithmType: AlgorithmType.PASSENGER_ORIENTED, + driver: false, + passenger: true, + frequency: Frequency.PUNCTUAL, + fromDate: '2023-08-28', + toDate: '2023-08-28', + schedule: [ + { + time: '07:05', + day: 1, + margin: 900, + }, + ], + strict: false, + waypoints: [originWaypoint, destinationWaypoint], + }, + mockRouteProvider, + ); + const matching: MatchingResult = await matchQueryHandler.execute( + matchQuery, + ); + expect(matching.id).toBe('a3b10efb-121e-4d08-9198-9f57afdb5e2d'); + expect(MatchingEntity.create).toHaveBeenCalledTimes(0); + }); + + it('should return a new matching if saved Matching is not found', async () => { + jest.spyOn(MatchingEntity, 'create'); + const matchQuery = new MatchQuery( + { + id: 'a3b10efb-121e-4d08-9198-9f57afdb5e2d', + algorithmType: AlgorithmType.PASSENGER_ORIENTED, + driver: false, + passenger: true, + frequency: Frequency.PUNCTUAL, + fromDate: '2023-08-28', + toDate: '2023-08-28', + schedule: [ + { + time: '07:05', + day: 1, + margin: 900, + }, + ], + strict: false, + waypoints: [originWaypoint, destinationWaypoint], + }, + mockRouteProvider, + ); + 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/core/match.query.spec.ts b/src/modules/ad/tests/unit/core/match.query.spec.ts new file mode 100644 index 0000000..62ecb04 --- /dev/null +++ b/src/modules/ad/tests/unit/core/match.query.spec.ts @@ -0,0 +1,304 @@ +import { DateTimeTransformerPort } from '@modules/ad/core/application/ports/datetime-transformer.port'; +import { DefaultParams } from '@modules/ad/core/application/ports/default-params.type'; +import { RouteProviderPort } from '@modules/ad/core/application/ports/route-provider.port'; +import { MatchQuery } from '@modules/ad/core/application/queries/match/match.query'; +import { AlgorithmType } from '@modules/ad/core/application/types/algorithm.types'; +import { Waypoint } from '@modules/ad/core/application/types/waypoint.type'; +import { Frequency } from '@modules/ad/core/domain/ad.types'; + +const originWaypoint: Waypoint = { + position: 0, + lat: 48.689445, + lon: 6.17651, + houseNumber: '5', + street: 'Avenue Foch', + locality: 'Nancy', + postalCode: '54000', + country: 'France', +}; +const destinationWaypoint: Waypoint = { + position: 1, + lat: 48.8566, + lon: 2.3522, + locality: 'Paris', + postalCode: '75000', + country: 'France', +}; +const intermediateWaypoint: Waypoint = { + position: 1, + lat: 48.966912, + lon: 4.3655, + locality: 'Châlons-en-Champagne', + postalCode: '51000', + country: 'France', +}; + +const defaultParams: DefaultParams = { + DEPARTURE_TIME_MARGIN: 900, + DRIVER: false, + SEATS_PROPOSED: 3, + PASSENGER: true, + SEATS_REQUESTED: 1, + STRICT: false, + TIMEZONE: 'Europe/Paris', + ALGORITHM_TYPE: AlgorithmType.PASSENGER_ORIENTED, + REMOTENESS: 15000, + USE_PROPORTION: true, + PROPORTION: 0.3, + USE_AZIMUTH: true, + AZIMUTH_MARGIN: 10, + MAX_DETOUR_DISTANCE_RATIO: 0.3, + MAX_DETOUR_DURATION_RATIO: 0.3, + PER_PAGE: 10, +}; + +const mockInputDateTimeTransformer: DateTimeTransformerPort = { + fromDate: jest.fn().mockImplementation(() => '2023-08-27'), + toDate: jest.fn().mockImplementation(() => '2023-08-27'), + day: jest.fn().mockImplementation(() => 0), + time: jest.fn().mockImplementation(() => '23:05'), +}; + +const mockRouteProvider: RouteProviderPort = { + getBasic: jest + .fn() + .mockImplementationOnce(() => ({ + distance: 350101, + duration: 14422, + fwdAzimuth: 273, + backAzimuth: 93, + distanceAzimuth: 336544, + points: [], + })) + .mockImplementationOnce(() => ({ + distance: 340102, + duration: 13423, + fwdAzimuth: 273, + backAzimuth: 93, + distanceAzimuth: 336544, + points: [], + })) + .mockImplementationOnce(() => ({ + distance: 350101, + duration: 14422, + fwdAzimuth: 273, + backAzimuth: 93, + distanceAzimuth: 336544, + points: [], + })) + .mockImplementationOnce(() => ({ + distance: 350101, + duration: 14422, + fwdAzimuth: 273, + backAzimuth: 93, + distanceAzimuth: 336544, + points: [], + })) + .mockImplementationOnce(() => ({ + distance: 340102, + duration: 13423, + fwdAzimuth: 273, + backAzimuth: 93, + distanceAzimuth: 336544, + points: [], + })) + .mockImplementationOnce(() => { + throw new Error(); + }), + getDetailed: jest.fn(), +}; + +describe('Match Query', () => { + it('should set default values', async () => { + const matchQuery = new MatchQuery( + { + frequency: Frequency.PUNCTUAL, + fromDate: '2023-08-28', + toDate: '2023-08-28', + schedule: [ + { + time: '01:05', + }, + ], + waypoints: [originWaypoint, destinationWaypoint], + }, + mockRouteProvider, + ); + matchQuery + .setMissingMarginDurations(defaultParams.DEPARTURE_TIME_MARGIN) + .setMissingStrict(defaultParams.STRICT) + .setDefaultDriverAndPassengerParameters({ + driver: defaultParams.DRIVER, + passenger: defaultParams.PASSENGER, + seatsProposed: defaultParams.SEATS_PROPOSED, + seatsRequested: defaultParams.SEATS_REQUESTED, + }) + .setDefaultAlgorithmParameters({ + algorithmType: defaultParams.ALGORITHM_TYPE, + remoteness: defaultParams.REMOTENESS, + useProportion: defaultParams.USE_PROPORTION, + proportion: defaultParams.PROPORTION, + useAzimuth: defaultParams.USE_AZIMUTH, + azimuthMargin: defaultParams.AZIMUTH_MARGIN, + maxDetourDistanceRatio: defaultParams.MAX_DETOUR_DISTANCE_RATIO, + maxDetourDurationRatio: defaultParams.MAX_DETOUR_DURATION_RATIO, + }) + .setDatesAndSchedule(mockInputDateTimeTransformer); + expect(matchQuery.strict).toBeFalsy(); + expect(matchQuery.driver).toBeFalsy(); + expect(matchQuery.seatsProposed).toBe(3); + expect(matchQuery.seatsRequested).toBe(1); + expect(matchQuery.algorithmType).toBe(AlgorithmType.PASSENGER_ORIENTED); + expect(matchQuery.remoteness).toBe(15000); + expect(matchQuery.useProportion).toBeTruthy(); + expect(matchQuery.proportion).toBe(0.3); + expect(matchQuery.useAzimuth).toBeTruthy(); + expect(matchQuery.azimuthMargin).toBe(10); + expect(matchQuery.maxDetourDistanceRatio).toBe(0.3); + expect(matchQuery.maxDetourDurationRatio).toBe(0.3); + expect(matchQuery.fromDate).toBe('2023-08-27'); + expect(matchQuery.toDate).toBe('2023-08-27'); + expect(matchQuery.schedule[0].day).toBe(0); + expect(matchQuery.schedule[0].time).toBe('23:05'); + expect(matchQuery.schedule[0].margin).toBe(900); + }); + + it('should set good values for seats', async () => { + const matchQuery = new MatchQuery( + { + frequency: Frequency.PUNCTUAL, + fromDate: '2023-08-28', + toDate: '2023-08-28', + seatsProposed: -1, + seatsRequested: -1, + schedule: [ + { + time: '07:05', + }, + ], + waypoints: [originWaypoint, destinationWaypoint], + }, + mockRouteProvider, + ); + matchQuery.setDefaultDriverAndPassengerParameters({ + driver: defaultParams.DRIVER, + passenger: defaultParams.PASSENGER, + seatsProposed: defaultParams.SEATS_PROPOSED, + seatsRequested: defaultParams.SEATS_REQUESTED, + }); + expect(matchQuery.seatsProposed).toBe(3); + expect(matchQuery.seatsRequested).toBe(1); + }); + + it('should set route for a driver only', async () => { + const matchQuery = new MatchQuery( + { + driver: true, + passenger: false, + frequency: Frequency.PUNCTUAL, + fromDate: '2023-08-28', + toDate: '2023-08-28', + schedule: [ + { + time: '01:05', + }, + ], + waypoints: [originWaypoint, destinationWaypoint], + }, + mockRouteProvider, + ); + await matchQuery.setRoutes(); + expect(matchQuery.driverRoute?.distance).toBe(350101); + expect(matchQuery.passengerRoute).toBeUndefined(); + }); + + it('should set route for a passenger only', async () => { + const matchQuery = new MatchQuery( + { + driver: false, + passenger: true, + frequency: Frequency.PUNCTUAL, + fromDate: '2023-08-28', + toDate: '2023-08-28', + schedule: [ + { + time: '01:05', + }, + ], + waypoints: [originWaypoint, destinationWaypoint], + }, + mockRouteProvider, + ); + await matchQuery.setRoutes(); + expect(matchQuery.passengerRoute?.distance).toBe(340102); + expect(matchQuery.driverRoute).toBeUndefined(); + }); + + it('should set route for a driver and passenger', async () => { + const matchQuery = new MatchQuery( + { + driver: true, + passenger: true, + frequency: Frequency.PUNCTUAL, + fromDate: '2023-08-28', + toDate: '2023-08-28', + schedule: [ + { + time: '01:05', + }, + ], + waypoints: [originWaypoint, destinationWaypoint], + }, + mockRouteProvider, + ); + await matchQuery.setRoutes(); + expect(matchQuery.driverRoute?.distance).toBe(350101); + expect(matchQuery.passengerRoute?.distance).toBe(350101); + }); + + it('should set route for a driver and passenger with 3 waypoints', async () => { + const matchQuery = new MatchQuery( + { + driver: true, + passenger: true, + frequency: Frequency.PUNCTUAL, + fromDate: '2023-08-28', + toDate: '2023-08-28', + schedule: [ + { + time: '01:05', + }, + ], + waypoints: [ + originWaypoint, + intermediateWaypoint, + { ...destinationWaypoint, position: 2 }, + ], + }, + mockRouteProvider, + ); + await matchQuery.setRoutes(); + expect(matchQuery.driverRoute?.distance).toBe(350101); + expect(matchQuery.passengerRoute?.distance).toBe(340102); + }); + + it('should throw an exception if route is not found', async () => { + const matchQuery = new MatchQuery( + { + driver: true, + passenger: false, + frequency: Frequency.PUNCTUAL, + fromDate: '2023-08-28', + toDate: '2023-08-28', + schedule: [ + { + time: '01:05', + }, + ], + waypoints: [originWaypoint, destinationWaypoint], + }, + mockRouteProvider, + ); + await expect(matchQuery.setRoutes()).rejects.toBeInstanceOf(Error); + }); +}); diff --git a/src/modules/ad/tests/unit/core/passenger-oriented-algorithm.spec.ts b/src/modules/ad/tests/unit/core/passenger-oriented-algorithm.spec.ts new file mode 100644 index 0000000..4e9748e --- /dev/null +++ b/src/modules/ad/tests/unit/core/passenger-oriented-algorithm.spec.ts @@ -0,0 +1,89 @@ +import { AdRepositoryPort } from '@modules/ad/core/application/ports/ad.repository.port'; +import { MatchQuery } from '@modules/ad/core/application/queries/match/match.query'; +import { PassengerOrientedAlgorithm } from '@modules/ad/core/application/queries/match/passenger-oriented-algorithm'; +import { AlgorithmType } from '@modules/ad/core/application/types/algorithm.types'; +import { Waypoint } from '@modules/ad/core/application/types/waypoint.type'; +import { Frequency } from '@modules/ad/core/domain/ad.types'; +import { MatchEntity } from '@modules/ad/core/domain/match.entity'; + +const originWaypoint: Waypoint = { + position: 0, + lat: 48.689445, + lon: 6.17651, + houseNumber: '5', + street: 'Avenue Foch', + locality: 'Nancy', + postalCode: '54000', + country: 'France', +}; +const destinationWaypoint: Waypoint = { + position: 1, + lat: 48.8566, + lon: 2.3522, + locality: 'Paris', + postalCode: '75000', + country: 'France', +}; + +const matchQuery = new MatchQuery( + { + algorithmType: AlgorithmType.PASSENGER_ORIENTED, + driver: false, + passenger: true, + frequency: Frequency.PUNCTUAL, + fromDate: '2023-08-28', + toDate: '2023-08-28', + schedule: [ + { + time: '07:05', + }, + ], + strict: false, + waypoints: [originWaypoint, destinationWaypoint], + }, + { + getBasic: jest.fn().mockImplementation(() => ({ + duration: 6500, + distance: 89745, + })), + getDetailed: jest.fn(), + }, +); + +const mockMatcherRepository: AdRepositoryPort = { + insertExtra: jest.fn(), + findOneById: jest.fn(), + findOne: jest.fn(), + insert: jest.fn(), + update: jest.fn(), + updateWhere: jest.fn(), + delete: jest.fn(), + count: jest.fn(), + healthCheck: jest.fn(), + getCandidateAds: jest.fn().mockImplementation(() => [ + { + id: 'cc260669-1c6d-441f-80a5-19cd59afb777', + getProps: jest.fn().mockImplementation(() => ({ + waypoints: [ + { + lat: 48.6645, + lon: 6.18457, + }, + { + lat: 48.7898, + lon: 2.36845, + }, + ], + })), + }, + ]), +}; + +describe('Passenger oriented algorithm', () => { + it('should return matching entities', async () => { + const passengerOrientedAlgorithm: PassengerOrientedAlgorithm = + new PassengerOrientedAlgorithm(matchQuery, mockMatcherRepository); + const matches: MatchEntity[] = await passengerOrientedAlgorithm.match(); + expect(matches.length).toBeGreaterThanOrEqual(0); + }); +}); diff --git a/src/modules/ad/tests/unit/core/passenger-oriented-carpool-path-completer.spec.ts b/src/modules/ad/tests/unit/core/passenger-oriented-carpool-path-completer.spec.ts new file mode 100644 index 0000000..524f982 --- /dev/null +++ b/src/modules/ad/tests/unit/core/passenger-oriented-carpool-path-completer.spec.ts @@ -0,0 +1,158 @@ +import { PassengerOrientedCarpoolPathCompleter } from '@modules/ad/core/application/queries/match/completer/passenger-oriented-carpool-path.completer'; +import { MatchQuery } from '@modules/ad/core/application/queries/match/match.query'; +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 { CandidateEntity } from '@modules/ad/core/domain/candidate.entity'; + +const originWaypoint: Waypoint = { + position: 0, + lat: 48.689445, + lon: 6.17651, + houseNumber: '5', + street: 'Avenue Foch', + locality: 'Nancy', + postalCode: '54000', + country: 'France', +}; +const destinationWaypoint: Waypoint = { + position: 1, + lat: 48.8566, + lon: 2.3522, + locality: 'Paris', + postalCode: '75000', + country: 'France', +}; + +const matchQuery = new MatchQuery( + { + algorithmType: AlgorithmType.PASSENGER_ORIENTED, + driver: true, + passenger: true, + frequency: Frequency.PUNCTUAL, + fromDate: '2023-08-28', + toDate: '2023-08-28', + schedule: [ + { + time: '07:05', + }, + ], + strict: false, + waypoints: [originWaypoint, destinationWaypoint], + }, + { + getBasic: jest.fn(), + getDetailed: jest.fn(), + }, +); + +const candidates: CandidateEntity[] = [ + CandidateEntity.create({ + id: 'cc260669-1c6d-441f-80a5-19cd59afb777', + role: Role.DRIVER, + frequency: Frequency.PUNCTUAL, + dateInterval: { + lowerDate: '2023-08-28', + higherDate: '2023-08-28', + }, + driverWaypoints: [ + { + lat: 48.678454, + lon: 6.189745, + }, + { + lat: 48.84877, + lon: 2.398457, + }, + ], + passengerWaypoints: [ + { + lat: 48.689445, + lon: 6.17651, + }, + { + lat: 48.8566, + lon: 2.3522, + }, + ], + driverDistance: 350145, + driverDuration: 13548, + driverSchedule: [ + { + day: 0, + time: '07:00', + margin: 900, + }, + ], + passengerSchedule: [ + { + day: 0, + time: '07:10', + margin: 900, + }, + ], + spacetimeDetourRatio: { + maxDistanceDetourRatio: 0.3, + maxDurationDetourRatio: 0.3, + }, + }), + CandidateEntity.create({ + id: '5600ccfb-ab69-4d03-aa30-0fbe84fcedc0', + role: Role.PASSENGER, + frequency: Frequency.PUNCTUAL, + dateInterval: { + lowerDate: '2023-08-28', + higherDate: '2023-08-28', + }, + driverWaypoints: [ + { + lat: 48.689445, + lon: 6.17651, + }, + { + lat: 48.8566, + lon: 2.3522, + }, + ], + passengerWaypoints: [ + { + lat: 48.689445, + lon: 6.17651, + }, + { + lat: 48.8566, + lon: 2.3522, + }, + ], + driverDistance: 350145, + driverDuration: 13548, + driverSchedule: [ + { + day: 0, + time: '07:00', + margin: 900, + }, + ], + passengerSchedule: [ + { + day: 0, + time: '07:10', + margin: 900, + }, + ], + spacetimeDetourRatio: { + maxDistanceDetourRatio: 0.3, + maxDurationDetourRatio: 0.3, + }, + }), +]; + +describe('Passenger oriented carpool path completer', () => { + it('should complete candidates', async () => { + const passengerOrientedCarpoolPathCompleter: PassengerOrientedCarpoolPathCompleter = + new PassengerOrientedCarpoolPathCompleter(matchQuery); + const completedCandidates: CandidateEntity[] = + await passengerOrientedCarpoolPathCompleter.complete(candidates); + expect(completedCandidates.length).toBe(2); + }); +}); diff --git a/src/modules/ad/tests/unit/core/passenger-oriented-geo-filter.spec.ts b/src/modules/ad/tests/unit/core/passenger-oriented-geo-filter.spec.ts new file mode 100644 index 0000000..6c8bf7b --- /dev/null +++ b/src/modules/ad/tests/unit/core/passenger-oriented-geo-filter.spec.ts @@ -0,0 +1,116 @@ +import { PassengerOrientedGeoFilter } from '@modules/ad/core/application/queries/match/filter/passenger-oriented-geo.filter'; +import { MatchQuery } from '@modules/ad/core/application/queries/match/match.query'; +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 { CandidateEntity } from '@modules/ad/core/domain/candidate.entity'; + +const originWaypoint: Waypoint = { + position: 0, + lat: 48.689445, + lon: 6.17651, + houseNumber: '5', + street: 'Avenue Foch', + locality: 'Nancy', + postalCode: '54000', + country: 'France', +}; +const destinationWaypoint: Waypoint = { + position: 1, + lat: 48.8566, + lon: 2.3522, + locality: 'Paris', + postalCode: '75000', + country: 'France', +}; + +const matchQuery = new MatchQuery( + { + algorithmType: AlgorithmType.PASSENGER_ORIENTED, + driver: true, + passenger: true, + frequency: Frequency.PUNCTUAL, + fromDate: '2023-08-28', + toDate: '2023-08-28', + schedule: [ + { + time: '07:05', + }, + ], + strict: false, + waypoints: [originWaypoint, destinationWaypoint], + }, + { + getBasic: jest.fn(), + getDetailed: jest.fn(), + }, +); + +const candidate: CandidateEntity = CandidateEntity.create({ + id: 'cc260669-1c6d-441f-80a5-19cd59afb777', + role: Role.DRIVER, + frequency: Frequency.PUNCTUAL, + dateInterval: { + lowerDate: '2023-08-28', + higherDate: '2023-08-28', + }, + driverWaypoints: [ + { + lat: 48.678454, + lon: 6.189745, + }, + { + lat: 48.84877, + lon: 2.398457, + }, + ], + passengerWaypoints: [ + { + lat: 48.689445, + lon: 6.17651, + }, + { + lat: 48.8566, + lon: 2.3522, + }, + ], + driverDistance: 350145, + driverDuration: 13548, + driverSchedule: [ + { + day: 0, + time: '07:00', + margin: 900, + }, + ], + passengerSchedule: [ + { + day: 0, + time: '07:10', + margin: 900, + }, + ], + spacetimeDetourRatio: { + maxDistanceDetourRatio: 0.3, + maxDurationDetourRatio: 0.3, + }, +}); + +describe('Passenger oriented geo filter', () => { + it('should not filter valid candidates', async () => { + const passengerOrientedGeoFilter: PassengerOrientedGeoFilter = + new PassengerOrientedGeoFilter(matchQuery); + candidate.isDetourValid = () => true; + const filteredCandidates: CandidateEntity[] = + await passengerOrientedGeoFilter.filter([candidate]); + expect(filteredCandidates.length).toBe(1); + }); + it('should filter invalid candidates', async () => { + const passengerOrientedGeoFilter: PassengerOrientedGeoFilter = + new PassengerOrientedGeoFilter(matchQuery); + candidate.isDetourValid = () => false; + const filteredCandidates: CandidateEntity[] = + await passengerOrientedGeoFilter.filter([candidate]); + expect(filteredCandidates.length).toBe(0); + }); +}); diff --git a/src/modules/ad/tests/unit/core/passenger-oriented-selector.spec.ts b/src/modules/ad/tests/unit/core/passenger-oriented-selector.spec.ts new file mode 100644 index 0000000..c3be07e --- /dev/null +++ b/src/modules/ad/tests/unit/core/passenger-oriented-selector.spec.ts @@ -0,0 +1,149 @@ +import { AdRepositoryPort } from '@modules/ad/core/application/ports/ad.repository.port'; +import { MatchQuery } from '@modules/ad/core/application/queries/match/match.query'; +import { PassengerOrientedSelector } from '@modules/ad/core/application/queries/match/selector/passenger-oriented.selector'; +import { AlgorithmType } from '@modules/ad/core/application/types/algorithm.types'; +import { Waypoint } from '@modules/ad/core/application/types/waypoint.type'; +import { Frequency } from '@modules/ad/core/domain/ad.types'; +import { CandidateEntity } from '@modules/ad/core/domain/candidate.entity'; + +const originWaypoint: Waypoint = { + position: 0, + lat: 48.689445, + lon: 6.17651, + houseNumber: '5', + street: 'Avenue Foch', + locality: 'Nancy', + postalCode: '54000', + country: 'France', +}; +const destinationWaypoint: Waypoint = { + position: 1, + lat: 48.8566, + lon: 2.3522, + locality: 'Paris', + postalCode: '75000', + country: 'France', +}; + +const matchQuery = new MatchQuery( + { + algorithmType: AlgorithmType.PASSENGER_ORIENTED, + driver: true, + passenger: true, + frequency: Frequency.PUNCTUAL, + fromDate: '2023-06-21', + toDate: '2023-06-21', + useAzimuth: true, + azimuthMargin: 10, + useProportion: true, + proportion: 0.3, + schedule: [ + { + day: 3, + time: '07:05', + margin: 900, + }, + ], + strict: false, + waypoints: [originWaypoint, destinationWaypoint], + }, + { + getBasic: jest.fn(), + getDetailed: jest.fn(), + }, +); +matchQuery.driverRoute = { + distance: 150120, + duration: 6540, + fwdAzimuth: 276, + backAzimuth: 96, + distanceAzimuth: 148321, + points: [ + { + lat: 48.689445, + lon: 6.17651, + }, + { + lat: 48.7566, + lon: 4.3522, + }, + { + lat: 48.8566, + lon: 2.3522, + }, + ], +}; +matchQuery.passengerRoute = { + distance: 150120, + duration: 6540, + fwdAzimuth: 276, + backAzimuth: 96, + distanceAzimuth: 148321, + points: [ + { + lat: 48.689445, + lon: 6.17651, + }, + { + lat: 48.7566, + lon: 4.3522, + }, + { + lat: 48.8566, + lon: 2.3522, + }, + ], +}; + +const mockMatcherRepository: AdRepositoryPort = { + insertExtra: jest.fn(), + findOneById: jest.fn(), + findOne: jest.fn(), + insert: jest.fn(), + update: jest.fn(), + updateWhere: jest.fn(), + delete: jest.fn(), + count: jest.fn(), + healthCheck: jest.fn(), + getCandidateAds: jest.fn().mockImplementation(() => [ + { + id: 'cc260669-1c6d-441f-80a5-19cd59afb777', + getProps: jest.fn().mockImplementation(() => ({ + driver: true, + passenger: true, + frequency: Frequency.PUNCTUAL, + fromDate: new Date('2023-06-21'), + toDate: new Date('2023-06-21'), + seatsProposed: 3, + seatsRequested: 1, + strict: false, + ddriverDistance: 350000, + driverDuration: 14400, + passengerDistance: 350000, + passengerDuration: 14400, + fwdAzimuth: 273, + backAzimuth: 93, + createdAt: new Date('2023-06-20T17:05:00Z'), + updatedAt: new Date('2023-06-20T17:05:00Z'), + waypoints: 'LINESTRING(6.1765102 48.689445,2.3522 48.8566)', + schedule: [ + { + day: 3, + time: new Date('2023-06-21T07:05:00Z'), + margin: 900, + }, + ], + })), + }, + ]), +}; + +describe('Passenger oriented selector', () => { + it('should select candidates', async () => { + const passengerOrientedSelector: PassengerOrientedSelector = + new PassengerOrientedSelector(matchQuery, mockMatcherRepository); + const candidates: CandidateEntity[] = + await passengerOrientedSelector.select(); + expect(candidates.length).toBe(2); + }); +}); diff --git a/src/modules/ad/tests/unit/core/path-creator.service.spec.ts b/src/modules/ad/tests/unit/core/path-creator.service.spec.ts new file mode 100644 index 0000000..895627d --- /dev/null +++ b/src/modules/ad/tests/unit/core/path-creator.service.spec.ts @@ -0,0 +1,85 @@ +import { Role } from '@modules/ad/core/domain/ad.types'; +import { PathCreatorException } from '@modules/ad/core/domain/match.errors'; +import { + Path, + PathCreator, + PathType, +} from '@modules/ad/core/domain/path-creator.service'; +import { Point } from '@modules/ad/core/domain/value-objects/point.value-object'; + +const originWaypoint: Point = new Point({ + lat: 48.689445, + lon: 6.17651, +}); +const destinationWaypoint: Point = new Point({ + lat: 48.8566, + lon: 2.3522, +}); +const intermediateWaypoint: Point = new Point({ + lat: 48.74488, + lon: 4.8972, +}); + +describe('Path Creator Service', () => { + it('should create a path for a driver only', () => { + const pathCreator: PathCreator = new PathCreator( + [Role.DRIVER], + [originWaypoint, destinationWaypoint], + ); + const paths: Path[] = pathCreator.getBasePaths(); + expect(paths).toHaveLength(1); + expect(paths[0].type).toBe(PathType.DRIVER); + }); + it('should create a path for a passenger only', () => { + const pathCreator: PathCreator = new PathCreator( + [Role.PASSENGER], + [originWaypoint, destinationWaypoint], + ); + const paths: Path[] = pathCreator.getBasePaths(); + expect(paths).toHaveLength(1); + expect(paths[0].type).toBe(PathType.PASSENGER); + }); + it('should create a single path for a driver and passenger', () => { + const pathCreator: PathCreator = new PathCreator( + [Role.DRIVER, Role.PASSENGER], + [originWaypoint, destinationWaypoint], + ); + const paths: Path[] = pathCreator.getBasePaths(); + expect(paths).toHaveLength(1); + expect(paths[0].type).toBe(PathType.GENERIC); + }); + it('should create two different paths for a driver and passenger with intermediate waypoint', () => { + const pathCreator: PathCreator = new PathCreator( + [Role.DRIVER, Role.PASSENGER], + [originWaypoint, intermediateWaypoint, destinationWaypoint], + ); + const paths: Path[] = pathCreator.getBasePaths(); + expect(paths).toHaveLength(2); + expect( + paths.filter((path: Path) => path.type == PathType.DRIVER), + ).toHaveLength(1); + expect( + paths.filter((path: Path) => path.type == PathType.DRIVER)[0].waypoints, + ).toHaveLength(3); + expect( + paths.filter((path: Path) => path.type == PathType.PASSENGER), + ).toHaveLength(1); + expect( + paths.filter((path: Path) => path.type == PathType.PASSENGER)[0] + .waypoints, + ).toHaveLength(2); + }); + it('should throw an exception if a role is not given', () => { + expect(() => { + new PathCreator( + [], + [originWaypoint, intermediateWaypoint, destinationWaypoint], + ); + }).toThrow(PathCreatorException); + }); + it('should throw an exception if less than 2 waypoints are given', () => { + expect(() => { + new PathCreator([Role.DRIVER], [originWaypoint]); + }).toThrow(PathCreatorException); + }); +}); diff --git a/src/modules/ad/tests/unit/core/point.value-object.spec.ts b/src/modules/ad/tests/unit/core/point.value-object.spec.ts index b6980e2..8ae5913 100644 --- a/src/modules/ad/tests/unit/core/point.value-object.spec.ts +++ b/src/modules/ad/tests/unit/core/point.value-object.spec.ts @@ -10,40 +10,48 @@ describe('Point value object', () => { expect(pointVO.lat).toBe(48.689445); expect(pointVO.lon).toBe(6.17651); }); + it('should check if two points are identical', () => { + const pointVO = new Point({ + lat: 48.689445, + lon: 6.17651, + }); + const identicalPointVO = new Point({ + lat: 48.689445, + lon: 6.17651, + }); + const differentPointVO = new Point({ + lat: 48.689446, + lon: 6.17651, + }); + expect(pointVO.equals(identicalPointVO)).toBeTruthy(); + expect(pointVO.equals(differentPointVO)).toBeFalsy(); + }); it('should throw an exception if longitude is invalid', () => { - try { + expect(() => { new Point({ lat: 48.689445, lon: 186.17651, }); - } catch (e: any) { - expect(e).toBeInstanceOf(ArgumentOutOfRangeException); - } - try { + }).toThrow(ArgumentOutOfRangeException); + expect(() => { new Point({ lat: 48.689445, lon: -186.17651, }); - } catch (e: any) { - expect(e).toBeInstanceOf(ArgumentOutOfRangeException); - } + }).toThrow(ArgumentOutOfRangeException); }); it('should throw an exception if latitude is invalid', () => { - try { + expect(() => { new Point({ lat: 148.689445, lon: 6.17651, }); - } catch (e: any) { - expect(e).toBeInstanceOf(ArgumentOutOfRangeException); - } - try { + }).toThrow(ArgumentOutOfRangeException); + expect(() => { new Point({ lat: -148.689445, lon: 6.17651, }); - } catch (e: any) { - expect(e).toBeInstanceOf(ArgumentOutOfRangeException); - } + }).toThrow(ArgumentOutOfRangeException); }); }); diff --git a/src/modules/ad/tests/unit/core/route.completer.spec.ts b/src/modules/ad/tests/unit/core/route.completer.spec.ts new file mode 100644 index 0000000..4a39362 --- /dev/null +++ b/src/modules/ad/tests/unit/core/route.completer.spec.ts @@ -0,0 +1,169 @@ +import { + RouteCompleter, + RouteCompleterType, +} from '@modules/ad/core/application/queries/match/completer/route.completer'; +import { MatchQuery } from '@modules/ad/core/application/queries/match/match.query'; +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 { CandidateEntity } from '@modules/ad/core/domain/candidate.entity'; +import { Target } from '@modules/ad/core/domain/candidate.types'; +import { Actor } from '@modules/ad/core/domain/value-objects/actor.value-object'; + +const originWaypoint: Waypoint = { + position: 0, + lat: 48.689445, + lon: 6.17651, + houseNumber: '5', + street: 'Avenue Foch', + locality: 'Nancy', + postalCode: '54000', + country: 'France', +}; +const destinationWaypoint: Waypoint = { + position: 1, + lat: 48.8566, + lon: 2.3522, + locality: 'Paris', + postalCode: '75000', + country: 'France', +}; + +const matchQuery = new MatchQuery( + { + algorithmType: AlgorithmType.PASSENGER_ORIENTED, + driver: true, + passenger: true, + frequency: Frequency.PUNCTUAL, + fromDate: '2023-08-28', + toDate: '2023-08-28', + schedule: [ + { + time: '07:05', + }, + ], + strict: false, + waypoints: [originWaypoint, destinationWaypoint], + }, + { + getBasic: jest.fn().mockImplementation(() => ({ + distance: 350101, + duration: 14422, + fwdAzimuth: 273, + backAzimuth: 93, + distanceAzimuth: 336544, + points: [], + })), + getDetailed: jest.fn().mockImplementation(() => ({ + distance: 350101, + duration: 14422, + fwdAzimuth: 273, + backAzimuth: 93, + distanceAzimuth: 336544, + points: [], + steps: [jest.fn(), jest.fn(), jest.fn(), jest.fn()], + })), + }, +); + +const candidate: CandidateEntity = CandidateEntity.create({ + id: 'cc260669-1c6d-441f-80a5-19cd59afb777', + role: Role.DRIVER, + frequency: Frequency.PUNCTUAL, + dateInterval: { + lowerDate: '2023-08-28', + higherDate: '2023-08-28', + }, + driverWaypoints: [ + { + lat: 48.678454, + lon: 6.189745, + }, + { + lat: 48.84877, + lon: 2.398457, + }, + ], + passengerWaypoints: [ + { + lat: 48.689445, + lon: 6.17651, + }, + { + lat: 48.8566, + lon: 2.3522, + }, + ], + driverDistance: 350145, + driverDuration: 13548, + driverSchedule: [ + { + day: 0, + time: '07:00', + margin: 900, + }, + ], + passengerSchedule: [ + { + day: 0, + time: '07:10', + margin: 900, + }, + ], + spacetimeDetourRatio: { + maxDistanceDetourRatio: 0.3, + maxDurationDetourRatio: 0.3, + }, +}).setCarpoolPath([ + { + lat: 48.689445, + lon: 6.17651, + actors: [ + new Actor({ + role: Role.DRIVER, + target: Target.START, + }), + new Actor({ + role: Role.PASSENGER, + target: Target.START, + }), + ], + }, + { + lat: 48.8566, + lon: 2.3522, + actors: [ + new Actor({ + role: Role.DRIVER, + target: Target.FINISH, + }), + new Actor({ + role: Role.PASSENGER, + target: Target.FINISH, + }), + ], + }, +]); + +describe('Route completer', () => { + it('should complete candidates with basic setting', async () => { + const routeCompleter: RouteCompleter = new RouteCompleter( + matchQuery, + RouteCompleterType.BASIC, + ); + const completedCandidates: CandidateEntity[] = + await routeCompleter.complete([candidate]); + expect(completedCandidates.length).toBe(1); + expect(completedCandidates[0].getProps().distance).toBe(350101); + }); + it('should complete candidates with detailed setting', async () => { + const routeCompleter: RouteCompleter = new RouteCompleter( + matchQuery, + RouteCompleterType.DETAILED, + ); + const completedCandidates: CandidateEntity[] = + await routeCompleter.complete([candidate]); + expect(completedCandidates.length).toBe(1); + expect(completedCandidates[0].getProps().steps).toHaveLength(4); + }); +}); diff --git a/src/modules/ad/tests/unit/core/schedule-item.value-object.spec.ts b/src/modules/ad/tests/unit/core/schedule-item.value-object.spec.ts index 65bacc9..87e6041 100644 --- a/src/modules/ad/tests/unit/core/schedule-item.value-object.spec.ts +++ b/src/modules/ad/tests/unit/core/schedule-item.value-object.spec.ts @@ -16,47 +16,39 @@ describe('Schedule item value object', () => { expect(scheduleItemVO.margin).toBe(900); }); it('should throw an exception if day is invalid', () => { - try { + expect(() => { new ScheduleItem({ day: 7, time: '07:00', margin: 900, }); - } catch (e: any) { - expect(e).toBeInstanceOf(ArgumentOutOfRangeException); - } + }).toThrow(ArgumentOutOfRangeException); }); it('should throw an exception if time is invalid', () => { - try { + expect(() => { new ScheduleItem({ day: 0, time: '07,00', margin: 900, }); - } catch (e: any) { - expect(e).toBeInstanceOf(ArgumentInvalidException); - } + }).toThrow(ArgumentInvalidException); }); it('should throw an exception if the hour of the time is invalid', () => { - try { + expect(() => { new ScheduleItem({ day: 0, time: '25:00', margin: 900, }); - } catch (e: any) { - expect(e).toBeInstanceOf(ArgumentInvalidException); - } + }).toThrow(ArgumentInvalidException); }); it('should throw an exception if the minutes of the time are invalid', () => { - try { + expect(() => { new ScheduleItem({ day: 0, time: '07:63', margin: 900, }); - } catch (e: any) { - expect(e).toBeInstanceOf(ArgumentInvalidException); - } + }).toThrow(ArgumentInvalidException); }); }); diff --git a/src/modules/ad/tests/unit/core/step.value-object.spec.ts b/src/modules/ad/tests/unit/core/step.value-object.spec.ts new file mode 100644 index 0000000..cadf89e --- /dev/null +++ b/src/modules/ad/tests/unit/core/step.value-object.spec.ts @@ -0,0 +1,76 @@ +import { + ArgumentInvalidException, + ArgumentOutOfRangeException, +} from '@mobicoop/ddd-library'; +import { Step } from '@modules/ad/core/domain/value-objects/step.value-object'; + +describe('Step value object', () => { + it('should create a step value object', () => { + const stepVO = new Step({ + lat: 48.689445, + lon: 6.17651, + duration: 150, + distance: 12000, + }); + expect(stepVO.duration).toBe(150); + expect(stepVO.distance).toBe(12000); + expect(stepVO.lat).toBe(48.689445); + expect(stepVO.lon).toBe(6.17651); + }); + it('should throw an exception if longitude is invalid', () => { + expect(() => { + new Step({ + lat: 48.689445, + lon: 186.17651, + duration: 150, + distance: 12000, + }); + }).toThrow(ArgumentOutOfRangeException); + expect(() => { + new Step({ + lat: 48.689445, + lon: -186.17651, + duration: 150, + distance: 12000, + }); + }).toThrow(ArgumentOutOfRangeException); + }); + it('should throw an exception if latitude is invalid', () => { + expect(() => { + new Step({ + lat: 248.689445, + lon: 6.17651, + duration: 150, + distance: 12000, + }); + }).toThrow(ArgumentOutOfRangeException); + expect(() => { + new Step({ + lat: -148.689445, + lon: 6.17651, + duration: 150, + distance: 12000, + }); + }).toThrow(ArgumentOutOfRangeException); + }); + it('should throw an exception if distance is invalid', () => { + expect(() => { + new Step({ + lat: 48.689445, + lon: 6.17651, + duration: 150, + distance: -12000, + }); + }).toThrow(ArgumentInvalidException); + }); + it('should throw an exception if duration is invalid', () => { + expect(() => { + new Step({ + lat: 48.689445, + lon: 6.17651, + duration: -150, + distance: 12000, + }); + }).toThrow(ArgumentInvalidException); + }); +}); diff --git a/src/modules/ad/tests/unit/core/waypoint.value-object.spec.ts b/src/modules/ad/tests/unit/core/waypoint.value-object.spec.ts deleted file mode 100644 index da67658..0000000 --- a/src/modules/ad/tests/unit/core/waypoint.value-object.spec.ts +++ /dev/null @@ -1,69 +0,0 @@ -import { - ArgumentInvalidException, - ArgumentOutOfRangeException, -} from '@mobicoop/ddd-library'; -import { Waypoint } from '@modules/ad/core/domain/value-objects/waypoint.value-object'; - -describe('Waypoint value object', () => { - it('should create a waypoint value object', () => { - const waypointVO = new Waypoint({ - position: 0, - lat: 48.689445, - lon: 6.17651, - }); - expect(waypointVO.position).toBe(0); - expect(waypointVO.lat).toBe(48.689445); - expect(waypointVO.lon).toBe(6.17651); - }); - it('should throw an exception if position is invalid', () => { - try { - new Waypoint({ - position: -1, - lat: 48.689445, - lon: 6.17651, - }); - } catch (e: any) { - expect(e).toBeInstanceOf(ArgumentInvalidException); - } - }); - it('should throw an exception if longitude is invalid', () => { - try { - new Waypoint({ - position: 0, - lat: 48.689445, - lon: 186.17651, - }); - } catch (e: any) { - expect(e).toBeInstanceOf(ArgumentOutOfRangeException); - } - try { - new Waypoint({ - position: 0, - lat: 48.689445, - lon: -186.17651, - }); - } catch (e: any) { - expect(e).toBeInstanceOf(ArgumentOutOfRangeException); - } - }); - it('should throw an exception if latitude is invalid', () => { - try { - new Waypoint({ - position: 0, - lat: 148.689445, - lon: 6.17651, - }); - } catch (e: any) { - expect(e).toBeInstanceOf(ArgumentOutOfRangeException); - } - try { - new Waypoint({ - position: 0, - lat: -148.689445, - lon: 6.17651, - }); - } catch (e: any) { - expect(e).toBeInstanceOf(ArgumentOutOfRangeException); - } - }); -}); diff --git a/src/modules/ad/tests/unit/infrastructure/ad.repository.spec.ts b/src/modules/ad/tests/unit/infrastructure/ad.repository.spec.ts index 789b547..8c68cf7 100644 --- a/src/modules/ad/tests/unit/infrastructure/ad.repository.spec.ts +++ b/src/modules/ad/tests/unit/infrastructure/ad.repository.spec.ts @@ -1,13 +1,16 @@ import { AD_DIRECTION_ENCODER, + AD_MESSAGE_PUBLISHER, AD_ROUTE_PROVIDER, } from '@modules/ad/ad.di-tokens'; import { AdMapper } from '@modules/ad/ad.mapper'; import { RouteProviderPort } from '@modules/ad/core/application/ports/route-provider.port'; +import { AdEntity } from '@modules/ad/core/domain/ad.entity'; +import { Frequency } from '@modules/ad/core/domain/ad.types'; import { AdRepository } from '@modules/ad/infrastructure/ad.repository'; import { PrismaService } from '@modules/ad/infrastructure/prisma.service'; import { DirectionEncoderPort } from '@modules/geography/core/application/ports/direction-encoder.port'; -import { EventEmitter2, EventEmitterModule } from '@nestjs/event-emitter'; +import { EventEmitterModule } from '@nestjs/event-emitter'; import { Test, TestingModule } from '@nestjs/testing'; const mockMessagePublisher = { @@ -16,24 +19,220 @@ const mockMessagePublisher = { const mockDirectionEncoder: DirectionEncoderPort = { encode: jest.fn(), - decode: jest.fn(), + decode: jest + .fn() + .mockImplementationOnce(() => [ + { + lon: 6.1765102, + lat: 48.689445, + }, + { + lon: 2.3522, + lat: 48.8566, + }, + ]) + .mockImplementationOnce(() => [ + { + lon: 6.1765109, + lat: 48.689455, + }, + { + lon: 2.3598, + lat: 48.8589, + }, + ]) + .mockImplementationOnce(() => [ + { + lon: 6.1765102, + lat: 48.689445, + }, + { + lon: 2.3522, + lat: 48.8566, + }, + ]) + .mockImplementationOnce(() => [ + { + lon: 6.1765102, + lat: 48.689445, + }, + { + lon: 2.3522, + lat: 48.8566, + }, + ]) + .mockImplementationOnce(() => [ + { + lon: 6.1765102, + lat: 48.689445, + }, + { + lon: 2.3522, + lat: 48.8566, + }, + ]), }; const mockRouteProvider: RouteProviderPort = { getBasic: jest.fn(), + getDetailed: jest.fn(), +}; + +const mockPrismaService = { + $queryRawUnsafe: jest + .fn() + .mockImplementationOnce(() => { + return [ + { + uuid: 'cc260669-1c6d-441f-80a5-19cd59afb777', + driver: true, + passenger: true, + frequency: Frequency.PUNCTUAL, + fromDate: new Date('2023-06-21'), + toDate: new Date('2023-06-21'), + seatsProposed: 3, + seatsRequested: 1, + strict: false, + driverDistance: 350000, + driverDuration: 14400, + passengerDistance: 350000, + passengerDuration: 14400, + fwdAzimuth: 273, + backAzimuth: 93, + createdAt: new Date('2023-06-20T17:05:00Z'), + updatedAt: new Date('2023-06-20T17:05:00Z'), + waypoints: 'LINESTRING(6.1765102 48.689445,2.3522 48.8566)', + scheduleItemUuid: 'b6bfac1f-e62e-4622-9641-a3475e15fc00', + day: 3, + time: new Date('2023-06-21T07:05:00Z'), + margin: 900, + scheduleItemCreatedAt: new Date('2023-06-20T17:05:00Z'), + scheduleItemUpdatedAt: new Date('2023-06-20T17:05:00Z'), + }, + { + uuid: '84af18ff-8779-4cac-9651-1ed5ab0713c4', + driver: true, + passenger: false, + frequency: Frequency.PUNCTUAL, + fromDate: new Date('2023-06-21'), + toDate: new Date('2023-06-21'), + seatsProposed: 3, + seatsRequested: 1, + strict: false, + driverDistance: 349000, + driverDuration: 14300, + passengerDistance: 350000, + passengerDuration: 14400, + fwdAzimuth: 273, + backAzimuth: 93, + createdAt: new Date('2023-06-18T14:16:10Z'), + updatedAt: new Date('2023-06-18T14:16:10Z'), + waypoints: 'LINESTRING(6.1765109 48.689455,2.3598 48.8589)', + scheduleItemUuid: '01524541-2044-49dc-8be6-1a3ccdc653b0', + day: 3, + time: new Date('2023-06-21T07:14:00Z'), + margin: 900, + scheduleItemCreatedAt: new Date('2023-06-18T14:16:10Z'), + scheduleItemUpdatedAt: new Date('2023-06-18T14:16:10Z'), + }, + ]; + }) + .mockImplementationOnce(() => { + return [ + { + uuid: 'cc260669-1c6d-441f-80a5-19cd59afb777', + driver: true, + passenger: true, + frequency: Frequency.RECURRENT, + fromDate: new Date('2023-06-21'), + toDate: new Date('2024-06-21'), + seatsProposed: 3, + seatsRequested: 1, + strict: false, + driverDistance: 350000, + driverDuration: 14400, + passengerDistance: 350000, + passengerDuration: 14400, + fwdAzimuth: 273, + backAzimuth: 93, + createdAt: new Date('2023-06-20T17:05:00Z'), + updatedAt: new Date('2023-06-20T17:05:00Z'), + waypoints: 'LINESTRING(6.1765102 48.689445,2.3522 48.8566)', + scheduleItemUuid: '1387b34f-8ab1-46e0-8d0f-803af0f40f28', + day: 3, + time: new Date('2023-06-21T07:05:00Z'), + margin: 900, + scheduleItemCreatedAt: new Date('2023-06-20T17:05:00Z'), + scheduleItemUpdatedAt: new Date('2023-06-20T17:05:00Z'), + }, + { + uuid: 'cc260669-1c6d-441f-80a5-19cd59afb777', + driver: true, + passenger: true, + frequency: Frequency.RECURRENT, + fromDate: new Date('2023-06-21'), + toDate: new Date('2024-06-21'), + seatsProposed: 3, + seatsRequested: 1, + strict: false, + driverDistance: 350000, + driverDuration: 14400, + passengerDistance: 350000, + passengerDuration: 14400, + fwdAzimuth: 273, + backAzimuth: 93, + createdAt: new Date('2023-06-20T17:05:00Z'), + updatedAt: new Date('2023-06-20T17:05:00Z'), + waypoints: 'LINESTRING(6.1765102 48.689445,2.3522 48.8566)', + scheduleItemUuid: '1fa88104-c50b-4f10-b8ce-389df765f3a6', + day: 4, + time: new Date('2023-06-21T07:15:00Z'), + margin: 900, + scheduleItemCreatedAt: new Date('2023-06-20T17:05:00Z'), + scheduleItemUpdatedAt: new Date('2023-06-20T17:05:00Z'), + }, + { + uuid: 'cc260669-1c6d-441f-80a5-19cd59afb777', + driver: true, + passenger: true, + frequency: Frequency.RECURRENT, + fromDate: new Date('2023-06-21'), + toDate: new Date('2024-06-21'), + seatsProposed: 3, + seatsRequested: 1, + strict: false, + driverDistance: 350000, + driverDuration: 14400, + passengerDistance: 350000, + passengerDuration: 14400, + fwdAzimuth: 273, + backAzimuth: 93, + createdAt: new Date('2023-06-20T17:05:00Z'), + updatedAt: new Date('2023-06-20T17:05:00Z'), + waypoints: 'LINESTRING(6.1765102 48.689445,2.3522 48.8566)', + scheduleItemUuid: '760bb1bb-256b-4e79-9d82-6d13011118f1', + day: 5, + time: new Date('2023-06-21T07:16:00Z'), + margin: 900, + scheduleItemCreatedAt: new Date('2023-06-20T17:05:00Z'), + scheduleItemUpdatedAt: new Date('2023-06-20T17:05:00Z'), + }, + ]; + }) + .mockImplementationOnce(() => { + return []; + }), }; describe('Ad repository', () => { - let prismaService: PrismaService; - let adMapper: AdMapper; - let eventEmitter: EventEmitter2; + let adRepository: AdRepository; beforeAll(async () => { const module: TestingModule = await Test.createTestingModule({ imports: [EventEmitterModule.forRoot()], providers: [ - PrismaService, AdMapper, + AdRepository, { provide: AD_DIRECTION_ENCODER, useValue: mockDirectionEncoder, @@ -42,21 +241,42 @@ describe('Ad repository', () => { provide: AD_ROUTE_PROVIDER, useValue: mockRouteProvider, }, + { + provide: AD_MESSAGE_PUBLISHER, + useValue: mockMessagePublisher, + }, + { + provide: PrismaService, + useValue: mockPrismaService, + }, ], }).compile(); - prismaService = module.get(PrismaService); - adMapper = module.get(AdMapper); - eventEmitter = module.get(EventEmitter2); + adRepository = module.get(AdRepository); }); it('should be defined', () => { - expect( - new AdRepository( - prismaService, - adMapper, - eventEmitter, - mockMessagePublisher, - ), - ).toBeDefined(); + expect(adRepository).toBeDefined(); + }); + + it('should get candidates if query returns punctual Ads', async () => { + const candidates: AdEntity[] = await adRepository.getCandidateAds( + 'somePunctualQueryString', + ); + expect(candidates.length).toBe(2); + }); + + it('should get candidates if query returns recurrent Ads', async () => { + const candidates: AdEntity[] = await adRepository.getCandidateAds( + 'someRecurrentQueryString', + ); + expect(candidates.length).toBe(1); + expect(candidates[0].getProps().schedule.length).toBe(3); + }); + + it('should return an empty array of candidates if query does not return Ads', async () => { + const candidates: AdEntity[] = await adRepository.getCandidateAds( + 'someQueryString', + ); + expect(candidates.length).toBe(0); }); }); diff --git a/src/modules/ad/tests/unit/infrastructure/default-param.provider.spec.ts b/src/modules/ad/tests/unit/infrastructure/default-param.provider.spec.ts new file mode 100644 index 0000000..c23ea19 --- /dev/null +++ b/src/modules/ad/tests/unit/infrastructure/default-param.provider.spec.ts @@ -0,0 +1,121 @@ +import { DefaultParams } from '@modules/ad/core/application/ports/default-params.type'; +import { DefaultParamsProvider } from '@modules/ad/infrastructure/default-params-provider'; +import { ConfigService } from '@nestjs/config'; +import { Test, TestingModule } from '@nestjs/testing'; + +const mockConfigServiceWithDefaults = { + get: jest.fn().mockImplementation((value: string) => { + switch (value) { + case 'DEPARTURE_TIME_MARGIN': + return 600; + case 'ROLE': + return 'passenger'; + case 'SEATS_PROPOSED': + return 2; + case 'SEATS_REQUESTED': + return 1; + case 'STRICT_FREQUENCY': + return 'false'; + case 'TIMEZONE': + return 'Europe/Paris'; + case 'ALGORITHM_TYPE': + return 'PASSENGER_ORIENTED'; + case 'REMOTENESS': + return 10000; + case 'USE_PROPORTION': + return 'true'; + case 'PROPORTION': + return 0.4; + case 'USE_AZIMUTH': + return 'true'; + case 'AZIMUTH_MARGIN': + return 15; + case 'MAX_DETOUR_DISTANCE_RATIO': + return 0.5; + case 'MAX_DETOUR_DURATION_RATIO': + return 0.6; + case 'PER_PAGE': + return 15; + default: + return 'some_default_value'; + } + }), +}; + +const mockConfigServiceWithoutDefaults = { + get: jest.fn(), +}; + +describe('DefaultParamsProvider', () => { + let defaultParamsProviderWithDefaults: DefaultParamsProvider; + let defaultParamsProviderWithoutDefaults: DefaultParamsProvider; + + beforeAll(async () => { + const moduleWithDefaults: TestingModule = await Test.createTestingModule({ + imports: [], + providers: [ + DefaultParamsProvider, + { + provide: ConfigService, + useValue: mockConfigServiceWithDefaults, + }, + ], + }).compile(); + + defaultParamsProviderWithDefaults = + moduleWithDefaults.get(DefaultParamsProvider); + + const moduleWithoutDefault: TestingModule = await Test.createTestingModule({ + imports: [], + providers: [ + DefaultParamsProvider, + { + provide: ConfigService, + useValue: mockConfigServiceWithoutDefaults, + }, + ], + }).compile(); + + defaultParamsProviderWithoutDefaults = + moduleWithoutDefault.get(DefaultParamsProvider); + }); + + it('should be defined', () => { + expect(defaultParamsProviderWithDefaults).toBeDefined(); + }); + + it('should provide default params if defaults are set', async () => { + const params: DefaultParams = defaultParamsProviderWithDefaults.getParams(); + expect(params.DEPARTURE_TIME_MARGIN).toBe(600); + expect(params.PASSENGER).toBeTruthy(); + expect(params.DRIVER).toBeFalsy(); + expect(params.TIMEZONE).toBe('Europe/Paris'); + expect(params.ALGORITHM_TYPE).toBe('PASSENGER_ORIENTED'); + expect(params.REMOTENESS).toBe(10000); + expect(params.USE_PROPORTION).toBeTruthy(); + expect(params.PROPORTION).toBe(0.4); + expect(params.USE_AZIMUTH).toBeTruthy(); + expect(params.AZIMUTH_MARGIN).toBe(15); + expect(params.MAX_DETOUR_DISTANCE_RATIO).toBe(0.5); + expect(params.MAX_DETOUR_DURATION_RATIO).toBe(0.6); + expect(params.PER_PAGE).toBe(15); + }); + + it('should provide default params if defaults are not set', async () => { + const params: DefaultParams = + defaultParamsProviderWithoutDefaults.getParams(); + expect(params.DEPARTURE_TIME_MARGIN).toBe(900); + expect(params.PASSENGER).toBeTruthy(); + expect(params.DRIVER).toBeFalsy(); + expect(params.TIMEZONE).toBe('Europe/Paris'); + expect(params.ALGORITHM_TYPE).toBe('PASSENGER_ORIENTED'); + expect(params.REMOTENESS).toBe(15000); + expect(params.USE_PROPORTION).toBeTruthy(); + expect(params.PROPORTION).toBe(0.3); + expect(params.USE_AZIMUTH).toBeTruthy(); + expect(params.AZIMUTH_MARGIN).toBe(10); + expect(params.MAX_DETOUR_DISTANCE_RATIO).toBe(0.3); + expect(params.MAX_DETOUR_DURATION_RATIO).toBe(0.3); + expect(params.PER_PAGE).toBe(10); + }); +}); diff --git a/src/modules/ad/tests/unit/infrastructure/input-datetime-transformer.spec.ts b/src/modules/ad/tests/unit/infrastructure/input-datetime-transformer.spec.ts new file mode 100644 index 0000000..166b21c --- /dev/null +++ b/src/modules/ad/tests/unit/infrastructure/input-datetime-transformer.spec.ts @@ -0,0 +1,281 @@ +import { + PARAMS_PROVIDER, + TIMEZONE_FINDER, + TIME_CONVERTER, +} from '@modules/ad/ad.di-tokens'; +import { Frequency } from '@modules/ad/core/application/ports/datetime-transformer.port'; +import { DefaultParamsProviderPort } from '@modules/ad/core/application/ports/default-params-provider.port'; +import { TimeConverterPort } from '@modules/ad/core/application/ports/time-converter.port'; +import { TimezoneFinderPort } from '@modules/ad/core/application/ports/timezone-finder.port'; +import { AlgorithmType } from '@modules/ad/core/application/types/algorithm.types'; +import { InputDateTimeTransformer } from '@modules/ad/infrastructure/input-datetime-transformer'; +import { Test, TestingModule } from '@nestjs/testing'; + +const mockDefaultParamsProvider: DefaultParamsProviderPort = { + getParams: () => { + return { + DEPARTURE_TIME_MARGIN: 900, + DRIVER: false, + SEATS_PROPOSED: 3, + PASSENGER: true, + SEATS_REQUESTED: 1, + STRICT: false, + TIMEZONE: 'Europe/Paris', + ALGORITHM_TYPE: AlgorithmType.PASSENGER_ORIENTED, + REMOTENESS: 15000, + USE_PROPORTION: true, + PROPORTION: 0.3, + USE_AZIMUTH: true, + AZIMUTH_MARGIN: 10, + MAX_DETOUR_DISTANCE_RATIO: 0.3, + MAX_DETOUR_DURATION_RATIO: 0.3, + PER_PAGE: 10, + }; + }, +}; + +const mockTimezoneFinder: TimezoneFinderPort = { + timezones: jest.fn().mockImplementation(() => ['Europe/Paris']), +}; + +const mockTimeConverter: TimeConverterPort = { + localStringTimeToUtcStringTime: jest + .fn() + .mockImplementationOnce(() => '00:15'), + utcStringTimeToLocalStringTime: jest.fn(), + localStringDateTimeToUtcDate: jest + .fn() + .mockImplementationOnce(() => new Date('2023-07-30T06:15:00.000Z')) + .mockImplementationOnce(() => new Date('2023-07-20T08:15:00.000Z')) + .mockImplementationOnce(() => new Date('2023-07-19T23:15:00.000Z')) + .mockImplementationOnce(() => new Date('2023-07-19T23:15:00.000Z')), + utcStringDateTimeToLocalIsoString: jest.fn(), + utcUnixEpochDayFromTime: jest + .fn() + .mockImplementationOnce(() => 4) + .mockImplementationOnce(() => 3) + .mockImplementationOnce(() => 3) + .mockImplementationOnce(() => 5) + .mockImplementationOnce(() => 5), + localUnixEpochDayFromTime: jest.fn(), +}; + +describe('Input Datetime Transformer', () => { + let inputDatetimeTransformer: InputDateTimeTransformer; + + beforeAll(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [ + { + provide: PARAMS_PROVIDER, + useValue: mockDefaultParamsProvider, + }, + { + provide: TIMEZONE_FINDER, + useValue: mockTimezoneFinder, + }, + { + provide: TIME_CONVERTER, + useValue: mockTimeConverter, + }, + InputDateTimeTransformer, + ], + }).compile(); + + inputDatetimeTransformer = module.get( + InputDateTimeTransformer, + ); + }); + + it('should be defined', () => { + expect(inputDatetimeTransformer).toBeDefined(); + }); + + describe('fromDate', () => { + it('should return fromDate as is if frequency is recurrent', () => { + const transformedFromDate: string = inputDatetimeTransformer.fromDate( + { + date: '2023-07-30', + time: '07:15', + coordinates: { + lon: 6.175, + lat: 48.685, + }, + }, + Frequency.RECURRENT, + ); + expect(transformedFromDate).toBe('2023-07-30'); + }); + it('should return transformed fromDate if frequency is punctual and coordinates are those of Nancy', () => { + const transformedFromDate: string = inputDatetimeTransformer.fromDate( + { + date: '2023-07-30', + time: '07:15', + coordinates: { + lon: 6.175, + lat: 48.685, + }, + }, + Frequency.PUNCTUAL, + ); + expect(transformedFromDate).toBe('2023-07-30'); + }); + }); + + describe('toDate', () => { + it('should return toDate as is if frequency is recurrent', () => { + const transformedToDate: string = inputDatetimeTransformer.toDate( + '2024-07-29', + { + date: '2023-07-20', + time: '10:15', + coordinates: { + lon: 6.175, + lat: 48.685, + }, + }, + Frequency.RECURRENT, + ); + expect(transformedToDate).toBe('2024-07-29'); + }); + it('should return transformed fromDate if frequency is punctual', () => { + const transformedToDate: string = inputDatetimeTransformer.toDate( + '2024-07-30', + { + date: '2023-07-20', + time: '10:15', + coordinates: { + lon: 6.175, + lat: 48.685, + }, + }, + Frequency.PUNCTUAL, + ); + expect(transformedToDate).toBe('2023-07-20'); + }); + }); + + describe('day', () => { + it('should not change day if frequency is recurrent and converted UTC time is on the same day', () => { + const day: number = inputDatetimeTransformer.day( + 1, + { + date: '2023-07-24', + time: '01:15', + coordinates: { + lon: 6.175, + lat: 48.685, + }, + }, + Frequency.RECURRENT, + ); + expect(day).toBe(1); + }); + it('should change day if frequency is recurrent and converted UTC time is on the previous day', () => { + const day: number = inputDatetimeTransformer.day( + 1, + { + date: '2023-07-24', + time: '00:15', + coordinates: { + lon: 6.175, + lat: 48.685, + }, + }, + Frequency.RECURRENT, + ); + expect(day).toBe(0); + }); + it('should change day if frequency is recurrent and converted UTC time is on the previous day and given day is sunday', () => { + const day: number = inputDatetimeTransformer.day( + 0, + { + date: '2023-07-23', + time: '00:15', + coordinates: { + lon: 6.175, + lat: 48.685, + }, + }, + Frequency.RECURRENT, + ); + expect(day).toBe(6); + }); + it('should change day if frequency is recurrent and converted UTC time is on the next day', () => { + const day: number = inputDatetimeTransformer.day( + 1, + { + date: '2023-07-24', + time: '23:15', + coordinates: { + lon: 30.82, + lat: 49.37, + }, + }, + Frequency.RECURRENT, + ); + expect(day).toBe(2); + }); + it('should change day if frequency is recurrent and converted UTC time is on the next day and given day is saturday(6)', () => { + const day: number = inputDatetimeTransformer.day( + 6, + { + date: '2023-07-29', + time: '23:15', + coordinates: { + lon: 30.82, + lat: 49.37, + }, + }, + Frequency.RECURRENT, + ); + expect(day).toBe(0); + }); + it('should return utc fromDate day if frequency is punctual', () => { + const day: number = inputDatetimeTransformer.day( + 1, + { + date: '2023-07-20', + time: '00:15', + coordinates: { + lon: 6.175, + lat: 48.685, + }, + }, + Frequency.PUNCTUAL, + ); + expect(day).toBe(3); + }); + }); + + describe('time', () => { + it('should transform given time to utc time if frequency is recurrent', () => { + const time: string = inputDatetimeTransformer.time( + { + date: '2023-07-24', + time: '01:15', + coordinates: { + lon: 6.175, + lat: 48.685, + }, + }, + Frequency.RECURRENT, + ); + expect(time).toBe('00:15'); + }); + it('should return given time to utc time if frequency is punctual', () => { + const time: string = inputDatetimeTransformer.time( + { + date: '2023-07-24', + time: '01:15', + coordinates: { + lon: 6.175, + lat: 48.685, + }, + }, + Frequency.PUNCTUAL, + ); + expect(time).toBe('23:15'); + }); + }); +}); 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..bf4795a --- /dev/null +++ b/src/modules/ad/tests/unit/infrastructure/matching.repository.spec.ts @@ -0,0 +1,225 @@ +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 { MatchEntity } from '@modules/ad/core/domain/match.entity'; +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: [ + MatchEntity.create({ + adId: 'dd937edf-1264-4868-b073-d1952abe30b1', + role: Role.DRIVER, + frequency: Frequency.PUNCTUAL, + distance: 356041, + duration: 12647, + initialDistance: 348745, + initialDuration: 12105, + 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/output-datetime-transformer.spec.ts b/src/modules/ad/tests/unit/infrastructure/output-datetime-transformer.spec.ts new file mode 100644 index 0000000..efa575f --- /dev/null +++ b/src/modules/ad/tests/unit/infrastructure/output-datetime-transformer.spec.ts @@ -0,0 +1,281 @@ +import { + PARAMS_PROVIDER, + TIMEZONE_FINDER, + TIME_CONVERTER, +} from '@modules/ad/ad.di-tokens'; +import { Frequency } from '@modules/ad/core/application/ports/datetime-transformer.port'; +import { DefaultParamsProviderPort } from '@modules/ad/core/application/ports/default-params-provider.port'; +import { TimeConverterPort } from '@modules/ad/core/application/ports/time-converter.port'; +import { TimezoneFinderPort } from '@modules/ad/core/application/ports/timezone-finder.port'; +import { AlgorithmType } from '@modules/ad/core/application/types/algorithm.types'; +import { OutputDateTimeTransformer } from '@modules/ad/infrastructure/output-datetime-transformer'; +import { Test, TestingModule } from '@nestjs/testing'; + +const mockDefaultParamsProvider: DefaultParamsProviderPort = { + getParams: () => { + return { + DEPARTURE_TIME_MARGIN: 900, + DRIVER: false, + SEATS_PROPOSED: 3, + PASSENGER: true, + SEATS_REQUESTED: 1, + STRICT: false, + TIMEZONE: 'Europe/Paris', + ALGORITHM_TYPE: AlgorithmType.PASSENGER_ORIENTED, + REMOTENESS: 15000, + USE_PROPORTION: true, + PROPORTION: 0.3, + USE_AZIMUTH: true, + AZIMUTH_MARGIN: 10, + MAX_DETOUR_DISTANCE_RATIO: 0.3, + MAX_DETOUR_DURATION_RATIO: 0.3, + PER_PAGE: 10, + }; + }, +}; + +const mockTimezoneFinder: TimezoneFinderPort = { + timezones: jest.fn().mockImplementation(() => ['Europe/Paris']), +}; + +const mockTimeConverter: TimeConverterPort = { + localStringTimeToUtcStringTime: jest.fn(), + utcStringTimeToLocalStringTime: jest + .fn() + .mockImplementationOnce(() => '00:15'), + localStringDateTimeToUtcDate: jest.fn(), + utcStringDateTimeToLocalIsoString: jest + .fn() + .mockImplementationOnce(() => '2023-07-30T08:15:00.000+02:00') + .mockImplementationOnce(() => '2023-07-20T10:15:00.000+02:00') + .mockImplementationOnce(() => '2023-07-19T23:15:00.000+02:00') + .mockImplementationOnce(() => '2023-07-20T00:15:00.000+02:00'), + utcUnixEpochDayFromTime: jest.fn(), + localUnixEpochDayFromTime: jest + .fn() + .mockImplementationOnce(() => 4) + .mockImplementationOnce(() => 5) + .mockImplementationOnce(() => 5) + .mockImplementationOnce(() => 3) + .mockImplementationOnce(() => 3), +}; + +describe('Output Datetime Transformer', () => { + let outputDatetimeTransformer: OutputDateTimeTransformer; + + beforeAll(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [ + { + provide: PARAMS_PROVIDER, + useValue: mockDefaultParamsProvider, + }, + { + provide: TIMEZONE_FINDER, + useValue: mockTimezoneFinder, + }, + { + provide: TIME_CONVERTER, + useValue: mockTimeConverter, + }, + OutputDateTimeTransformer, + ], + }).compile(); + + outputDatetimeTransformer = module.get( + OutputDateTimeTransformer, + ); + }); + + it('should be defined', () => { + expect(outputDatetimeTransformer).toBeDefined(); + }); + + describe('fromDate', () => { + it('should return fromDate as is if frequency is recurrent', () => { + const transformedFromDate: string = outputDatetimeTransformer.fromDate( + { + date: '2023-07-30', + time: '07:15', + coordinates: { + lon: 6.175, + lat: 48.685, + }, + }, + Frequency.RECURRENT, + ); + expect(transformedFromDate).toBe('2023-07-30'); + }); + it('should return transformed fromDate if frequency is punctual and coordinates are those of Nancy', () => { + const transformedFromDate: string = outputDatetimeTransformer.fromDate( + { + date: '2023-07-30', + time: '07:15', + coordinates: { + lon: 6.175, + lat: 48.685, + }, + }, + Frequency.PUNCTUAL, + ); + expect(transformedFromDate).toBe('2023-07-30'); + }); + }); + + describe('toDate', () => { + it('should return toDate as is if frequency is recurrent', () => { + const transformedToDate: string = outputDatetimeTransformer.toDate( + '2024-07-29', + { + date: '2023-07-20', + time: '10:15', + coordinates: { + lon: 6.175, + lat: 48.685, + }, + }, + Frequency.RECURRENT, + ); + expect(transformedToDate).toBe('2024-07-29'); + }); + it('should return transformed fromDate if frequency is punctual', () => { + const transformedToDate: string = outputDatetimeTransformer.toDate( + '2024-07-30', + { + date: '2023-07-20', + time: '08:15', + coordinates: { + lon: 6.175, + lat: 48.685, + }, + }, + Frequency.PUNCTUAL, + ); + expect(transformedToDate).toBe('2023-07-20'); + }); + }); + + describe('day', () => { + it('should not change day if frequency is recurrent and converted local time is on the same day', () => { + const day: number = outputDatetimeTransformer.day( + 1, + { + date: '2023-07-24', + time: '00:15', + coordinates: { + lon: 6.175, + lat: 48.685, + }, + }, + Frequency.RECURRENT, + ); + expect(day).toBe(1); + }); + it('should change day if frequency is recurrent and converted local time is on the next day', () => { + const day: number = outputDatetimeTransformer.day( + 0, + { + date: '2023-07-23', + time: '23:15', + coordinates: { + lon: 6.175, + lat: 48.685, + }, + }, + Frequency.RECURRENT, + ); + expect(day).toBe(1); + }); + it('should change day if frequency is recurrent and converted local time is on the next day and given day is saturday', () => { + const day: number = outputDatetimeTransformer.day( + 6, + { + date: '2023-07-23', + time: '23:15', + coordinates: { + lon: 6.175, + lat: 48.685, + }, + }, + Frequency.RECURRENT, + ); + expect(day).toBe(0); + }); + it('should change day if frequency is recurrent and converted local time is on the previous day', () => { + const day: number = outputDatetimeTransformer.day( + 1, + { + date: '2023-07-25', + time: '00:15', + coordinates: { + lon: 30.82, + lat: 49.37, + }, + }, + Frequency.RECURRENT, + ); + expect(day).toBe(0); + }); + it('should change day if frequency is recurrent and converted local time is on the previous day and given day is sunday(0)', () => { + const day: number = outputDatetimeTransformer.day( + 0, + { + date: '2023-07-30', + time: '00:15', + coordinates: { + lon: 30.82, + lat: 49.37, + }, + }, + Frequency.RECURRENT, + ); + expect(day).toBe(6); + }); + it('should return local fromDate day if frequency is punctual', () => { + const day: number = outputDatetimeTransformer.day( + 1, + { + date: '2023-07-20', + time: '00:15', + coordinates: { + lon: 6.175, + lat: 48.685, + }, + }, + Frequency.PUNCTUAL, + ); + expect(day).toBe(3); + }); + }); + + describe('time', () => { + it('should transform utc time to local time if frequency is recurrent', () => { + const time: string = outputDatetimeTransformer.time( + { + date: '2023-07-23', + time: '23:15', + coordinates: { + lon: 6.175, + lat: 48.685, + }, + }, + Frequency.RECURRENT, + ); + expect(time).toBe('00:15'); + }); + it('should return local time if frequency is punctual', () => { + const time: string = outputDatetimeTransformer.time( + { + date: '2023-07-19', + time: '23:15', + coordinates: { + lon: 6.175, + lat: 48.685, + }, + }, + Frequency.PUNCTUAL, + ); + expect(time).toBe('00:15'); + }); + }); +}); diff --git a/src/modules/ad/tests/unit/infrastructure/route-provider.spec.ts b/src/modules/ad/tests/unit/infrastructure/route-provider.spec.ts index 7a41ed8..9d2bf51 100644 --- a/src/modules/ad/tests/unit/infrastructure/route-provider.spec.ts +++ b/src/modules/ad/tests/unit/infrastructure/route-provider.spec.ts @@ -1,27 +1,65 @@ -import { AD_GET_BASIC_ROUTE_CONTROLLER } from '@modules/ad/ad.di-tokens'; +import { + AD_GET_BASIC_ROUTE_CONTROLLER, + AD_GET_DETAILED_ROUTE_CONTROLLER, +} from '@modules/ad/ad.di-tokens'; +import { Point } from '@modules/ad/core/application/types/point.type'; import { Route } from '@modules/ad/core/application/types/route.type'; -import { Role } from '@modules/ad/core/domain/ad.types'; import { RouteProvider } from '@modules/ad/infrastructure/route-provider'; -import { GetBasicRouteControllerPort } from '@modules/geography/core/application/ports/get-basic-route-controller.port'; +import { GetRouteControllerPort } from '@modules/geography/core/application/ports/get-route-controller.port'; import { Test, TestingModule } from '@nestjs/testing'; -const mockGetBasicRouteController: GetBasicRouteControllerPort = { - get: jest.fn().mockImplementation(() => ({ - driverDistance: 23000, - driverDuration: 900, - passengerDistance: 23000, - passengerDuration: 900, - fwdAzimuth: 283, +const originPoint: Point = { + lat: 48.689445, + lon: 6.17651, +}; +const destinationPoint: Point = { + lat: 48.8566, + lon: 2.3522, +}; + +const mockGetBasicRouteController: GetRouteControllerPort = { + get: jest.fn().mockImplementationOnce(() => ({ + distance: 350101, + duration: 14422, + fwdAzimuth: 273, backAzimuth: 93, - distanceAzimuth: 19840, + distanceAzimuth: 336544, points: [ { - lon: 6.1765103, - lat: 48.689446, + lon: 6.1765102, + lat: 48.689445, }, { - lon: 2.3523, - lat: 48.8567, + lon: 4.984578, + lat: 48.725687, + }, + { + lon: 2.3522, + lat: 48.8566, + }, + ], + })), +}; + +const mockGetDetailedRouteController: GetRouteControllerPort = { + get: jest.fn().mockImplementationOnce(() => ({ + distance: 350102, + duration: 14423, + fwdAzimuth: 273, + backAzimuth: 93, + distanceAzimuth: 336544, + points: [ + { + lon: 6.1765102, + lat: 48.689445, + }, + { + lon: 4.984578, + lat: 48.725687, + }, + { + lon: 2.3522, + lat: 48.8566, }, ], })), @@ -38,6 +76,10 @@ describe('Route provider', () => { provide: AD_GET_BASIC_ROUTE_CONTROLLER, useValue: mockGetBasicRouteController, }, + { + provide: AD_GET_DETAILED_ROUTE_CONTROLLER, + useValue: mockGetDetailedRouteController, + }, ], }).compile(); @@ -48,22 +90,21 @@ describe('Route provider', () => { expect(routeProvider).toBeDefined(); }); - it('should provide a route', async () => { - const route: Route = await routeProvider.getBasic( - [Role.DRIVER], - [ - { - position: 0, - lat: 48.689445, - lon: 6.1765102, - }, - { - position: 1, - lat: 48.8566, - lon: 2.3522, - }, - ], - ); - expect(route.driverDistance).toBe(23000); + it('should provide a basic route', async () => { + const route: Route = await routeProvider.getBasic([ + originPoint, + destinationPoint, + ]); + expect(route.distance).toBe(350101); + expect(route.duration).toBe(14422); + }); + + it('should provide a detailed route', async () => { + const route: Route = await routeProvider.getDetailed([ + originPoint, + destinationPoint, + ]); + expect(route.distance).toBe(350102); + expect(route.duration).toBe(14423); }); }); diff --git a/src/modules/ad/tests/unit/infrastructure/time-converter.spec.ts b/src/modules/ad/tests/unit/infrastructure/time-converter.spec.ts new file mode 100644 index 0000000..45941f1 --- /dev/null +++ b/src/modules/ad/tests/unit/infrastructure/time-converter.spec.ts @@ -0,0 +1,311 @@ +import { TimeConverter } from '@modules/ad/infrastructure/time-converter'; + +describe('Time Converter', () => { + it('should be defined', () => { + const timeConverter: TimeConverter = new TimeConverter(); + expect(timeConverter).toBeDefined(); + }); + + describe('localStringTimeToUtcStringTime', () => { + it('should convert a paris time to utc time', () => { + const timeConverter: TimeConverter = new TimeConverter(); + const parisTime = '08:00'; + const utcDatetime = timeConverter.localStringTimeToUtcStringTime( + parisTime, + 'Europe/Paris', + ); + expect(utcDatetime).toBe('07:00'); + }); + it('should throw an error if timezone is invalid', () => { + const timeConverter: TimeConverter = new TimeConverter(); + const fooBarTime = '08:00'; + expect(() => { + timeConverter.localStringTimeToUtcStringTime(fooBarTime, 'Foo/Bar'); + }).toThrow(); + }); + }); + + describe('utcStringTimeToLocalStringTime', () => { + it('should convert a utc time to a paris time', () => { + const timeConverter: TimeConverter = new TimeConverter(); + const utcTime = '07:00'; + const parisTime = timeConverter.utcStringTimeToLocalStringTime( + utcTime, + 'Europe/Paris', + ); + expect(parisTime).toBe('08:00'); + }); + it('should throw an error if time is invalid', () => { + const timeConverter: TimeConverter = new TimeConverter(); + const utcTime = '27:00'; + expect(() => { + timeConverter.utcStringTimeToLocalStringTime(utcTime, 'Europe/Paris'); + }).toThrow(); + }); + it('should throw an error if timezone is invalid', () => { + const timeConverter: TimeConverter = new TimeConverter(); + const utcTime = '07:00'; + expect(() => { + timeConverter.utcStringTimeToLocalStringTime(utcTime, 'Foo/Bar'); + }).toThrow(); + }); + }); + + describe('localStringDateTimeToUtcDate', () => { + 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'; + const utcDate = timeConverter.localStringDateTimeToUtcDate( + parisDate, + parisTime, + 'Europe/Paris', + true, + ); + expect(utcDate.toISOString()).toBe('2023-06-22T10:00:00.000Z'); + }); + 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'; + const utcDate = timeConverter.localStringDateTimeToUtcDate( + parisDate, + parisTime, + 'Europe/Paris', + true, + ); + expect(utcDate.toISOString()).toBe('2023-02-02T11:00:00.000Z'); + }); + it('should convert a summer paris date and time to a utc date without dst', () => { + const timeConverter: TimeConverter = new TimeConverter(); + const parisDate = '2023-06-22'; + const parisTime = '12:00'; + const utcDate = timeConverter.localStringDateTimeToUtcDate( + parisDate, + parisTime, + 'Europe/Paris', + ); + expect(utcDate.toISOString()).toBe('2023-06-22T11:00:00.000Z'); + }); + it('should convert a tonga date and time to a utc date', () => { + const timeConverter: TimeConverter = new TimeConverter(); + const tongaDate = '2023-02-02'; + const tongaTime = '12:00'; + const utcDate = timeConverter.localStringDateTimeToUtcDate( + tongaDate, + tongaTime, + 'Pacific/Tongatapu', + ); + expect(utcDate.toISOString()).toBe('2023-02-01T23:00:00.000Z'); + }); + it('should convert a papeete date and time to a utc date', () => { + const timeConverter: TimeConverter = new TimeConverter(); + const papeeteDate = '2023-02-02'; + const papeeteTime = '15:00'; + const utcDate = timeConverter.localStringDateTimeToUtcDate( + papeeteDate, + papeeteTime, + 'Pacific/Tahiti', + ); + expect(utcDate.toISOString()).toBe('2023-02-03T01:00:00.000Z'); + }); + it('should throw an error if date is invalid', () => { + const timeConverter: TimeConverter = new TimeConverter(); + const parisDate = '2023-06-32'; + const parisTime = '08:00'; + expect(() => { + timeConverter.localStringDateTimeToUtcDate( + parisDate, + parisTime, + 'Europe/Paris', + ); + }).toThrow(); + }); + it('should throw an error if time is invalid', () => { + const timeConverter: TimeConverter = new TimeConverter(); + const parisDate = '2023-06-22'; + const parisTime = '28:00'; + expect(() => { + timeConverter.localStringDateTimeToUtcDate( + parisDate, + parisTime, + 'Europe/Paris', + ); + }).toThrow(); + }); + it('should throw an error if timezone is invalid', () => { + const timeConverter: TimeConverter = new TimeConverter(); + const parisDate = '2023-06-22'; + const parisTime = '12:00'; + expect(() => { + timeConverter.localStringDateTimeToUtcDate( + parisDate, + parisTime, + 'Foo/Bar', + ); + }).toThrow(); + }); + }); + + 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'; + const utcTime = '10:00'; + const localIsoString = timeConverter.utcStringDateTimeToLocalIsoString( + utcDate, + utcTime, + 'Europe/Paris', + ); + expect(localIsoString).toBe('2023-06-22T11:00:00.000+01:00'); + }); + it('should convert a utc date to a tonga date isostring', () => { + const timeConverter: TimeConverter = new TimeConverter(); + const utcDate = '2023-02-01'; + const utcTime = '23:00'; + const localIsoString = timeConverter.utcStringDateTimeToLocalIsoString( + utcDate, + utcTime, + 'Pacific/Tongatapu', + ); + expect(localIsoString).toBe('2023-02-02T12:00:00.000+13:00'); + }); + it('should convert a utc date to a papeete date isostring', () => { + const timeConverter: TimeConverter = new TimeConverter(); + const utcDate = '2023-02-03'; + const utcTime = '01:00'; + const localIsoString = timeConverter.utcStringDateTimeToLocalIsoString( + utcDate, + utcTime, + 'Pacific/Tahiti', + ); + expect(localIsoString).toBe('2023-02-02T15:00:00.000-10:00'); + }); + it('should throw an error if date is invalid', () => { + const timeConverter: TimeConverter = new TimeConverter(); + const utcDate = '2023-06-32'; + const utcTime = '07:00'; + expect(() => { + timeConverter.utcStringDateTimeToLocalIsoString( + utcDate, + utcTime, + 'Europe/Paris', + ); + }).toThrow(); + }); + it('should throw an error if time is invalid', () => { + const timeConverter: TimeConverter = new TimeConverter(); + const utcDate = '2023-06-22'; + const utcTime = '27:00'; + expect(() => { + timeConverter.utcStringDateTimeToLocalIsoString( + utcDate, + utcTime, + 'Europe/Paris', + ); + }).toThrow(); + }); + it('should throw an error if timezone is invalid', () => { + const timeConverter: TimeConverter = new TimeConverter(); + const utcDate = '2023-06-22'; + const utcTime = '07:00'; + expect(() => { + timeConverter.utcStringDateTimeToLocalIsoString( + utcDate, + utcTime, + 'Foo/Bar', + ); + }).toThrow(); + }); + }); + + describe('utcUnixEpochDayFromTime', () => { + it('should get the utc day of paris at 12:00', () => { + const timeConverter: TimeConverter = new TimeConverter(); + expect( + timeConverter.utcUnixEpochDayFromTime('12:00', 'Europe/Paris'), + ).toBe(4); + }); + it('should get the utc day of paris at 00:00', () => { + const timeConverter: TimeConverter = new TimeConverter(); + expect( + timeConverter.utcUnixEpochDayFromTime('00:00', 'Europe/Paris'), + ).toBe(3); + }); + it('should get the utc day of papeete at 16:00', () => { + const timeConverter: TimeConverter = new TimeConverter(); + expect( + timeConverter.utcUnixEpochDayFromTime('16:00', 'Pacific/Tahiti'), + ).toBe(5); + }); + it('should throw an error if time is invalid', () => { + const timeConverter: TimeConverter = new TimeConverter(); + expect(() => { + timeConverter.utcUnixEpochDayFromTime('28:00', 'Europe/Paris'); + }).toThrow(); + }); + it('should throw an error if timezone is invalid', () => { + const timeConverter: TimeConverter = new TimeConverter(); + expect(() => { + timeConverter.utcUnixEpochDayFromTime('12:00', 'Foo/Bar'); + }).toThrow(); + }); + }); + + describe('localUnixEpochDayFromTime', () => { + it('should get the day of paris at 12:00 utc', () => { + const timeConverter: TimeConverter = new TimeConverter(); + expect( + timeConverter.localUnixEpochDayFromTime('12:00', 'Europe/Paris'), + ).toBe(4); + }); + it('should get the day of paris at 23:00 utc', () => { + const timeConverter: TimeConverter = new TimeConverter(); + expect( + timeConverter.localUnixEpochDayFromTime('23:00', 'Europe/Paris'), + ).toBe(5); + }); + it('should get the day of papeete at 05:00 utc', () => { + const timeConverter: TimeConverter = new TimeConverter(); + expect( + timeConverter.localUnixEpochDayFromTime('05:00', 'Pacific/Tahiti'), + ).toBe(3); + }); + it('should throw an error if time is invalid', () => { + const timeConverter: TimeConverter = new TimeConverter(); + expect(() => { + timeConverter.localUnixEpochDayFromTime('28:00', 'Europe/Paris'); + }).toThrow(); + }); + it('should throw an error if timezone is invalid', () => { + const timeConverter: TimeConverter = new TimeConverter(); + expect(() => { + timeConverter.localUnixEpochDayFromTime('12:00', 'Foo/Bar'); + }).toThrow(); + }); + }); +}); diff --git a/src/modules/ad/tests/unit/infrastructure/timezone-finder.spec.ts b/src/modules/ad/tests/unit/infrastructure/timezone-finder.spec.ts new file mode 100644 index 0000000..46e3ab8 --- /dev/null +++ b/src/modules/ad/tests/unit/infrastructure/timezone-finder.spec.ts @@ -0,0 +1,14 @@ +import { TimezoneFinder } from '@modules/ad/infrastructure/timezone-finder'; + +describe('Timezone Finder', () => { + it('should be defined', () => { + const timezoneFinder: TimezoneFinder = new TimezoneFinder(); + expect(timezoneFinder).toBeDefined(); + }); + it('should get timezone for Nancy(France) as Europe/Paris', () => { + const timezoneFinder: TimezoneFinder = new TimezoneFinder(); + const timezones = timezoneFinder.timezones(6.179373, 48.687913); + expect(timezones.length).toBe(1); + expect(timezones[0]).toBe('Europe/Paris'); + }); +}); diff --git a/src/modules/ad/tests/unit/interface/has-day.decorator.spec.ts b/src/modules/ad/tests/unit/interface/has-day.decorator.spec.ts new file mode 100644 index 0000000..ef27a62 --- /dev/null +++ b/src/modules/ad/tests/unit/interface/has-day.decorator.spec.ts @@ -0,0 +1,60 @@ +import { Frequency } from '@modules/ad/core/domain/ad.types'; +import { ScheduleItemDto } from '@modules/ad/interface/grpc-controllers/dtos/schedule-item.dto'; +import { HasDay } from '@modules/ad/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); + }); +}); diff --git a/src/modules/ad/tests/unit/interface/has-valid-position-indexes.decorator.spec.ts b/src/modules/ad/tests/unit/interface/has-valid-position-indexes.decorator.spec.ts new file mode 100644 index 0000000..bf61ce6 --- /dev/null +++ b/src/modules/ad/tests/unit/interface/has-valid-position-indexes.decorator.spec.ts @@ -0,0 +1,62 @@ +import { HasValidPositionIndexes } from '@modules/ad/interface/grpc-controllers/dtos/validators/decorators/has-valid-position-indexes.decorator'; +import { WaypointDto } from '@modules/ad/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); + }); +}); diff --git a/src/modules/ad/tests/unit/interface/has-valid-position-indexes.validator.spec.ts b/src/modules/ad/tests/unit/interface/has-valid-position-indexes.validator.spec.ts new file mode 100644 index 0000000..98e9d24 --- /dev/null +++ b/src/modules/ad/tests/unit/interface/has-valid-position-indexes.validator.spec.ts @@ -0,0 +1,62 @@ +import { hasValidPositionIndexes } from '@modules/ad/interface/grpc-controllers/dtos/validators/has-valid-position-indexes.validator'; +import { WaypointDto } from '@modules/ad/interface/grpc-controllers/dtos/waypoint.dto'; + +describe('Waypoint position validator', () => { + const mockAddress1: WaypointDto = { + position: 0, + lon: 48.689445, + lat: 6.17651, + houseNumber: '5', + street: 'Avenue Foch', + locality: 'Nancy', + postalCode: '54000', + country: 'France', + }; + const mockAddress2: WaypointDto = { + position: 1, + lon: 48.8566, + lat: 2.3522, + locality: 'Paris', + postalCode: '75000', + country: 'France', + }; + const mockAddress3: WaypointDto = { + position: 2, + lon: 49.2628, + lat: 4.0347, + locality: 'Reims', + postalCode: '51000', + country: 'France', + }; + + it('should validate if positions are ordered', () => { + 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 positions are not valid', () => { + mockAddress1.position = 0; + mockAddress2.position = 2; + mockAddress3.position = 3; + 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 not validate if no waypoints are defined', () => { + expect(hasValidPositionIndexes([])).toBeFalsy(); + }); +}); diff --git a/src/modules/ad/tests/unit/interface/is-after-or-equal.decorator.spec.ts b/src/modules/ad/tests/unit/interface/is-after-or-equal.decorator.spec.ts new file mode 100644 index 0000000..71ab685 --- /dev/null +++ b/src/modules/ad/tests/unit/interface/is-after-or-equal.decorator.spec.ts @@ -0,0 +1,45 @@ +import { IsAfterOrEqual } from '@modules/ad/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); + }); +}); 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 new file mode 100644 index 0000000..a138f04 --- /dev/null +++ b/src/modules/ad/tests/unit/interface/match.grpc.controller.spec.ts @@ -0,0 +1,357 @@ +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'; +import { MatchMapper } from '@modules/ad/match.mapper'; +import { QueryBus } from '@nestjs/cqrs'; +import { RpcException } from '@nestjs/microservices'; +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 recurrentMatchRequestDto: MatchRequestDto = { + driver: false, + passenger: true, + frequency: Frequency.RECURRENT, + fromDate: '2023-08-15', + toDate: '2024-09-30', + schedule: [ + { + time: '07:00', + day: 5, + margin: 900, + }, + ], + waypoints: [originWaypoint, destinationWaypoint], + strict: false, + algorithmType: AlgorithmType.PASSENGER_ORIENTED, +}; + +const mockQueryBus = { + execute: jest + .fn() + .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(); + }), +}; + +const mockRouteProvider: RouteProviderPort = { + getBasic: jest.fn(), + getDetailed: jest.fn(), +}; + +const mockMatchMapper = { + toResponse: jest.fn().mockImplementation(() => ({ + adId: '53a0bf71-4132-4f7b-a4cc-88c796b6bdf1', + role: 'DRIVER', + frequency: 'RECURRENT', + distance: 356041, + duration: 12647, + journeys: [ + { + firstDate: '2023-09-01', + lastDate: '2024-08-30', + journeyItems: [ + { + lat: 48.689445, + lon: 6.17651, + duration: 0, + distance: 0, + actorTimes: [ + { + role: 'DRIVER', + target: 'START', + firstDatetime: '2023-09-01 07:00', + firstMinDatetime: '2023-09-01 06:45', + firstMaxDatetime: '2023-09-01 07:15', + lastDatetime: '2024-08-30 07:00', + lastMinDatetime: '2024-08-30 06:45', + lastMaxDatetime: '2024-08-30 07:15', + }, + ], + }, + { + lat: 48.369445, + lon: 6.67487, + duration: 2100, + distance: 56878, + actorTimes: [ + { + role: 'DRIVER', + target: 'NEUTRAL', + firstDatetime: '2023-09-01 07:35', + firstMinDatetime: '2023-09-01 07:20', + firstMaxDatetime: '2023-09-01 07:50', + lastDatetime: '2024-08-30 07:35', + lastMinDatetime: '2024-08-30 07:20', + lastMaxDatetime: '2024-08-30 07:50', + }, + { + role: 'PASSENGER', + target: 'START', + firstDatetime: '2023-09-01 07:32', + firstMinDatetime: '2023-09-01 07:17', + firstMaxDatetime: '2023-09-01 07:47', + lastDatetime: '2024-08-30 07:32', + lastMinDatetime: '2024-08-30 07:17', + lastMaxDatetime: '2024-08-30 07:47', + }, + ], + }, + { + lat: 47.98487, + lon: 6.9427, + duration: 3840, + distance: 76491, + actorTimes: [ + { + role: 'DRIVER', + target: 'NEUTRAL', + firstDatetime: '2023-09-01 08:04', + firstMinDatetime: '2023-09-01 07:51', + firstMaxDatetime: '2023-09-01 08:19', + lastDatetime: '2024-08-30 08:04', + lastMinDatetime: '2024-08-30 07:51', + lastMaxDatetime: '2024-08-30 08:19', + }, + { + role: 'PASSENGER', + target: 'FINISH', + firstDatetime: '2023-09-01 08:01', + firstMinDatetime: '2023-09-01 07:46', + firstMaxDatetime: '2023-09-01 08:16', + lastDatetime: '2024-08-30 08:01', + lastMinDatetime: '2024-08-30 07:46', + lastMaxDatetime: '2024-08-30 08:16', + }, + ], + }, + { + lat: 47.365987, + lon: 7.02154, + duration: 4980, + distance: 96475, + actorTimes: [ + { + role: 'DRIVER', + target: 'FINISH', + firstDatetime: '2023-09-01 08:23', + firstMinDatetime: '2023-09-01 08:08', + firstMaxDatetime: '2023-09-01 08:38', + lastDatetime: '2024-08-30 08:23', + lastMinDatetime: '2024-08-30 08:08', + lastMaxDatetime: '2024-08-30 08:38', + }, + ], + }, + ], + }, + ], + })), +}; + +describe('Match Grpc Controller', () => { + let matchGrpcController: MatchGrpcController; + + beforeAll(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [ + MatchGrpcController, + { + provide: QueryBus, + useValue: mockQueryBus, + }, + { + provide: AD_ROUTE_PROVIDER, + useValue: mockRouteProvider, + }, + { + provide: MatchMapper, + useValue: mockMatchMapper, + }, + ], + }).compile(); + + matchGrpcController = module.get(MatchGrpcController); + }); + + afterEach(async () => { + jest.clearAllMocks(); + }); + + it('should be defined', () => { + expect(matchGrpcController).toBeDefined(); + }); + + it('should return a matching', async () => { + jest.spyOn(mockQueryBus, 'execute'); + const matchingPaginatedResponseDto: MatchingPaginatedResponseDto = + await matchGrpcController.match(recurrentMatchRequestDto); + expect(matchingPaginatedResponseDto.id).toBe( + '43c83ae2-f4b0-4ac6-b8bf-8071801924d4', + ); + expect(matchingPaginatedResponseDto.data).toHaveLength(1); + expect(matchingPaginatedResponseDto.page).toBe(1); + expect(matchingPaginatedResponseDto.perPage).toBe(10); + expect(mockQueryBus.execute).toHaveBeenCalledTimes(1); + }); + + it('should throw a generic RpcException', async () => { + jest.spyOn(mockQueryBus, 'execute'); + expect.assertions(3); + try { + await matchGrpcController.match(recurrentMatchRequestDto); + } catch (e: any) { + expect(e).toBeInstanceOf(RpcException); + expect(e.error.code).toBe(RpcExceptionCode.UNKNOWN); + } + expect(mockQueryBus.execute).toHaveBeenCalledTimes(1); + }); +}); diff --git a/src/modules/ad/tests/unit/match.mapper.spec.ts b/src/modules/ad/tests/unit/match.mapper.spec.ts new file mode 100644 index 0000000..b2ce143 --- /dev/null +++ b/src/modules/ad/tests/unit/match.mapper.spec.ts @@ -0,0 +1,154 @@ +import { OUTPUT_DATETIME_TRANSFORMER } from '@modules/ad/ad.di-tokens'; +import { DateTimeTransformerPort } from '@modules/ad/core/application/ports/datetime-transformer.port'; +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 { Journey } from '@modules/ad/core/domain/value-objects/journey.value-object'; +import { MatchResponseDto } from '@modules/ad/interface/dtos/match.response.dto'; +import { MatchMapper } from '@modules/ad/match.mapper'; +import { Test } from '@nestjs/testing'; + +const matchEntity: MatchEntity = MatchEntity.create({ + adId: '53a0bf71-4132-4f7b-a4cc-88c796b6bdf1', + role: Role.DRIVER, + frequency: Frequency.RECURRENT, + distance: 356041, + duration: 12647, + initialDistance: 315478, + initialDuration: 12105, + journeys: [ + new Journey({ + 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'), + }), + ], + }), + ], + }), + ], +}); + +const mockOutputDatetimeTransformer: DateTimeTransformerPort = { + fromDate: jest.fn(), + toDate: jest.fn(), + day: jest.fn(), + time: jest.fn(), +}; + +describe('Match Mapper', () => { + let matchMapper: MatchMapper; + + beforeAll(async () => { + const module = await Test.createTestingModule({ + providers: [ + MatchMapper, + { + provide: OUTPUT_DATETIME_TRANSFORMER, + useValue: mockOutputDatetimeTransformer, + }, + ], + }).compile(); + matchMapper = module.get(MatchMapper); + }); + + it('should be defined', () => { + expect(matchMapper).toBeDefined(); + }); + + it('should map domain entity to response', async () => { + const mapped: MatchResponseDto = matchMapper.toResponse(matchEntity); + expect(mapped.journeys).toHaveLength(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..2b79b0d --- /dev/null +++ b/src/modules/ad/tests/unit/matching.mapper.spec.ts @@ -0,0 +1,164 @@ +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 { 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: [ + new MatchEntity({ + id: '4bd4e90b-ffba-4f5f-b904-48ad0667a1d7', + createdAt: new Date('2023-08-30T08:45:00Z'), + updatedAt: new Date('2023-08-30T08:45:00Z'), + props: { + 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.389745, + lat: 48.32644, + 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'), + }, + { + role: Role.PASSENGER, + 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'), + }, + ], + }, + { + lon: 6.984567, + lat: 48.021548, + distance: 356041, + duration: 12647, + actorTimes: [ + { + role: Role.DRIVER, + target: Target.FINISH, + 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'), + }, + { + role: Role.PASSENGER, + target: Target.FINISH, + 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","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.389745,"lat":48.32644,"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"},{"role":"PASSENGER","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"}]},{"lon":6.984567,"lat":48.021548,"duration":12647,"distance":356041,"actorTimes":[{"role":"DRIVER","target":"FINISH","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"},{"role":"PASSENGER","target":"FINISH","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}}', + ); + }); + + 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","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.389745,"lat":48.32644,"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"},{"role":"PASSENGER","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"}]},{"lon":6.984567,"lat":48.021548,"duration":12647,"distance":356041,"actorTimes":[{"role":"DRIVER","target":"FINISH","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"},{"role":"PASSENGER","target":"FINISH","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}}', + ); + expect(matchingEntity.getProps().query.fromDate).toBe('2023-09-01'); + expect(matchingEntity.getProps().matches[0].getProps().adId).toBe( + 'dd937edf-1264-4868-b073-d1952abe30b1', + ); + }); +}); diff --git a/src/modules/geography/core/application/ports/direction-encoder.port.ts b/src/modules/geography/core/application/ports/direction-encoder.port.ts index 737456a..316de85 100644 --- a/src/modules/geography/core/application/ports/direction-encoder.port.ts +++ b/src/modules/geography/core/application/ports/direction-encoder.port.ts @@ -1,6 +1,6 @@ -import { Coordinates } from '../../domain/route.types'; +import { Point } from '../../domain/route.types'; export interface DirectionEncoderPort { - encode(coordinates: Coordinates[]): string; - decode(direction: string): Coordinates[]; + encode(coordinates: Point[]): string; + decode(direction: string): Point[]; } diff --git a/src/modules/geography/core/application/ports/geodesic.port.ts b/src/modules/geography/core/application/ports/geodesic.port.ts index 77b45ba..6da4a05 100644 --- a/src/modules/geography/core/application/ports/geodesic.port.ts +++ b/src/modules/geography/core/application/ports/geodesic.port.ts @@ -8,4 +8,6 @@ export interface GeodesicPort { azimuth: number; distance: number; }; + distance(lon1: number, lat1: number, lon2: number, lat2: number): number; + azimuth(lon1: number, lat1: number, lon2: number, lat2: number): number; } diff --git a/src/modules/geography/core/application/ports/georouter.port.ts b/src/modules/geography/core/application/ports/georouter.port.ts index 1990e99..fa78b58 100644 --- a/src/modules/geography/core/application/ports/georouter.port.ts +++ b/src/modules/geography/core/application/ports/georouter.port.ts @@ -1,6 +1,6 @@ -import { Path, Route } from '../../domain/route.types'; +import { Route, Point } from '../../domain/route.types'; import { GeorouterSettings } from '../types/georouter-settings.type'; export interface GeorouterPort { - routes(paths: Path[], settings: GeorouterSettings): Promise; + route(waypoints: Point[], settings: GeorouterSettings): Promise; } diff --git a/src/modules/geography/core/application/ports/get-basic-route-controller.port.ts b/src/modules/geography/core/application/ports/get-route-controller.port.ts similarity index 84% rename from src/modules/geography/core/application/ports/get-basic-route-controller.port.ts rename to src/modules/geography/core/application/ports/get-route-controller.port.ts index a1f0bd4..0217143 100644 --- a/src/modules/geography/core/application/ports/get-basic-route-controller.port.ts +++ b/src/modules/geography/core/application/ports/get-route-controller.port.ts @@ -1,6 +1,6 @@ import { GetRouteRequestDto } from '@modules/geography/interface/controllers/dtos/get-route.request.dto'; import { RouteResponseDto } from '@modules/geography/interface/dtos/route.response.dto'; -export interface GetBasicRouteControllerPort { +export interface GetRouteControllerPort { get(data: GetRouteRequestDto): Promise; } diff --git a/src/modules/geography/core/application/queries/get-route/get-route.query-handler.ts b/src/modules/geography/core/application/queries/get-route/get-route.query-handler.ts index 1a255aa..77d88ad 100644 --- a/src/modules/geography/core/application/queries/get-route/get-route.query-handler.ts +++ b/src/modules/geography/core/application/queries/get-route/get-route.query-handler.ts @@ -11,7 +11,6 @@ export class GetRouteQueryHandler implements IQueryHandler { execute = async (query: GetRouteQuery): Promise => await RouteEntity.create({ - roles: query.roles, waypoints: query.waypoints, georouter: this.georouter, georouterSettings: query.georouterSettings, diff --git a/src/modules/geography/core/application/queries/get-route/get-route.query.ts b/src/modules/geography/core/application/queries/get-route/get-route.query.ts index eef3ed1..56e33d6 100644 --- a/src/modules/geography/core/application/queries/get-route/get-route.query.ts +++ b/src/modules/geography/core/application/queries/get-route/get-route.query.ts @@ -1,19 +1,20 @@ import { QueryBase } from '@mobicoop/ddd-library'; -import { Role, Waypoint } from '@modules/geography/core/domain/route.types'; import { GeorouterSettings } from '../../types/georouter-settings.type'; +import { Point } from '@modules/geography/core/domain/route.types'; export class GetRouteQuery extends QueryBase { - readonly roles: Role[]; - readonly waypoints: Waypoint[]; + readonly waypoints: Point[]; readonly georouterSettings: GeorouterSettings; constructor( - roles: Role[], - waypoints: Waypoint[], - georouterSettings: GeorouterSettings, + waypoints: Point[], + georouterSettings: GeorouterSettings = { + detailedDistance: false, + detailedDuration: false, + points: true, + }, ) { super(); - this.roles = roles; this.waypoints = waypoints; this.georouterSettings = georouterSettings; } diff --git a/src/modules/geography/core/application/types/default-params.type.ts b/src/modules/geography/core/application/types/default-params.type.ts index 12ea88e..ba61d39 100644 --- a/src/modules/geography/core/application/types/default-params.type.ts +++ b/src/modules/geography/core/application/types/default-params.type.ts @@ -1,4 +1,4 @@ export type DefaultParams = { - GEOROUTER_TYPE: string; - GEOROUTER_URL: string; + GEOROUTER_TYPE?: string; + GEOROUTER_URL?: string; }; diff --git a/src/modules/geography/core/domain/route.entity.ts b/src/modules/geography/core/domain/route.entity.ts index 46177b7..be0613d 100644 --- a/src/modules/geography/core/domain/route.entity.ts +++ b/src/modules/geography/core/domain/route.entity.ts @@ -1,13 +1,5 @@ import { AggregateRoot, AggregateID } from '@mobicoop/ddd-library'; -import { - CreateRouteProps, - Path, - Role, - RouteProps, - PathType, - Route, -} from './route.types'; -import { WaypointProps } from './value-objects/waypoint.value-object'; +import { CreateRouteProps, RouteProps, Route } from './route.types'; import { v4 } from 'uuid'; import { RouteNotFoundException } from './route.errors'; @@ -15,48 +7,19 @@ export class RouteEntity extends AggregateRoot { protected readonly _id: AggregateID; static create = async (create: CreateRouteProps): Promise => { - let routes: Route[]; - try { - routes = await create.georouter.routes( - this.getPaths(create.roles, create.waypoints), - create.georouterSettings, - ); - if (!routes || routes.length == 0) throw new RouteNotFoundException(); - } catch (e: any) { - throw e; - } - let driverRoute: Route; - let passengerRoute: Route; - if (routes.some((route: Route) => route.type == PathType.GENERIC)) { - driverRoute = passengerRoute = routes.find( - (route: Route) => route.type == PathType.GENERIC, - ); - } else { - driverRoute = routes.some((route: Route) => route.type == PathType.DRIVER) - ? routes.find((route: Route) => route.type == PathType.DRIVER) - : undefined; - passengerRoute = routes.some( - (route: Route) => route.type == PathType.PASSENGER, - ) - ? routes.find((route: Route) => route.type == PathType.PASSENGER) - : undefined; - } + const route: Route = await create.georouter.route( + create.waypoints, + create.georouterSettings, + ); + if (!route) throw new RouteNotFoundException(); const routeProps: RouteProps = { - driverDistance: driverRoute?.distance, - driverDuration: driverRoute?.duration, - passengerDistance: passengerRoute?.distance, - passengerDuration: passengerRoute?.duration, - fwdAzimuth: driverRoute - ? driverRoute.fwdAzimuth - : passengerRoute.fwdAzimuth, - backAzimuth: driverRoute - ? driverRoute.backAzimuth - : passengerRoute.backAzimuth, - distanceAzimuth: driverRoute - ? driverRoute.distanceAzimuth - : passengerRoute.distanceAzimuth, - waypoints: create.waypoints, - points: driverRoute ? driverRoute.points : passengerRoute.points, + distance: route.distance, + duration: route.duration, + fwdAzimuth: route.fwdAzimuth, + backAzimuth: route.backAzimuth, + distanceAzimuth: route.distanceAzimuth, + points: route.points, + steps: route.steps, }; return new RouteEntity({ id: v4(), @@ -67,96 +30,4 @@ export class RouteEntity extends AggregateRoot { validate(): void { // entity business rules validation to protect it's invariant before saving entity to a database } - - private static getPaths = ( - roles: Role[], - waypoints: WaypointProps[], - ): Path[] => { - const paths: Path[] = []; - if (roles.includes(Role.DRIVER) && roles.includes(Role.PASSENGER)) { - if (waypoints.length == 2) { - // 2 points => same route for driver and passenger - paths.push(this.createGenericPath(waypoints)); - } else { - paths.push( - this.createDriverPath(waypoints), - this.createPassengerPath(waypoints), - ); - } - } else if (roles.includes(Role.DRIVER)) { - paths.push(this.createDriverPath(waypoints)); - } else if (roles.includes(Role.PASSENGER)) { - paths.push(this.createPassengerPath(waypoints)); - } - return paths; - }; - - private static createGenericPath = (waypoints: WaypointProps[]): Path => - this.createPath(waypoints, PathType.GENERIC); - - private static createDriverPath = (waypoints: WaypointProps[]): Path => - this.createPath(waypoints, PathType.DRIVER); - - private static createPassengerPath = (waypoints: WaypointProps[]): Path => - this.createPath( - [waypoints[0], waypoints[waypoints.length - 1]], - PathType.PASSENGER, - ); - - private static createPath = ( - points: WaypointProps[], - type: PathType, - ): Path => ({ - type, - points, - }); } - -// import { IGeodesic } from '../interfaces/geodesic.interface'; -// import { Point } from '../types/point.type'; -// import { SpacetimePoint } from './spacetime-point'; - -// export class Route { -// distance: number; -// duration: number; -// fwdAzimuth: number; -// backAzimuth: number; -// distanceAzimuth: number; -// points: Point[]; -// spacetimePoints: SpacetimePoint[]; -// private geodesic: IGeodesic; - -// constructor(geodesic: IGeodesic) { -// this.distance = undefined; -// this.duration = undefined; -// this.fwdAzimuth = undefined; -// this.backAzimuth = undefined; -// this.distanceAzimuth = undefined; -// this.points = []; -// this.spacetimePoints = []; -// this.geodesic = geodesic; -// } - -// setPoints = (points: Point[]): void => { -// this.points = points; -// this.setAzimuth(points); -// }; - -// setSpacetimePoints = (spacetimePoints: SpacetimePoint[]): void => { -// this.spacetimePoints = spacetimePoints; -// }; - -// protected setAzimuth = (points: Point[]): void => { -// const inverse = this.geodesic.inverse( -// points[0].lon, -// points[0].lat, -// points[points.length - 1].lon, -// points[points.length - 1].lat, -// ); -// this.fwdAzimuth = -// inverse.azimuth >= 0 ? inverse.azimuth : 360 - Math.abs(inverse.azimuth); -// this.backAzimuth = -// this.fwdAzimuth > 180 ? this.fwdAzimuth - 180 : this.fwdAzimuth + 180; -// this.distanceAzimuth = inverse.distance; -// }; -// } diff --git a/src/modules/geography/core/domain/route.types.ts b/src/modules/geography/core/domain/route.types.ts index 5860261..e478575 100644 --- a/src/modules/geography/core/domain/route.types.ts +++ b/src/modules/geography/core/domain/route.types.ts @@ -1,67 +1,43 @@ import { GeorouterPort } from '../application/ports/georouter.port'; import { GeorouterSettings } from '../application/types/georouter-settings.type'; -import { CoordinatesProps } from './value-objects/coordinates.value-object'; -import { SpacetimePointProps } from './value-objects/spacetime-point.value-object'; -import { WaypointProps } from './value-objects/waypoint.value-object'; +import { PointProps } from './value-objects/point.value-object'; +import { StepProps } from './value-objects/step.value-object'; // All properties that a Route has export interface RouteProps { - driverDistance?: number; - driverDuration?: number; - passengerDistance?: number; - passengerDuration?: number; + distance: number; + duration: number; fwdAzimuth: number; backAzimuth: number; distanceAzimuth: number; - waypoints: WaypointProps[]; - points: SpacetimePointProps[] | CoordinatesProps[]; + points: PointProps[]; + steps?: StepProps[]; } // Properties that are needed for a Route creation export interface CreateRouteProps { - roles: Role[]; - waypoints: WaypointProps[]; + waypoints: PointProps[]; georouter: GeorouterPort; georouterSettings: GeorouterSettings; } +// Types used outside the domain export type Route = { - type: PathType; distance: number; duration: number; fwdAzimuth: number; backAzimuth: number; distanceAzimuth: number; - points: Coordinates[]; - spacetimeWaypoints: SpacetimePoint[]; + points: Point[]; + steps?: Step[]; }; -export type Path = { - type: PathType; - points: Coordinates[]; -}; - -export type Coordinates = { +export type Point = { lon: number; lat: number; }; -export type Waypoint = Coordinates & { - position: number; -}; - -export type SpacetimePoint = Coordinates & { +export type Step = Point & { duration: number; - distance: number; + distance?: number; }; - -export enum Role { - DRIVER = 'DRIVER', - PASSENGER = 'PASSENGER', -} - -export enum PathType { - GENERIC = 'generic', - DRIVER = 'driver', - PASSENGER = 'passenger', -} diff --git a/src/modules/geography/core/domain/value-objects/coordinates.value-object.ts b/src/modules/geography/core/domain/value-objects/point.value-object.ts similarity index 79% rename from src/modules/geography/core/domain/value-objects/coordinates.value-object.ts rename to src/modules/geography/core/domain/value-objects/point.value-object.ts index 9bd81e7..48e6564 100644 --- a/src/modules/geography/core/domain/value-objects/coordinates.value-object.ts +++ b/src/modules/geography/core/domain/value-objects/point.value-object.ts @@ -8,12 +8,12 @@ import { * other Value Objects inside if needed. * */ -export interface CoordinatesProps { +export interface PointProps { lon: number; lat: number; } -export class Coordinates extends ValueObject { +export class Point extends ValueObject { get lon(): number { return this.props.lon; } @@ -22,7 +22,7 @@ export class Coordinates extends ValueObject { return this.props.lat; } - protected validate(props: CoordinatesProps): void { + validate(props: PointProps): void { if (props.lon > 180 || props.lon < -180) throw new ArgumentOutOfRangeException('lon must be between -180 and 180'); if (props.lat > 90 || props.lat < -90) diff --git a/src/modules/geography/core/domain/value-objects/spacetime-point.value-object.ts b/src/modules/geography/core/domain/value-objects/spacetime-point.value-object.ts deleted file mode 100644 index c7bfbce..0000000 --- a/src/modules/geography/core/domain/value-objects/spacetime-point.value-object.ts +++ /dev/null @@ -1,50 +0,0 @@ -import { - ArgumentInvalidException, - ArgumentOutOfRangeException, - ValueObject, -} from '@mobicoop/ddd-library'; - -/** Note: - * Value Objects with multiple properties can contain - * other Value Objects inside if needed. - * */ - -export interface SpacetimePointProps { - lon: number; - lat: number; - duration: number; - distance: number; -} - -export class SpacetimePoint extends ValueObject { - get lon(): number { - return this.props.lon; - } - - get lat(): number { - return this.props.lat; - } - - get duration(): number { - return this.props.duration; - } - - get distance(): number { - return this.props.distance; - } - - protected validate(props: SpacetimePointProps): void { - if (props.duration < 0) - throw new ArgumentInvalidException( - 'duration must be greater than or equal to 0', - ); - if (props.distance < 0) - throw new ArgumentInvalidException( - 'distance must be greater than or equal to 0', - ); - if (props.lon > 180 || props.lon < -180) - throw new ArgumentOutOfRangeException('lon must be between -180 and 180'); - if (props.lat > 90 || props.lat < -90) - throw new ArgumentOutOfRangeException('lat must be between -90 and 90'); - } -} diff --git a/src/modules/geography/core/domain/value-objects/step.value-object.ts b/src/modules/geography/core/domain/value-objects/step.value-object.ts new file mode 100644 index 0000000..fbcc410 --- /dev/null +++ b/src/modules/geography/core/domain/value-objects/step.value-object.ts @@ -0,0 +1,46 @@ +import { ArgumentInvalidException, ValueObject } from '@mobicoop/ddd-library'; +import { Point, PointProps } from './point.value-object'; + +/** Note: + * Value Objects with multiple properties can contain + * other Value Objects inside if needed. + * */ + +export interface StepProps extends PointProps { + duration: number; + distance?: number; +} + +export class Step extends ValueObject { + get duration(): number { + return this.props.duration; + } + + get distance(): number | undefined { + return this.props.distance; + } + + get lon(): number { + return this.props.lon; + } + + get lat(): number { + return this.props.lat; + } + + protected validate(props: StepProps): void { + // validate point props + new Point({ + lon: props.lon, + lat: props.lat, + }); + if (props.duration < 0) + throw new ArgumentInvalidException( + 'duration must be greater than or equal to 0', + ); + if (props.distance !== undefined && props.distance < 0) + throw new ArgumentInvalidException( + 'distance must be greater than or equal to 0', + ); + } +} diff --git a/src/modules/geography/core/domain/value-objects/waypoint.value-object.ts b/src/modules/geography/core/domain/value-objects/waypoint.value-object.ts deleted file mode 100644 index 353f51d..0000000 --- a/src/modules/geography/core/domain/value-objects/waypoint.value-object.ts +++ /dev/null @@ -1,41 +0,0 @@ -import { - ArgumentInvalidException, - ArgumentOutOfRangeException, - ValueObject, -} from '@mobicoop/ddd-library'; - -/** Note: - * Value Objects with multiple properties can contain - * other Value Objects inside if needed. - * */ - -export interface WaypointProps { - position: number; - lon: number; - lat: number; -} - -export class Waypoint extends ValueObject { - get position(): number { - return this.props.position; - } - - get lon(): number { - return this.props.lon; - } - - get lat(): number { - return this.props.lat; - } - - protected validate(props: WaypointProps): void { - if (props.position < 0) - throw new ArgumentInvalidException( - 'position must be greater than or equal to 0', - ); - if (props.lon > 180 || props.lon < -180) - throw new ArgumentOutOfRangeException('lon must be between -180 and 180'); - if (props.lat > 90 || props.lat < -90) - throw new ArgumentOutOfRangeException('lat must be between -90 and 90'); - } -} diff --git a/src/modules/geography/geography.module.ts b/src/modules/geography/geography.module.ts index 6c04e79..77a6d58 100644 --- a/src/modules/geography/geography.module.ts +++ b/src/modules/geography/geography.module.ts @@ -14,6 +14,7 @@ import { Geodesic } from './infrastructure/geodesic'; import { GraphhopperGeorouter } from './infrastructure/graphhopper-georouter'; import { HttpModule } from '@nestjs/axios'; import { GetRouteQueryHandler } from './core/application/queries/get-route/get-route.query-handler'; +import { GetDetailedRouteController } from './interface/controllers/get-detailed-route.controller'; const queryHandlers: Provider[] = [GetRouteQueryHandler]; @@ -37,11 +38,17 @@ const adapters: Provider[] = [ useClass: Geodesic, }, GetBasicRouteController, + GetDetailedRouteController, ]; @Module({ imports: [CqrsModule, HttpModule], providers: [...queryHandlers, ...mappers, ...adapters], - exports: [RouteMapper, DIRECTION_ENCODER, GetBasicRouteController], + exports: [ + RouteMapper, + DIRECTION_ENCODER, + GetBasicRouteController, + GetDetailedRouteController, + ], }) export class GeographyModule {} diff --git a/src/modules/geography/infrastructure/geodesic.ts b/src/modules/geography/infrastructure/geodesic.ts index a0f1e76..5655585 100644 --- a/src/modules/geography/infrastructure/geodesic.ts +++ b/src/modules/geography/infrastructure/geodesic.ts @@ -22,6 +22,38 @@ export class Geodesic implements GeodesicPort { lat2, lon2, ); + if (!azimuth || !distance) + throw new Error( + `Inverse not found for coordinates ${lon1} ${lat1} / ${lon2} ${lat2}`, + ); return { azimuth, distance }; }; + + azimuth = ( + lon1: number, + lat1: number, + lon2: number, + lat2: number, + ): number => { + const { azi2: azimuth } = this.geod.Inverse(lat1, lon1, lat2, lon2); + if (!azimuth) + throw new Error( + `Azimuth not found for coordinates ${lon1} ${lat1} / ${lon2} ${lat2}`, + ); + return azimuth; + }; + + distance = ( + lon1: number, + lat1: number, + lon2: number, + lat2: number, + ): number => { + const { s12: distance } = this.geod.Inverse(lat1, lon1, lat2, lon2); + if (!distance) + throw new Error( + `Distance not found for coordinates ${lon1} ${lat1} / ${lon2} ${lat2}`, + ); + return distance; + }; } diff --git a/src/modules/geography/infrastructure/graphhopper-georouter.ts b/src/modules/geography/infrastructure/graphhopper-georouter.ts index ce67939..635ac3f 100644 --- a/src/modules/geography/infrastructure/graphhopper-georouter.ts +++ b/src/modules/geography/infrastructure/graphhopper-georouter.ts @@ -2,12 +2,7 @@ import { Inject, Injectable } from '@nestjs/common'; import { HttpService } from '@nestjs/axios'; import { GeorouterPort } from '../core/application/ports/georouter.port'; import { GeorouterSettings } from '../core/application/types/georouter-settings.type'; -import { - Path, - PathType, - Route, - SpacetimePoint, -} from '../core/domain/route.types'; +import { Route, Step, Point } from '../core/domain/route.types'; import { DefaultParamsProviderPort } from '../core/application/ports/default-params-provider.port'; import { GEODESIC, PARAMS_PROVIDER } from '../geography.di-tokens'; import { catchError, lastValueFrom, map } from 'rxjs'; @@ -35,20 +30,20 @@ export class GraphhopperGeorouter implements GeorouterPort { ].join(''); } - routes = async ( - paths: Path[], + route = async ( + waypoints: Point[], settings: GeorouterSettings, - ): Promise => { - this.setDefaultUrlArgs(); - this.setSettings(settings); - return this.getRoutes(paths); + ): Promise => { + this._setDefaultUrlArgs(); + this._setSettings(settings); + return this._getRoute(waypoints); }; - private setDefaultUrlArgs = (): void => { + private _setDefaultUrlArgs = (): void => { this.urlArgs = ['vehicle=car', 'weighting=fastest', 'points_encoded=false']; }; - private setSettings = (settings: GeorouterSettings): void => { + private _setSettings = (settings: GeorouterSettings): void => { if (settings.detailedDuration) { this.urlArgs.push('details=time'); } @@ -62,46 +57,39 @@ export class GraphhopperGeorouter implements GeorouterPort { } }; - private getRoutes = async (paths: Path[]): Promise => { - const routes = Promise.all( - paths.map(async (path) => { - const url: string = [ - this.getUrl(), - '&point=', - path.points - .map((point) => [point.lat, point.lon].join('%2C')) - .join('&point='), - ].join(''); - const route = await lastValueFrom( - this.httpService.get(url).pipe( - map((res) => - res.data ? this.createRoute(res, path.type) : undefined, - ), - catchError((error: AxiosError) => { - if (error.code == AxiosError.ERR_BAD_REQUEST) { - throw new RouteNotFoundException( - error, - 'No route found for given coordinates', - ); - } - throw new GeorouterUnavailableException(error); - }), - ), - ); - return route; - }), + private _getRoute = async (waypoints: Point[]): Promise => { + const url: string = [ + this.getUrl(), + '&point=', + waypoints + .map((point: Point) => [point.lat, point.lon].join('%2C')) + .join('&point='), + ].join(''); + return await lastValueFrom( + this.httpService.get(url).pipe( + map((response) => { + if (response.data) return this.createRoute(response); + throw new Error(); + }), + catchError((error: AxiosError) => { + if (error.code == AxiosError.ERR_BAD_REQUEST) { + throw new RouteNotFoundException( + error, + 'No route found for given coordinates', + ); + } + throw new GeorouterUnavailableException(error); + }), + ), ); - return routes; }; private getUrl = (): string => [this.url, this.urlArgs.join('&')].join(''); private createRoute = ( response: AxiosResponse, - type: PathType, ): Route => { const route = {} as Route; - route.type = type; if (response.data.paths && response.data.paths[0]) { const shortestPath = response.data.paths[0]; route.distance = shortestPath.distance ?? 0; @@ -135,7 +123,7 @@ export class GraphhopperGeorouter implements GeorouterPort { let instructions: GraphhopperInstruction[] = []; if (shortestPath.instructions) instructions = shortestPath.instructions; - route.spacetimeWaypoints = this.generateSpacetimePoints( + route.steps = this.generateSteps( shortestPath.points.coordinates, shortestPath.snapped_waypoints.coordinates, shortestPath.details.time, @@ -147,33 +135,43 @@ export class GraphhopperGeorouter implements GeorouterPort { return route; }; - private generateSpacetimePoints = ( + private generateSteps = ( points: [[number, number]], snappedWaypoints: [[number, number]], durations: [[number, number, number]], instructions: GraphhopperInstruction[], - ): SpacetimePoint[] => { + ): Step[] => { const indices = this.getIndices(points, snappedWaypoints); const times = this.getTimes(durations, indices); const distances = this.getDistances(instructions, indices); - return indices.map((index) => ({ - lon: points[index][1], - lat: points[index][0], - distance: distances.find((distance) => distance.index == index)?.distance, - duration: times.find((time) => time.index == index)?.duration, - })); + return indices.map((index) => { + const duration = times.find((time) => time.index == index); + if (!duration) + throw new Error(`Duration not found for waypoint #${index}`); + const distance = distances.find((distance) => distance.index == index); + if (!distance && instructions.length > 0) + throw new Error(`Distance not found for waypoint #${index}`); + return { + lon: points[index][1], + lat: points[index][0], + distance: distance?.distance, + duration: duration.duration, + }; + }); }; private getIndices = ( points: [[number, number]], snappedWaypoints: [[number, number]], ): number[] => { - const indices = snappedWaypoints.map((waypoint) => - points.findIndex( - (point) => point[0] == waypoint[0] && point[1] == waypoint[1], - ), + const indices: number[] = snappedWaypoints.map( + (waypoint: [number, number]) => + points.findIndex( + (point) => point[0] == waypoint[0] && point[1] == waypoint[1], + ), ); - if (indices.find((index) => index == -1) === undefined) return indices; + if (indices.find((index: number) => index == -1) === undefined) + return indices; const missedWaypoints = indices .map( (value, index) => @@ -182,7 +180,7 @@ export class GraphhopperGeorouter implements GeorouterPort { index: number; originIndex: number; waypoint: number[]; - nearest: number; + nearest?: number; distance: number; } >{ @@ -196,20 +194,20 @@ export class GraphhopperGeorouter implements GeorouterPort { .filter((element) => element.index == -1); for (const index in points) { for (const missedWaypoint of missedWaypoints) { - const inverse = this.geodesic.inverse( + const distance = this.geodesic.distance( missedWaypoint.waypoint[0], missedWaypoint.waypoint[1], points[index][0], points[index][1], ); - if (inverse.distance < missedWaypoint.distance) { - missedWaypoint.distance = inverse.distance; + if (distance < missedWaypoint.distance) { + missedWaypoint.distance = distance; missedWaypoint.nearest = parseInt(index); } } } for (const missedWaypoint of missedWaypoints) { - indices[missedWaypoint.originIndex] = missedWaypoint.nearest; + indices[missedWaypoint.originIndex] = missedWaypoint.nearest as number; } return indices; }; diff --git a/src/modules/geography/infrastructure/postgres-direction-encoder.ts b/src/modules/geography/infrastructure/postgres-direction-encoder.ts index d6cb0b6..05cae0b 100644 --- a/src/modules/geography/infrastructure/postgres-direction-encoder.ts +++ b/src/modules/geography/infrastructure/postgres-direction-encoder.ts @@ -1,16 +1,16 @@ import { DirectionEncoderPort } from '../core/application/ports/direction-encoder.port'; import { Injectable } from '@nestjs/common'; -import { Coordinates } from '../core/domain/route.types'; +import { Point } from '../core/domain/route.types'; @Injectable() export class PostgresDirectionEncoder implements DirectionEncoderPort { - encode = (coordinates: Coordinates[]): string => + encode = (coordinates: Point[]): string => [ "'LINESTRING(", coordinates.map((point) => [point.lon, point.lat].join(' ')).join(), ")'", ].join(''); - decode = (direction: string): Coordinates[] => + decode = (direction: string): Point[] => direction .split('(')[1] .split(')')[0] diff --git a/src/modules/geography/interface/controllers/dtos/get-route.request.dto.ts b/src/modules/geography/interface/controllers/dtos/get-route.request.dto.ts index 7790bba..651df23 100644 --- a/src/modules/geography/interface/controllers/dtos/get-route.request.dto.ts +++ b/src/modules/geography/interface/controllers/dtos/get-route.request.dto.ts @@ -1,6 +1,5 @@ -import { Role, Waypoint } from '@modules/geography/core/domain/route.types'; +import { Point } from '@modules/geography/core/domain/route.types'; export type GetRouteRequestDto = { - roles: Role[]; - waypoints: Waypoint[]; + waypoints: Point[]; }; diff --git a/src/modules/geography/interface/controllers/get-basic-route.controller.ts b/src/modules/geography/interface/controllers/get-basic-route.controller.ts index 3bdc42e..b28b88e 100644 --- a/src/modules/geography/interface/controllers/get-basic-route.controller.ts +++ b/src/modules/geography/interface/controllers/get-basic-route.controller.ts @@ -5,10 +5,10 @@ import { RouteEntity } from '@modules/geography/core/domain/route.entity'; import { GetRouteQuery } from '@modules/geography/core/application/queries/get-route/get-route.query'; import { RouteMapper } from '@modules/geography/route.mapper'; import { Controller } from '@nestjs/common'; -import { GetBasicRouteControllerPort } from '@modules/geography/core/application/ports/get-basic-route-controller.port'; +import { GetRouteControllerPort } from '@modules/geography/core/application/ports/get-route-controller.port'; @Controller() -export class GetBasicRouteController implements GetBasicRouteControllerPort { +export class GetBasicRouteController implements GetRouteControllerPort { constructor( private readonly queryBus: QueryBus, private readonly mapper: RouteMapper, @@ -16,11 +16,7 @@ export class GetBasicRouteController implements GetBasicRouteControllerPort { async get(data: GetRouteRequestDto): Promise { const route: RouteEntity = await this.queryBus.execute( - new GetRouteQuery(data.roles, data.waypoints, { - detailedDistance: false, - detailedDuration: false, - points: true, - }), + new GetRouteQuery(data.waypoints), ); return this.mapper.toResponse(route); } diff --git a/src/modules/geography/interface/controllers/get-detailed-route.controller.ts b/src/modules/geography/interface/controllers/get-detailed-route.controller.ts new file mode 100644 index 0000000..34cf693 --- /dev/null +++ b/src/modules/geography/interface/controllers/get-detailed-route.controller.ts @@ -0,0 +1,27 @@ +import { QueryBus } from '@nestjs/cqrs'; +import { RouteResponseDto } from '../dtos/route.response.dto'; +import { GetRouteRequestDto } from './dtos/get-route.request.dto'; +import { RouteEntity } from '@modules/geography/core/domain/route.entity'; +import { GetRouteQuery } from '@modules/geography/core/application/queries/get-route/get-route.query'; +import { RouteMapper } from '@modules/geography/route.mapper'; +import { Controller } from '@nestjs/common'; +import { GetRouteControllerPort } from '@modules/geography/core/application/ports/get-route-controller.port'; + +@Controller() +export class GetDetailedRouteController implements GetRouteControllerPort { + constructor( + private readonly queryBus: QueryBus, + private readonly mapper: RouteMapper, + ) {} + + async get(data: GetRouteRequestDto): Promise { + const route: RouteEntity = await this.queryBus.execute( + new GetRouteQuery(data.waypoints, { + detailedDistance: true, + detailedDuration: true, + points: true, + }), + ); + return this.mapper.toResponse(route); + } +} diff --git a/src/modules/geography/interface/dtos/route.response.dto.ts b/src/modules/geography/interface/dtos/route.response.dto.ts index 714fb68..21d2ec1 100644 --- a/src/modules/geography/interface/dtos/route.response.dto.ts +++ b/src/modules/geography/interface/dtos/route.response.dto.ts @@ -1,15 +1,11 @@ -import { - Coordinates, - SpacetimePoint, -} from '@modules/geography/core/domain/route.types'; +import { Point, Step } from '@modules/geography/core/domain/route.types'; export class RouteResponseDto { - driverDistance?: number; - driverDuration?: number; - passengerDistance?: number; - passengerDuration?: number; + distance: number; + duration: number; fwdAzimuth: number; backAzimuth: number; distanceAzimuth: number; - points: SpacetimePoint[] | Coordinates[]; + points: Point[]; + steps?: Step[]; } diff --git a/src/modules/geography/route.mapper.ts b/src/modules/geography/route.mapper.ts index 4f5d63c..43728f9 100644 --- a/src/modules/geography/route.mapper.ts +++ b/src/modules/geography/route.mapper.ts @@ -14,30 +14,15 @@ import { RouteResponseDto } from './interface/dtos/route.response.dto'; export class RouteMapper implements Mapper { - toPersistence = (): undefined => { - return undefined; - }; - - // eslint-disable-next-line @typescript-eslint/no-unused-vars - toDomain = (): undefined => { - return undefined; - }; - - // eslint-disable-next-line @typescript-eslint/no-unused-vars toResponse = (entity: RouteEntity): RouteResponseDto => { const response = new RouteResponseDto(); - response.driverDistance = Math.round(entity.getProps().driverDistance); - response.driverDuration = Math.round(entity.getProps().driverDuration); - response.passengerDistance = Math.round( - entity.getProps().passengerDistance, - ); - response.passengerDuration = Math.round( - entity.getProps().passengerDuration, - ); + response.distance = Math.round(entity.getProps().distance); + response.duration = Math.round(entity.getProps().duration); response.fwdAzimuth = Math.round(entity.getProps().fwdAzimuth); response.backAzimuth = Math.round(entity.getProps().backAzimuth); response.distanceAzimuth = Math.round(entity.getProps().distanceAzimuth); response.points = entity.getProps().points; + response.steps = entity.getProps().steps; return response; }; } diff --git a/src/modules/geography/tests/unit/core/coordinates.value-object.spec.ts b/src/modules/geography/tests/unit/core/coordinates.value-object.spec.ts deleted file mode 100644 index 4c7b1f7..0000000 --- a/src/modules/geography/tests/unit/core/coordinates.value-object.spec.ts +++ /dev/null @@ -1,49 +0,0 @@ -import { ArgumentOutOfRangeException } from '@mobicoop/ddd-library'; -import { Coordinates } from '@modules/geography/core/domain/value-objects/coordinates.value-object'; - -describe('Waypoint value object', () => { - it('should create a waypoint value object', () => { - const coordinatesVO = new Coordinates({ - lat: 48.689445, - lon: 6.17651, - }); - expect(coordinatesVO.lat).toBe(48.689445); - expect(coordinatesVO.lon).toBe(6.17651); - }); - it('should throw an exception if longitude is invalid', () => { - try { - new Coordinates({ - lat: 48.689445, - lon: 186.17651, - }); - } catch (e: any) { - expect(e).toBeInstanceOf(ArgumentOutOfRangeException); - } - try { - new Coordinates({ - lat: 48.689445, - lon: -186.17651, - }); - } catch (e: any) { - expect(e).toBeInstanceOf(ArgumentOutOfRangeException); - } - }); - it('should throw an exception if latitude is invalid', () => { - try { - new Coordinates({ - lat: 148.689445, - lon: 6.17651, - }); - } catch (e: any) { - expect(e).toBeInstanceOf(ArgumentOutOfRangeException); - } - try { - new Coordinates({ - lat: -148.689445, - lon: 6.17651, - }); - } catch (e: any) { - expect(e).toBeInstanceOf(ArgumentOutOfRangeException); - } - }); -}); diff --git a/src/modules/geography/tests/unit/core/get-route.query-handler.spec.ts b/src/modules/geography/tests/unit/core/get-route.query-handler.spec.ts index 948ed0e..ca8a367 100644 --- a/src/modules/geography/tests/unit/core/get-route.query-handler.spec.ts +++ b/src/modules/geography/tests/unit/core/get-route.query-handler.spec.ts @@ -2,23 +2,21 @@ import { GeorouterPort } from '@modules/geography/core/application/ports/georout import { GetRouteQuery } from '@modules/geography/core/application/queries/get-route/get-route.query'; import { GetRouteQueryHandler } from '@modules/geography/core/application/queries/get-route/get-route.query-handler'; import { RouteEntity } from '@modules/geography/core/domain/route.entity'; -import { Role, Waypoint } from '@modules/geography/core/domain/route.types'; +import { Point } from '@modules/geography/core/domain/route.types'; import { GEOROUTER } from '@modules/geography/geography.di-tokens'; import { Test, TestingModule } from '@nestjs/testing'; -const originWaypoint: Waypoint = { - position: 0, +const originWaypoint: Point = { lat: 48.689445, lon: 6.17651, }; -const destinationWaypoint: Waypoint = { - position: 1, +const destinationWaypoint: Point = { lat: 48.8566, lon: 2.3522, }; const mockGeorouter: GeorouterPort = { - routes: jest.fn(), + route: jest.fn(), }; describe('Get route query handler', () => { @@ -44,9 +42,8 @@ describe('Get route query handler', () => { }); describe('execution', () => { - it('should get a route for a driver only', async () => { + it('should get a route', async () => { const getRoutequery = new GetRouteQuery( - [Role.DRIVER], [originWaypoint, destinationWaypoint], { detailedDistance: false, diff --git a/src/modules/geography/tests/unit/core/point.value-object.spec.ts b/src/modules/geography/tests/unit/core/point.value-object.spec.ts new file mode 100644 index 0000000..bf11cd7 --- /dev/null +++ b/src/modules/geography/tests/unit/core/point.value-object.spec.ts @@ -0,0 +1,41 @@ +import { ArgumentOutOfRangeException } from '@mobicoop/ddd-library'; +import { Point } from '@modules/geography/core/domain/value-objects/point.value-object'; + +describe('Point value object', () => { + it('should create a point value object', () => { + const pointVO = new Point({ + lat: 48.689445, + lon: 6.17651, + }); + expect(pointVO.lat).toBe(48.689445); + expect(pointVO.lon).toBe(6.17651); + }); + it('should throw an exception if longitude is invalid', () => { + expect(() => { + new Point({ + lat: 48.689445, + lon: 186.17651, + }); + }).toThrow(ArgumentOutOfRangeException); + expect(() => { + new Point({ + lat: 48.689445, + lon: -186.17651, + }); + }).toThrow(ArgumentOutOfRangeException); + }); + it('should throw an exception if latitude is invalid', () => { + expect(() => { + new Point({ + lat: 148.689445, + lon: 6.17651, + }); + }).toThrow(ArgumentOutOfRangeException); + expect(() => { + new Point({ + lat: -148.689445, + lon: 6.17651, + }); + }).toThrow(ArgumentOutOfRangeException); + }); +}); diff --git a/src/modules/geography/tests/unit/core/route.entity.spec.ts b/src/modules/geography/tests/unit/core/route.entity.spec.ts index 7cf810a..93226d5 100644 --- a/src/modules/geography/tests/unit/core/route.entity.spec.ts +++ b/src/modules/geography/tests/unit/core/route.entity.spec.ts @@ -2,207 +2,49 @@ import { GeorouterPort } from '@modules/geography/core/application/ports/georout import { RouteEntity } from '@modules/geography/core/domain/route.entity'; import { RouteNotFoundException } from '@modules/geography/core/domain/route.errors'; import { - Coordinates, + Point, CreateRouteProps, - PathType, - Role, } from '@modules/geography/core/domain/route.types'; -const originCoordinates: Coordinates = { +const originPoint: Point = { lat: 48.689445, lon: 6.17651, }; -const destinationCoordinates: Coordinates = { +const destinationPoint: Point = { lat: 48.8566, lon: 2.3522, }; -const additionalCoordinates: Coordinates = { - lon: 48.7566, - lat: 4.4498, -}; const mockGeorouter: GeorouterPort = { - routes: jest + route: jest .fn() - .mockImplementationOnce(() => [ - { - type: PathType.DRIVER, - distance: 350101, - duration: 14422, - fwdAzimuth: 273, - backAzimuth: 93, - distanceAzimuth: 336544, - points: [ - { - lon: 6.1765102, - lat: 48.689445, - }, - { - lon: 4.984578, - lat: 48.725687, - }, - { - lon: 2.3522, - lat: 48.8566, - }, - ], - spacetimePoints: [], - }, - ]) - .mockImplementationOnce(() => [ - { - type: PathType.PASSENGER, - distance: 350102, - duration: 14423, - fwdAzimuth: 273, - backAzimuth: 93, - distanceAzimuth: 336545, - points: [ - { - lon: 6.1765103, - lat: 48.689446, - }, - { - lon: 4.984579, - lat: 48.725688, - }, - { - lon: 2.3523, - lat: 48.8567, - }, - ], - spacetimePoints: [], - }, - ]) - .mockImplementationOnce(() => [ - { - type: PathType.GENERIC, - distance: 350100, - duration: 14421, - fwdAzimuth: 273, - backAzimuth: 93, - distanceAzimuth: 336543, - points: [ - { - lon: 6.1765101, - lat: 48.689444, - }, - { - lon: 4.984577, - lat: 48.725686, - }, - { - lon: 2.3521, - lat: 48.8565, - }, - ], - spacetimePoints: [], - }, - ]) - .mockImplementationOnce(() => [ - { - type: PathType.GENERIC, - distance: 350108, - duration: 14428, - fwdAzimuth: 273, - backAzimuth: 93, - distanceAzimuth: 336548, - points: [ - { - lon: 6.1765101, - lat: 48.689444, - }, - { - lon: 4.984577, - lat: 48.725686, - }, - { - lon: 2.3521, - lat: 48.8565, - }, - ], - spacetimePoints: [], - }, - ]) + .mockImplementationOnce(() => ({ + distance: 350101, + duration: 14422, + fwdAzimuth: 273, + backAzimuth: 93, + distanceAzimuth: 336544, + points: [ + { + lon: 6.1765102, + lat: 48.689445, + }, + { + lon: 4.984578, + lat: 48.725687, + }, + { + lon: 2.3522, + lat: 48.8566, + }, + ], + steps: [], + })) .mockImplementationOnce(() => []), }; -const createDriverRouteProps: CreateRouteProps = { - roles: [Role.DRIVER], - waypoints: [ - { - position: 0, - ...originCoordinates, - }, - { - position: 1, - ...destinationCoordinates, - }, - ], - georouter: mockGeorouter, - georouterSettings: { - points: true, - detailedDistance: false, - detailedDuration: false, - }, -}; - -const createPassengerRouteProps: CreateRouteProps = { - roles: [Role.PASSENGER], - waypoints: [ - { - position: 0, - ...originCoordinates, - }, - { - position: 1, - ...destinationCoordinates, - }, - ], - georouter: mockGeorouter, - georouterSettings: { - points: true, - detailedDistance: false, - detailedDuration: false, - }, -}; - -const createSimpleDriverAndPassengerRouteProps: CreateRouteProps = { - roles: [Role.DRIVER, Role.PASSENGER], - waypoints: [ - { - position: 0, - ...originCoordinates, - }, - { - position: 1, - ...destinationCoordinates, - }, - ], - georouter: mockGeorouter, - georouterSettings: { - points: true, - detailedDistance: false, - detailedDuration: false, - }, -}; - -const createComplexDriverAndPassengerRouteProps: CreateRouteProps = { - roles: [Role.DRIVER, Role.PASSENGER], - waypoints: [ - { - position: 0, - ...originCoordinates, - }, - { - position: 1, - ...additionalCoordinates, - }, - { - position: 2, - ...destinationCoordinates, - }, - ], +const createRouteProps: CreateRouteProps = { + waypoints: [originPoint, destinationPoint], georouter: mockGeorouter, georouterSettings: { points: true, @@ -212,43 +54,15 @@ const createComplexDriverAndPassengerRouteProps: CreateRouteProps = { }; describe('Route entity create', () => { - it('should create a new entity for a driver only', async () => { - const route: RouteEntity = await RouteEntity.create(createDriverRouteProps); + it('should create a new entity', async () => { + const route: RouteEntity = await RouteEntity.create(createRouteProps); expect(route.id.length).toBe(36); - expect(route.getProps().driverDuration).toBe(14422); - expect(route.getProps().passengerDistance).toBeUndefined(); - }); - it('should create a new entity for a passenger only', async () => { - const route: RouteEntity = await RouteEntity.create( - createPassengerRouteProps, - ); - expect(route.id.length).toBe(36); - expect(route.getProps().passengerDuration).toBe(14423); - expect(route.getProps().driverDistance).toBeUndefined(); - }); - it('should create a new entity for a simple driver and passenger route', async () => { - const route: RouteEntity = await RouteEntity.create( - createSimpleDriverAndPassengerRouteProps, - ); - expect(route.id.length).toBe(36); - expect(route.getProps().driverDuration).toBe(14421); - expect(route.getProps().driverDistance).toBe(350100); - expect(route.getProps().passengerDuration).toBe(14421); - expect(route.getProps().passengerDistance).toBe(350100); - }); - it('should create a new entity for a complex driver and passenger route', async () => { - const route: RouteEntity = await RouteEntity.create( - createComplexDriverAndPassengerRouteProps, - ); - expect(route.id.length).toBe(36); - expect(route.getProps().driverDuration).toBe(14428); - expect(route.getProps().driverDistance).toBe(350108); - expect(route.getProps().passengerDuration).toBe(14428); - expect(route.getProps().passengerDistance).toBe(350108); + expect(route.getProps().duration).toBe(14422); }); + it('should throw an exception if route is not found', async () => { try { - await RouteEntity.create(createDriverRouteProps); + await RouteEntity.create(createRouteProps); } catch (e: any) { expect(e).toBeInstanceOf(RouteNotFoundException); } diff --git a/src/modules/geography/tests/unit/core/spacetime-point.value-object.spec.ts b/src/modules/geography/tests/unit/core/spacetime-point.value-object.spec.ts deleted file mode 100644 index 405190c..0000000 --- a/src/modules/geography/tests/unit/core/spacetime-point.value-object.spec.ts +++ /dev/null @@ -1,88 +0,0 @@ -import { - ArgumentInvalidException, - ArgumentOutOfRangeException, -} from '@mobicoop/ddd-library'; -import { SpacetimePoint } from '@modules/geography/core/domain/value-objects/spacetime-point.value-object'; - -describe('Timepoint value object', () => { - it('should create a timepoint value object', () => { - const timepointVO = new SpacetimePoint({ - lat: 48.689445, - lon: 6.17651, - duration: 150, - distance: 12000, - }); - expect(timepointVO.duration).toBe(150); - expect(timepointVO.distance).toBe(12000); - expect(timepointVO.lat).toBe(48.689445); - expect(timepointVO.lon).toBe(6.17651); - }); - it('should throw an exception if longitude is invalid', () => { - try { - new SpacetimePoint({ - lat: 48.689445, - lon: 186.17651, - duration: 150, - distance: 12000, - }); - } catch (e: any) { - expect(e).toBeInstanceOf(ArgumentOutOfRangeException); - } - try { - new SpacetimePoint({ - lon: 48.689445, - lat: -186.17651, - duration: 150, - distance: 12000, - }); - } catch (e: any) { - expect(e).toBeInstanceOf(ArgumentOutOfRangeException); - } - }); - it('should throw an exception if latitude is invalid', () => { - try { - new SpacetimePoint({ - lat: 248.689445, - lon: 6.17651, - duration: 150, - distance: 12000, - }); - } catch (e: any) { - expect(e).toBeInstanceOf(ArgumentOutOfRangeException); - } - try { - new SpacetimePoint({ - lon: -148.689445, - lat: 6.17651, - duration: 150, - distance: 12000, - }); - } catch (e: any) { - expect(e).toBeInstanceOf(ArgumentOutOfRangeException); - } - }); - it('should throw an exception if distance is invalid', () => { - try { - new SpacetimePoint({ - lat: 48.689445, - lon: 6.17651, - duration: 150, - distance: -12000, - }); - } catch (e: any) { - expect(e).toBeInstanceOf(ArgumentInvalidException); - } - }); - it('should throw an exception if duration is invalid', () => { - try { - new SpacetimePoint({ - lat: 48.689445, - lon: 6.17651, - duration: -150, - distance: 12000, - }); - } catch (e: any) { - expect(e).toBeInstanceOf(ArgumentInvalidException); - } - }); -}); diff --git a/src/modules/geography/tests/unit/core/step.value-object.spec.ts b/src/modules/geography/tests/unit/core/step.value-object.spec.ts new file mode 100644 index 0000000..ed84ad8 --- /dev/null +++ b/src/modules/geography/tests/unit/core/step.value-object.spec.ts @@ -0,0 +1,76 @@ +import { + ArgumentInvalidException, + ArgumentOutOfRangeException, +} from '@mobicoop/ddd-library'; +import { Step } from '@modules/geography/core/domain/value-objects/step.value-object'; + +describe('Step value object', () => { + it('should create a step value object', () => { + const stepVO = new Step({ + lat: 48.689445, + lon: 6.17651, + duration: 150, + distance: 12000, + }); + expect(stepVO.duration).toBe(150); + expect(stepVO.distance).toBe(12000); + expect(stepVO.lat).toBe(48.689445); + expect(stepVO.lon).toBe(6.17651); + }); + it('should throw an exception if longitude is invalid', () => { + expect(() => { + new Step({ + lat: 48.689445, + lon: 186.17651, + duration: 150, + distance: 12000, + }); + }).toThrow(ArgumentOutOfRangeException); + expect(() => { + new Step({ + lat: 48.689445, + lon: -186.17651, + duration: 150, + distance: 12000, + }); + }).toThrow(ArgumentOutOfRangeException); + }); + it('should throw an exception if latitude is invalid', () => { + expect(() => { + new Step({ + lat: 248.689445, + lon: 6.17651, + duration: 150, + distance: 12000, + }); + }).toThrow(ArgumentOutOfRangeException); + expect(() => { + new Step({ + lat: -148.689445, + lon: 6.17651, + duration: 150, + distance: 12000, + }); + }).toThrow(ArgumentOutOfRangeException); + }); + it('should throw an exception if distance is invalid', () => { + expect(() => { + new Step({ + lat: 48.689445, + lon: 6.17651, + duration: 150, + distance: -12000, + }); + }).toThrow(ArgumentInvalidException); + }); + it('should throw an exception if duration is invalid', () => { + expect(() => { + new Step({ + lat: 48.689445, + lon: 6.17651, + duration: -150, + distance: 12000, + }); + }).toThrow(ArgumentInvalidException); + }); +}); diff --git a/src/modules/geography/tests/unit/core/waypoint.value-object.spec.ts b/src/modules/geography/tests/unit/core/waypoint.value-object.spec.ts deleted file mode 100644 index 3723338..0000000 --- a/src/modules/geography/tests/unit/core/waypoint.value-object.spec.ts +++ /dev/null @@ -1,69 +0,0 @@ -import { - ArgumentInvalidException, - ArgumentOutOfRangeException, -} from '@mobicoop/ddd-library'; -import { Waypoint } from '@modules/geography/core/domain/value-objects/waypoint.value-object'; - -describe('Waypoint value object', () => { - it('should create a waypoint value object', () => { - const waypointVO = new Waypoint({ - position: 0, - lat: 48.689445, - lon: 6.17651, - }); - expect(waypointVO.position).toBe(0); - expect(waypointVO.lat).toBe(48.689445); - expect(waypointVO.lon).toBe(6.17651); - }); - it('should throw an exception if position is invalid', () => { - try { - new Waypoint({ - position: -1, - lat: 48.689445, - lon: 6.17651, - }); - } catch (e: any) { - expect(e).toBeInstanceOf(ArgumentInvalidException); - } - }); - it('should throw an exception if longitude is invalid', () => { - try { - new Waypoint({ - position: 0, - lat: 48.689445, - lon: 186.17651, - }); - } catch (e: any) { - expect(e).toBeInstanceOf(ArgumentOutOfRangeException); - } - try { - new Waypoint({ - position: 0, - lat: 48.689445, - lon: -186.17651, - }); - } catch (e: any) { - expect(e).toBeInstanceOf(ArgumentOutOfRangeException); - } - }); - it('should throw an exception if latitude is invalid', () => { - try { - new Waypoint({ - position: 0, - lat: 148.689445, - lon: 6.17651, - }); - } catch (e: any) { - expect(e).toBeInstanceOf(ArgumentOutOfRangeException); - } - try { - new Waypoint({ - position: 0, - lat: -148.689445, - lon: 6.17651, - }); - } catch (e: any) { - expect(e).toBeInstanceOf(ArgumentOutOfRangeException); - } - }); -}); diff --git a/src/modules/geography/tests/unit/infrastructure/geodesic.spec.ts b/src/modules/geography/tests/unit/infrastructure/geodesic.spec.ts index a71df2e..82f29f7 100644 --- a/src/modules/geography/tests/unit/infrastructure/geodesic.spec.ts +++ b/src/modules/geography/tests/unit/infrastructure/geodesic.spec.ts @@ -8,7 +8,29 @@ describe('Matcher geodesic', () => { it('should get inverse values', () => { const geodesic: Geodesic = new Geodesic(); const inv = geodesic.inverse(0, 0, 1, 1); - expect(Math.round(inv.azimuth)).toBe(45); - expect(Math.round(inv.distance)).toBe(156900); + expect(Math.round(inv.azimuth as number)).toBe(45); + expect(Math.round(inv.distance as number)).toBe(156900); + }); + it('should get azimuth value', () => { + const geodesic: Geodesic = new Geodesic(); + const azimuth = geodesic.azimuth(0, 0, 1, 1); + expect(Math.round(azimuth as number)).toBe(45); + }); + it('should get distance value', () => { + const geodesic: Geodesic = new Geodesic(); + const distance = geodesic.distance(0, 0, 1, 1); + expect(Math.round(distance as number)).toBe(156900); + }); + it('should throw an exception if inverse fails', () => { + const geodesic: Geodesic = new Geodesic(); + expect(() => { + geodesic.inverse(7.74547, 48.583035, 7.74547, 48.583036); + }).toThrow(); + }); + it('should throw an exception if azimuth fails', () => { + const geodesic: Geodesic = new Geodesic(); + expect(() => { + geodesic.azimuth(7.74547, 48.583035, 7.74547, 48.583036); + }).toThrow(); }); }); diff --git a/src/modules/geography/tests/unit/infrastructure/graphhopper-georouter.spec.ts b/src/modules/geography/tests/unit/infrastructure/graphhopper-georouter.spec.ts index c167240..ce9dec2 100644 --- a/src/modules/geography/tests/unit/infrastructure/graphhopper-georouter.spec.ts +++ b/src/modules/geography/tests/unit/infrastructure/graphhopper-georouter.spec.ts @@ -4,7 +4,7 @@ import { GeorouterUnavailableException, RouteNotFoundException, } from '@modules/geography/core/domain/route.errors'; -import { PathType, Route } from '@modules/geography/core/domain/route.types'; +import { Route, Step } from '@modules/geography/core/domain/route.types'; import { GEODESIC, PARAMS_PROVIDER, @@ -26,6 +26,11 @@ const mockHttpService = { .mockImplementationOnce(() => { return throwError(() => 'Router unavailable'); }) + .mockImplementationOnce(() => { + return of({ + status: 200, + }); + }) .mockImplementationOnce(() => { return of({ status: 200, @@ -253,6 +258,8 @@ const mockGeodesic: GeodesicPort = { azimuth: 45, distance: 50000, })), + azimuth: jest.fn().mockImplementation(() => 45), + distance: jest.fn().mockImplementation(() => 50000), }; const mockDefaultParamsProvider: DefaultParamsProviderPort = { @@ -294,20 +301,15 @@ describe('Graphhopper Georouter', () => { it('should fail if route is not found', async () => { await expect( - graphhopperGeorouter.routes( + graphhopperGeorouter.route( [ { - type: PathType.DRIVER, - points: [ - { - lon: 0, - lat: 0, - }, - { - lon: 1, - lat: 1, - }, - ], + lon: 0, + lat: 0, + }, + { + lon: 1, + lat: 1, }, ], { @@ -321,20 +323,37 @@ describe('Graphhopper Georouter', () => { it('should fail if georouter is unavailable', async () => { await expect( - graphhopperGeorouter.routes( + graphhopperGeorouter.route( [ { - type: PathType.DRIVER, - points: [ - { - lon: 0, - lat: 0, - }, - { - lon: 1, - lat: 1, - }, - ], + lon: 0, + lat: 0, + }, + { + lon: 1, + lat: 1, + }, + ], + { + detailedDistance: false, + detailedDuration: false, + points: false, + }, + ), + ).rejects.toBeInstanceOf(GeorouterUnavailableException); + }); + + it('should fail if georouter response is corrupted', async () => { + await expect( + graphhopperGeorouter.route( + [ + { + lon: 0, + lat: 0, + }, + { + lon: 1, + lat: 1, }, ], { @@ -347,20 +366,15 @@ describe('Graphhopper Georouter', () => { }); it('should create a basic route', async () => { - const routes: Route[] = await graphhopperGeorouter.routes( + const route: Route = await graphhopperGeorouter.route( [ { - type: PathType.DRIVER, - points: [ - { - lon: 0, - lat: 0, - }, - { - lon: 10, - lat: 10, - }, - ], + lon: 0, + lat: 0, + }, + { + lon: 10, + lat: 10, }, ], { @@ -369,25 +383,19 @@ describe('Graphhopper Georouter', () => { points: false, }, ); - expect(routes).toHaveLength(1); - expect(routes[0].distance).toBe(50000); + expect(route.distance).toBe(50000); }); - it('should create one route with points', async () => { - const routes = await graphhopperGeorouter.routes( + it('should create a route with points', async () => { + const route: Route = await graphhopperGeorouter.route( [ { - type: PathType.DRIVER, - points: [ - { - lat: 0, - lon: 0, - }, - { - lat: 10, - lon: 10, - }, - ], + lon: 0, + lat: 0, + }, + { + lon: 10, + lat: 10, }, ], { @@ -396,29 +404,23 @@ describe('Graphhopper Georouter', () => { points: true, }, ); - expect(routes).toHaveLength(1); - expect(routes[0].distance).toBe(50000); - expect(routes[0].duration).toBe(1800); - expect(routes[0].fwdAzimuth).toBe(45); - expect(routes[0].backAzimuth).toBe(225); - expect(routes[0].points).toHaveLength(11); + expect(route.distance).toBe(50000); + expect(route.duration).toBe(1800); + expect(route.fwdAzimuth).toBe(45); + expect(route.backAzimuth).toBe(225); + expect(route.points).toHaveLength(11); }); - it('should create one route with points and time', async () => { - const routes = await graphhopperGeorouter.routes( + it('should create a route with points and time', async () => { + const route: Route = await graphhopperGeorouter.route( [ { - type: PathType.DRIVER, - points: [ - { - lat: 0, - lon: 0, - }, - { - lat: 10, - lon: 10, - }, - ], + lon: 0, + lat: 0, + }, + { + lon: 10, + lat: 10, }, ], { @@ -427,31 +429,25 @@ describe('Graphhopper Georouter', () => { points: true, }, ); - expect(routes).toHaveLength(1); - expect(routes[0].spacetimeWaypoints).toHaveLength(2); - expect(routes[0].spacetimeWaypoints[1].duration).toBe(1800); - expect(routes[0].spacetimeWaypoints[1].distance).toBeUndefined(); + expect(route.steps).toHaveLength(2); + expect((route.steps as Step[])[1].duration).toBe(1800); + expect((route.steps as Step[])[1].distance).toBeUndefined(); }); it('should create one route with points and missed waypoints extrapolations', async () => { - const routes = await graphhopperGeorouter.routes( + const route: Route = await graphhopperGeorouter.route( [ { - type: PathType.DRIVER, - points: [ - { - lat: 0, - lon: 0, - }, - { - lat: 5, - lon: 5, - }, - { - lat: 10, - lon: 10, - }, - ], + lon: 0, + lat: 0, + }, + { + lon: 5, + lat: 5, + }, + { + lon: 10, + lat: 10, }, ], { @@ -460,30 +456,24 @@ describe('Graphhopper Georouter', () => { points: true, }, ); - expect(routes).toHaveLength(1); - expect(routes[0].spacetimeWaypoints).toHaveLength(3); - expect(routes[0].distance).toBe(50000); - expect(routes[0].duration).toBe(1800); - expect(routes[0].fwdAzimuth).toBe(45); - expect(routes[0].backAzimuth).toBe(225); - expect(routes[0].points.length).toBe(9); + expect(route.steps).toHaveLength(3); + expect(route.distance).toBe(50000); + expect(route.duration).toBe(1800); + expect(route.fwdAzimuth).toBe(45); + expect(route.backAzimuth).toBe(225); + expect(route.points.length).toBe(9); }); - it('should create one route with points, time and distance', async () => { - const routes = await graphhopperGeorouter.routes( + it('should create a route with points, time and distance', async () => { + const route: Route = await graphhopperGeorouter.route( [ { - type: PathType.DRIVER, - points: [ - { - lat: 0, - lon: 0, - }, - { - lat: 10, - lon: 10, - }, - ], + lon: 0, + lat: 0, + }, + { + lon: 10, + lat: 10, }, ], { @@ -492,9 +482,8 @@ describe('Graphhopper Georouter', () => { points: true, }, ); - expect(routes).toHaveLength(1); - expect(routes[0].spacetimeWaypoints.length).toBe(3); - expect(routes[0].spacetimeWaypoints[1].duration).toBe(990); - expect(routes[0].spacetimeWaypoints[1].distance).toBe(25000); + expect(route.steps).toHaveLength(3); + expect((route.steps as Step[])[1].duration).toBe(990); + expect((route.steps as Step[])[1].distance).toBe(25000); }); }); diff --git a/src/modules/geography/tests/unit/infrastructure/postgres-direction-encoder.spec.ts b/src/modules/geography/tests/unit/infrastructure/postgres-direction-encoder.spec.ts index fd4cbab..8a2527c 100644 --- a/src/modules/geography/tests/unit/infrastructure/postgres-direction-encoder.spec.ts +++ b/src/modules/geography/tests/unit/infrastructure/postgres-direction-encoder.spec.ts @@ -1,4 +1,4 @@ -import { Coordinates } from '@modules/geography/core/domain/route.types'; +import { Point } from '@modules/geography/core/domain/route.types'; import { PostgresDirectionEncoder } from '@modules/geography/infrastructure/postgres-direction-encoder'; describe('Postgres direction encoder', () => { @@ -7,10 +7,10 @@ describe('Postgres direction encoder', () => { new PostgresDirectionEncoder(); expect(postgresDirectionEncoder).toBeDefined(); }); - it('should encode coordinates to a postgres direction', () => { + it('should encode points to a postgres direction', () => { const postgresDirectionEncoder: PostgresDirectionEncoder = new PostgresDirectionEncoder(); - const coordinates: Coordinates[] = [ + const points: Point[] = [ { lon: 6, lat: 47, @@ -24,18 +24,17 @@ describe('Postgres direction encoder', () => { lat: 47.2, }, ]; - const direction = postgresDirectionEncoder.encode(coordinates); + const direction = postgresDirectionEncoder.encode(points); expect(direction).toBe("'LINESTRING(6 47,6.1 47.1,6.2 47.2)'"); }); it('should decode a postgres direction to coordinates', () => { const postgresDirectionEncoder: PostgresDirectionEncoder = new PostgresDirectionEncoder(); const direction = "'LINESTRING(6 47,6.1 47.1,6.2 47.2)'"; - const coordinates: Coordinates[] = - postgresDirectionEncoder.decode(direction); - expect(coordinates.length).toBe(3); - expect(coordinates[0].lat).toBe(47); - expect(coordinates[1].lon).toBe(6.1); - expect(coordinates[2].lat).toBe(47.2); + const points: Point[] = postgresDirectionEncoder.decode(direction); + expect(points.length).toBe(3); + expect(points[0].lat).toBe(47); + expect(points[1].lon).toBe(6.1); + expect(points[2].lat).toBe(47.2); }); }); diff --git a/src/modules/geography/tests/unit/interface/get-basic-route.controller.spec.ts b/src/modules/geography/tests/unit/interface/get-basic-route.controller.spec.ts index 7e2de20..96484fc 100644 --- a/src/modules/geography/tests/unit/interface/get-basic-route.controller.spec.ts +++ b/src/modules/geography/tests/unit/interface/get-basic-route.controller.spec.ts @@ -1,4 +1,3 @@ -import { Role } from '@modules/geography/core/domain/route.types'; import { GetBasicRouteController } from '@modules/geography/interface/controllers/get-basic-route.controller'; import { RouteMapper } from '@modules/geography/route.mapper'; import { QueryBus } from '@nestjs/cqrs'; @@ -48,15 +47,12 @@ describe('Get Basic Route Controller', () => { it('should get a route', async () => { jest.spyOn(mockQueryBus, 'execute'); await getBasicRouteController.get({ - roles: [Role.DRIVER], waypoints: [ { - position: 0, lat: 48.689445, lon: 6.17651, }, { - position: 1, lat: 48.8566, lon: 2.3522, }, diff --git a/src/modules/geography/tests/unit/interface/get-detailed-route.controller.spec.ts b/src/modules/geography/tests/unit/interface/get-detailed-route.controller.spec.ts new file mode 100644 index 0000000..e61e04e --- /dev/null +++ b/src/modules/geography/tests/unit/interface/get-detailed-route.controller.spec.ts @@ -0,0 +1,63 @@ +import { GetDetailedRouteController } from '@modules/geography/interface/controllers/get-detailed-route.controller'; +import { RouteMapper } from '@modules/geography/route.mapper'; +import { QueryBus } from '@nestjs/cqrs'; +import { Test, TestingModule } from '@nestjs/testing'; + +const mockQueryBus = { + execute: jest.fn(), +}; + +const mockRouteMapper = { + toPersistence: jest.fn(), + toDomain: jest.fn(), + toResponse: jest.fn(), +}; + +describe('Get Detailed Route Controller', () => { + let getDetailedRouteController: GetDetailedRouteController; + + beforeAll(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [ + { + provide: QueryBus, + useValue: mockQueryBus, + }, + { + provide: RouteMapper, + useValue: mockRouteMapper, + }, + GetDetailedRouteController, + ], + }).compile(); + + getDetailedRouteController = module.get( + GetDetailedRouteController, + ); + }); + + afterEach(async () => { + jest.clearAllMocks(); + }); + + it('should be defined', () => { + expect(getDetailedRouteController).toBeDefined(); + }); + + it('should get a route', async () => { + jest.spyOn(mockQueryBus, 'execute'); + await getDetailedRouteController.get({ + waypoints: [ + { + lat: 48.689445, + lon: 6.17651, + }, + { + lat: 48.8566, + lon: 2.3522, + }, + ], + }); + expect(mockQueryBus.execute).toHaveBeenCalledTimes(1); + }); +}); diff --git a/src/modules/geography/tests/unit/route.mapper.spec.ts b/src/modules/geography/tests/unit/route.mapper.spec.ts index 0846a7b..0d0ccf5 100644 --- a/src/modules/geography/tests/unit/route.mapper.spec.ts +++ b/src/modules/geography/tests/unit/route.mapper.spec.ts @@ -16,14 +16,6 @@ describe('Route Mapper', () => { expect(routeMapper).toBeDefined(); }); - it('should map domain entity to persistence data', async () => { - expect(routeMapper.toPersistence()).toBeUndefined(); - }); - - it('should map persisted data to domain entity', async () => { - expect(routeMapper.toDomain()).toBeUndefined(); - }); - it('should map domain entity to response', async () => { const now = new Date(); const routeEntity: RouteEntity = new RouteEntity({ @@ -31,28 +23,23 @@ describe('Route Mapper', () => { createdAt: now, updatedAt: now, props: { - driverDistance: 23000, - driverDuration: 900, - passengerDistance: 23000, - passengerDuration: 900, + distance: 23000, + duration: 900, fwdAzimuth: 283, backAzimuth: 93, distanceAzimuth: 19840, - points: [], - waypoints: [ + points: [ { - position: 0, lon: 6.1765103, lat: 48.689446, }, { - position: 1, lon: 2.3523, lat: 48.8567, }, ], }, }); - expect(routeMapper.toResponse(routeEntity).driverDistance).toBe(23000); + expect(routeMapper.toResponse(routeEntity).distance).toBe(23000); }); }); diff --git a/src/modules/messager/messager.module.ts b/src/modules/messager/messager.module.ts index 64bafed..9bb5108 100644 --- a/src/modules/messager/messager.module.ts +++ b/src/modules/messager/messager.module.ts @@ -14,8 +14,8 @@ const imports = [ useFactory: async ( configService: ConfigService, ): Promise => ({ - uri: configService.get('MESSAGE_BROKER_URI'), - exchange: configService.get('MESSAGE_BROKER_EXCHANGE'), + uri: configService.get('MESSAGE_BROKER_URI') as string, + exchange: configService.get('MESSAGE_BROKER_EXCHANGE') as string, name: 'matcher', handlers: { adCreated: { diff --git a/tsconfig.json b/tsconfig.json index ed12947..3f2a1e4 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -12,8 +12,8 @@ "baseUrl": "./", "incremental": true, "skipLibCheck": true, - "strictNullChecks": false, - "noImplicitAny": false, + "strictNullChecks": true, + "noImplicitAny": true, "strictBindCallApply": false, "forceConsistentCasingInFileNames": false, "noFallthroughCasesInSwitch": false,