Remove most of the geography module and delegate it to external gRPC microservice

This commit is contained in:
Romain Thouvenin 2024-03-13 17:54:34 +01:00
parent d09bad60f7
commit 96c30cb1cc
59 changed files with 237 additions and 2037 deletions

View File

@ -3,6 +3,8 @@ export const SERVICE_NAME = 'matcher';
// grpc // grpc
export const GRPC_PACKAGE_NAME = 'matcher'; export const GRPC_PACKAGE_NAME = 'matcher';
export const GRPC_GEOGRAPHY_PACKAGE_NAME = 'geocoder';
export const GRPC_GEOROUTER_SERVICE_NAME = 'GeorouterService';
// messaging output // messaging output
export const MATCHER_AD_CREATED_ROUTING_KEY = 'matcher-ad.created'; export const MATCHER_AD_CREATED_ROUTING_KEY = 'matcher-ad.created';

View File

@ -10,6 +10,7 @@ import { MESSAGE_PUBLISHER } from '@modules/messager/messager.di-tokens';
import { HealthModuleOptions } from '@mobicoop/health-module/dist/core/domain/types/health.types'; import { HealthModuleOptions } from '@mobicoop/health-module/dist/core/domain/types/health.types';
import { MessagePublisherPort } from '@mobicoop/ddd-library'; import { MessagePublisherPort } from '@mobicoop/ddd-library';
import { GeographyModule } from '@modules/geography/geography.module'; import { GeographyModule } from '@modules/geography/geography.module';
import producerServicesConfig from './config/producer-services.config';
import { import {
HEALTH_AD_REPOSITORY, HEALTH_AD_REPOSITORY,
HEALTH_CRITICAL_LOGGING_KEY, HEALTH_CRITICAL_LOGGING_KEY,
@ -18,7 +19,7 @@ import {
@Module({ @Module({
imports: [ imports: [
ConfigModule.forRoot({ isGlobal: true }), ConfigModule.forRoot({ isGlobal: true, load: [producerServicesConfig] }),
EventEmitterModule.forRoot(), EventEmitterModule.forRoot(),
RequestContextModule, RequestContextModule,
HealthModule.forRootAsync({ HealthModule.forRootAsync({

View File

@ -0,0 +1,8 @@
import { registerAs } from '@nestjs/config';
export default registerAs('producerServices', () => ({
geographyUrl: process.env.GEOGRAPHY_SERVICE_URL ?? 'v3-geocoder-api',
geographyPort: process.env.GEOGRAPHY_SERVICE_PORT
? parseInt(process.env.GEOGRAPHY_SERVICE_PORT, 10)
: 5007,
}));

View File

@ -18,3 +18,4 @@ export const OUTPUT_DATETIME_TRANSFORMER = Symbol(
export const AD_CONFIGURATION_REPOSITORY = Symbol( export const AD_CONFIGURATION_REPOSITORY = Symbol(
'AD_CONFIGURATION_REPOSITORY', 'AD_CONFIGURATION_REPOSITORY',
); );
export const GEOGRAPHY_PACKAGE = Symbol('GEOGRAPHY_PACKAGE');

View File

@ -5,14 +5,13 @@ import {
AD_REPOSITORY, AD_REPOSITORY,
AD_DIRECTION_ENCODER, AD_DIRECTION_ENCODER,
AD_ROUTE_PROVIDER, AD_ROUTE_PROVIDER,
AD_GET_BASIC_ROUTE_CONTROLLER,
TIMEZONE_FINDER, TIMEZONE_FINDER,
TIME_CONVERTER, TIME_CONVERTER,
INPUT_DATETIME_TRANSFORMER, INPUT_DATETIME_TRANSFORMER,
AD_GET_DETAILED_ROUTE_CONTROLLER,
OUTPUT_DATETIME_TRANSFORMER, OUTPUT_DATETIME_TRANSFORMER,
MATCHING_REPOSITORY, MATCHING_REPOSITORY,
AD_CONFIGURATION_REPOSITORY, AD_CONFIGURATION_REPOSITORY,
GEOGRAPHY_PACKAGE,
} from './ad.di-tokens'; } from './ad.di-tokens';
import { MessageBrokerPublisher } from '@mobicoop/message-broker-module'; import { MessageBrokerPublisher } from '@mobicoop/message-broker-module';
import { AdRepository } from './infrastructure/ad.repository'; import { AdRepository } from './infrastructure/ad.repository';
@ -20,8 +19,6 @@ import { PrismaService } from './infrastructure/prisma.service';
import { AdMapper } from './ad.mapper'; import { AdMapper } from './ad.mapper';
import { AdCreatedMessageHandler } from './interface/message-handlers/ad-created.message-handler'; import { AdCreatedMessageHandler } from './interface/message-handlers/ad-created.message-handler';
import { PostgresDirectionEncoder } from '@modules/geography/infrastructure/postgres-direction-encoder'; import { PostgresDirectionEncoder } from '@modules/geography/infrastructure/postgres-direction-encoder';
import { GetBasicRouteController } from '@modules/geography/interface/controllers/get-basic-route.controller';
import { RouteProvider } from './infrastructure/route-provider';
import { GeographyModule } from '@modules/geography/geography.module'; import { GeographyModule } from '@modules/geography/geography.module';
import { CreateAdService } from './core/application/commands/create-ad/create-ad.service'; import { CreateAdService } from './core/application/commands/create-ad/create-ad.service';
import { MatchGrpcController } from './interface/grpc-controllers/match.grpc-controller'; import { MatchGrpcController } from './interface/grpc-controllers/match.grpc-controller';
@ -29,7 +26,6 @@ import { MatchQueryHandler } from './core/application/queries/match/match.query-
import { TimezoneFinder } from './infrastructure/timezone-finder'; import { TimezoneFinder } from './infrastructure/timezone-finder';
import { TimeConverter } from './infrastructure/time-converter'; import { TimeConverter } from './infrastructure/time-converter';
import { InputDateTimeTransformer } from './infrastructure/input-datetime-transformer'; import { InputDateTimeTransformer } from './infrastructure/input-datetime-transformer';
import { GetDetailedRouteController } from '@modules/geography/interface/controllers/get-detailed-route.controller';
import { MatchMapper } from './match.mapper'; import { MatchMapper } from './match.mapper';
import { OutputDateTimeTransformer } from './infrastructure/output-datetime-transformer'; import { OutputDateTimeTransformer } from './infrastructure/output-datetime-transformer';
import { MatchingRepository } from './infrastructure/matching.repository'; import { MatchingRepository } from './infrastructure/matching.repository';
@ -44,9 +40,30 @@ import {
} from '@songkeys/nestjs-redis'; } from '@songkeys/nestjs-redis';
import { ConfigurationRepository } from '@mobicoop/configuration-module'; import { ConfigurationRepository } from '@mobicoop/configuration-module';
import { PublishMessageWhenMatcherAdIsCreatedDomainEventHandler } from './core/application/event-handlers/publish-message-when-matcher-ad-is-created.domain-event-handler'; import { PublishMessageWhenMatcherAdIsCreatedDomainEventHandler } from './core/application/event-handlers/publish-message-when-matcher-ad-is-created.domain-event-handler';
import { GeorouterProvider } from './infrastructure/georouter-provider';
import { ClientsModule, Transport } from '@nestjs/microservices';
import { GRPC_GEOGRAPHY_PACKAGE_NAME } from '@src/app.constants';
import { join } from 'path';
const imports = [ const imports = [
CqrsModule, CqrsModule,
ClientsModule.registerAsync([
{
name: GEOGRAPHY_PACKAGE,
imports: [ConfigModule],
inject: [ConfigService],
useFactory: async (configService: ConfigService) => ({
transport: Transport.GRPC,
options: {
package: GRPC_GEOGRAPHY_PACKAGE_NAME,
protoPath: join(__dirname, '/infrastructure/georouter.proto'),
url: `${configService.get<string>(
'producerServices.geographyUrl',
)}:${configService.get<string>('producerServices.geographyPort')}`,
},
}),
},
]),
CacheModule.registerAsync<RedisClientOptions>({ CacheModule.registerAsync<RedisClientOptions>({
imports: [ConfigModule], imports: [ConfigModule],
useFactory: async (configService: ConfigService) => ({ useFactory: async (configService: ConfigService) => ({
@ -122,15 +139,7 @@ const adapters: Provider[] = [
}, },
{ {
provide: AD_ROUTE_PROVIDER, provide: AD_ROUTE_PROVIDER,
useClass: RouteProvider, useClass: GeorouterProvider,
},
{
provide: AD_GET_BASIC_ROUTE_CONTROLLER,
useClass: GetBasicRouteController,
},
{
provide: AD_GET_DETAILED_ROUTE_CONTROLLER,
useClass: GetDetailedRouteController,
}, },
{ {
provide: TIMEZONE_FINDER, provide: TIMEZONE_FINDER,

View File

@ -14,7 +14,6 @@ import {
MessagePublisherPort, MessagePublisherPort,
} from '@mobicoop/ddd-library'; } from '@mobicoop/ddd-library';
import { AdAlreadyExistsException } from '@modules/ad/core/domain/ad.errors'; 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 { Role } from '@modules/ad/core/domain/ad.types';
import { import {
Path, Path,
@ -27,6 +26,7 @@ import { Point as PointValueObject } from '@modules/ad/core/domain/value-objects
import { Point } from '@modules/geography/core/domain/route.types'; import { Point } from '@modules/geography/core/domain/route.types';
import { MatcherAdCreationFailedIntegrationEvent } from '../../events/matcher-ad-creation-failed.integration-event'; import { MatcherAdCreationFailedIntegrationEvent } from '../../events/matcher-ad-creation-failed.integration-event';
import { MATCHER_AD_CREATION_FAILED_ROUTING_KEY } from '@src/app.constants'; import { MATCHER_AD_CREATION_FAILED_ROUTING_KEY } from '@src/app.constants';
import { GeorouterProviderPort } from '../../ports/georouter-provider.port';
@CommandHandler(CreateAdCommand) @CommandHandler(CreateAdCommand)
export class CreateAdService implements ICommandHandler { export class CreateAdService implements ICommandHandler {
@ -36,7 +36,7 @@ export class CreateAdService implements ICommandHandler {
@Inject(AD_REPOSITORY) @Inject(AD_REPOSITORY)
private readonly repository: AdRepositoryPort, private readonly repository: AdRepositoryPort,
@Inject(AD_ROUTE_PROVIDER) @Inject(AD_ROUTE_PROVIDER)
private readonly routeProvider: RouteProviderPort, private readonly routeProvider: GeorouterProviderPort,
) {} ) {}
async execute(command: CreateAdCommand): Promise<AggregateID> { async execute(command: CreateAdCommand): Promise<AggregateID> {
@ -69,7 +69,9 @@ export class CreateAdService implements ICommandHandler {
typedRoutes = await Promise.all( typedRoutes = await Promise.all(
pathCreator.getBasePaths().map(async (path: Path) => ({ pathCreator.getBasePaths().map(async (path: Path) => ({
type: path.type, type: path.type,
route: await this.routeProvider.getBasic(path.waypoints), route: await this.routeProvider.getRoute({
waypoints: path.waypoints,
}),
})), })),
); );
} catch (e: any) { } catch (e: any) {

View File

@ -0,0 +1,31 @@
import { Injectable } from '@nestjs/common';
export type Point = {
lon: number;
lat: number;
};
export type Step = Point & {
duration: number;
distance?: number;
};
export type RouteRequest = {
waypoints: Point[];
detailsSettings?: { points: boolean; steps: boolean };
};
export type RouteResponse = {
distance: number;
duration: number;
fwdAzimuth: number;
backAzimuth: number;
distanceAzimuth: number;
points: Point[];
steps?: Step[];
};
@Injectable()
export abstract class GeorouterProviderPort {
abstract getRoute(request: RouteRequest): Promise<RouteResponse>;
}

View File

@ -1,19 +0,0 @@
import { Point } from '../types/point.type';
import { Route } from '../types/route.type';
export interface RouteProviderPort {
/**
* Get a basic route :
* - simple points (coordinates only)
* - overall duration
* - overall distance
*/
getBasic(waypoints: Point[]): Promise<Route>;
/**
* Get a detailed route :
* - detailed points (coordinates and time / distance to reach the point)
* - overall duration
* - overall distance
*/
getDetailed(waypoints: Point[]): Promise<Route>;
}

View File

@ -3,6 +3,7 @@ import { Completer } from './completer.abstract';
import { MatchQuery } from '../match.query'; import { MatchQuery } from '../match.query';
import { Step } from '../../../types/step.type'; import { Step } from '../../../types/step.type';
import { CarpoolPathItem } from '@modules/ad/core/domain/value-objects/carpool-path-item.value-object'; import { CarpoolPathItem } from '@modules/ad/core/domain/value-objects/carpool-path-item.value-object';
import { RouteResponse } from '../../../ports/georouter-provider.port';
export class RouteCompleter extends Completer { export class RouteCompleter extends Completer {
protected readonly type: RouteCompleterType; protected readonly type: RouteCompleterType;
@ -18,23 +19,20 @@ export class RouteCompleter extends Completer {
candidates.map(async (candidate: CandidateEntity) => { candidates.map(async (candidate: CandidateEntity) => {
switch (this.type) { switch (this.type) {
case RouteCompleterType.BASIC: case RouteCompleterType.BASIC:
const basicCandidateRoute = await this.query.routeProvider.getBasic( const basicCandidateRoute = await this._getRoute(candidate, {
(candidate.getProps().carpoolPath as CarpoolPathItem[]).map( points: true,
(carpoolPathItem: CarpoolPathItem) => carpoolPathItem, steps: false,
), });
);
candidate.setMetrics( candidate.setMetrics(
basicCandidateRoute.distance, basicCandidateRoute.distance,
basicCandidateRoute.duration, basicCandidateRoute.duration,
); );
break; break;
case RouteCompleterType.DETAILED: case RouteCompleterType.DETAILED:
const detailedCandidateRoute = const detailedCandidateRoute = await this._getRoute(candidate, {
await this.query.routeProvider.getDetailed( points: true,
(candidate.getProps().carpoolPath as CarpoolPathItem[]).map( steps: true,
(carpoolPathItem: CarpoolPathItem) => carpoolPathItem, });
),
);
candidate.setSteps(detailedCandidateRoute.steps as Step[]); candidate.setSteps(detailedCandidateRoute.steps as Step[]);
break; break;
} }
@ -43,6 +41,17 @@ export class RouteCompleter extends Completer {
); );
return candidates; return candidates;
}; };
_getRoute = async (
candidate: CandidateEntity,
detailsSettings: { points: boolean; steps: boolean },
): Promise<RouteResponse> =>
this.query.routeProvider.getRoute({
waypoints: (candidate.getProps().carpoolPath as CarpoolPathItem[]).map(
(carpoolPathItem: CarpoolPathItem) => carpoolPathItem,
),
detailsSettings: detailsSettings,
});
} }
export enum RouteCompleterType { export enum RouteCompleterType {

View File

@ -4,7 +4,6 @@ import { Waypoint } from '../../types/waypoint.type';
import { Frequency, Role } from '@modules/ad/core/domain/ad.types'; import { Frequency, Role } from '@modules/ad/core/domain/ad.types';
import { MatchRequestDto } from '@modules/ad/interface/grpc-controllers/dtos/match.request.dto'; import { MatchRequestDto } from '@modules/ad/interface/grpc-controllers/dtos/match.request.dto';
import { DateTimeTransformerPort } from '../../ports/datetime-transformer.port'; import { DateTimeTransformerPort } from '../../ports/datetime-transformer.port';
import { RouteProviderPort } from '../../ports/route-provider.port';
import { import {
Path, Path,
PathCreator, PathCreator,
@ -13,6 +12,7 @@ import {
} from '@modules/ad/core/domain/path-creator.service'; } from '@modules/ad/core/domain/path-creator.service';
import { Point } from '@modules/ad/core/domain/value-objects/point.value-object'; import { Point } from '@modules/ad/core/domain/value-objects/point.value-object';
import { Route } from '../../types/route.type'; import { Route } from '../../types/route.type';
import { GeorouterProviderPort } from '../../ports/georouter-provider.port';
export class MatchQuery extends QueryBase { export class MatchQuery extends QueryBase {
id?: string; id?: string;
@ -40,10 +40,10 @@ export class MatchQuery extends QueryBase {
passengerRoute?: Route; passengerRoute?: Route;
backAzimuth?: number; backAzimuth?: number;
private readonly originWaypoint: Waypoint; private readonly originWaypoint: Waypoint;
routeProvider: RouteProviderPort; routeProvider: GeorouterProviderPort;
// TODO: remove MatchRequestDto depency (here core domain depends on interface /!\) // TODO: remove MatchRequestDto depency (here core domain depends on interface /!\)
constructor(props: MatchRequestDto, routeProvider: RouteProviderPort) { constructor(props: MatchRequestDto, routeProvider: GeorouterProviderPort) {
super(); super();
this.id = props.id; this.id = props.id;
this.driver = props.driver; this.driver = props.driver;
@ -208,7 +208,9 @@ export class MatchQuery extends QueryBase {
await Promise.all( await Promise.all(
pathCreator.getBasePaths().map(async (path: Path) => ({ pathCreator.getBasePaths().map(async (path: Path) => ({
type: path.type, type: path.type,
route: await this.routeProvider.getBasic(path.waypoints), route: await this.routeProvider.getRoute({
waypoints: path.waypoints,
}),
})), })),
) )
).forEach((typedRoute: TypedRoute) => { ).forEach((typedRoute: TypedRoute) => {

View File

@ -0,0 +1,35 @@
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 {
GeorouterProviderPort,
RouteRequest,
RouteResponse,
} from '../core/application/ports/georouter-provider.port';
import { GEOGRAPHY_PACKAGE } from '../ad.di-tokens';
interface GeorouterService {
getRoute(request: RouteRequest): Observable<RouteResponse>;
}
@Injectable()
export class GeorouterProvider implements GeorouterProviderPort, OnModuleInit {
private georouterService: GeorouterService;
constructor(@Inject(GEOGRAPHY_PACKAGE) private readonly client: ClientGrpc) {}
onModuleInit() {
this.georouterService = this.client.getService<GeorouterService>(
GRPC_GEOROUTER_SERVICE_NAME,
);
}
getRoute = async (request: RouteRequest): Promise<RouteResponse> => {
try {
return await lastValueFrom(this.georouterService.getRoute(request));
} catch (error: any) {
throw error;
}
};
}

View File

@ -0,0 +1,39 @@
syntax = "proto3";
package geocoder;
service GeorouterService {
rpc GetRoute(RouteRequest) returns (Route);
}
message RouteRequest {
repeated Point waypoints = 1;
optional DetailsSettings detailsSettings = 2;
}
message Point {
double lon = 1;
double lat = 2;
}
message DetailsSettings {
bool points = 1;
bool steps = 2;
}
message Route {
int32 distance = 1;
int32 duration = 2;
int32 fwdAzimuth = 3;
int32 backAzimuth = 4;
int32 distanceAzimuth = 5;
repeated Point points = 6;
repeated Step steps = 7;
}
message Step {
double lon = 1;
double lat = 2;
int32 duration = 3;
int32 distance = 4;
}

View File

@ -1,28 +0,0 @@
import { Inject, Injectable } from '@nestjs/common';
import { RouteProviderPort } from '../core/application/ports/route-provider.port';
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: GetRouteControllerPort,
@Inject(AD_GET_DETAILED_ROUTE_CONTROLLER)
private readonly getDetailedRouteController: GetRouteControllerPort,
) {}
getBasic = async (waypoints: Point[]): Promise<Route> =>
await this.getBasicRouteController.get({
waypoints,
});
getDetailed = async (waypoints: Point[]): Promise<Route> =>
await this.getDetailedRouteController.get({
waypoints,
});
}

View File

@ -8,10 +8,10 @@ import { MatchRequestDto } from './dtos/match.request.dto';
import { MatchQuery } from '@modules/ad/core/application/queries/match/match.query'; import { MatchQuery } from '@modules/ad/core/application/queries/match/match.query';
import { MatchEntity } from '@modules/ad/core/domain/match.entity'; import { MatchEntity } from '@modules/ad/core/domain/match.entity';
import { AD_ROUTE_PROVIDER } from '@modules/ad/ad.di-tokens'; 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 { MatchMapper } from '@modules/ad/match.mapper';
import { MatchingResult } from '@modules/ad/core/application/queries/match/match.query-handler'; import { MatchingResult } from '@modules/ad/core/application/queries/match/match.query-handler';
import { CacheInterceptor, CacheKey } from '@nestjs/cache-manager'; import { CacheInterceptor, CacheKey } from '@nestjs/cache-manager';
import { GeorouterProviderPort } from '@modules/ad/core/application/ports/georouter-provider.port';
@UsePipes( @UsePipes(
new RpcValidationPipe({ new RpcValidationPipe({
@ -24,7 +24,7 @@ export class MatchGrpcController {
constructor( constructor(
private readonly queryBus: QueryBus, private readonly queryBus: QueryBus,
@Inject(AD_ROUTE_PROVIDER) @Inject(AD_ROUTE_PROVIDER)
private readonly routeProvider: RouteProviderPort, private readonly routeProvider: GeorouterProviderPort,
private readonly matchMapper: MatchMapper, private readonly matchMapper: MatchMapper,
) {} ) {}

View File

@ -1,5 +1,4 @@
import { AdRepositoryPort } from '@modules/ad/core/application/ports/ad.repository.port'; import { AdRepositoryPort } from '@modules/ad/core/application/ports/ad.repository.port';
import { RouteProviderPort } from '@modules/ad/core/application/ports/route-provider.port';
import { import {
Algorithm, Algorithm,
Selector, Selector,
@ -9,6 +8,7 @@ import { Waypoint } from '@modules/ad/core/application/types/waypoint.type';
import { Frequency, Role } from '@modules/ad/core/domain/ad.types'; import { Frequency, Role } from '@modules/ad/core/domain/ad.types';
import { CandidateEntity } from '@modules/ad/core/domain/candidate.entity'; import { CandidateEntity } from '@modules/ad/core/domain/candidate.entity';
import { MatchEntity } from '@modules/ad/core/domain/match.entity'; import { MatchEntity } from '@modules/ad/core/domain/match.entity';
import { bareMockGeorouter } from '../georouter.mock';
const originWaypoint: Waypoint = { const originWaypoint: Waypoint = {
position: 0, position: 0,
@ -29,11 +29,6 @@ const destinationWaypoint: Waypoint = {
country: 'France', country: 'France',
}; };
const mockRouteProvider: RouteProviderPort = {
getBasic: jest.fn(),
getDetailed: jest.fn(),
};
const matchQuery = new MatchQuery( const matchQuery = new MatchQuery(
{ {
frequency: Frequency.PUNCTUAL, frequency: Frequency.PUNCTUAL,
@ -46,7 +41,7 @@ const matchQuery = new MatchQuery(
], ],
waypoints: [originWaypoint, destinationWaypoint], waypoints: [originWaypoint, destinationWaypoint],
}, },
mockRouteProvider, bareMockGeorouter,
); );
const mockAdRepository: AdRepositoryPort = { const mockAdRepository: AdRepositoryPort = {

View File

@ -11,8 +11,8 @@ import { CreateAdProps, Frequency } from '@modules/ad/core/domain/ad.types';
import { CreateAdService } from '@modules/ad/core/application/commands/create-ad/create-ad.service'; 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 { CreateAdCommand } from '@modules/ad/core/application/commands/create-ad/create-ad.command';
import { AdAlreadyExistsException } from '@modules/ad/core/domain/ad.errors'; import { AdAlreadyExistsException } from '@modules/ad/core/domain/ad.errors';
import { RouteProviderPort } from '@modules/ad/core/application/ports/route-provider.port';
import { PointProps } from '@modules/ad/core/domain/value-objects/point.value-object'; import { PointProps } from '@modules/ad/core/domain/value-objects/point.value-object';
import { GeorouterProviderPort } from '@modules/ad/core/application/ports/georouter-provider.port';
const originWaypoint: PointProps = { const originWaypoint: PointProps = {
lat: 48.689445, lat: 48.689445,
@ -62,8 +62,8 @@ const mockAdRepository = {
}), }),
}; };
const mockRouteProvider: RouteProviderPort = { const mockRouteProvider: GeorouterProviderPort = {
getBasic: jest getRoute: jest
.fn() .fn()
.mockImplementationOnce(() => { .mockImplementationOnce(() => {
throw new Error(); throw new Error();
@ -97,7 +97,6 @@ const mockRouteProvider: RouteProviderPort = {
}, },
], ],
})), })),
getDetailed: jest.fn(),
}; };
const mockMessagePublisher = { const mockMessagePublisher = {

View File

@ -6,6 +6,7 @@ import { Frequency, Role } from '@modules/ad/core/domain/ad.types';
import { CandidateEntity } from '@modules/ad/core/domain/candidate.entity'; import { CandidateEntity } from '@modules/ad/core/domain/candidate.entity';
import { Target } from '@modules/ad/core/domain/candidate.types'; import { Target } from '@modules/ad/core/domain/candidate.types';
import { Actor } from '@modules/ad/core/domain/value-objects/actor.value-object'; import { Actor } from '@modules/ad/core/domain/value-objects/actor.value-object';
import { simpleMockGeorouter } from '../georouter.mock';
const originWaypoint: Waypoint = { const originWaypoint: Waypoint = {
position: 0, position: 0,
@ -42,24 +43,7 @@ const matchQuery = new MatchQuery(
strict: false, strict: false,
waypoints: [originWaypoint, destinationWaypoint], waypoints: [originWaypoint, destinationWaypoint],
}, },
{ simpleMockGeorouter,
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({ const candidate: CandidateEntity = CandidateEntity.create({

View File

@ -4,6 +4,7 @@ import { AlgorithmType } from '@modules/ad/core/application/types/algorithm.type
import { Waypoint } from '@modules/ad/core/application/types/waypoint.type'; import { Waypoint } from '@modules/ad/core/application/types/waypoint.type';
import { Frequency, Role } from '@modules/ad/core/domain/ad.types'; import { Frequency, Role } from '@modules/ad/core/domain/ad.types';
import { CandidateEntity } from '@modules/ad/core/domain/candidate.entity'; import { CandidateEntity } from '@modules/ad/core/domain/candidate.entity';
import { bareMockGeorouter } from '../georouter.mock';
const originWaypoint: Waypoint = { const originWaypoint: Waypoint = {
position: 0, position: 0,
@ -40,10 +41,7 @@ const matchQuery = new MatchQuery(
strict: false, strict: false,
waypoints: [originWaypoint, destinationWaypoint], waypoints: [originWaypoint, destinationWaypoint],
}, },
{ bareMockGeorouter,
getBasic: jest.fn(),
getDetailed: jest.fn(),
},
); );
const candidate: CandidateEntity = CandidateEntity.create({ const candidate: CandidateEntity = CandidateEntity.create({

View File

@ -19,7 +19,6 @@ import {
} from '@modules/ad/ad.di-tokens'; } from '@modules/ad/ad.di-tokens';
import { DateTimeTransformerPort } from '@modules/ad/core/application/ports/datetime-transformer.port'; import { DateTimeTransformerPort } from '@modules/ad/core/application/ports/datetime-transformer.port';
import { MatchingRepositoryPort } from '@modules/ad/core/application/ports/matching.repository.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 { MatchQuery } from '@modules/ad/core/application/queries/match/match.query';
import { import {
MatchQueryHandler, MatchQueryHandler,
@ -43,6 +42,7 @@ import {
PAGINATION_CONFIG_PER_PAGE, PAGINATION_CONFIG_PER_PAGE,
} from '@modules/ad/match.constants'; } from '@modules/ad/match.constants';
import { Test, TestingModule } from '@nestjs/testing'; import { Test, TestingModule } from '@nestjs/testing';
import { simpleMockGeorouter } from '../georouter.mock';
const originWaypoint: Waypoint = { const originWaypoint: Waypoint = {
position: 0, position: 0,
@ -351,17 +351,7 @@ const mockInputDateTimeTransformer: DateTimeTransformerPort = {
time: jest.fn(), time: jest.fn(),
}; };
const mockRouteProvider: RouteProviderPort = { const mockRouteProvider = simpleMockGeorouter;
getBasic: jest.fn().mockImplementation(() => ({
distance: 350101,
duration: 14422,
fwdAzimuth: 273,
backAzimuth: 93,
distanceAzimuth: 336544,
points: [],
})),
getDetailed: jest.fn(),
};
describe('Match Query Handler', () => { describe('Match Query Handler', () => {
let matchQueryHandler: MatchQueryHandler; let matchQueryHandler: MatchQueryHandler;

View File

@ -1,9 +1,10 @@
import { DateTimeTransformerPort } from '@modules/ad/core/application/ports/datetime-transformer.port'; import { DateTimeTransformerPort } from '@modules/ad/core/application/ports/datetime-transformer.port';
import { RouteProviderPort } from '@modules/ad/core/application/ports/route-provider.port'; import { GeorouterProviderPort } from '@modules/ad/core/application/ports/georouter-provider.port';
import { MatchQuery } from '@modules/ad/core/application/queries/match/match.query'; import { MatchQuery } from '@modules/ad/core/application/queries/match/match.query';
import { AlgorithmType } from '@modules/ad/core/application/types/algorithm.types'; import { AlgorithmType } from '@modules/ad/core/application/types/algorithm.types';
import { Waypoint } from '@modules/ad/core/application/types/waypoint.type'; import { Waypoint } from '@modules/ad/core/application/types/waypoint.type';
import { Frequency } from '@modules/ad/core/domain/ad.types'; import { Frequency } from '@modules/ad/core/domain/ad.types';
import { simpleMockGeorouter } from '../georouter.mock';
const originWaypoint: Waypoint = { const originWaypoint: Waypoint = {
position: 0, position: 0,
@ -57,17 +58,10 @@ const mockInputDateTimeTransformer: DateTimeTransformerPort = {
time: jest.fn().mockImplementation(() => '23:05'), time: jest.fn().mockImplementation(() => '23:05'),
}; };
const mockRouteProvider: RouteProviderPort = { const mockRouteProvider: GeorouterProviderPort = {
getBasic: jest getRoute: jest
.fn() .fn()
.mockImplementationOnce(() => ({ .mockImplementationOnce(simpleMockGeorouter.getRoute)
distance: 350101,
duration: 14422,
fwdAzimuth: 273,
backAzimuth: 93,
distanceAzimuth: 336544,
points: [],
}))
.mockImplementationOnce(() => ({ .mockImplementationOnce(() => ({
distance: 340102, distance: 340102,
duration: 13423, duration: 13423,
@ -76,22 +70,8 @@ const mockRouteProvider: RouteProviderPort = {
distanceAzimuth: 336544, distanceAzimuth: 336544,
points: [], points: [],
})) }))
.mockImplementationOnce(() => ({ .mockImplementationOnce(simpleMockGeorouter.getRoute)
distance: 350101, .mockImplementationOnce(simpleMockGeorouter.getRoute)
duration: 14422,
fwdAzimuth: 273,
backAzimuth: 93,
distanceAzimuth: 336544,
points: [],
}))
.mockImplementationOnce(() => ({
distance: 350101,
duration: 14422,
fwdAzimuth: 273,
backAzimuth: 93,
distanceAzimuth: 336544,
points: [],
}))
.mockImplementationOnce(() => ({ .mockImplementationOnce(() => ({
distance: 340102, distance: 340102,
duration: 13423, duration: 13423,
@ -103,7 +83,6 @@ const mockRouteProvider: RouteProviderPort = {
.mockImplementationOnce(() => { .mockImplementationOnce(() => {
throw new Error(); throw new Error();
}), }),
getDetailed: jest.fn(),
}; };
describe('Match Query', () => { describe('Match Query', () => {

View File

@ -42,11 +42,10 @@ const matchQuery = new MatchQuery(
waypoints: [originWaypoint, destinationWaypoint], waypoints: [originWaypoint, destinationWaypoint],
}, },
{ {
getBasic: jest.fn().mockImplementation(() => ({ getRoute: jest.fn().mockImplementation(() => ({
duration: 6500, duration: 6500,
distance: 89745, distance: 89745,
})), })),
getDetailed: jest.fn(),
}, },
); );

View File

@ -4,6 +4,7 @@ import { AlgorithmType } from '@modules/ad/core/application/types/algorithm.type
import { Waypoint } from '@modules/ad/core/application/types/waypoint.type'; import { Waypoint } from '@modules/ad/core/application/types/waypoint.type';
import { Frequency, Role } from '@modules/ad/core/domain/ad.types'; import { Frequency, Role } from '@modules/ad/core/domain/ad.types';
import { CandidateEntity } from '@modules/ad/core/domain/candidate.entity'; import { CandidateEntity } from '@modules/ad/core/domain/candidate.entity';
import { bareMockGeorouter } from '../georouter.mock';
const originWaypoint: Waypoint = { const originWaypoint: Waypoint = {
position: 0, position: 0,
@ -40,10 +41,7 @@ const matchQuery = new MatchQuery(
strict: false, strict: false,
waypoints: [originWaypoint, destinationWaypoint], waypoints: [originWaypoint, destinationWaypoint],
}, },
{ bareMockGeorouter,
getBasic: jest.fn(),
getDetailed: jest.fn(),
},
); );
const candidates: CandidateEntity[] = [ const candidates: CandidateEntity[] = [

View File

@ -4,6 +4,7 @@ import { AlgorithmType } from '@modules/ad/core/application/types/algorithm.type
import { Waypoint } from '@modules/ad/core/application/types/waypoint.type'; import { Waypoint } from '@modules/ad/core/application/types/waypoint.type';
import { Frequency, Role } from '@modules/ad/core/domain/ad.types'; import { Frequency, Role } from '@modules/ad/core/domain/ad.types';
import { CandidateEntity } from '@modules/ad/core/domain/candidate.entity'; import { CandidateEntity } from '@modules/ad/core/domain/candidate.entity';
import { bareMockGeorouter } from '../georouter.mock';
const originWaypoint: Waypoint = { const originWaypoint: Waypoint = {
position: 0, position: 0,
@ -40,10 +41,7 @@ const matchQuery = new MatchQuery(
strict: false, strict: false,
waypoints: [originWaypoint, destinationWaypoint], waypoints: [originWaypoint, destinationWaypoint],
}, },
{ bareMockGeorouter,
getBasic: jest.fn(),
getDetailed: jest.fn(),
},
); );
const candidate: CandidateEntity = CandidateEntity.create({ const candidate: CandidateEntity = CandidateEntity.create({

View File

@ -5,6 +5,7 @@ import { AlgorithmType } from '@modules/ad/core/application/types/algorithm.type
import { Waypoint } from '@modules/ad/core/application/types/waypoint.type'; import { Waypoint } from '@modules/ad/core/application/types/waypoint.type';
import { Frequency } from '@modules/ad/core/domain/ad.types'; import { Frequency } from '@modules/ad/core/domain/ad.types';
import { CandidateEntity } from '@modules/ad/core/domain/candidate.entity'; import { CandidateEntity } from '@modules/ad/core/domain/candidate.entity';
import { bareMockGeorouter } from '../georouter.mock';
const originWaypoint: Waypoint = { const originWaypoint: Waypoint = {
position: 0, position: 0,
@ -47,10 +48,7 @@ const matchQuery = new MatchQuery(
strict: false, strict: false,
waypoints: [originWaypoint, destinationWaypoint], waypoints: [originWaypoint, destinationWaypoint],
}, },
{ bareMockGeorouter,
getBasic: jest.fn(),
getDetailed: jest.fn(),
},
); );
matchQuery.driverRoute = { matchQuery.driverRoute = {
distance: 150120, distance: 150120,

View File

@ -1,3 +1,7 @@
import {
RouteRequest,
RouteResponse,
} from '@modules/ad/core/application/ports/georouter-provider.port';
import { import {
RouteCompleter, RouteCompleter,
RouteCompleterType, RouteCompleterType,
@ -9,6 +13,8 @@ import { Frequency, Role } from '@modules/ad/core/domain/ad.types';
import { CandidateEntity } from '@modules/ad/core/domain/candidate.entity'; import { CandidateEntity } from '@modules/ad/core/domain/candidate.entity';
import { Target } from '@modules/ad/core/domain/candidate.types'; import { Target } from '@modules/ad/core/domain/candidate.types';
import { Actor } from '@modules/ad/core/domain/value-objects/actor.value-object'; 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';
const originWaypoint: Waypoint = { const originWaypoint: Waypoint = {
position: 0, position: 0,
@ -46,23 +52,16 @@ const matchQuery = new MatchQuery(
waypoints: [originWaypoint, destinationWaypoint], waypoints: [originWaypoint, destinationWaypoint],
}, },
{ {
getBasic: jest.fn().mockImplementation(() => ({ getRoute: jest
distance: 350101, .fn()
duration: 14422, .mockImplementation(async (req: RouteRequest): Promise<RouteResponse> => {
fwdAzimuth: 273, const response = await simpleMockGeorouter.getRoute(req);
backAzimuth: 93, if (req.detailsSettings?.steps) {
distanceAzimuth: 336544, const step: Step = { lon: 0, lat: 0, duration: 0 };
points: [], response.steps = [step, step, step, step];
})), }
getDetailed: jest.fn().mockImplementation(() => ({ return response;
distance: 350101, }),
duration: 14422,
fwdAzimuth: 273,
backAzimuth: 93,
distanceAzimuth: 336544,
points: [],
steps: [jest.fn(), jest.fn(), jest.fn(), jest.fn()],
})),
}, },
); );

View File

@ -0,0 +1,16 @@
import { GeorouterProviderPort } from '@modules/ad/core/application/ports/georouter-provider.port';
export const bareMockGeorouter: GeorouterProviderPort = {
getRoute: jest.fn(),
};
export const simpleMockGeorouter: GeorouterProviderPort = {
getRoute: jest.fn().mockImplementation(() => ({
distance: 350101,
duration: 14422,
fwdAzimuth: 273,
backAzimuth: 93,
distanceAzimuth: 336544,
points: [],
})),
};

View File

@ -4,7 +4,6 @@ import {
AD_ROUTE_PROVIDER, AD_ROUTE_PROVIDER,
} from '@modules/ad/ad.di-tokens'; } from '@modules/ad/ad.di-tokens';
import { AdMapper } from '@modules/ad/ad.mapper'; import { AdMapper } from '@modules/ad/ad.mapper';
import { RouteProviderPort } from '@modules/ad/core/application/ports/route-provider.port';
import { AdEntity } from '@modules/ad/core/domain/ad.entity'; import { AdEntity } from '@modules/ad/core/domain/ad.entity';
import { 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 { AdRepository } from '@modules/ad/infrastructure/ad.repository';
@ -12,6 +11,7 @@ import { PrismaService } from '@modules/ad/infrastructure/prisma.service';
import { DirectionEncoderPort } from '@modules/geography/core/application/ports/direction-encoder.port'; import { DirectionEncoderPort } from '@modules/geography/core/application/ports/direction-encoder.port';
import { EventEmitterModule } from '@nestjs/event-emitter'; import { EventEmitterModule } from '@nestjs/event-emitter';
import { Test, TestingModule } from '@nestjs/testing'; import { Test, TestingModule } from '@nestjs/testing';
import { bareMockGeorouter } from '../georouter.mock';
const mockMessagePublisher = { const mockMessagePublisher = {
publish: jest.fn().mockImplementation(), publish: jest.fn().mockImplementation(),
@ -73,11 +73,6 @@ const mockDirectionEncoder: DirectionEncoderPort = {
]), ]),
}; };
const mockRouteProvider: RouteProviderPort = {
getBasic: jest.fn(),
getDetailed: jest.fn(),
};
const mockPrismaService = { const mockPrismaService = {
$queryRawUnsafe: jest $queryRawUnsafe: jest
.fn() .fn()
@ -239,7 +234,7 @@ describe('Ad repository', () => {
}, },
{ {
provide: AD_ROUTE_PROVIDER, provide: AD_ROUTE_PROVIDER,
useValue: mockRouteProvider, useValue: bareMockGeorouter,
}, },
{ {
provide: AD_MESSAGE_PUBLISHER, provide: AD_MESSAGE_PUBLISHER,

View File

@ -1,110 +0,0 @@
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 { RouteProvider } from '@modules/ad/infrastructure/route-provider';
import { GetRouteControllerPort } from '@modules/geography/core/application/ports/get-route-controller.port';
import { Test, TestingModule } from '@nestjs/testing';
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: 336544,
points: [
{
lon: 6.1765102,
lat: 48.689445,
},
{
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,
},
],
})),
};
describe('Route provider', () => {
let routeProvider: RouteProvider;
beforeAll(async () => {
const module: TestingModule = await Test.createTestingModule({
providers: [
RouteProvider,
{
provide: AD_GET_BASIC_ROUTE_CONTROLLER,
useValue: mockGetBasicRouteController,
},
{
provide: AD_GET_DETAILED_ROUTE_CONTROLLER,
useValue: mockGetDetailedRouteController,
},
],
}).compile();
routeProvider = module.get<RouteProvider>(RouteProvider);
});
it('should be defined', () => {
expect(routeProvider).toBeDefined();
});
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);
});
});

View File

@ -1,6 +1,5 @@
import { RpcExceptionCode } from '@mobicoop/ddd-library'; import { RpcExceptionCode } from '@mobicoop/ddd-library';
import { AD_ROUTE_PROVIDER } from '@modules/ad/ad.di-tokens'; 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 { MatchingResult } from '@modules/ad/core/application/queries/match/match.query-handler';
import { AlgorithmType } from '@modules/ad/core/application/types/algorithm.types'; import { AlgorithmType } from '@modules/ad/core/application/types/algorithm.types';
import { Frequency, Role } from '@modules/ad/core/domain/ad.types'; import { Frequency, Role } from '@modules/ad/core/domain/ad.types';
@ -17,6 +16,7 @@ import { CACHE_MANAGER } from '@nestjs/cache-manager';
import { QueryBus } from '@nestjs/cqrs'; import { QueryBus } from '@nestjs/cqrs';
import { RpcException } from '@nestjs/microservices'; import { RpcException } from '@nestjs/microservices';
import { Test, TestingModule } from '@nestjs/testing'; import { Test, TestingModule } from '@nestjs/testing';
import { bareMockGeorouter } from '../georouter.mock';
const originWaypoint: WaypointDto = { const originWaypoint: WaypointDto = {
position: 0, position: 0,
@ -183,11 +183,6 @@ const mockQueryBus = {
}), }),
}; };
const mockRouteProvider: RouteProviderPort = {
getBasic: jest.fn(),
getDetailed: jest.fn(),
};
const mockMatchMapper = { const mockMatchMapper = {
toResponse: jest.fn().mockImplementation(() => ({ toResponse: jest.fn().mockImplementation(() => ({
adId: '53a0bf71-4132-4f7b-a4cc-88c796b6bdf1', adId: '53a0bf71-4132-4f7b-a4cc-88c796b6bdf1',
@ -286,7 +281,7 @@ describe('Match Grpc Controller', () => {
}, },
{ {
provide: AD_ROUTE_PROVIDER, provide: AD_ROUTE_PROVIDER,
useValue: mockRouteProvider, useValue: bareMockGeorouter,
}, },
{ {
provide: MatchMapper, provide: MatchMapper,

View File

@ -1,13 +0,0 @@
export interface GeodesicPort {
inverse(
lon1: number,
lat1: number,
lon2: number,
lat2: number,
): {
azimuth: number;
distance: number;
};
distance(lon1: number, lat1: number, lon2: number, lat2: number): number;
azimuth(lon1: number, lat1: number, lon2: number, lat2: number): number;
}

View File

@ -1,6 +0,0 @@
import { Route, Point } from '../../domain/route.types';
import { GeorouterSettings } from '../types/georouter-settings.type';
export interface GeorouterPort {
route(waypoints: Point[], settings: GeorouterSettings): Promise<Route>;
}

View File

@ -1,6 +0,0 @@
import { GetRouteRequestDto } from '@modules/geography/interface/controllers/dtos/get-route.request.dto';
import { RouteResponseDto } from '@modules/geography/interface/dtos/route.response.dto';
export interface GetRouteControllerPort {
get(data: GetRouteRequestDto): Promise<RouteResponseDto>;
}

View File

@ -1,18 +0,0 @@
import { IQueryHandler, QueryHandler } from '@nestjs/cqrs';
import { GetRouteQuery } from './get-route.query';
import { RouteEntity } from '@modules/geography/core/domain/route.entity';
import { Inject } from '@nestjs/common';
import { GEOROUTER } from '@modules/geography/geography.di-tokens';
import { GeorouterPort } from '../../ports/georouter.port';
@QueryHandler(GetRouteQuery)
export class GetRouteQueryHandler implements IQueryHandler {
constructor(@Inject(GEOROUTER) private readonly georouter: GeorouterPort) {}
execute = async (query: GetRouteQuery): Promise<RouteEntity> =>
await RouteEntity.create({
waypoints: query.waypoints,
georouter: this.georouter,
georouterSettings: query.georouterSettings,
});
}

View File

@ -1,21 +0,0 @@
import { QueryBase } from '@mobicoop/ddd-library';
import { GeorouterSettings } from '../../types/georouter-settings.type';
import { Point } from '@modules/geography/core/domain/route.types';
export class GetRouteQuery extends QueryBase {
readonly waypoints: Point[];
readonly georouterSettings: GeorouterSettings;
constructor(
waypoints: Point[],
georouterSettings: GeorouterSettings = {
detailedDistance: false,
detailedDuration: false,
points: true,
},
) {
super();
this.waypoints = waypoints;
this.georouterSettings = georouterSettings;
}
}

View File

@ -1,5 +0,0 @@
export type GeorouterSettings = {
points: boolean;
detailedDuration: boolean;
detailedDistance: boolean;
};

View File

@ -1,33 +0,0 @@
import { AggregateRoot, AggregateID } from '@mobicoop/ddd-library';
import { CreateRouteProps, RouteProps, Route } from './route.types';
import { v4 } from 'uuid';
import { RouteNotFoundException } from './route.errors';
export class RouteEntity extends AggregateRoot<RouteProps> {
protected readonly _id: AggregateID;
static create = async (create: CreateRouteProps): Promise<RouteEntity> => {
const route: Route = await create.georouter.route(
create.waypoints,
create.georouterSettings,
);
if (!route) throw new RouteNotFoundException();
const routeProps: RouteProps = {
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(),
props: routeProps,
});
};
validate(): void {
// entity business rules validation to protect it's invariant before saving entity to a database
}
}

View File

@ -1,21 +0,0 @@
import { ExceptionBase } from '@mobicoop/ddd-library';
export class RouteNotFoundException extends ExceptionBase {
static readonly message = 'Route not found';
public readonly code = 'ROUTE.NOT_FOUND';
constructor(cause?: Error, metadata?: unknown) {
super(RouteNotFoundException.message, cause, metadata);
}
}
export class GeorouterUnavailableException extends ExceptionBase {
static readonly message = 'Georouter unavailable';
public readonly code = 'GEOROUTER.UNAVAILABLE';
constructor(cause?: Error, metadata?: unknown) {
super(GeorouterUnavailableException.message, cause, metadata);
}
}

View File

@ -1,26 +1,3 @@
import { GeorouterPort } from '../application/ports/georouter.port';
import { GeorouterSettings } from '../application/types/georouter-settings.type';
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 {
distance: number;
duration: number;
fwdAzimuth: number;
backAzimuth: number;
distanceAzimuth: number;
points: PointProps[];
steps?: StepProps[];
}
// Properties that are needed for a Route creation
export interface CreateRouteProps {
waypoints: PointProps[];
georouter: GeorouterPort;
georouterSettings: GeorouterSettings;
}
// Types used outside the domain // Types used outside the domain
export type Route = { export type Route = {
distance: number; distance: number;

View File

@ -1,31 +0,0 @@
import {
ArgumentOutOfRangeException,
ValueObject,
} from '@mobicoop/ddd-library';
/** Note:
* Value Objects with multiple properties can contain
* other Value Objects inside if needed.
* */
export interface PointProps {
lon: number;
lat: number;
}
export class Point extends ValueObject<PointProps> {
get lon(): number {
return this.props.lon;
}
get lat(): number {
return this.props.lat;
}
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)
throw new ArgumentOutOfRangeException('lat must be between -90 and 90');
}
}

View File

@ -1,46 +0,0 @@
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<StepProps> {
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',
);
}
}

View File

@ -1,15 +0,0 @@
import { KeyType, Type } from '@mobicoop/configuration-module';
export const GEOGRAPHY_CONFIG_GEOROUTER_TYPE = 'georouterType';
export const GEOGRAPHY_CONFIG_GEOROUTER_URL = 'georouterUrl';
export const GeographyKeyTypes: KeyType[] = [
{
key: GEOGRAPHY_CONFIG_GEOROUTER_TYPE,
type: Type.STRING,
},
{
key: GEOGRAPHY_CONFIG_GEOROUTER_URL,
type: Type.STRING,
},
];

View File

@ -1,6 +1,4 @@
export const DIRECTION_ENCODER = Symbol('DIRECTION_ENCODER'); export const DIRECTION_ENCODER = Symbol('DIRECTION_ENCODER');
export const GEOROUTER = Symbol('GEOROUTER');
export const GEODESIC = Symbol('GEODESIC');
export const GEOGRAPHY_CONFIGURATION_REPOSITORY = Symbol( export const GEOGRAPHY_CONFIGURATION_REPOSITORY = Symbol(
'GEOGRAPHY_CONFIGURATION_REPOSITORY', 'GEOGRAPHY_CONFIGURATION_REPOSITORY',
); );

View File

@ -2,24 +2,12 @@ import { Module, Provider } from '@nestjs/common';
import { CqrsModule } from '@nestjs/cqrs'; import { CqrsModule } from '@nestjs/cqrs';
import { import {
DIRECTION_ENCODER, DIRECTION_ENCODER,
GEODESIC,
GEOGRAPHY_CONFIGURATION_REPOSITORY, GEOGRAPHY_CONFIGURATION_REPOSITORY,
GEOROUTER,
} from './geography.di-tokens'; } from './geography.di-tokens';
import { PostgresDirectionEncoder } from './infrastructure/postgres-direction-encoder'; import { PostgresDirectionEncoder } from './infrastructure/postgres-direction-encoder';
import { GetBasicRouteController } from './interface/controllers/get-basic-route.controller';
import { RouteMapper } from './route.mapper';
import { Geodesic } from './infrastructure/geodesic';
import { GraphhopperGeorouter } from './infrastructure/graphhopper-georouter';
import { HttpModule } from '@nestjs/axios'; 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';
import { ConfigurationRepository } from '@mobicoop/configuration-module'; import { ConfigurationRepository } from '@mobicoop/configuration-module';
const queryHandlers: Provider[] = [GetRouteQueryHandler];
const mappers: Provider[] = [RouteMapper];
const adapters: Provider[] = [ const adapters: Provider[] = [
{ {
provide: GEOGRAPHY_CONFIGURATION_REPOSITORY, provide: GEOGRAPHY_CONFIGURATION_REPOSITORY,
@ -29,26 +17,11 @@ const adapters: Provider[] = [
provide: DIRECTION_ENCODER, provide: DIRECTION_ENCODER,
useClass: PostgresDirectionEncoder, useClass: PostgresDirectionEncoder,
}, },
{
provide: GEOROUTER,
useClass: GraphhopperGeorouter,
},
{
provide: GEODESIC,
useClass: Geodesic,
},
GetBasicRouteController,
GetDetailedRouteController,
]; ];
@Module({ @Module({
imports: [CqrsModule, HttpModule], imports: [CqrsModule, HttpModule],
providers: [...queryHandlers, ...mappers, ...adapters], providers: [...adapters],
exports: [ exports: [DIRECTION_ENCODER],
RouteMapper,
DIRECTION_ENCODER,
GetBasicRouteController,
GetDetailedRouteController,
],
}) })
export class GeographyModule {} export class GeographyModule {}

View File

@ -1,59 +0,0 @@
import { Injectable } from '@nestjs/common';
import { Geodesic as Geolib, GeodesicClass } from 'geographiclib-geodesic';
import { GeodesicPort } from '../core/application/ports/geodesic.port';
@Injectable()
export class Geodesic implements GeodesicPort {
private geod: GeodesicClass;
constructor() {
this.geod = Geolib.WGS84;
}
inverse = (
lon1: number,
lat1: number,
lon2: number,
lat2: number,
): { azimuth: number; distance: number } => {
const { azi2: azimuth, s12: distance } = this.geod.Inverse(
lat1,
lon1,
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;
};
}

View File

@ -1,342 +0,0 @@
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 { Route, Step, Point } from '../core/domain/route.types';
import {
GEODESIC,
GEOGRAPHY_CONFIGURATION_REPOSITORY,
} from '../geography.di-tokens';
import { catchError, lastValueFrom, map } from 'rxjs';
import { AxiosError, AxiosResponse } from 'axios';
import {
GeorouterUnavailableException,
RouteNotFoundException,
} from '../core/domain/route.errors';
import { GeodesicPort } from '../core/application/ports/geodesic.port';
import {
Domain,
Configurator,
GetConfigurationRepositoryPort,
} from '@mobicoop/configuration-module';
import {
GEOGRAPHY_CONFIG_GEOROUTER_URL,
GeographyKeyTypes,
} from '../geography.constants';
@Injectable()
export class GraphhopperGeorouter implements GeorouterPort {
private url: string;
private urlArgs: string[];
constructor(
private readonly httpService: HttpService,
@Inject(GEOGRAPHY_CONFIGURATION_REPOSITORY)
private readonly configurationRepository: GetConfigurationRepositoryPort,
@Inject(GEODESIC) private readonly geodesic: GeodesicPort,
) {}
route = async (
waypoints: Point[],
settings: GeorouterSettings,
): Promise<Route> => {
const geographyConfigurator: Configurator =
await this.configurationRepository.mget(
Domain.GEOGRAPHY,
GeographyKeyTypes,
);
this.url = [
geographyConfigurator.get<string>(GEOGRAPHY_CONFIG_GEOROUTER_URL),
'/route?',
].join('');
this._setDefaultUrlArgs();
this._setSettings(settings);
return this._getRoute(waypoints);
};
private _setDefaultUrlArgs = (): void => {
this.urlArgs = ['profile=car', 'points_encoded=false'];
};
private _setSettings = (settings: GeorouterSettings): void => {
if (settings.detailedDuration) {
this.urlArgs.push('details=time');
}
if (settings.detailedDistance) {
this.urlArgs.push('instructions=true');
} else {
this.urlArgs.push('instructions=false');
}
if (!settings.points) {
this.urlArgs.push('calc_points=false');
}
};
private _getRoute = async (waypoints: Point[]): Promise<Route> => {
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);
}),
),
);
};
private getUrl = (): string => [this.url, this.urlArgs.join('&')].join('');
private createRoute = (
response: AxiosResponse<GraphhopperResponse>,
): Route => {
const route = {} as Route;
if (response.data.paths && response.data.paths[0]) {
const shortestPath = response.data.paths[0];
route.distance = shortestPath.distance ?? 0;
route.duration = shortestPath.time ? shortestPath.time / 1000 : 0;
if (shortestPath.points && shortestPath.points.coordinates) {
route.points = shortestPath.points.coordinates.map((coordinate) => ({
lon: coordinate[0],
lat: coordinate[1],
}));
const inverse = this.geodesic.inverse(
route.points[0].lon,
route.points[0].lat,
route.points[route.points.length - 1].lon,
route.points[route.points.length - 1].lat,
);
route.fwdAzimuth =
inverse.azimuth >= 0
? inverse.azimuth
: 360 - Math.abs(inverse.azimuth);
route.backAzimuth =
route.fwdAzimuth > 180
? route.fwdAzimuth - 180
: route.fwdAzimuth + 180;
route.distanceAzimuth = inverse.distance;
if (
shortestPath.details &&
shortestPath.details.time &&
shortestPath.snapped_waypoints &&
shortestPath.snapped_waypoints.coordinates
) {
let instructions: GraphhopperInstruction[] = [];
if (shortestPath.instructions)
instructions = shortestPath.instructions;
route.steps = this.generateSteps(
shortestPath.points.coordinates,
shortestPath.snapped_waypoints.coordinates,
shortestPath.details.time,
instructions,
);
}
}
}
return route;
};
private generateSteps = (
points: [[number, number]],
snappedWaypoints: [[number, number]],
durations: [[number, number, number]],
instructions: GraphhopperInstruction[],
): Step[] => {
const indices = this.getIndices(points, snappedWaypoints);
const times = this.getTimes(durations, indices);
const distances = this.getDistances(instructions, indices);
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: number[] = snappedWaypoints.map(
(waypoint: [number, number]) =>
points.findIndex(
(point) => point[0] == waypoint[0] && point[1] == waypoint[1],
),
);
if (indices.find((index: number) => index == -1) === undefined)
return indices;
const missedWaypoints = indices
.map(
(value, index) =>
<
{
index: number;
originIndex: number;
waypoint: number[];
nearest?: number;
distance: number;
}
>{
index: value,
originIndex: index,
waypoint: snappedWaypoints[index],
nearest: undefined,
distance: 999999999,
},
)
.filter((element) => element.index == -1);
for (const index in points) {
for (const missedWaypoint of missedWaypoints) {
const distance = this.geodesic.distance(
missedWaypoint.waypoint[0],
missedWaypoint.waypoint[1],
points[index][0],
points[index][1],
);
if (distance < missedWaypoint.distance) {
missedWaypoint.distance = distance;
missedWaypoint.nearest = parseInt(index);
}
}
}
for (const missedWaypoint of missedWaypoints) {
indices[missedWaypoint.originIndex] = missedWaypoint.nearest as number;
}
return indices;
};
private getTimes = (
durations: [[number, number, number]],
indices: number[],
): Array<{ index: number; duration: number }> => {
const times: Array<{ index: number; duration: number }> = [];
let duration = 0;
for (const [origin, destination, stepDuration] of durations) {
let indexFound = false;
const indexAsOrigin = indices.find((index) => index == origin);
if (
indexAsOrigin !== undefined &&
times.find((time) => origin == time.index) == undefined
) {
times.push({
index: indexAsOrigin,
duration: Math.round(stepDuration / 1000),
});
indexFound = true;
}
if (!indexFound) {
const indexAsDestination = indices.find(
(index) => index == destination,
);
if (
indexAsDestination !== undefined &&
times.find((time) => destination == time.index) == undefined
) {
times.push({
index: indexAsDestination,
duration: Math.round((duration + stepDuration) / 1000),
});
indexFound = true;
}
}
if (!indexFound) {
const indexInBetween = indices.find(
(index) => origin < index && index < destination,
);
if (indexInBetween !== undefined) {
times.push({
index: indexInBetween,
duration: Math.round((duration + stepDuration / 2) / 1000),
});
}
}
duration += stepDuration;
}
return times;
};
private getDistances = (
instructions: GraphhopperInstruction[],
indices: number[],
): Array<{ index: number; distance: number }> => {
let distance = 0;
const distances: Array<{ index: number; distance: number }> = [
{
index: 0,
distance,
},
];
for (const instruction of instructions) {
distance += instruction.distance;
if (
(instruction.sign == GraphhopperSign.SIGN_WAYPOINT ||
instruction.sign == GraphhopperSign.SIGN_FINISH) &&
indices.find((index) => index == instruction.interval[0]) !== undefined
) {
distances.push({
index: instruction.interval[0],
distance: Math.round(distance),
});
}
}
return distances;
};
}
type GraphhopperResponse = {
paths: [
{
distance: number;
weight: number;
time: number;
points_encoded: boolean;
bbox: number[];
points: GraphhopperCoordinates;
snapped_waypoints: GraphhopperCoordinates;
details: {
time: [[number, number, number]];
};
instructions: GraphhopperInstruction[];
},
];
};
type GraphhopperCoordinates = {
coordinates: [[number, number]];
};
type GraphhopperInstruction = {
distance: number;
heading: number;
sign: GraphhopperSign;
interval: [number, number];
text: string;
};
enum GraphhopperSign {
SIGN_START = 0,
SIGN_FINISH = 4,
SIGN_WAYPOINT = 5,
}

View File

@ -1,5 +0,0 @@
import { Point } from '@modules/geography/core/domain/route.types';
export type GetRouteRequestDto = {
waypoints: Point[];
};

View File

@ -1,23 +0,0 @@
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 GetBasicRouteController implements GetRouteControllerPort {
constructor(
private readonly queryBus: QueryBus,
private readonly mapper: RouteMapper,
) {}
async get(data: GetRouteRequestDto): Promise<RouteResponseDto> {
const route: RouteEntity = await this.queryBus.execute(
new GetRouteQuery(data.waypoints),
);
return this.mapper.toResponse(route);
}
}

View File

@ -1,27 +0,0 @@
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<RouteResponseDto> {
const route: RouteEntity = await this.queryBus.execute(
new GetRouteQuery(data.waypoints, {
detailedDistance: true,
detailedDuration: true,
points: true,
}),
);
return this.mapper.toResponse(route);
}
}

View File

@ -1,11 +0,0 @@
import { Point, Step } from '@modules/geography/core/domain/route.types';
export class RouteResponseDto {
distance: number;
duration: number;
fwdAzimuth: number;
backAzimuth: number;
distanceAzimuth: number;
points: Point[];
steps?: Step[];
}

View File

@ -1,28 +0,0 @@
import { Mapper } from '@mobicoop/ddd-library';
import { Injectable } from '@nestjs/common';
import { RouteEntity } from './core/domain/route.entity';
import { RouteResponseDto } from './interface/dtos/route.response.dto';
/**
* Mapper constructs objects that are used in different layers:
* Record is an object that is stored in a database,
* Entity is an object that is used in application domain layer,
* and a ResponseDTO is an object returned to a user (usually as json).
*/
@Injectable()
export class RouteMapper
implements Mapper<RouteEntity, undefined, undefined, RouteResponseDto>
{
toResponse = (entity: RouteEntity): RouteResponseDto => {
const response = new RouteResponseDto();
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;
};
}

View File

@ -1,61 +0,0 @@
import { GeorouterPort } from '@modules/geography/core/application/ports/georouter.port';
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 { Point } from '@modules/geography/core/domain/route.types';
import { GEOROUTER } from '@modules/geography/geography.di-tokens';
import { Test, TestingModule } from '@nestjs/testing';
const originWaypoint: Point = {
lat: 48.689445,
lon: 6.17651,
};
const destinationWaypoint: Point = {
lat: 48.8566,
lon: 2.3522,
};
const mockGeorouter: GeorouterPort = {
route: jest.fn(),
};
describe('Get route query handler', () => {
let getRoutequeryHandler: GetRouteQueryHandler;
beforeAll(async () => {
const module: TestingModule = await Test.createTestingModule({
providers: [
{
provide: GEOROUTER,
useValue: mockGeorouter,
},
GetRouteQueryHandler,
],
}).compile();
getRoutequeryHandler =
module.get<GetRouteQueryHandler>(GetRouteQueryHandler);
});
it('should be defined', () => {
expect(getRoutequeryHandler).toBeDefined();
});
describe('execution', () => {
it('should get a route', async () => {
const getRoutequery = new GetRouteQuery(
[originWaypoint, destinationWaypoint],
{
detailedDistance: false,
detailedDuration: false,
points: true,
},
);
RouteEntity.create = jest.fn().mockReturnValue({
id: '047a6ecf-23d4-4d3e-877c-3225d560a8da',
});
const result = await getRoutequeryHandler.execute(getRoutequery);
expect(result.id).toBe('047a6ecf-23d4-4d3e-877c-3225d560a8da');
});
});
});

View File

@ -1,41 +0,0 @@
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);
});
});

View File

@ -1,70 +0,0 @@
import { GeorouterPort } from '@modules/geography/core/application/ports/georouter.port';
import { RouteEntity } from '@modules/geography/core/domain/route.entity';
import { RouteNotFoundException } from '@modules/geography/core/domain/route.errors';
import {
Point,
CreateRouteProps,
} from '@modules/geography/core/domain/route.types';
const originPoint: Point = {
lat: 48.689445,
lon: 6.17651,
};
const destinationPoint: Point = {
lat: 48.8566,
lon: 2.3522,
};
const mockGeorouter: GeorouterPort = {
route: jest
.fn()
.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 createRouteProps: CreateRouteProps = {
waypoints: [originPoint, destinationPoint],
georouter: mockGeorouter,
georouterSettings: {
points: true,
detailedDistance: false,
detailedDuration: false,
},
};
describe('Route entity create', () => {
it('should create a new entity', async () => {
const route: RouteEntity = await RouteEntity.create(createRouteProps);
expect(route.id.length).toBe(36);
expect(route.getProps().duration).toBe(14422);
});
it('should throw an exception if route is not found', async () => {
try {
await RouteEntity.create(createRouteProps);
} catch (e: any) {
expect(e).toBeInstanceOf(RouteNotFoundException);
}
});
});

View File

@ -1,76 +0,0 @@
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);
});
});

View File

@ -1,36 +0,0 @@
import { Geodesic } from '@modules/geography/infrastructure/geodesic';
describe('Matcher geodesic', () => {
it('should be defined', () => {
const geodesic: Geodesic = new Geodesic();
expect(geodesic).toBeDefined();
});
it('should get inverse values', () => {
const geodesic: Geodesic = new Geodesic();
const inv = geodesic.inverse(0, 0, 1, 1);
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();
});
});

View File

@ -1,508 +0,0 @@
import {
Domain,
KeyType,
Configurator,
GetConfigurationRepositoryPort,
} from '@mobicoop/configuration-module';
import { GeodesicPort } from '@modules/geography/core/application/ports/geodesic.port';
import {
GeorouterUnavailableException,
RouteNotFoundException,
} from '@modules/geography/core/domain/route.errors';
import { Route, Step } from '@modules/geography/core/domain/route.types';
import { GEOGRAPHY_CONFIG_GEOROUTER_URL } from '@modules/geography/geography.constants';
import {
GEODESIC,
GEOGRAPHY_CONFIGURATION_REPOSITORY,
} from '@modules/geography/geography.di-tokens';
import { GraphhopperGeorouter } from '@modules/geography/infrastructure/graphhopper-georouter';
import { HttpService } from '@nestjs/axios';
import { Test, TestingModule } from '@nestjs/testing';
import { AxiosError } from 'axios';
import { of, throwError } from 'rxjs';
const mockHttpService = {
get: jest
.fn()
.mockImplementationOnce(() => {
return throwError(
() => new AxiosError('Axios error', AxiosError.ERR_BAD_REQUEST),
);
})
.mockImplementationOnce(() => {
return throwError(() => 'Router unavailable');
})
.mockImplementationOnce(() => {
return of({
status: 200,
});
})
.mockImplementationOnce(() => {
return of({
status: 200,
data: {
paths: [
{
distance: 50000,
time: 1800000,
snapped_waypoints: {
coordinates: [
[0, 0],
[10, 10],
],
},
},
],
},
});
})
.mockImplementationOnce(() => {
return of({
status: 200,
data: {
paths: [
{
distance: 50000,
time: 1800000,
points: {
coordinates: [
[0, 0],
[1, 1],
[2, 2],
[3, 3],
[4, 4],
[5, 5],
[6, 6],
[7, 7],
[8, 8],
[9, 9],
[10, 10],
],
},
snapped_waypoints: {
coordinates: [
[0, 0],
[10, 10],
],
},
},
],
},
});
})
.mockImplementationOnce(() => {
return of({
status: 200,
data: {
paths: [
{
distance: 50000,
time: 1800000,
points: {
coordinates: [
[0, 0],
[1, 1],
[2, 2],
[3, 3],
[4, 4],
[5, 5],
[6, 6],
[7, 7],
[8, 8],
[9, 9],
[10, 10],
],
},
details: {
time: [
[0, 1, 180000],
[1, 2, 180000],
[2, 3, 180000],
[3, 4, 180000],
[4, 5, 180000],
[5, 6, 180000],
[6, 7, 180000],
[7, 9, 360000],
[9, 10, 180000],
],
},
snapped_waypoints: {
coordinates: [
[0, 0],
[10, 10],
],
},
},
],
},
});
})
.mockImplementationOnce(() => {
return of({
status: 200,
data: {
paths: [
{
distance: 50000,
time: 1800000,
points: {
coordinates: [
[0, 0],
[1, 1],
[2, 2],
[3, 3],
[4, 4],
[7, 7],
[8, 8],
[9, 9],
[10, 10],
],
},
snapped_waypoints: {
coordinates: [
[0, 0],
[5, 5],
[10, 10],
],
},
details: {
time: [
[0, 1, 180000],
[1, 2, 180000],
[2, 3, 180000],
[3, 4, 180000],
[4, 7, 540000],
[7, 9, 360000],
[9, 10, 180000],
],
},
},
],
},
});
})
.mockImplementationOnce(() => {
return of({
status: 200,
data: {
paths: [
{
distance: 50000,
time: 1800000,
points: {
coordinates: [
[0, 0],
[1, 1],
[2, 2],
[3, 3],
[4, 4],
[5, 5],
[6, 6],
[7, 7],
[8, 8],
[9, 9],
[10, 10],
],
},
snapped_waypoints: {
coordinates: [
[0, 0],
[5, 5],
[10, 10],
],
},
details: {
time: [
[0, 1, 180000],
[1, 2, 180000],
[2, 3, 180000],
[3, 4, 180000],
[4, 7, 540000],
[7, 9, 360000],
[9, 10, 180000],
],
},
instructions: [
{
distance: 25000,
sign: 0,
interval: [0, 5],
text: 'Some instructions',
time: 900000,
},
{
distance: 0,
sign: 5,
interval: [5, 5],
text: 'Waypoint 1',
time: 0,
},
{
distance: 25000,
sign: 2,
interval: [5, 10],
text: 'Some instructions',
time: 900000,
},
{
distance: 0.0,
sign: 4,
interval: [10, 10],
text: 'Arrive at destination',
time: 0,
},
],
},
],
},
});
}),
};
const mockGeodesic: GeodesicPort = {
inverse: jest.fn().mockImplementation(() => ({
azimuth: 45,
distance: 50000,
})),
azimuth: jest.fn().mockImplementation(() => 45),
distance: jest.fn().mockImplementation(() => 50000),
};
const mockConfigurationRepository: GetConfigurationRepositoryPort = {
get: jest.fn(),
mget: jest.fn().mockImplementation(
// eslint-disable-next-line @typescript-eslint/no-unused-vars
(domain: Domain, keyTypes: KeyType[]) => {
switch (domain) {
case Domain.GEOGRAPHY:
return new Configurator(Domain.GEOGRAPHY, [
{
domain: Domain.GEOGRAPHY,
key: GEOGRAPHY_CONFIG_GEOROUTER_URL,
value: 'http://localhost:8989',
},
]);
}
},
),
};
describe('Graphhopper Georouter', () => {
let graphhopperGeorouter: GraphhopperGeorouter;
beforeAll(async () => {
const module: TestingModule = await Test.createTestingModule({
imports: [],
providers: [
GraphhopperGeorouter,
{
provide: HttpService,
useValue: mockHttpService,
},
{
provide: GEOGRAPHY_CONFIGURATION_REPOSITORY,
useValue: mockConfigurationRepository,
},
{
provide: GEODESIC,
useValue: mockGeodesic,
},
],
}).compile();
graphhopperGeorouter =
module.get<GraphhopperGeorouter>(GraphhopperGeorouter);
});
it('should be defined', () => {
expect(graphhopperGeorouter).toBeDefined();
});
it('should fail if route is not found', async () => {
await expect(
graphhopperGeorouter.route(
[
{
lon: 0,
lat: 0,
},
{
lon: 1,
lat: 1,
},
],
{
detailedDistance: false,
detailedDuration: false,
points: false,
},
),
).rejects.toBeInstanceOf(RouteNotFoundException);
});
it('should fail if georouter is unavailable', async () => {
await expect(
graphhopperGeorouter.route(
[
{
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,
},
],
{
detailedDistance: false,
detailedDuration: false,
points: false,
},
),
).rejects.toBeInstanceOf(GeorouterUnavailableException);
});
it('should create a basic route', async () => {
const route: Route = await graphhopperGeorouter.route(
[
{
lon: 0,
lat: 0,
},
{
lon: 10,
lat: 10,
},
],
{
detailedDistance: false,
detailedDuration: false,
points: false,
},
);
expect(route.distance).toBe(50000);
});
it('should create a route with points', async () => {
const route: Route = await graphhopperGeorouter.route(
[
{
lon: 0,
lat: 0,
},
{
lon: 10,
lat: 10,
},
],
{
detailedDistance: false,
detailedDuration: false,
points: true,
},
);
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 a route with points and time', async () => {
const route: Route = await graphhopperGeorouter.route(
[
{
lon: 0,
lat: 0,
},
{
lon: 10,
lat: 10,
},
],
{
detailedDistance: false,
detailedDuration: true,
points: true,
},
);
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 route: Route = await graphhopperGeorouter.route(
[
{
lon: 0,
lat: 0,
},
{
lon: 5,
lat: 5,
},
{
lon: 10,
lat: 10,
},
],
{
detailedDistance: false,
detailedDuration: true,
points: true,
},
);
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 a route with points, time and distance', async () => {
const route: Route = await graphhopperGeorouter.route(
[
{
lon: 0,
lat: 0,
},
{
lon: 10,
lat: 10,
},
],
{
detailedDistance: true,
detailedDuration: true,
points: true,
},
);
expect(route.steps).toHaveLength(3);
expect((route.steps as Step[])[1].duration).toBe(990);
expect((route.steps as Step[])[1].distance).toBe(25000);
});
});

View File

@ -1,63 +0,0 @@
import { GetBasicRouteController } from '@modules/geography/interface/controllers/get-basic-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 Basic Route Controller', () => {
let getBasicRouteController: GetBasicRouteController;
beforeAll(async () => {
const module: TestingModule = await Test.createTestingModule({
providers: [
{
provide: QueryBus,
useValue: mockQueryBus,
},
{
provide: RouteMapper,
useValue: mockRouteMapper,
},
GetBasicRouteController,
],
}).compile();
getBasicRouteController = module.get<GetBasicRouteController>(
GetBasicRouteController,
);
});
afterEach(async () => {
jest.clearAllMocks();
});
it('should be defined', () => {
expect(getBasicRouteController).toBeDefined();
});
it('should get a route', async () => {
jest.spyOn(mockQueryBus, 'execute');
await getBasicRouteController.get({
waypoints: [
{
lat: 48.689445,
lon: 6.17651,
},
{
lat: 48.8566,
lon: 2.3522,
},
],
});
expect(mockQueryBus.execute).toHaveBeenCalledTimes(1);
});
});

View File

@ -1,63 +0,0 @@
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>(
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);
});
});

View File

@ -1,45 +0,0 @@
import { RouteEntity } from '@modules/geography/core/domain/route.entity';
import { RouteMapper } from '@modules/geography/route.mapper';
import { Test } from '@nestjs/testing';
describe('Route Mapper', () => {
let routeMapper: RouteMapper;
beforeAll(async () => {
const module = await Test.createTestingModule({
providers: [RouteMapper],
}).compile();
routeMapper = module.get<RouteMapper>(RouteMapper);
});
it('should be defined', () => {
expect(routeMapper).toBeDefined();
});
it('should map domain entity to response', async () => {
const now = new Date();
const routeEntity: RouteEntity = new RouteEntity({
id: '047a6ecf-23d4-4d3e-877c-3225d560a8da',
createdAt: now,
updatedAt: now,
props: {
distance: 23000,
duration: 900,
fwdAzimuth: 283,
backAzimuth: 93,
distanceAzimuth: 19840,
points: [
{
lon: 6.1765103,
lat: 48.689446,
},
{
lon: 2.3523,
lat: 48.8567,
},
],
},
});
expect(routeMapper.toResponse(routeEntity).distance).toBe(23000);
});
});