diff --git a/package-lock.json b/package-lock.json index 2375cff..43189b3 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,18 +1,18 @@ { "name": "@mobicoop/matcher", - "version": "1.5.5", + "version": "1.8.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@mobicoop/matcher", - "version": "1.5.5", + "version": "1.8.0", "license": "AGPL", "dependencies": { "@grpc/grpc-js": "^1.9.14", "@grpc/proto-loader": "^0.7.10", "@mobicoop/configuration-module": "^8.0.0", - "@mobicoop/ddd-library": "^2.4.3", + "@mobicoop/ddd-library": "^2.5.0", "@mobicoop/health-module": "^2.3.2", "@mobicoop/message-broker-module": "^2.1.2", "@nestjs/axios": "^3.0.1", @@ -1881,9 +1881,9 @@ } }, "node_modules/@mobicoop/ddd-library": { - "version": "2.4.3", - "resolved": "https://registry.npmjs.org/@mobicoop/ddd-library/-/ddd-library-2.4.3.tgz", - "integrity": "sha512-HxNtAfov8ne7XsFTSIDI811r3L1VDV9YUikgX7HPjrB8u2gQh6FQFnIz3Fjb/zWOGxrDEIy8HEM0AYmXkf8ULA==", + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/@mobicoop/ddd-library/-/ddd-library-2.5.0.tgz", + "integrity": "sha512-dTx7KTILs53HCqNx0BDVTzIZxfPW3pi0fZ4UMw/vDNm3oTqGA+jg7YBfNxn8yadM+j2dDIN5Kum43CmKGH8yYA==", "dependencies": { "@nestjs/event-emitter": "^2.0.3", "@nestjs/microservices": "^10.3.0", diff --git a/package.json b/package.json index 5835ca6..ac22430 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@mobicoop/matcher", - "version": "1.6.0", + "version": "1.8.0", "description": "Mobicoop V3 Matcher", "author": "sbriat", "private": true, @@ -22,7 +22,7 @@ "test:unit:ci": "jest --testPathPattern 'tests/unit/' --coverage", "test:integration": "npm run migrate:test && dotenv -e .env.test -- jest --testPathPattern 'tests/integration/' --verbose --runInBand", "test:integration:ci": "npm run migrate:test:ci && dotenv -e ci/.env.ci -- jest --testPathPattern 'tests/integration/' --runInBand", - "test:cov": "jest --testPathPattern 'tests/unit/' --coverage", + "test:watch": "jest --testPathPattern 'tests/unit/' --watch", "test:e2e": "jest --config ./test/jest-e2e.json", "migrate": "docker exec v3-matcher-api sh -c 'npx prisma migrate deploy'", "migrate:dev": "docker exec v3-matcher-api sh -c 'npx prisma migrate dev'", @@ -35,7 +35,7 @@ "@grpc/proto-loader": "^0.7.10", "@songkeys/nestjs-redis": "^10.0.0", "@mobicoop/configuration-module": "^8.0.0", - "@mobicoop/ddd-library": "^2.4.3", + "@mobicoop/ddd-library": "^2.5.0", "@mobicoop/health-module": "^2.3.2", "@mobicoop/message-broker-module": "^2.1.2", "@nestjs/axios": "^3.0.1", diff --git a/src/app.constants.ts b/src/app.constants.ts index c13df6c..ac3626d 100644 --- a/src/app.constants.ts +++ b/src/app.constants.ts @@ -10,11 +10,16 @@ export const GRPC_GEOROUTER_SERVICE_NAME = 'GeorouterService'; export const MATCHER_AD_CREATED_ROUTING_KEY = 'matcher-ad.created'; export const MATCHER_AD_CREATION_FAILED_ROUTING_KEY = 'matcher-ad.creation-failed'; +export const MATCHER_AD_UPDATED_ROUTING_KEY = 'matcher-ad.updated'; +export const MATCHER_AD_UPDATE_FAILED_ROUTING_KEY = 'matcher-ad.update-failed'; // messaging input export const AD_CREATED_MESSAGE_HANDLER = 'adCreated'; export const AD_CREATED_ROUTING_KEY = 'ad.created'; export const AD_CREATED_QUEUE = 'matcher.ad.created'; +export const AD_UPDATED_MESSAGE_HANDLER = 'adUpdated'; +export const AD_UPDATED_ROUTING_KEY = 'ad.updated'; +export const AD_UPDATED_QUEUE = 'matcher.ad.updated'; export const AD_DELETED_MESSAGE_HANDLER = 'adDeleted'; export const AD_DELETED_ROUTING_KEY = 'ad.deleted'; export const AD_DELETED_QUEUE = 'matcher.ad.deleted'; diff --git a/src/modules/ad/ad.mapper.ts b/src/modules/ad/ad.mapper.ts index 929f72e..373ec5d 100644 --- a/src/modules/ad/ad.mapper.ts +++ b/src/modules/ad/ad.mapper.ts @@ -13,6 +13,7 @@ import { AdWriteExtraModel, AdWriteModel, ScheduleItemModel, + ScheduleWriteModel, } from './infrastructure/ad.repository'; /** @@ -38,9 +39,8 @@ export class AdMapper private readonly directionEncoder: DirectionEncoderPort, ) {} - toPersistence = (entity: AdEntity): AdWriteModel => { + toPersistence = (entity: AdEntity, update?: boolean): AdWriteModel => { const copy = entity.getProps(); - const now = new Date(); const record: AdWriteModel = { uuid: copy.id, driver: copy.driver, @@ -48,22 +48,7 @@ export class AdMapper frequency: copy.frequency, fromDate: new Date(copy.fromDate), toDate: new Date(copy.toDate), - schedule: { - create: copy.schedule.map((scheduleItem: ScheduleItemProps) => ({ - uuid: v4(), - day: scheduleItem.day, - time: new Date( - 1970, - 0, - 1, - parseInt(scheduleItem.time.split(':')[0]), - parseInt(scheduleItem.time.split(':')[1]), - ), - margin: scheduleItem.margin, - createdAt: now, - updatedAt: now, - })), - }, + schedule: this.toScheduleItemWriteModel(copy.schedule, update), seatsProposed: copy.seatsProposed, seatsRequested: copy.seatsRequested, strict: copy.strict, @@ -73,12 +58,39 @@ export class AdMapper passengerDistance: copy.passengerDistance, fwdAzimuth: copy.fwdAzimuth, backAzimuth: copy.backAzimuth, - createdAt: copy.createdAt, - updatedAt: copy.updatedAt, }; return record; }; + toScheduleItemWriteModel = ( + schedule: ScheduleItemProps[], + update?: boolean, + ): ScheduleWriteModel => { + const now = new Date(); + const record: ScheduleWriteModel = { + create: schedule.map((scheduleItem: ScheduleItemProps) => ({ + uuid: v4(), + day: scheduleItem.day, + time: new Date( + 1970, + 0, + 1, + parseInt(scheduleItem.time.split(':')[0]), + parseInt(scheduleItem.time.split(':')[1]), + ), + margin: scheduleItem.margin, + createdAt: now, + updatedAt: now, + })), + }; + if (update) { + record.deleteMany = { + createdAt: { lt: now }, + }; + } + return record; + }; + toDomain = (record: AdReadModel): AdEntity => new AdEntity({ id: record.uuid, diff --git a/src/modules/ad/ad.module.ts b/src/modules/ad/ad.module.ts index 9040de9..a2668d2 100644 --- a/src/modules/ad/ad.module.ts +++ b/src/modules/ad/ad.module.ts @@ -31,6 +31,7 @@ import { import { AdMapper } from './ad.mapper'; import { CreateAdService } from './core/application/commands/create-ad/create-ad.service'; import { DeleteAdService } from './core/application/commands/delete-ad/delete-ad.service'; +import { UpdateAdService } from './core/application/commands/update-ad/update-ad.service'; import { PublishMessageWhenMatcherAdIsCreatedDomainEventHandler } from './core/application/event-handlers/publish-message-when-matcher-ad-is-created.domain-event-handler'; import { MatchQueryHandler } from './core/application/queries/match/match.query-handler'; import { AdRepository } from './infrastructure/ad.repository'; @@ -44,6 +45,7 @@ import { TimezoneFinder } from './infrastructure/timezone-finder'; import { MatchGrpcController } from './interface/grpc-controllers/match.grpc-controller'; import { AdCreatedMessageHandler } from './interface/message-handlers/ad-created.message-handler'; import { AdDeletedMessageHandler } from './interface/message-handlers/ad-deleted.message-handler'; +import { AdUpdatedMessageHandler } from './interface/message-handlers/ad-updated.message-handler'; import { MatchMapper } from './match.mapper'; import { MatchingMapper } from './matching.mapper'; @@ -98,13 +100,21 @@ const imports = [ const grpcControllers = [MatchGrpcController]; -const messageHandlers = [AdCreatedMessageHandler, AdDeletedMessageHandler]; +const messageHandlers = [ + AdCreatedMessageHandler, + AdUpdatedMessageHandler, + AdDeletedMessageHandler, +]; const eventHandlers: Provider[] = [ PublishMessageWhenMatcherAdIsCreatedDomainEventHandler, ]; -const commandHandlers: Provider[] = [CreateAdService, DeleteAdService]; +const commandHandlers: Provider[] = [ + CreateAdService, + UpdateAdService, + DeleteAdService, +]; const queryHandlers: Provider[] = [MatchQueryHandler]; 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 a3a6d9a..a2f4922 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,9 +1,9 @@ -import { Frequency } from '@modules/ad/core/domain/ad.types'; import { Command, CommandProps } from '@mobicoop/ddd-library'; -import { ScheduleItem } from '../../types/schedule-item.type'; +import { Frequency, UserAd } from '@modules/ad/core/domain/ad.types'; import { Address } from '../../types/address.type'; +import { ScheduleItem } from '../../types/schedule-item.type'; -export class CreateAdCommand extends Command { +export class CreateAdCommand extends Command implements UserAd { readonly id: string; readonly driver: boolean; readonly passenger: boolean; 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 4158100..e3d92c5 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 @@ -1,32 +1,22 @@ -import { CommandHandler, ICommandHandler } from '@nestjs/cqrs'; -import { CreateAdCommand } from './create-ad.command'; -import { Inject } from '@nestjs/common'; -import { - AD_MESSAGE_PUBLISHER, - AD_REPOSITORY, - AD_ROUTE_PROVIDER, -} from '@modules/ad/ad.di-tokens'; -import { AdEntity } from '@modules/ad/core/domain/ad.entity'; -import { AdRepositoryPort } from '../../ports/ad.repository.port'; import { AggregateID, ConflictException, MessagePublisherPort, } from '@mobicoop/ddd-library'; -import { AdAlreadyExistsException } from '@modules/ad/core/domain/ad.errors'; -import { Role } from '@modules/ad/core/domain/ad.types'; 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'; -import { MatcherAdCreationFailedIntegrationEvent } from '../../events/matcher-ad-creation-failed.integration-event'; + AD_MESSAGE_PUBLISHER, + AD_REPOSITORY, + AD_ROUTE_PROVIDER, +} from '@modules/ad/ad.di-tokens'; +import { AdAlreadyExistsException } from '@modules/ad/core/domain/ad.errors'; +import { AdFactory } from '@modules/ad/core/domain/ad.factory'; +import { Inject } from '@nestjs/common'; +import { CommandHandler, ICommandHandler } from '@nestjs/cqrs'; import { MATCHER_AD_CREATION_FAILED_ROUTING_KEY } from '@src/app.constants'; -import { GeorouterPort } from '../../ports/georouter.port'; +import { GeorouterService } from '../../../domain/georouter.service'; +import { MatcherAdCreationFailedIntegrationEvent } from '../../events/matcher-ad-failure.integration-event'; +import { AdRepositoryPort } from '../../ports/ad.repository.port'; +import { CreateAdCommand } from './create-ad.command'; @CommandHandler(CreateAdCommand) export class CreateAdService implements ICommandHandler { @@ -36,106 +26,16 @@ export class CreateAdService implements ICommandHandler { @Inject(AD_REPOSITORY) private readonly repository: AdRepositoryPort, @Inject(AD_ROUTE_PROVIDER) - private readonly routeProvider: GeorouterPort, + private readonly routeProvider: GeorouterService, ) {} async execute(command: CreateAdCommand): Promise { - const roles: Role[] = []; - if (command.driver) roles.push(Role.DRIVER); - if (command.passenger) roles.push(Role.PASSENGER); - - const pathCreator: PathCreator = new PathCreator( - roles, - command.waypoints.map( - (waypoint: Waypoint) => - new PointValueObject({ - lon: waypoint.lon, - lat: waypoint.lat, - }), - ), - ); - - let typedRoutes: TypedRoute[]; - 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 { - try { - typedRoutes = await Promise.all( - pathCreator.getBasePaths().map(async (path: Path) => ({ - type: path.type, - route: await this.routeProvider.getRoute({ - waypoints: path.waypoints, - }), - })), - ); - } catch (e: any) { - throw new Error('Unable to find a route for given waypoints'); - } - - 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, - passenger: command.passenger, - frequency: command.frequency, - fromDate: command.fromDate, - toDate: command.toDate, - schedule: command.schedule, - seatsProposed: command.seatsProposed, - seatsRequested: command.seatsRequested, - strict: command.strict, - waypoints: command.waypoints, - points: points as PointValueObject[], - driverDistance, - driverDuration, - passengerDistance, - passengerDuration, - fwdAzimuth: fwdAzimuth as number, - backAzimuth: backAzimuth as number, - }); + const adFactory = new AdFactory(this.routeProvider); + const ad = await adFactory.create(command); try { + //TODO it should not be this service's concern that Prisma does not support postgis types await this.repository.insertExtra(ad, 'ad'); return ad.id; } catch (error: any) { diff --git a/src/modules/ad/core/application/commands/update-ad/update-ad.command.ts b/src/modules/ad/core/application/commands/update-ad/update-ad.command.ts new file mode 100644 index 0000000..898c968 --- /dev/null +++ b/src/modules/ad/core/application/commands/update-ad/update-ad.command.ts @@ -0,0 +1,3 @@ +import { CreateAdCommand } from '../create-ad/create-ad.command'; + +export class UpdateAdCommand extends CreateAdCommand {} diff --git a/src/modules/ad/core/application/commands/update-ad/update-ad.service.ts b/src/modules/ad/core/application/commands/update-ad/update-ad.service.ts new file mode 100644 index 0000000..99aafb9 --- /dev/null +++ b/src/modules/ad/core/application/commands/update-ad/update-ad.service.ts @@ -0,0 +1,48 @@ +import { MessagePublisherPort } from '@mobicoop/ddd-library'; +import { + AD_MESSAGE_PUBLISHER, + AD_REPOSITORY, + AD_ROUTE_PROVIDER, +} from '@modules/ad/ad.di-tokens'; +import { AdFactory } from '@modules/ad/core/domain/ad.factory'; +import { Inject } from '@nestjs/common'; +import { CommandHandler, ICommandHandler } from '@nestjs/cqrs'; +import { MATCHER_AD_UPDATE_FAILED_ROUTING_KEY } from '@src/app.constants'; +import { GeorouterService } from '../../../domain/georouter.service'; +import { MatcherAdUpdateFailedIntegrationEvent } from '../../events/matcher-ad-failure.integration-event'; +import { AdRepositoryPort } from '../../ports/ad.repository.port'; +import { UpdateAdCommand } from './update-ad.command'; + +@CommandHandler(UpdateAdCommand) +export class UpdateAdService implements ICommandHandler { + constructor( + @Inject(AD_MESSAGE_PUBLISHER) + private readonly messagePublisher: MessagePublisherPort, + @Inject(AD_REPOSITORY) + private readonly repository: AdRepositoryPort, + @Inject(AD_ROUTE_PROVIDER) + private readonly routeProvider: GeorouterService, + ) {} + + async execute(command: UpdateAdCommand): Promise { + try { + const adFactory = new AdFactory(this.routeProvider); + const ad = await adFactory.create(command); + return this.repository.update(ad.id, ad); + } catch (error: any) { + const integrationEvent = new MatcherAdUpdateFailedIntegrationEvent({ + id: command.id, + metadata: { + correlationId: command.id, + timestamp: Date.now(), + }, + cause: error.message, + }); + this.messagePublisher.publish( + MATCHER_AD_UPDATE_FAILED_ROUTING_KEY, + JSON.stringify(integrationEvent), + ); + throw error; + } + } +} diff --git a/src/modules/ad/core/application/events/matcher-ad-creation-failed.integration-event.ts b/src/modules/ad/core/application/events/matcher-ad-creation-failed.integration-event.ts deleted file mode 100644 index c932c6f..0000000 --- a/src/modules/ad/core/application/events/matcher-ad-creation-failed.integration-event.ts +++ /dev/null @@ -1,12 +0,0 @@ -import { IntegrationEvent, IntegrationEventProps } from '@mobicoop/ddd-library'; - -export class MatcherAdCreationFailedIntegrationEvent extends IntegrationEvent { - readonly cause?: string; - - constructor( - props: IntegrationEventProps, - ) { - super(props); - this.cause = props.cause; - } -} diff --git a/src/modules/ad/core/application/events/matcher-ad-failure.integration-event.ts b/src/modules/ad/core/application/events/matcher-ad-failure.integration-event.ts new file mode 100644 index 0000000..ff177c3 --- /dev/null +++ b/src/modules/ad/core/application/events/matcher-ad-failure.integration-event.ts @@ -0,0 +1,15 @@ +import { IntegrationEvent, IntegrationEventProps } from '@mobicoop/ddd-library'; + +export class MatcherAdFailureIntegrationEvent extends IntegrationEvent { + readonly cause?: string; + + constructor( + props: IntegrationEventProps, + ) { + super(props); + this.cause = props.cause; + } +} + +export class MatcherAdCreationFailedIntegrationEvent extends MatcherAdFailureIntegrationEvent {} +export class MatcherAdUpdateFailedIntegrationEvent extends MatcherAdFailureIntegrationEvent {} 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 index aca1a73..672d967 100644 --- a/src/modules/ad/core/application/queries/match/completer/route.completer.ts +++ b/src/modules/ad/core/application/queries/match/completer/route.completer.ts @@ -1,9 +1,9 @@ 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'; -import { RouteResponse } from '../../../ports/georouter.port'; +import { RouteResponse } from '../../../../domain/georouter.service'; +import { Step } from '../../../types/step.type'; +import { MatchQuery } from '../match.query'; +import { Completer } from './completer.abstract'; export class RouteCompleter extends Completer { protected readonly type: RouteCompleterType; diff --git a/src/modules/ad/core/application/queries/match/match.query.ts b/src/modules/ad/core/application/queries/match/match.query.ts index 8e41752..ed1341a 100644 --- a/src/modules/ad/core/application/queries/match/match.query.ts +++ b/src/modules/ad/core/application/queries/match/match.query.ts @@ -8,8 +8,8 @@ import { } from '@modules/ad/core/domain/path-creator.service'; import { Point } from '@modules/ad/core/domain/value-objects/point.value-object'; import { MatchRequestDto } from '@modules/ad/interface/grpc-controllers/dtos/match.request.dto'; +import { GeorouterService } from '../../../domain/georouter.service'; import { DateTimeTransformerPort } from '../../ports/datetime-transformer.port'; -import { GeorouterPort } from '../../ports/georouter.port'; import { AlgorithmType } from '../../types/algorithm.types'; import { Route } from '../../types/route.type'; import { Waypoint } from '../../types/waypoint.type'; @@ -41,10 +41,10 @@ export class MatchQuery extends QueryBase { passengerRoute?: Route; backAzimuth?: number; private readonly originWaypoint: Waypoint; - routeProvider: GeorouterPort; + routeProvider: GeorouterService; // TODO: remove MatchRequestDto depency (here core domain depends on interface /!\) - constructor(props: MatchRequestDto, routeProvider: GeorouterPort) { + constructor(props: MatchRequestDto, routeProvider: GeorouterService) { super(); this.id = props.id; this.driver = props.driver; 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 index baea02b..a385727 100644 --- 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 @@ -1,11 +1,21 @@ -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'; +import { Frequency, Role } from '@modules/ad/core/domain/ad.types'; +import { CandidateEntity } from '@modules/ad/core/domain/candidate.entity'; +import { DateInterval } from '../../../../domain/candidate.types'; +import { Point } from '../../../types/point.type'; +import { Waypoint } from '../../../types/waypoint.type'; +import { Selector } from '../algorithm.abstract'; +import { ScheduleItem } from '../match.query'; +/** + * This class complements the AdRepository prisma service by turning a match query object into a SQL query, + * with the assumption that the query is passenger-oriented (i.e. it is up to the driver to go out of his way to pick up the passenger). + * The idea is to make a rough filter of the ads in DB to limit the number of ads to be processed more precisely by the application code. + * TODO: Converting the query object into a SQL query is a job for the repository implementation + * (or anything behind the repository interface), + * any logic related to being passenger-oriented should be in the domain layer. + * (though it might be difficult to describe generically the search criteria with a query object) + */ export class PassengerOrientedSelector extends Selector { select = async (): Promise => { const queryStringRoles: QueryStringRole[] = []; @@ -19,6 +29,7 @@ export class PassengerOrientedSelector extends Selector { query: this._createQueryString(Role.PASSENGER), role: Role.PASSENGER, }); + return ( await Promise.all( queryStringRoles.map>( @@ -36,7 +47,7 @@ export class PassengerOrientedSelector extends Selector { id: adEntity.id, role: adsRole.role == Role.DRIVER ? Role.PASSENGER : Role.DRIVER, frequency: adEntity.getProps().frequency, - dateInterval: { + dateInterval: this._fixDateInterval({ lowerDate: this._maxDateString( this.query.fromDate, adEntity.getProps().fromDate, @@ -45,7 +56,7 @@ export class PassengerOrientedSelector extends Selector { this.query.toDate, adEntity.getProps().toDate, ), - }, + }), driverWaypoints: adsRole.role == Role.PASSENGER ? adEntity.getProps().waypoints @@ -134,8 +145,7 @@ export class PassengerOrientedSelector extends Selector { [ this._whereRole(role), this._whereStrict(), - this._whereDate(), - this._whereSchedule(role), + this._whereDate(role), this._whereExcludedAd(), this._whereAzimuth(), this._whereProportion(role), @@ -154,110 +164,58 @@ export class PassengerOrientedSelector extends Selector { : `frequency='${Frequency.RECURRENT}'` : ''; - private _whereDate = (): string => - this.query.frequency == Frequency.PUNCTUAL - ? `("fromDate" <= '${this.query.fromDate}' AND "toDate" >= '${this.query.fromDate}')` - : `(\ - (\ - "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}'\ - )\ - )`; + /** + * Generates the WHERE clause checking that the date range of the query intersects with the range of the ad. + * Note that driver dates might not be comparable with passenger dates when the trip is by night or very long. + * For this reason, the pickup date is adjusted with the driver duration, + * so as to compare with the maximum / minimum driver date that could make sense for the passenger. + * This may return more ads than necessary, but they will be filtered out in further processing. + */ + private _whereDate = (role: Role): string => { + const maxFromDate = this._maxFromDate(role); + const minToDate = this._minToDate(role); + return `("fromDate" <= ${maxFromDate} AND "toDate" >= ${minToDate})`; + }; - private _whereSchedule = (role: Role): string => { - // no schedule filtering if schedule is not set - if (this.query.schedule === undefined) return ''; - 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 as ScheduleItem[]) - .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(''); + private _maxFromDate = (role: Role): string => { + if (role == Role.DRIVER) { + //When looking for a passenger, we add the duration of the driver route to the latest toDate + //to compute the maximum sensible passenger fromDate, in case the pickup date could be on the next day + const querySchedule = this.query.schedule; + // When there is no schedule (search whole day), we consider the driver accepts to depart until 23:59 + const maxScheduleTime = + querySchedule === undefined + ? '23:59' + : querySchedule.reduce( + (max, s) => (s.time > max ? s.time : max), + '00:00', + ); + const [h, m] = maxScheduleTime.split(':'); + const maxFromDate = new Date(this.query.toDate); + maxFromDate.setHours(parseInt(h)); + maxFromDate.setMinutes(parseInt(m)); + maxFromDate.setSeconds(this.query.driverRoute!.duration); + return `'${maxFromDate.getUTCFullYear()}-${maxFromDate.getUTCMonth() + 1}-${maxFromDate.getUTCDate()}'`; + } else { + return `'${this.query.toDate}'`; + } + }; + + private _minToDate = (role: Role): string => { + if (role == Role.PASSENGER) { + // When looking for a driver, we look for a toDate that is one day before the fromDate of the query + // so that the driver will be able to pick up the passenger even during a long trip that starts the day before + const oneDayBeforeFromDate = new Date(this.query.fromDate); + oneDayBeforeFromDate.setDate(oneDayBeforeFromDate.getDate() - 1); + return `'${oneDayBeforeFromDate.getUTCFullYear()}-${oneDayBeforeFromDate.getUTCMonth() + 1}-${oneDayBeforeFromDate.getUTCDate()}'`; + } else { + return `'${this.query.fromDate}'`; } - return ''; }; private _whereExcludedAd = (): string => this.query.excludedAdId ? `ad.uuid <> '${this.query.excludedAdId}'` : ''; - 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( @@ -317,37 +275,6 @@ export class PassengerOrientedSelector extends Selector { } }; - /** - * Returns an array of dates containing all the dates (limited to 7 by default) between 2 boundary dates. - * - * The array length can be limited to a _max_ number of dates (default: 7) - */ - 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, @@ -358,11 +285,26 @@ export class PassengerOrientedSelector extends Selector { azimuth + margin > 360 ? azimuth + margin - 360 : azimuth + margin, }); + //TODO If the dates are always formatted with '%Y-%m-%d', no conversion to Date is needed 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; + + /** + * When a punctual ad matches a punctual query, it may be on a different date than the query + * (for routes by night), and the range produced by _minDateString and _maxDateString is not correct. + * This function fixes that by inverting the dates if necessary. + */ + private _fixDateInterval(interval: DateInterval): DateInterval { + if (interval.lowerDate > interval.higherDate) { + const tmp = interval.lowerDate; + interval.lowerDate = interval.higherDate; + interval.higherDate = tmp; + } + return interval; + } } export type QueryStringRole = { diff --git a/src/modules/ad/core/domain/ad.factory.ts b/src/modules/ad/core/domain/ad.factory.ts new file mode 100644 index 0000000..076b443 --- /dev/null +++ b/src/modules/ad/core/domain/ad.factory.ts @@ -0,0 +1,94 @@ +import { AdEntity } from './ad.entity'; +import { Role, UserAd } from './ad.types'; +import { GeorouterService } from './georouter.service'; +import { + Path, + PathCreator, + PathType, + TypedRoute, +} from './path-creator.service'; +import { Point } from './value-objects/point.value-object'; + +export class AdFactory { + constructor(private readonly routeProvider: GeorouterService) {} + /** + * Create an AdEntity (a "matcher ad", that is: the data needed to match an ad with a match query) + * from a "user ad" (the data provided by the user). + */ + public async create(ad: UserAd): Promise { + const roles: Role[] = []; + if (ad.driver) roles.push(Role.DRIVER); + if (ad.passenger) roles.push(Role.PASSENGER); + + const pathCreator = new PathCreator( + roles, + ad.waypoints.map((wp) => new Point({ lon: wp.lon, lat: wp.lat })), + ); + + let typedRoutes: TypedRoute[]; + try { + typedRoutes = await Promise.all( + pathCreator.getBasePaths().map(async (path: Path) => ({ + type: path.type, + route: await this.routeProvider.getRoute({ + waypoints: 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: Point[]; + let fwdAzimuth: number; + let backAzimuth: number; + 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) => new Point(point)); + 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) => new Point(point)); + } + if (!fwdAzimuth) fwdAzimuth = typedRoute.route.fwdAzimuth; + if (!backAzimuth) backAzimuth = typedRoute.route.backAzimuth; + } + }); + } catch (error: any) { + throw new Error('Invalid route'); + } + + return AdEntity.create({ + id: ad.id, + driver: ad.driver, + passenger: ad.passenger, + frequency: ad.frequency, + fromDate: ad.fromDate, + toDate: ad.toDate, + schedule: ad.schedule, + seatsProposed: ad.seatsProposed, + seatsRequested: ad.seatsRequested, + strict: ad.strict, + waypoints: ad.waypoints, + points: points!, + driverDistance, + driverDuration, + passengerDistance, + passengerDuration, + fwdAzimuth: fwdAzimuth!, + backAzimuth: backAzimuth!, + }); + } +} diff --git a/src/modules/ad/core/domain/ad.types.ts b/src/modules/ad/core/domain/ad.types.ts index 10f906b..ee68974 100644 --- a/src/modules/ad/core/domain/ad.types.ts +++ b/src/modules/ad/core/domain/ad.types.ts @@ -1,6 +1,23 @@ import { PointProps } from './value-objects/point.value-object'; import { ScheduleItemProps } from './value-objects/schedule-item.value-object'; +/** + * The data provided by the end-user to publish an ad + */ +export interface UserAd { + id: string; + driver: boolean; + passenger: boolean; + frequency: Frequency; + fromDate: string; + toDate: string; + schedule: ScheduleItemProps[]; + seatsProposed: number; + seatsRequested: number; + strict: boolean; + waypoints: PointProps[]; +} + // All properties that an Ad has export interface AdProps { driver: boolean; diff --git a/src/modules/ad/core/domain/candidate.entity.ts b/src/modules/ad/core/domain/candidate.entity.ts index 26bd990..943a540 100644 --- a/src/modules/ad/core/domain/candidate.entity.ts +++ b/src/modules/ad/core/domain/candidate.entity.ts @@ -323,7 +323,7 @@ export class CandidateEntity extends AggregateRoot { } //TODO Use this class as part of the CandidateEntity aggregate -class Schedule extends ValueObject<{ +export class Schedule extends ValueObject<{ items: ScheduleItemProps[]; dateInterval: DateInterval; }> { @@ -353,7 +353,7 @@ class Schedule extends ValueObject<{ duration, ); acc.push({ - day: itemDate.getUTCDay(), + day: driverStartDatetime.getUTCDay(), margin: scheduleItemProps.margin, time: this._formatTime(driverStartDatetime), }); diff --git a/src/modules/ad/core/application/ports/georouter.port.ts b/src/modules/ad/core/domain/georouter.service.ts similarity index 72% rename from src/modules/ad/core/application/ports/georouter.port.ts rename to src/modules/ad/core/domain/georouter.service.ts index 752e843..ac0c4d4 100644 --- a/src/modules/ad/core/application/ports/georouter.port.ts +++ b/src/modules/ad/core/domain/georouter.service.ts @@ -1,5 +1,3 @@ -import { Injectable } from '@nestjs/common'; - export type Point = { lon: number; lat: number; @@ -25,7 +23,6 @@ export type RouteResponse = { steps?: Step[]; }; -@Injectable() -export abstract class GeorouterPort { - abstract getRoute(request: RouteRequest): Promise; +export interface GeorouterService { + getRoute(request: RouteRequest): Promise; } diff --git a/src/modules/ad/infrastructure/ad.repository.ts b/src/modules/ad/infrastructure/ad.repository.ts index eda2c3c..42c2957 100644 --- a/src/modules/ad/infrastructure/ad.repository.ts +++ b/src/modules/ad/infrastructure/ad.repository.ts @@ -1,14 +1,14 @@ +import { LoggerBase, MessagePublisherPort } from '@mobicoop/ddd-library'; +import { ExtendedPrismaRepositoryBase } from '@mobicoop/ddd-library/dist/db/prisma-repository.base'; import { Inject, Injectable, Logger } from '@nestjs/common'; import { EventEmitter2 } from '@nestjs/event-emitter'; -import { AdRepositoryPort } from '../core/application/ports/ad.repository.port'; -import { LoggerBase, MessagePublisherPort } from '@mobicoop/ddd-library'; -import { PrismaService } from './prisma.service'; -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'; import { SERVICE_NAME } from '@src/app.constants'; +import { AD_MESSAGE_PUBLISHER } from '../ad.di-tokens'; +import { AdMapper } from '../ad.mapper'; +import { AdRepositoryPort } from '../core/application/ports/ad.repository.port'; +import { AdEntity } from '../core/domain/ad.entity'; +import { Frequency } from '../core/domain/ad.types'; +import { PrismaService } from './prisma.service'; export type AdModel = { uuid: string; @@ -26,8 +26,6 @@ export type AdModel = { passengerDistance?: number; fwdAzimuth: number; backAzimuth: number; - createdAt: Date; - updatedAt: Date; }; /** @@ -36,15 +34,26 @@ export type AdModel = { export type AdReadModel = AdModel & { waypoints: string; schedule: ScheduleItemModel[]; + createdAt: Date; + updatedAt: Date; }; /** * The record ready to be sent to the persistence system */ export type AdWriteModel = AdModel & { - schedule: { - create: ScheduleItemModel[]; - }; + schedule: ScheduleWriteModel; +}; + +export type ScheduleWriteModel = { + deleteMany?: PastCreatedFilter; + create: ScheduleItemModel[]; +}; + +// used to delete records created in the past, +// because the order of `create` and `deleteMany` is not guaranteed +export type PastCreatedFilter = { + createdAt: { lt: Date }; }; export type AdWriteExtraModel = { @@ -70,11 +79,15 @@ export type UngroupedAdModel = AdModel & scheduleItemCreatedAt: Date; scheduleItemUpdatedAt: Date; waypoints: string; + createdAt: Date; + updatedAt: Date; }; export type GroupedAdModel = AdModel & { schedule: ScheduleItemModel[]; waypoints: string; + createdAt: Date; + updatedAt: Date; }; /** @@ -169,4 +182,12 @@ export class AdRepository }); return adReadModels; }; + + async update( + id: string, + entity: AdEntity, + identifier?: string, + ): Promise { + this.updateExtra(id, entity, 'ad', identifier); + } } diff --git a/src/modules/ad/infrastructure/georouter.ts b/src/modules/ad/infrastructure/georouter.ts index 1280791..feddaeb 100644 --- a/src/modules/ad/infrastructure/georouter.ts +++ b/src/modules/ad/infrastructure/georouter.ts @@ -1,26 +1,26 @@ -import { Observable, lastValueFrom } from 'rxjs'; import { Inject, Injectable, OnModuleInit } from '@nestjs/common'; import { ClientGrpc } from '@nestjs/microservices'; import { GRPC_GEOROUTER_SERVICE_NAME } from '@src/app.constants'; +import { Observable, lastValueFrom } from 'rxjs'; +import { GEOGRAPHY_PACKAGE } from '../ad.di-tokens'; import { - GeorouterPort, + GeorouterService, RouteRequest, RouteResponse, -} from '../core/application/ports/georouter.port'; -import { GEOGRAPHY_PACKAGE } from '../ad.di-tokens'; +} from '../core/domain/georouter.service'; -interface GeorouterService { +interface GeorouterPort { getRoute(request: RouteRequest): Observable; } @Injectable() -export class Georouter implements GeorouterPort, OnModuleInit { - private georouterService: GeorouterService; +export class Georouter implements GeorouterService, OnModuleInit { + private georouterService: GeorouterPort; constructor(@Inject(GEOGRAPHY_PACKAGE) private readonly client: ClientGrpc) {} onModuleInit() { - this.georouterService = this.client.getService( + this.georouterService = this.client.getService( GRPC_GEOROUTER_SERVICE_NAME, ); } diff --git a/src/modules/ad/interface/grpc-controllers/match.grpc-controller.ts b/src/modules/ad/interface/grpc-controllers/match.grpc-controller.ts index 0801443..4774cd7 100644 --- a/src/modules/ad/interface/grpc-controllers/match.grpc-controller.ts +++ b/src/modules/ad/interface/grpc-controllers/match.grpc-controller.ts @@ -1,8 +1,8 @@ import { RpcValidationPipe } from '@mobicoop/ddd-library'; import { AD_ROUTE_PROVIDER } from '@modules/ad/ad.di-tokens'; -import { GeorouterPort } from '@modules/ad/core/application/ports/georouter.port'; import { MatchQuery } from '@modules/ad/core/application/queries/match/match.query'; import { MatchingResult } from '@modules/ad/core/application/queries/match/match.query-handler'; +import { GeorouterService } from '@modules/ad/core/domain/georouter.service'; import { MatchEntity } from '@modules/ad/core/domain/match.entity'; import { MatchMapper } from '@modules/ad/match.mapper'; import { Controller, Inject, UseFilters, UsePipes } from '@nestjs/common'; @@ -24,7 +24,7 @@ export class MatchGrpcController { constructor( private readonly queryBus: QueryBus, @Inject(AD_ROUTE_PROVIDER) - private readonly routeProvider: GeorouterPort, + private readonly routeProvider: GeorouterService, private readonly matchMapper: MatchMapper, ) {} diff --git a/src/modules/ad/interface/message-handlers/ad-updated.message-handler.ts b/src/modules/ad/interface/message-handlers/ad-updated.message-handler.ts new file mode 100644 index 0000000..46c3488 --- /dev/null +++ b/src/modules/ad/interface/message-handlers/ad-updated.message-handler.ts @@ -0,0 +1,28 @@ +import { RabbitSubscribe } from '@mobicoop/message-broker-module'; +import { UpdateAdCommand } from '@modules/ad/core/application/commands/update-ad/update-ad.command'; +import { Injectable } from '@nestjs/common'; +import { CommandBus } from '@nestjs/cqrs'; +import { + AD_UPDATED_MESSAGE_HANDLER, + AD_UPDATED_ROUTING_KEY, +} from '@src/app.constants'; +import { Ad } from './ad.types'; + +@Injectable() +export class AdUpdatedMessageHandler { + constructor(private readonly commandBus: CommandBus) {} + + @RabbitSubscribe({ + name: AD_UPDATED_MESSAGE_HANDLER, + routingKey: AD_UPDATED_ROUTING_KEY, + }) + public async adUpdated(message: string) { + try { + const updatedAd: { data: Ad } = JSON.parse(message); + await this.commandBus.execute(new UpdateAdCommand(updatedAd.data)); + } catch (error: any) { + // do not throw error to acknowledge incoming message + // error handling should be done in the command handler, if relevant + } + } +} diff --git a/src/modules/ad/tests/integration/ad.fixtures.ts b/src/modules/ad/tests/integration/ad.fixtures.ts new file mode 100644 index 0000000..c777374 --- /dev/null +++ b/src/modules/ad/tests/integration/ad.fixtures.ts @@ -0,0 +1,110 @@ +import { CreateAdProps, Frequency } from '@modules/ad/core/domain/ad.types'; +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 { v4 as uuidv4 } from 'uuid'; + +export const Nice: PointProps = { + lat: 43.7102, + lon: 7.262, +}; + +export const Marseille: PointProps = { + lat: 43.2965, + lon: 5.3698, +}; + +export const SaintRaphael: PointProps = { + lat: 43.4268, + lon: 6.769, +}; + +export const Toulon: PointProps = { + lat: 43.1167, + lon: 5.95, +}; + +export function monday(time: string): ScheduleItemProps { + return { day: 1, time: time, margin: 900 }; +} + +export function wednesday(time: string): ScheduleItemProps { + return { day: 3, time: time, margin: 900 }; +} +export function thursday(time: string): ScheduleItemProps { + return { day: 4, time: time, margin: 900 }; +} + +export function weekdays(time: string): ScheduleItemProps[] { + return [1, 2, 3, 4, 5].map((day) => ({ + day: day, + time: time, + margin: 900, + })); +} + +function createAdPropsDefaults(): CreateAdProps { + return { + id: uuidv4(), + driver: false, + passenger: false, + frequency: Frequency.PUNCTUAL, + fromDate: '', + toDate: '', + schedule: [], + seatsProposed: 1, + seatsRequested: 1, + strict: false, + waypoints: [], + points: [], + driverDuration: 0, + driverDistance: 0, + passengerDuration: 0, + passengerDistance: 0, + fwdAzimuth: 0, + backAzimuth: 0, + }; +} + +export function driverNiceMarseille( + frequency: Frequency, + dates: string[], + schedule: ScheduleItemProps[], +): CreateAdProps { + return { + ...createAdPropsDefaults(), + driver: true, + frequency: frequency, + fromDate: dates[0], + toDate: dates[1], + schedule: schedule, + waypoints: [Nice, Marseille], + points: [Nice, SaintRaphael, Toulon, Marseille], + driverDuration: 7668, + driverDistance: 199000, + passengerDuration: 7668, + passengerDistance: 199000, + fwdAzimuth: 273, + backAzimuth: 93, + }; +} + +export function passengerToulonMarseille( + frequency: Frequency, + dates: string[], + schedule: ScheduleItemProps[], +): CreateAdProps { + return { + ...createAdPropsDefaults(), + passenger: true, + frequency: frequency, + fromDate: dates[0], + toDate: dates[1], + schedule: schedule, + waypoints: [Toulon, Marseille], + points: [Toulon, Marseille], + driverDuration: 2460, + driverDistance: 64000, + passengerDuration: 2460, + passengerDistance: 64000, + }; +} diff --git a/src/modules/ad/tests/integration/ad.repository.spec.ts b/src/modules/ad/tests/integration/ad.repository.spec.ts index 2b29066..13ee97b 100644 --- a/src/modules/ad/tests/integration/ad.repository.spec.ts +++ b/src/modules/ad/tests/integration/ad.repository.spec.ts @@ -1,61 +1,16 @@ -import { - AD_DIRECTION_ENCODER, - AD_MESSAGE_PUBLISHER, - AD_REPOSITORY, -} from '@modules/ad/ad.di-tokens'; -import { AdMapper } from '@modules/ad/ad.mapper'; import { AdEntity } from '@modules/ad/core/domain/ad.entity'; -import { CreateAdProps, Frequency } from '@modules/ad/core/domain/ad.types'; +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 { PostgresDirectionEncoder } from '@modules/geography/infrastructure/postgres-direction-encoder'; -import { ConfigModule } from '@nestjs/config'; -import { EventEmitterModule } from '@nestjs/event-emitter'; -import { Test } from '@nestjs/testing'; +import { driverNiceMarseille, wednesday, weekdays } from './ad.fixtures'; +import { integrationTestingModule } from './integration.setup'; describe('Ad Repository', () => { let prismaService: PrismaService; let adRepository: AdRepository; - const mockMessagePublisher = { - publish: jest.fn().mockImplementation(), - }; - - const mockLogger = { - log: jest.fn(), - warn: jest.fn(), - error: jest.fn(), - }; - beforeAll(async () => { - const module = await Test.createTestingModule({ - imports: [ - EventEmitterModule.forRoot(), - ConfigModule.forRoot({ isGlobal: true }), - ], - providers: [ - PrismaService, - AdMapper, - { - provide: AD_REPOSITORY, - useClass: AdRepository, - }, - { - provide: AD_MESSAGE_PUBLISHER, - useValue: mockMessagePublisher, - }, - { - provide: AD_DIRECTION_ENCODER, - useClass: PostgresDirectionEncoder, - }, - ], - }) - // disable logging - .setLogger(mockLogger) - .compile(); - - prismaService = module.get(PrismaService); - adRepository = module.get(AD_REPOSITORY); + ({ prismaService, adRepository } = await integrationTestingModule()); }); afterAll(async () => { @@ -70,60 +25,12 @@ describe('Ad Repository', () => { it('should create a punctual ad', async () => { const beforeCount = await prismaService.ad.count(); - const createAdProps: CreateAdProps = { - id: 'b4b56444-f8d3-4110-917c-e37bba77f383', - driver: true, - passenger: false, - frequency: Frequency.PUNCTUAL, - fromDate: '2023-02-01', - toDate: '2023-02-01', - schedule: [ - { - day: 3, - time: '12:05', - margin: 900, - }, - ], - seatsProposed: 3, - seatsRequested: 1, - strict: false, - waypoints: [ - { - lon: 43.7102, - lat: 7.262, - }, - { - lon: 43.2965, - lat: 5.3698, - }, - ], - points: [ - { - lon: 7.262, - lat: 43.7102, - }, - { - lon: 6.797838, - lat: 43.547031, - }, - { - lon: 6.18535, - lat: 43.407517, - }, - { - lon: 5.3698, - lat: 43.2965, - }, - ], - driverDuration: 7668, - driverDistance: 199000, - passengerDuration: 7668, - passengerDistance: 199000, - fwdAzimuth: 273, - backAzimuth: 93, - }; - - const adToCreate: AdEntity = AdEntity.create(createAdProps); + const createAdProps = driverNiceMarseille( + Frequency.PUNCTUAL, + ['2023-02-01', '2023-02-01'], + [wednesday('08:30')], + ); + const adToCreate = AdEntity.create(createAdProps); await adRepository.insertExtra(adToCreate, 'ad'); const afterCount = await prismaService.ad.count(); @@ -134,80 +41,13 @@ describe('Ad Repository', () => { it('should create a recurrent ad', async () => { const beforeCount = await prismaService.ad.count(); - const createAdProps: CreateAdProps = { - id: 'b4b56444-f8d3-4110-917c-e37bba77f383', - driver: true, - passenger: false, - frequency: Frequency.RECURRENT, - fromDate: '2023-02-01', - toDate: '2024-01-31', - schedule: [ - { - day: 1, - time: '08:00', - margin: 900, - }, - { - day: 2, - time: '08:00', - margin: 900, - }, - { - day: 3, - time: '09:00', - margin: 900, - }, - { - day: 4, - time: '08:00', - margin: 900, - }, - { - day: 5, - time: '08:00', - margin: 900, - }, - ], - seatsProposed: 3, - seatsRequested: 1, - strict: false, - waypoints: [ - { - lon: 43.7102, - lat: 7.262, - }, - { - lon: 43.2965, - lat: 5.3698, - }, - ], - points: [ - { - lon: 7.262, - lat: 43.7102, - }, - { - lon: 6.797838, - lat: 43.547031, - }, - { - lon: 6.18535, - lat: 43.407517, - }, - { - lon: 5.3698, - lat: 43.2965, - }, - ], - driverDuration: 7668, - driverDistance: 199000, - passengerDuration: 7668, - passengerDistance: 199000, - fwdAzimuth: 273, - backAzimuth: 93, - }; + const createAdProps = driverNiceMarseille( + Frequency.RECURRENT, + ['2023-02-01', '2024-01-31'], + weekdays('08:30'), + ); - const adToCreate: AdEntity = AdEntity.create(createAdProps); + const adToCreate = AdEntity.create(createAdProps); await adRepository.insertExtra(adToCreate, 'ad'); const afterCount = await prismaService.ad.count(); diff --git a/src/modules/ad/tests/integration/integration.setup.ts b/src/modules/ad/tests/integration/integration.setup.ts new file mode 100644 index 0000000..d1cb231 --- /dev/null +++ b/src/modules/ad/tests/integration/integration.setup.ts @@ -0,0 +1,57 @@ +import { + AD_DIRECTION_ENCODER, + AD_MESSAGE_PUBLISHER, + AD_REPOSITORY, +} from '@modules/ad/ad.di-tokens'; +import { AdMapper } from '@modules/ad/ad.mapper'; +import { AdRepository } from '@modules/ad/infrastructure/ad.repository'; +import { PrismaService } from '@modules/ad/infrastructure/prisma.service'; +import { PostgresDirectionEncoder } from '@modules/geography/infrastructure/postgres-direction-encoder'; +import { ConfigModule } from '@nestjs/config'; +import { EventEmitterModule } from '@nestjs/event-emitter'; +import { Test } from '@nestjs/testing'; + +export async function integrationTestingModule(): Promise<{ + prismaService: PrismaService; + adRepository: AdRepository; +}> { + const mockMessagePublisher = { + publish: jest.fn().mockImplementation(), + }; + + const mockLogger = { + log: jest.fn(), + warn: jest.fn(), + error: jest.fn(), + }; + + const module = await Test.createTestingModule({ + imports: [ + EventEmitterModule.forRoot(), + ConfigModule.forRoot({ isGlobal: true }), + ], + providers: [ + PrismaService, + AdMapper, + { + provide: AD_REPOSITORY, + useClass: AdRepository, + }, + { + provide: AD_MESSAGE_PUBLISHER, + useValue: mockMessagePublisher, + }, + { + provide: AD_DIRECTION_ENCODER, + useClass: PostgresDirectionEncoder, + }, + ], + }) + .setLogger(mockLogger) + .compile(); + + return { + prismaService: module.get(PrismaService), + adRepository: module.get(AD_REPOSITORY), + }; +} diff --git a/src/modules/ad/tests/integration/passenger-oriented.selector.spec.ts b/src/modules/ad/tests/integration/passenger-oriented.selector.spec.ts new file mode 100644 index 0000000..ba481b1 --- /dev/null +++ b/src/modules/ad/tests/integration/passenger-oriented.selector.spec.ts @@ -0,0 +1,467 @@ +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 { AdEntity } from '@modules/ad/core/domain/ad.entity'; +import { CreateAdProps, Frequency } from '@modules/ad/core/domain/ad.types'; +import { ScheduleItemProps } from '@modules/ad/core/domain/value-objects/schedule-item.value-object'; +import { AdRepository } from '@modules/ad/infrastructure/ad.repository'; +import { PrismaService } from '@modules/ad/infrastructure/prisma.service'; +import { WaypointDto } from '@modules/ad/interface/grpc-controllers/dtos/waypoint.dto'; +import { bareMockGeorouter } from '../unit/georouter.mock'; +import { + Marseille, + Nice, + SaintRaphael, + Toulon, + driverNiceMarseille, + monday, + passengerToulonMarseille, + thursday, + wednesday, +} from './ad.fixtures'; +import { integrationTestingModule } from './integration.setup'; +function baseMatchQuery( + frequency: Frequency, + dates: [string, string], + scheduleItems: ScheduleItemProps[], + waypoints: WaypointDto[], +): MatchQuery { + return new MatchQuery( + { + algorithmType: AlgorithmType.PASSENGER_ORIENTED, + driver: false, + passenger: false, + frequency: frequency, + fromDate: dates[0], + toDate: dates[1], + useAzimuth: false, + useProportion: false, + remoteness: 15000, + schedule: scheduleItems, + strict: false, + waypoints: waypoints, + }, + bareMockGeorouter, + ); +} + +function passengerQueryToulonMarseille( + frequency: Frequency, + dates: [string, string], + scheduleItems: ScheduleItemProps[], +): MatchQuery { + const matchQuery = baseMatchQuery(frequency, dates, scheduleItems, [ + { position: 0, ...Toulon }, + { position: 1, ...Marseille }, + ]); + matchQuery.passenger = true; + matchQuery.passengerRoute = { + distance: 64000, + duration: 2460, + points: [Toulon, Marseille], + // Not used by this query + fwdAzimuth: 0, + backAzimuth: 0, + distanceAzimuth: 0, + }; + return matchQuery; +} + +function driverQueryNiceMarseille( + frequency: Frequency, + dates: [string, string], + scheduleItems: ScheduleItemProps[], +): MatchQuery { + const matchQuery = baseMatchQuery(frequency, dates, scheduleItems, [ + { position: 0, ...Nice }, + { position: 1, ...Marseille }, + ]); + matchQuery.driver = true; + matchQuery.driverRoute = { + distance: 199000, + duration: 7668, + points: [Nice, SaintRaphael, Toulon, Marseille], + // Not used by this query + fwdAzimuth: 0, + backAzimuth: 0, + distanceAzimuth: 0, + }; + return matchQuery; +} + +describe('PassengerOriented selector', () => { + let prismaService: PrismaService; + let adRepository: AdRepository; + + const insertAd = async (adProps: CreateAdProps): Promise => { + const ad = AdEntity.create(adProps); + return adRepository.insertExtra(ad, 'ad'); + }; + + beforeAll(async () => { + ({ prismaService, adRepository } = await integrationTestingModule()); + }); + + afterAll(async () => { + await prismaService.$disconnect(); + }); + + beforeEach(async () => { + await prismaService.ad.deleteMany(); + }); + + describe('select', () => { + it('should find a driver that departs on the same day', async () => { + await insertAd( + driverNiceMarseille( + Frequency.PUNCTUAL, + ['2023-02-01', '2023-02-01'], + [wednesday('08:30')], + ), + ); + const passengerOrientedSelector = new PassengerOrientedSelector( + passengerQueryToulonMarseille( + Frequency.PUNCTUAL, + ['2023-02-01', '2023-02-01'], + [wednesday('10:00')], + ), + adRepository, + ); + const candidates = await passengerOrientedSelector.select(); + expect(candidates.length).toBe(1); + }); + + it('should find a passenger that departs on the same day', async () => { + await insertAd( + passengerToulonMarseille( + Frequency.PUNCTUAL, + ['2023-02-01', '2023-02-01'], + [wednesday('10:00')], + ), + ); + const passengerOrientedSelector = new PassengerOrientedSelector( + driverQueryNiceMarseille( + Frequency.PUNCTUAL, + ['2023-02-01', '2023-02-01'], + [wednesday('08:30')], + ), + adRepository, + ); + const candidates = await passengerOrientedSelector.select(); + expect(candidates.length).toBe(1); + }); + + it('should find a driver that departs the day before', async () => { + await insertAd( + driverNiceMarseille( + Frequency.PUNCTUAL, + ['2023-02-01', '2023-02-01'], + [wednesday('23:45')], + ), + ); + + const passengerOrientedSelector = new PassengerOrientedSelector( + passengerQueryToulonMarseille( + Frequency.PUNCTUAL, + ['2023-02-02', '2023-02-02'], + [thursday('01:15')], + ), + adRepository, + ); + const candidates = await passengerOrientedSelector.select(); + expect(candidates.length).toBe(1); + }); + + it('should find a passenger that departs the day after', async () => { + await insertAd( + passengerToulonMarseille( + Frequency.PUNCTUAL, + ['2023-02-02', '2023-02-02'], + [thursday('01:15')], + ), + ); + + const passengerOrientedSelector = new PassengerOrientedSelector( + driverQueryNiceMarseille( + Frequency.PUNCTUAL, + ['2023-02-01', '2023-02-01'], + [wednesday('23:45')], + ), + adRepository, + ); + const candidates = await passengerOrientedSelector.select(); + expect(candidates.length).toBe(1); + }); + + it('should find a driver that departs shortly after midnight', async () => { + await insertAd( + driverNiceMarseille( + Frequency.PUNCTUAL, + ['2023-02-02', '2023-02-02'], + //01:30 in Nice is 00:30 in UTC + [thursday('01:30')], + ), + ); + + const passengerOrientedSelector = new PassengerOrientedSelector( + passengerQueryToulonMarseille( + Frequency.PUNCTUAL, + ['2023-02-02', '2023-02-02'], + [thursday('03:00')], + ), + adRepository, + ); + const candidates = await passengerOrientedSelector.select(); + expect(candidates.length).toBe(1); + }); + + it('should find a passenger that departs shortly after midnight', async () => { + await insertAd( + passengerToulonMarseille( + Frequency.PUNCTUAL, + ['2023-02-02', '2023-02-02'], + [thursday('03:00')], + ), + ); + const passengerOrientedSelector = new PassengerOrientedSelector( + driverQueryNiceMarseille( + Frequency.PUNCTUAL, + ['2023-02-02', '2023-02-02'], + [thursday('01:30')], + ), + adRepository, + ); + const candidates = await passengerOrientedSelector.select(); + expect(candidates.length).toBe(1); + }); + + it('should NOT find a driver that departs the day after', async () => { + await insertAd( + driverNiceMarseille( + Frequency.PUNCTUAL, + ['2023-02-02', '2023-02-02'], + [thursday('08:30')], + ), + ); + + const passengerOrientedSelector = new PassengerOrientedSelector( + passengerQueryToulonMarseille( + Frequency.PUNCTUAL, + ['2023-02-01', '2023-02-01'], + [wednesday('10:00')], + ), + adRepository, + ); + const candidates = await passengerOrientedSelector.select(); + expect(candidates.length).toBe(0); + }); + + it('should NOT find a passenger that departs the day before', async () => { + await insertAd( + passengerToulonMarseille( + Frequency.PUNCTUAL, + ['2023-02-01', '2023-02-01'], + [wednesday('10:00')], + ), + ); + const passengerOrientedSelector = new PassengerOrientedSelector( + driverQueryNiceMarseille( + Frequency.PUNCTUAL, + ['2023-02-02', '2023-02-02'], + [thursday('08:30')], + ), + adRepository, + ); + const candidates = await passengerOrientedSelector.select(); + expect(candidates.length).toBe(0); + }); + + it('should find a recurring driver that interesects', async () => { + await Promise.all([ + insertAd( + driverNiceMarseille( + Frequency.RECURRENT, + ['2023-02-01', '2023-02-28'], + [wednesday('08:30')], + ), + ), + insertAd( + driverNiceMarseille( + Frequency.RECURRENT, + ['2023-02-01', '2023-02-18'], + [wednesday('08:30')], + ), + ), + insertAd( + driverNiceMarseille( + Frequency.RECURRENT, + ['2023-02-12', '2023-02-28'], + [wednesday('08:30')], + ), + ), + insertAd( + driverNiceMarseille( + Frequency.RECURRENT, + ['2023-02-12', '2023-02-18'], + [wednesday('08:30')], + ), + ), + ]); + + const passengerOrientedSelector = new PassengerOrientedSelector( + passengerQueryToulonMarseille( + Frequency.RECURRENT, + ['2023-02-10', '2023-02-20'], + [wednesday('10:00')], + ), + adRepository, + ); + const candidates = await passengerOrientedSelector.select(); + expect(candidates.length).toBe(4); + }); + + it("should NOT find a recurring driver that doesn't interesect", async () => { + await Promise.all([ + insertAd( + driverNiceMarseille( + Frequency.RECURRENT, + ['2023-02-01', '2023-02-10'], + [wednesday('08:30')], + ), + ), + insertAd( + driverNiceMarseille( + Frequency.RECURRENT, + ['2023-02-20', '2023-02-28'], + [wednesday('08:30')], + ), + ), + ]); + + const passengerOrientedSelector = new PassengerOrientedSelector( + passengerQueryToulonMarseille( + Frequency.RECURRENT, + ['2023-02-12', '2023-02-18'], + [wednesday('10:00')], + ), + adRepository, + ); + const candidates = await passengerOrientedSelector.select(); + expect(candidates.length).toBe(0); + }); + + it('should find a recurring passenger that interesects', async () => { + await Promise.all([ + insertAd( + passengerToulonMarseille( + Frequency.RECURRENT, + ['2023-02-01', '2023-02-28'], + [wednesday('10:00')], + ), + ), + insertAd( + passengerToulonMarseille( + Frequency.RECURRENT, + ['2023-02-01', '2023-02-18'], + [wednesday('10:00')], + ), + ), + insertAd( + passengerToulonMarseille( + Frequency.RECURRENT, + ['2023-02-12', '2023-02-28'], + [wednesday('10:00')], + ), + ), + insertAd( + passengerToulonMarseille( + Frequency.RECURRENT, + ['2023-02-12', '2023-02-18'], + [wednesday('10:00')], + ), + ), + ]); + const passengerOrientedSelector = new PassengerOrientedSelector( + driverQueryNiceMarseille( + Frequency.RECURRENT, + ['2023-02-10', '2023-02-20'], + [wednesday('08:30')], + ), + adRepository, + ); + const candidates = await passengerOrientedSelector.select(); + expect(candidates.length).toBe(4); + }); + + it("should NOT find a recurring passenger that doesn't interesect", async () => { + await Promise.all([ + insertAd( + passengerToulonMarseille( + Frequency.RECURRENT, + ['2023-02-01', '2023-02-10'], + [wednesday('10:00')], + ), + ), + insertAd( + passengerToulonMarseille( + Frequency.RECURRENT, + ['2023-02-20', '2023-02-28'], + [wednesday('10:00')], + ), + ), + ]); + const passengerOrientedSelector = new PassengerOrientedSelector( + driverQueryNiceMarseille( + Frequency.RECURRENT, + ['2023-02-12', '2023-02-18'], + [wednesday('08:30')], + ), + adRepository, + ); + const candidates = await passengerOrientedSelector.select(); + expect(candidates.length).toBe(0); + }); + + it('should find a borderline driver that departs the day before a recurring query', async () => { + await insertAd( + driverNiceMarseille( + Frequency.PUNCTUAL, + ['2023-02-01', '2023-02-01'], + [wednesday('23:45')], + ), + ); + + const passengerOrientedSelector = new PassengerOrientedSelector( + passengerQueryToulonMarseille( + Frequency.RECURRENT, + ['2023-02-02', '2023-02-28'], + [monday('13:45'), thursday('01:15')], + ), + adRepository, + ); + const candidates = await passengerOrientedSelector.select(); + expect(candidates.length).toBe(1); + }); + + it('should find a borderline passenger that departs the day after a recurring query', async () => { + await insertAd( + passengerToulonMarseille( + Frequency.PUNCTUAL, + ['2023-02-02', '2023-02-02'], + [thursday('01:15')], + ), + ); + + const passengerOrientedSelector = new PassengerOrientedSelector( + driverQueryNiceMarseille( + Frequency.RECURRENT, + ['2023-01-01', '2023-02-01'], + [monday('13:45'), wednesday('23:45')], + ), + adRepository, + ); + const candidates = await passengerOrientedSelector.select(); + expect(candidates.length).toBe(1); + }); + }); +}); 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 626dc71..fb7ac33 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 @@ -1,18 +1,17 @@ -import { Test, TestingModule } from '@nestjs/testing'; +import { AggregateID, ConflictException } from '@mobicoop/ddd-library'; import { AD_MESSAGE_PUBLISHER, AD_REPOSITORY, AD_ROUTE_PROVIDER, } from '@modules/ad/ad.di-tokens'; -import { AggregateID } from '@mobicoop/ddd-library'; -import { AdEntity } from '@modules/ad/core/domain/ad.entity'; -import { ConflictException } from '@mobicoop/ddd-library'; -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 { CreateAdService } from '@modules/ad/core/application/commands/create-ad/create-ad.service'; +import { AdEntity } from '@modules/ad/core/domain/ad.entity'; import { AdAlreadyExistsException } from '@modules/ad/core/domain/ad.errors'; +import { CreateAdProps, Frequency } from '@modules/ad/core/domain/ad.types'; +import { GeorouterService } from '@modules/ad/core/domain/georouter.service'; import { PointProps } from '@modules/ad/core/domain/value-objects/point.value-object'; -import { GeorouterPort } from '@modules/ad/core/application/ports/georouter.port'; +import { Test, TestingModule } from '@nestjs/testing'; const originWaypoint: PointProps = { lat: 48.689445, @@ -62,7 +61,7 @@ const mockAdRepository = { }), }; -const mockRouteProvider: GeorouterPort = { +const mockRouteProvider: GeorouterService = { getRoute: jest .fn() .mockImplementationOnce(() => { diff --git a/src/modules/ad/tests/unit/core/match.query-handler.spec.ts b/src/modules/ad/tests/unit/core/match.query-handler.spec.ts index ee60fa3..e6e9eea 100644 --- a/src/modules/ad/tests/unit/core/match.query-handler.spec.ts +++ b/src/modules/ad/tests/unit/core/match.query-handler.spec.ts @@ -1,8 +1,8 @@ import { - Domain, - KeyType, Configurator, + Domain, GetConfigurationRepositoryPort, + KeyType, } from '@mobicoop/configuration-module'; import { CARPOOL_CONFIG_DEPARTURE_TIME_MARGIN, @@ -16,8 +16,9 @@ import { AD_REPOSITORY, INPUT_DATETIME_TRANSFORMER, MATCHING_REPOSITORY, + TIMEZONE_FINDER, + TIME_CONVERTER, } from '@modules/ad/ad.di-tokens'; -import { DateTimeTransformerPort } from '@modules/ad/core/application/ports/datetime-transformer.port'; import { MatchingRepositoryPort } from '@modules/ad/core/application/ports/matching.repository.port'; import { MatchQuery } from '@modules/ad/core/application/queries/match/match.query'; import { @@ -30,6 +31,9 @@ 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 { InputDateTimeTransformer } from '@modules/ad/infrastructure/input-datetime-transformer'; +import { TimeConverter } from '@modules/ad/infrastructure/time-converter'; +import { TimezoneFinder } from '@modules/ad/infrastructure/timezone-finder'; import { MATCH_CONFIG_ALGORITHM, MATCH_CONFIG_AZIMUTH_MARGIN, @@ -344,13 +348,6 @@ const mockConfigurationRepository: GetConfigurationRepositoryPort = { ), }; -const mockInputDateTimeTransformer: DateTimeTransformerPort = { - fromDate: jest.fn(), - toDate: jest.fn(), - day: jest.fn(), - time: jest.fn(), -}; - const mockRouteProvider = simpleMockGeorouter; describe('Match Query Handler', () => { @@ -372,9 +369,17 @@ describe('Match Query Handler', () => { provide: AD_CONFIGURATION_REPOSITORY, useValue: mockConfigurationRepository, }, + { + provide: TIMEZONE_FINDER, + useClass: TimezoneFinder, + }, + { + provide: TIME_CONVERTER, + useClass: TimeConverter, + }, { provide: INPUT_DATETIME_TRANSFORMER, - useValue: mockInputDateTimeTransformer, + useClass: InputDateTimeTransformer, }, ], }).compile(); diff --git a/src/modules/ad/tests/unit/core/match.query.spec.ts b/src/modules/ad/tests/unit/core/match.query.spec.ts index 4379f60..c0ea5de 100644 --- a/src/modules/ad/tests/unit/core/match.query.spec.ts +++ b/src/modules/ad/tests/unit/core/match.query.spec.ts @@ -1,5 +1,4 @@ import { DateTimeTransformerPort } from '@modules/ad/core/application/ports/datetime-transformer.port'; -import { GeorouterPort } from '@modules/ad/core/application/ports/georouter.port'; import { MatchQuery, ScheduleItem, @@ -7,6 +6,7 @@ import { 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 { GeorouterService } from '@modules/ad/core/domain/georouter.service'; import { simpleMockGeorouter } from '../georouter.mock'; const originWaypoint: Waypoint = { @@ -61,7 +61,7 @@ const mockInputDateTimeTransformer: DateTimeTransformerPort = { time: jest.fn().mockImplementation(() => '23:05'), }; -const mockRouteProvider: GeorouterPort = { +const mockRouteProvider: GeorouterService = { getRoute: jest .fn() .mockImplementationOnce(simpleMockGeorouter.getRoute) 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 index f1b00ff..ff44323 100644 --- a/src/modules/ad/tests/unit/core/passenger-oriented-selector.spec.ts +++ b/src/modules/ad/tests/unit/core/passenger-oriented-selector.spec.ts @@ -72,27 +72,7 @@ matchQuery.driverRoute = { }, ], }; -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, - }, - ], -}; +matchQuery.passengerRoute = { ...matchQuery.driverRoute }; const mockMatcherRepository: AdRepositoryPort = { insertExtra: jest.fn(), diff --git a/src/modules/ad/tests/unit/core/route.completer.spec.ts b/src/modules/ad/tests/unit/core/route.completer.spec.ts index 3e536f9..85dcc62 100644 --- a/src/modules/ad/tests/unit/core/route.completer.spec.ts +++ b/src/modules/ad/tests/unit/core/route.completer.spec.ts @@ -1,7 +1,3 @@ -import { - RouteRequest, - RouteResponse, -} from '@modules/ad/core/application/ports/georouter.port'; import { RouteCompleter, RouteCompleterType, @@ -12,6 +8,10 @@ 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 { + RouteRequest, + RouteResponse, +} from '@modules/ad/core/domain/georouter.service'; import { Actor } from '@modules/ad/core/domain/value-objects/actor.value-object'; import { Step } from '@modules/geography/core/domain/route.types'; import { simpleMockGeorouter } from '../georouter.mock'; diff --git a/src/modules/ad/tests/unit/core/update-ad.service.spec.ts b/src/modules/ad/tests/unit/core/update-ad.service.spec.ts new file mode 100644 index 0000000..c5f347a --- /dev/null +++ b/src/modules/ad/tests/unit/core/update-ad.service.spec.ts @@ -0,0 +1,94 @@ +import { + AD_MESSAGE_PUBLISHER, + AD_REPOSITORY, + AD_ROUTE_PROVIDER, +} from '@modules/ad/ad.di-tokens'; +import { UpdateAdCommand } from '@modules/ad/core/application/commands/update-ad/update-ad.command'; +import { UpdateAdService } from '@modules/ad/core/application/commands/update-ad/update-ad.service'; +import { GeorouterService } from '@modules/ad/core/domain/georouter.service'; +import { Test, TestingModule } from '@nestjs/testing'; +import { createAdProps } from './ad.fixtures'; + +const mockAdRepository = { + update: jest.fn().mockImplementation((id) => { + if (id === '42') { + throw 'Bad id!'; + } + }), +}; + +const mockRouteProvider: GeorouterService = { + getRoute: jest.fn().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, + }, + ], + })), +}; + +const mockMessagePublisher = { + publish: jest.fn().mockImplementation(), +}; + +describe('update-ad.service', () => { + let updateAdService: UpdateAdService; + + beforeAll(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [ + { + provide: AD_REPOSITORY, + useValue: mockAdRepository, + }, + { + provide: AD_ROUTE_PROVIDER, + useValue: mockRouteProvider, + }, + { + provide: AD_MESSAGE_PUBLISHER, + useValue: mockMessagePublisher, + }, + UpdateAdService, + ], + }).compile(); + + updateAdService = module.get(UpdateAdService); + }); + + it('should be defined', () => { + expect(updateAdService).toBeDefined(); + }); + + describe('execute', () => { + it('should call the repository update method', async () => { + const updateAdCommand = new UpdateAdCommand(createAdProps()); + await updateAdService.execute(updateAdCommand); + expect(mockAdRepository.update).toHaveBeenCalled(); + }); + + it('should emit an event when an error occurs', async () => { + const commandProps = createAdProps(); + commandProps.id = '42'; + const updateAdCommand = new UpdateAdCommand(commandProps); + await expect(updateAdService.execute(updateAdCommand)).rejects.toBe( + 'Bad id!', + ); + expect(mockMessagePublisher.publish).toHaveBeenCalled(); + }); + }); +}); diff --git a/src/modules/ad/tests/unit/georouter.mock.ts b/src/modules/ad/tests/unit/georouter.mock.ts index 75332c9..269e79c 100644 --- a/src/modules/ad/tests/unit/georouter.mock.ts +++ b/src/modules/ad/tests/unit/georouter.mock.ts @@ -1,10 +1,10 @@ -import { GeorouterPort } from '@modules/ad/core/application/ports/georouter.port'; +import { GeorouterService } from '@modules/ad/core/domain/georouter.service'; -export const bareMockGeorouter: GeorouterPort = { +export const bareMockGeorouter: GeorouterService = { getRoute: jest.fn(), }; -export const simpleMockGeorouter: GeorouterPort = { +export const simpleMockGeorouter: GeorouterService = { getRoute: jest.fn().mockImplementation(() => ({ distance: 350101, duration: 14422, diff --git a/src/modules/ad/tests/unit/interface/ad-updated.message-handler.spec.ts b/src/modules/ad/tests/unit/interface/ad-updated.message-handler.spec.ts new file mode 100644 index 0000000..d827fdf --- /dev/null +++ b/src/modules/ad/tests/unit/interface/ad-updated.message-handler.spec.ts @@ -0,0 +1,43 @@ +import { AdUpdatedMessageHandler } from '@modules/ad/interface/message-handlers/ad-updated.message-handler'; +import { CommandBus } from '@nestjs/cqrs'; +import { Test, TestingModule } from '@nestjs/testing'; + +const adUpdatedMessage = + '{"data": {"id":"4eb6a6af-ecfd-41c3-9118-473a507014d4","driver":"true","passenger":"true","frequency":"PUNCTUAL","fromDate":"2023-08-18","toDate":"2023-08-18","schedule":[{"day":"5","time":"10:00","margin":"900"}],"seatsProposed":"3","seatsRequested":"1","strict":"false","waypoints":[{"position":"0","houseNumber":"5","street":"rue de la monnaie","locality":"Nancy","postalCode":"54000","country":"France","lon":"48.689445","lat":"6.17651"},{"position":"1","locality":"Paris","postalCode":"75000","country":"France","lon":"48.8566","lat":"2.3522"}]}}'; + +const mockCommandBus = { + execute: jest.fn(), +}; + +describe('Ad Updated Message Handler', () => { + let adUpdatedMessageHandler: AdUpdatedMessageHandler; + + beforeAll(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [ + { + provide: CommandBus, + useValue: mockCommandBus, + }, + AdUpdatedMessageHandler, + ], + }).compile(); + + adUpdatedMessageHandler = module.get( + AdUpdatedMessageHandler, + ); + }); + + afterEach(async () => { + jest.clearAllMocks(); + }); + + it('should be defined', () => { + expect(adUpdatedMessageHandler).toBeDefined(); + }); + + it('should update an ad', async () => { + await adUpdatedMessageHandler.adUpdated(adUpdatedMessage); + expect(mockCommandBus.execute).toHaveBeenCalledTimes(1); + }); +}); diff --git a/src/modules/messager/messager.module.ts b/src/modules/messager/messager.module.ts index 73086db..ec29ef4 100644 --- a/src/modules/messager/messager.module.ts +++ b/src/modules/messager/messager.module.ts @@ -12,6 +12,9 @@ import { AD_DELETED_MESSAGE_HANDLER, AD_DELETED_QUEUE, AD_DELETED_ROUTING_KEY, + AD_UPDATED_MESSAGE_HANDLER, + AD_UPDATED_QUEUE, + AD_UPDATED_ROUTING_KEY, SERVICE_NAME, } from '@src/app.constants'; import { MESSAGE_PUBLISHER } from './messager.di-tokens'; @@ -36,6 +39,10 @@ const imports = [ routingKey: AD_CREATED_ROUTING_KEY, queue: AD_CREATED_QUEUE, }, + [AD_UPDATED_MESSAGE_HANDLER]: { + routingKey: AD_UPDATED_ROUTING_KEY, + queue: AD_UPDATED_QUEUE, + }, [AD_DELETED_MESSAGE_HANDLER]: { routingKey: AD_DELETED_ROUTING_KEY, queue: AD_DELETED_QUEUE, diff --git a/tsconfig.build.json b/tsconfig.build.json index 64f86c6..3dbec9b 100644 --- a/tsconfig.build.json +++ b/tsconfig.build.json @@ -1,4 +1,4 @@ { "extends": "./tsconfig.json", - "exclude": ["node_modules", "test", "dist", "**/*spec.ts"] + "exclude": ["node_modules", "tests", "dist", "**/*spec.ts"] }