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

@@ -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
export type Route = {
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 GEOROUTER = Symbol('GEOROUTER');
export const GEODESIC = Symbol('GEODESIC');
export const GEOGRAPHY_CONFIGURATION_REPOSITORY = Symbol(
'GEOGRAPHY_CONFIGURATION_REPOSITORY',
);

View File

@@ -2,24 +2,12 @@ import { Module, Provider } from '@nestjs/common';
import { CqrsModule } from '@nestjs/cqrs';
import {
DIRECTION_ENCODER,
GEODESIC,
GEOGRAPHY_CONFIGURATION_REPOSITORY,
GEOROUTER,
} from './geography.di-tokens';
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 { 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';
const queryHandlers: Provider[] = [GetRouteQueryHandler];
const mappers: Provider[] = [RouteMapper];
const adapters: Provider[] = [
{
provide: GEOGRAPHY_CONFIGURATION_REPOSITORY,
@@ -29,26 +17,11 @@ const adapters: Provider[] = [
provide: DIRECTION_ENCODER,
useClass: PostgresDirectionEncoder,
},
{
provide: GEOROUTER,
useClass: GraphhopperGeorouter,
},
{
provide: GEODESIC,
useClass: Geodesic,
},
GetBasicRouteController,
GetDetailedRouteController,
];
@Module({
imports: [CqrsModule, HttpModule],
providers: [...queryHandlers, ...mappers, ...adapters],
exports: [
RouteMapper,
DIRECTION_ENCODER,
GetBasicRouteController,
GetDetailedRouteController,
],
providers: [...adapters],
exports: [DIRECTION_ENCODER],
})
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);
});
});