tests and refactor for ad geography

This commit is contained in:
sbriat 2023-05-22 11:25:09 +02:00
parent e950efe221
commit 17acaa449c
14 changed files with 264 additions and 80 deletions

View File

@ -12,7 +12,7 @@ import {
IsString, IsString,
} from 'class-validator'; } from 'class-validator';
import { Frequency } from '../types/frequency.enum'; import { Frequency } from '../types/frequency.enum';
import { Coordinates } from '../../../geography/domain/entities/coordinates'; import { Coordinate } from '../../../geography/domain/entities/coordinate';
import { Type } from 'class-transformer'; import { Type } from 'class-transformer';
import { HasTruthyWith } from './has-truthy-with.validator'; import { HasTruthyWith } from './has-truthy-with.validator';
@ -115,11 +115,11 @@ export class CreateAdRequest {
@AutoMap() @AutoMap()
sunMargin: number; sunMargin: number;
@Type(() => Coordinates) @Type(() => Coordinate)
@IsArray() @IsArray()
@ArrayMinSize(2) @ArrayMinSize(2)
@AutoMap(() => [Coordinates]) @AutoMap(() => [Coordinate])
waypoints: Coordinates[]; waypoints: Coordinate[];
@IsNumber() @IsNumber()
@AutoMap() @AutoMap()

View File

@ -1,24 +1,17 @@
import { Coordinates } from '../../../geography/domain/entities/coordinates'; import { Coordinate } from '../../../geography/domain/entities/coordinate';
import { Route } from '../../../geography/domain/entities/route'; import { Route } from '../../../geography/domain/entities/route';
import { IFindTimezone } from '../../../geography/domain/interfaces/timezone-finder.interface';
import { Role } from '../types/role.enum'; import { Role } from '../types/role.enum';
import { IGeorouter } from '../../../geography/domain/interfaces/georouter.interface'; import { IGeorouter } from '../../../geography/domain/interfaces/georouter.interface';
import { Path } from '../../../geography/domain/types/path.type'; import { Path } from '../../../geography/domain/types/path.type';
import { Timezoner } from '../../../geography/domain/types/timezoner';
import { GeorouterSettings } from '../../../geography/domain/types/georouter-settings.type'; import { GeorouterSettings } from '../../../geography/domain/types/georouter-settings.type';
export class Geography { export class Geography {
private points: Coordinates[]; private coordinates: Coordinate[];
timezones: string[];
driverRoute: Route; driverRoute: Route;
passengerRoute: Route; passengerRoute: Route;
timezoneFinder: IFindTimezone;
constructor(points: Coordinates[], timezoner: Timezoner) { constructor(coordinates: Coordinate[]) {
this.points = points; this.coordinates = coordinates;
this.timezones = [timezoner.timezone];
this.timezoneFinder = timezoner.finder;
this.setTimezones();
} }
createRoutes = async ( createRoutes = async (
@ -26,39 +19,7 @@ export class Geography {
georouter: IGeorouter, georouter: IGeorouter,
settings: GeorouterSettings, settings: GeorouterSettings,
): Promise<void> => { ): Promise<void> => {
const paths: Path[] = []; const paths: Path[] = this.getPaths(roles);
if (roles.includes(Role.DRIVER) && roles.includes(Role.PASSENGER)) {
if (this.points.length == 2) {
// 2 points => same route for driver and passenger
const commonPath: Path = {
key: RouteKey.COMMON,
points: this.points,
};
paths.push(commonPath);
} else {
const driverPath: Path = {
key: RouteKey.DRIVER,
points: this.points,
};
const passengerPath: Path = {
key: RouteKey.PASSENGER,
points: [this.points[0], this.points[this.points.length - 1]],
};
paths.push(driverPath, passengerPath);
}
} else if (roles.includes(Role.DRIVER)) {
const driverPath: Path = {
key: RouteKey.DRIVER,
points: this.points,
};
paths.push(driverPath);
} else if (roles.includes(Role.PASSENGER)) {
const passengerPath: Path = {
key: RouteKey.PASSENGER,
points: [this.points[0], this.points[this.points.length - 1]],
};
paths.push(passengerPath);
}
const routes = await georouter.route(paths, settings); const routes = await georouter.route(paths, settings);
if (routes.some((route) => route.key == RouteKey.COMMON)) { if (routes.some((route) => route.key == RouteKey.COMMON)) {
this.driverRoute = routes.find( this.driverRoute = routes.find(
@ -81,11 +42,46 @@ export class Geography {
} }
}; };
private setTimezones = (): void => { private getPaths = (roles: Role[]): Path[] => {
this.timezones = this.timezoneFinder.timezones( const paths: Path[] = [];
this.points[0].lat, if (roles.includes(Role.DRIVER) && roles.includes(Role.PASSENGER)) {
this.points[0].lon, if (this.coordinates.length == 2) {
); // 2 points => same route for driver and passenger
const commonPath: Path = {
key: RouteKey.COMMON,
points: this.coordinates,
};
paths.push(commonPath);
} else {
const driverPath: Path = this.createDriverPath();
const passengerPath: Path = this.createPassengerPath();
paths.push(driverPath, passengerPath);
}
} else if (roles.includes(Role.DRIVER)) {
const driverPath: Path = this.createDriverPath();
paths.push(driverPath);
} else if (roles.includes(Role.PASSENGER)) {
const passengerPath: Path = this.createPassengerPath();
paths.push(passengerPath);
}
return paths;
};
private createDriverPath = (): Path => {
return {
key: RouteKey.DRIVER,
points: this.coordinates,
};
};
private createPassengerPath = (): Path => {
return {
key: RouteKey.PASSENGER,
points: [
this.coordinates[0],
this.coordinates[this.coordinates.length - 1],
],
};
}; };
} }

View File

@ -15,6 +15,7 @@ import { Role } from '../types/role.enum';
import { Geography } from '../entities/geography'; import { Geography } from '../entities/geography';
import { IEncodeDirection } from '../../../geography/domain/interfaces/direction-encoder.interface'; import { IEncodeDirection } from '../../../geography/domain/interfaces/direction-encoder.interface';
import { TimeConverter } from '../entities/time-converter'; import { TimeConverter } from '../entities/time-converter';
import { Coordinate } from 'src/modules/geography/domain/entities/coordinate';
@CommandHandler(CreateAdCommand) @CommandHandler(CreateAdCommand)
export class CreateAdUseCase { export class CreateAdUseCase {
@ -38,7 +39,6 @@ export class CreateAdUseCase {
private readonly directionEncoder: IEncodeDirection, private readonly directionEncoder: IEncodeDirection,
) { ) {
this.defaultParams = defaultParamsProvider.getParams(); this.defaultParams = defaultParamsProvider.getParams();
this.timezone = this.defaultParams.DEFAULT_TIMEZONE;
this.georouter = georouterCreator.create( this.georouter = georouterCreator.create(
this.defaultParams.GEOROUTER_TYPE, this.defaultParams.GEOROUTER_TYPE,
this.defaultParams.GEOROUTER_URL, this.defaultParams.GEOROUTER_URL,
@ -48,8 +48,9 @@ export class CreateAdUseCase {
async execute(command: CreateAdCommand): Promise<Ad> { async execute(command: CreateAdCommand): Promise<Ad> {
try { try {
this.ad = this.mapper.map(command.createAdRequest, CreateAdRequest, Ad); this.ad = this.mapper.map(command.createAdRequest, CreateAdRequest, Ad);
this.setTimezone(command.createAdRequest.waypoints);
this.setGeography(command.createAdRequest.waypoints);
this.setRoles(command.createAdRequest); this.setRoles(command.createAdRequest);
this.setGeography(command.createAdRequest);
await this.geography.createRoutes(this.roles, this.georouter, { await this.geography.createRoutes(this.roles, this.georouter, {
withDistance: false, withDistance: false,
withPoints: true, withPoints: true,
@ -112,18 +113,24 @@ export class CreateAdUseCase {
} }
} }
private setTimezone = (coordinates: Coordinate[]): void => {
this.timezone = this.defaultParams.DEFAULT_TIMEZONE;
try {
const timezones = this.timezoneFinder.timezones(
coordinates[0].lat,
coordinates[0].lon,
);
if (timezones.length > 0) this.timezone = timezones[0];
} catch (e) {}
};
private setRoles = (createAdRequest: CreateAdRequest): void => { private setRoles = (createAdRequest: CreateAdRequest): void => {
this.roles = []; this.roles = [];
if (createAdRequest.driver) this.roles.push(Role.DRIVER); if (createAdRequest.driver) this.roles.push(Role.DRIVER);
if (createAdRequest.passenger) this.roles.push(Role.PASSENGER); if (createAdRequest.passenger) this.roles.push(Role.PASSENGER);
}; };
private setGeography = (createAdRequest: CreateAdRequest): void => { private setGeography = (coordinates: Coordinate[]): void => {
this.geography = new Geography(createAdRequest.waypoints, { this.geography = new Geography(coordinates);
timezone: this.defaultParams.DEFAULT_TIMEZONE,
finder: this.timezoneFinder,
});
if (this.geography.timezones.length > 0)
this.timezone = this.geography.timezones[0];
}; };
} }

View File

@ -0,0 +1,138 @@
import { Role } from '../../../domain/types/role.enum';
import { Geography } from '../../../domain/entities/geography';
import { Coordinate } from '../../../../geography/domain/entities/coordinate';
import { IGeorouter } from '../../../../geography/domain/interfaces/georouter.interface';
import { GeorouterSettings } from '../../../../geography/domain/types/georouter-settings.type';
import { Route } from '../../../../geography/domain/entities/route';
import { IGeodesic } from '../../../../geography/domain/interfaces/geodesic.interface';
const simpleCoordinates: Coordinate[] = [
{
lon: 6,
lat: 47,
},
{
lon: 6.1,
lat: 47.1,
},
];
const complexCoordinates: Coordinate[] = [
{
lon: 6,
lat: 47,
},
{
lon: 6.1,
lat: 47.1,
},
{
lon: 6.2,
lat: 47.2,
},
];
const mockGeodesic: IGeodesic = {
inverse: jest.fn(),
};
const driverRoute: Route = new Route(mockGeodesic);
driverRoute.distance = 25000;
const commonRoute: Route = new Route(mockGeodesic);
commonRoute.distance = 20000;
const mockGeorouter: IGeorouter = {
route: jest
.fn()
.mockResolvedValueOnce([
{
key: 'driver',
route: driverRoute,
},
])
.mockResolvedValueOnce([
{
key: 'passenger',
route: commonRoute,
},
])
.mockResolvedValueOnce([
{
key: 'common',
route: commonRoute,
},
])
.mockResolvedValueOnce([
{
key: 'driver',
route: driverRoute,
},
{
key: 'passenger',
route: commonRoute,
},
]),
};
const georouterSettings: GeorouterSettings = {
withDistance: false,
withPoints: true,
withTime: false,
};
describe('Geography entity', () => {
it('should be defined', () => {
expect(new Geography(simpleCoordinates)).toBeDefined();
});
it('should create a route as driver', async () => {
const geography = new Geography(complexCoordinates);
await geography.createRoutes(
[Role.DRIVER],
mockGeorouter,
georouterSettings,
);
expect(geography.driverRoute).toBeDefined();
expect(geography.passengerRoute).toBeUndefined();
expect(geography.driverRoute.distance).toBe(25000);
});
it('should create a route as passenger', async () => {
const geography = new Geography(simpleCoordinates);
await geography.createRoutes(
[Role.PASSENGER],
mockGeorouter,
georouterSettings,
);
expect(geography.driverRoute).toBeUndefined();
expect(geography.passengerRoute).toBeDefined();
expect(geography.passengerRoute.distance).toBe(20000);
});
it('should create routes as driver and passenger with simple coordinates', async () => {
const geography = new Geography(simpleCoordinates);
await geography.createRoutes(
[Role.DRIVER, Role.PASSENGER],
mockGeorouter,
georouterSettings,
);
expect(geography.driverRoute).toBeDefined();
expect(geography.passengerRoute).toBeDefined();
expect(geography.driverRoute.distance).toBe(20000);
expect(geography.passengerRoute.distance).toBe(20000);
});
it('should create routes as driver and passenger with complex coordinates', async () => {
const geography = new Geography(complexCoordinates);
await geography.createRoutes(
[Role.DRIVER, Role.PASSENGER],
mockGeorouter,
georouterSettings,
);
expect(geography.driverRoute).toBeDefined();
expect(geography.passengerRoute).toBeDefined();
expect(geography.driverRoute.distance).toBe(25000);
expect(geography.passengerRoute.distance).toBe(20000);
});
});

View File

@ -15,7 +15,7 @@ describe('TimeConverter', () => {
).toBe(6); ).toBe(6);
}); });
it('should return undefined when trying to convert a Europe/Paris datetime to utc datetime without a valid date, time or timezone', () => { it('should return undefined when trying to convert a Europe/Paris datetime to utc datetime without a valid date', () => {
expect( expect(
TimeConverter.toUtcDatetime(undefined, '07:00', 'Europe/Paris'), TimeConverter.toUtcDatetime(undefined, '07:00', 'Europe/Paris'),
).toBeUndefined(); ).toBeUndefined();
@ -26,6 +26,9 @@ describe('TimeConverter', () => {
'Europe/Paris', 'Europe/Paris',
), ),
).toBeUndefined(); ).toBeUndefined();
});
it('should return undefined when trying to convert a Europe/Paris datetime to utc datetime without a valid time', () => {
expect( expect(
TimeConverter.toUtcDatetime( TimeConverter.toUtcDatetime(
new Date('2023-05-01'), new Date('2023-05-01'),
@ -36,9 +39,12 @@ describe('TimeConverter', () => {
expect( expect(
TimeConverter.toUtcDatetime(new Date('2023-05-01'), 'a', 'Europe/Paris'), TimeConverter.toUtcDatetime(new Date('2023-05-01'), 'a', 'Europe/Paris'),
).toBeUndefined(); ).toBeUndefined();
});
it('should return undefined when trying to convert a datetime to utc datetime without a valid timezone', () => {
expect( expect(
TimeConverter.toUtcDatetime( TimeConverter.toUtcDatetime(
new Date('2023-13-01'), new Date('2023-12-01'),
'07:00', '07:00',
'OlympusMons/Mars', 'OlympusMons/Mars',
), ),

View File

@ -205,7 +205,6 @@ export abstract class PrismaRepository<T> implements IRepository<T> {
const command = `INSERT INTO ${this.model} ("${Object.keys(fields).join( const command = `INSERT INTO ${this.model} ("${Object.keys(fields).join(
'","', '","',
)}") VALUES (${Object.values(fields).join(',')})`; )}") VALUES (${Object.values(fields).join(',')})`;
console.log(command);
return await this._prisma.$executeRawUnsafe(command); return await this._prisma.$executeRawUnsafe(command);
} catch (e) { } catch (e) {
if (e instanceof Prisma.PrismaClientKnownRequestError) { if (e instanceof Prisma.PrismaClientKnownRequestError) {

View File

@ -1,8 +1,8 @@
import { Coordinates } from '../../domain/entities/coordinates'; import { Coordinate } from '../../domain/entities/coordinate';
import { IEncodeDirection } from '../../domain/interfaces/direction-encoder.interface'; import { IEncodeDirection } from '../../domain/interfaces/direction-encoder.interface';
export class PostgresDirectionEncoder implements IEncodeDirection { export class PostgresDirectionEncoder implements IEncodeDirection {
encode = (coordinates: Coordinates[]): string => encode = (coordinates: Coordinate[]): string =>
[ [
"'LINESTRING(", "'LINESTRING(",
coordinates.map((point) => [point.lon, point.lat].join(' ')).join(), coordinates.map((point) => [point.lon, point.lat].join(' ')).join(),

View File

@ -1,7 +1,7 @@
import { AutoMap } from '@automapper/classes'; import { AutoMap } from '@automapper/classes';
import { IsLatitude, IsLongitude, IsNumber } from 'class-validator'; import { IsLatitude, IsLongitude, IsNumber } from 'class-validator';
export class Coordinates { export class Coordinate {
constructor(lon: number, lat: number) { constructor(lon: number, lat: number) {
this.lon = lon; this.lon = lon;
this.lat = lat; this.lat = lat;

View File

@ -1,12 +1,12 @@
import { Coordinates } from './coordinates'; import { Coordinate } from './coordinate';
export class SpacetimePoint { export class SpacetimePoint {
coordinates: Coordinates; coordinate: Coordinate;
duration: number; duration: number;
distance: number; distance: number;
constructor(coordinates: Coordinates, duration: number, distance: number) { constructor(coordinate: Coordinate, duration: number, distance: number) {
this.coordinates = coordinates; this.coordinate = coordinate;
this.duration = duration; this.duration = duration;
this.distance = distance; this.distance = distance;
} }

View File

@ -1,5 +1,5 @@
import { Coordinates } from '../entities/coordinates'; import { Coordinate } from '../entities/coordinate';
export interface IEncodeDirection { export interface IEncodeDirection {
encode(coordinates: Coordinates[]): string; encode(coordinates: Coordinate[]): string;
} }

View File

@ -1,6 +1,6 @@
import { PointType } from './point-type.enum'; import { PointType } from './point-type.enum';
import { Coordinates } from '../entities/coordinates'; import { Coordinate } from '../entities/coordinate';
export type Point = Coordinates & { export type Point = Coordinate & {
type?: PointType; type?: PointType;
}; };

View File

@ -0,0 +1,8 @@
import { Coordinate } from '../../domain/entities/coordinate';
describe('Coordinate entity', () => {
it('should be defined', () => {
const coordinate: Coordinate = new Coordinate(6, 47);
expect(coordinate).toBeDefined();
});
});

View File

@ -0,0 +1,30 @@
import { PostgresDirectionEncoder } from '../../adapters/secondaries/postgres-direction-encoder';
import { Coordinate } from '../../domain/entities/coordinate';
describe('Postgres direction encoder', () => {
it('should be defined', () => {
const postgresDirectionEncoder: PostgresDirectionEncoder =
new PostgresDirectionEncoder();
expect(postgresDirectionEncoder).toBeDefined();
});
it('should encode coordinates to a postgres direction', () => {
const postgresDirectionEncoder: PostgresDirectionEncoder =
new PostgresDirectionEncoder();
const coordinates: Coordinate[] = [
{
lon: 6,
lat: 47,
},
{
lon: 6.1,
lat: 47.1,
},
{
lon: 6.2,
lat: 47.2,
},
];
const direction = postgresDirectionEncoder.encode(coordinates);
expect(direction).toBe("'LINESTRING(6 47,6.1 47.1,6.2 47.2)'");
});
});

View File

@ -1,12 +1,12 @@
import { Coordinates } from '../../../../geography/domain/entities/coordinates'; import { Coordinate } from '../../../../geography/domain/entities/coordinate';
export class SpacetimePoint { export class SpacetimePoint {
coordinates: Coordinates; coordinate: Coordinate;
duration: number; duration: number;
distance: number; distance: number;
constructor(coordinates: Coordinates, duration: number, distance: number) { constructor(coordinate: Coordinate, duration: number, distance: number) {
this.coordinates = coordinates; this.coordinate = coordinate;
this.duration = duration; this.duration = duration;
this.distance = distance; this.distance = distance;
} }