This commit is contained in:
sbriat
2023-05-11 17:47:55 +02:00
parent 2a2cfa5c0f
commit da96f52c1e
69 changed files with 1700 additions and 492 deletions

View File

@@ -10,11 +10,17 @@ import { CqrsModule } from '@nestjs/cqrs';
import { Messager } from './adapters/secondaries/messager';
import { TimezoneFinder } from './adapters/secondaries/timezone-finder';
import { GeoTimezoneFinder } from '../geography/adapters/secondaries/geo-timezone-finder';
import { DefaultParamsProvider } from './adapters/secondaries/default-params.provider';
import { GeorouterCreator } from '../geography/adapters/secondaries/georouter-creator';
import { GeographyModule } from '../geography/geography.module';
import { HttpModule } from '@nestjs/axios';
@Module({
imports: [
GeographyModule,
DatabaseModule,
CqrsModule,
HttpModule,
RabbitMQModule.forRootAsync(RabbitMQModule, {
imports: [ConfigModule],
useFactory: async (configService: ConfigService) => ({
@@ -46,6 +52,8 @@ import { GeoTimezoneFinder } from '../geography/adapters/secondaries/geo-timezon
TimezoneFinder,
GeoTimezoneFinder,
CreateAdUseCase,
DefaultParamsProvider,
GeorouterCreator,
],
exports: [],
})

View File

@@ -8,7 +8,10 @@ import { CreateAdCommand } from '../../commands/create-ad.command';
import { CreateAdRequest } from '../../domain/dtos/create-ad.request';
import { validateOrReject } from 'class-validator';
import { Messager } from '../secondaries/messager';
import { GeoTimezoneFinder } from 'src/modules/geography/adapters/secondaries/geo-timezone-finder';
import { GeoTimezoneFinder } from '../../../geography/adapters/secondaries/geo-timezone-finder';
import { plainToInstance } from 'class-transformer';
import { DefaultParamsProvider } from '../secondaries/default-params.provider';
import { GeorouterCreator } from '../../../geography/adapters/secondaries/georouter-creator';
@Controller()
export class AdMessagerController {
@@ -16,6 +19,8 @@ export class AdMessagerController {
private readonly messager: Messager,
private readonly commandBus: CommandBus,
@InjectMapper() private readonly mapper: Mapper,
private readonly defaultParamsProvider: DefaultParamsProvider,
private readonly georouterCreator: GeorouterCreator,
private readonly timezoneFinder: GeoTimezoneFinder,
) {}
@@ -23,15 +28,10 @@ export class AdMessagerController {
name: 'adCreated',
})
async adCreatedHandler(message: string): Promise<void> {
let createAdRequest: CreateAdRequest;
try {
// parse message to conform to CreateAdRequest (not a real instance yet)
const parsedMessage: CreateAdRequest = JSON.parse(message);
// create a real instance of CreateAdRequest from parsed message
createAdRequest = this.mapper.map(
parsedMessage,
CreateAdRequest,
const createAdRequest: CreateAdRequest = plainToInstance(
CreateAdRequest,
JSON.parse(message),
);
// validate instance
await validateOrReject(createAdRequest);
@@ -48,14 +48,20 @@ export class AdMessagerController {
createAdRequest.waypoints[0].lat,
)[0];
const ad: Ad = await this.commandBus.execute(
new CreateAdCommand(createAdRequest),
new CreateAdCommand(
createAdRequest,
this.defaultParamsProvider.getParams(),
this.georouterCreator,
this.timezoneFinder,
),
);
console.log(ad);
} catch (e) {
console.log(e);
this.messager.publish(
'logging.matcher.ad.crit',
JSON.stringify({
createAdRequest,
message,
error: e,
}),
);

View File

@@ -2,10 +2,11 @@ import { Injectable } from '@nestjs/common';
import { MatcherRepository } from '../../../database/domain/matcher-repository';
import { Ad } from '../../domain/entities/ad';
import { DatabaseException } from '../../../database/exceptions/database.exception';
import { Frequency } from '../../domain/types/frequency.enum';
@Injectable()
export class AdRepository extends MatcherRepository<Ad> {
protected _model = 'ad';
protected model = 'ad';
async createAd(ad: Partial<Ad>): Promise<Ad> {
try {
@@ -44,7 +45,7 @@ type AdFields = {
uuid: string;
driver: string;
passenger: string;
frequency: number;
frequency: Frequency;
fromDate: string;
toDate: string;
monTime: string;

View File

@@ -0,0 +1,16 @@
import { Injectable } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { IDefaultParams } from '../../domain/types/default-params.type';
@Injectable()
export class DefaultParamsProvider {
constructor(private readonly configService: ConfigService) {}
getParams = (): IDefaultParams => {
return {
DEFAULT_TIMEZONE: this.configService.get('DEFAULT_TIMEZONE'),
GEOROUTER_TYPE: this.configService.get('GEOROUTER_TYPE'),
GEOROUTER_URL: this.configService.get('GEOROUTER_URL'),
};
};
}

View File

@@ -6,13 +6,13 @@ import { MessageBroker } from './message-broker';
@Injectable()
export class Messager extends MessageBroker {
constructor(
private readonly _amqpConnection: AmqpConnection,
private readonly amqpConnection: AmqpConnection,
configService: ConfigService,
) {
super(configService.get<string>('RMQ_EXCHANGE'));
}
publish = (routingKey: string, message: string): void => {
this._amqpConnection.publish(this.exchange, routingKey, message);
this.amqpConnection.publish(this.exchange, routingKey, message);
};
}

View File

@@ -1,9 +1,54 @@
import { IGeorouter } from '../../geography/domain/interfaces/georouter.interface';
import { ICreateGeorouter } from '../../geography/domain/interfaces/georouter-creator.interface';
import { CreateAdRequest } from '../domain/dtos/create-ad.request';
import { Geography } from '../domain/entities/geography';
import { IDefaultParams } from '../domain/types/default-params.type';
import { Role } from '../domain/types/role.enum';
import { IFindTimezone } from '../../geography/domain/interfaces/timezone-finder.interface';
export class CreateAdCommand {
readonly createAdRequest: CreateAdRequest;
private readonly defaultParams: IDefaultParams;
private readonly georouter: IGeorouter;
private readonly timezoneFinder: IFindTimezone;
roles: Role[];
geography: Geography;
timezone: string;
constructor(request: CreateAdRequest) {
constructor(
request: CreateAdRequest,
defaultParams: IDefaultParams,
georouterCreator: ICreateGeorouter,
timezoneFinder: IFindTimezone,
) {
this.createAdRequest = request;
this.defaultParams = defaultParams;
this.georouter = georouterCreator.create(
defaultParams.GEOROUTER_TYPE,
defaultParams.GEOROUTER_URL,
);
this.timezoneFinder = timezoneFinder;
this.setRoles();
this.setGeography();
}
private setRoles = (): void => {
this.roles = [];
if (this.createAdRequest.driver) this.roles.push(Role.DRIVER);
if (this.createAdRequest.passenger) this.roles.push(Role.PASSENGER);
};
private setGeography = async (): Promise<void> => {
this.geography = new Geography(this.createAdRequest.waypoints, {
timezone: this.defaultParams.DEFAULT_TIMEZONE,
finder: this.timezoneFinder,
});
if (this.geography.timezones.length > 0)
this.createAdRequest.timezone = this.geography.timezones[0];
try {
await this.geography.createRoutes(this.roles, this.georouter);
} catch (e) {
console.log(e);
}
};
}

View File

@@ -3,15 +3,17 @@ import {
ArrayMinSize,
IsArray,
IsBoolean,
IsDate,
IsEnum,
IsMilitaryTime,
IsNotEmpty,
IsNumber,
IsOptional,
IsString,
} from 'class-validator';
import { PointType } from '../../../geography/domain/types/point-type.enum';
import { Frequency } from '../types/frequency.enum';
import { Coordinates } from '../../../geography/domain/entities/coordinates';
import { Type } from 'class-transformer';
export class CreateAdRequest {
@IsString()
@@ -19,6 +21,11 @@ export class CreateAdRequest {
@AutoMap()
uuid: string;
@IsString()
@IsNotEmpty()
@AutoMap()
userUuid: string;
@IsBoolean()
@AutoMap()
driver: boolean;
@@ -27,53 +34,54 @@ export class CreateAdRequest {
@AutoMap()
passenger: boolean;
@IsNotEmpty()
@IsEnum(Frequency)
@AutoMap()
frequency: Frequency;
@IsString()
@Type(() => Date)
@IsDate()
@AutoMap()
fromDate: string;
fromDate: Date;
@IsString()
@Type(() => Date)
@IsDate()
@AutoMap()
toDate: string;
toDate: Date;
@IsOptional()
@IsString()
@IsMilitaryTime()
@AutoMap()
monTime?: string | null;
monTime?: string;
@IsOptional()
@IsString()
@IsMilitaryTime()
@AutoMap()
tueTime?: string | null;
tueTime?: string;
@IsOptional()
@IsString()
@IsMilitaryTime()
@AutoMap()
wedTime?: string | null;
wedTime?: string;
@IsOptional()
@IsString()
@IsMilitaryTime()
@AutoMap()
thuTime?: string | null;
thuTime?: string;
@IsOptional()
@IsString()
@IsMilitaryTime()
@AutoMap()
friTime?: string | null;
friTime?: string;
@IsOptional()
@IsString()
@IsMilitaryTime()
@AutoMap()
satTime?: string | null;
satTime?: string;
@IsOptional()
@IsString()
@IsMilitaryTime()
@AutoMap()
sunTime?: string | null;
sunTime?: string;
@IsNumber()
@AutoMap()
@@ -103,19 +111,33 @@ export class CreateAdRequest {
@AutoMap()
sunMargin: number;
@IsEnum(PointType)
@AutoMap()
originType: PointType;
@IsEnum(PointType)
@AutoMap()
destinationType: PointType;
@Type(() => Coordinates)
@IsArray()
@ArrayMinSize(2)
@AutoMap(() => [Coordinates])
waypoints: Coordinates[];
@AutoMap()
driverDuration?: number;
@AutoMap()
driverDistance?: number;
@AutoMap()
passengerDuration?: number;
@AutoMap()
passengerDistance?: number;
@AutoMap()
direction: string;
@AutoMap()
fwdAzimuth: number;
@AutoMap()
backAzimuth: number;
@IsNumber()
@AutoMap()
seatsDriver: number;
@@ -129,13 +151,9 @@ export class CreateAdRequest {
@AutoMap()
seatsUsed?: number;
@IsString()
@IsBoolean()
@AutoMap()
createdAt: string;
@IsString()
@AutoMap()
updatedAt: string;
strict: boolean;
timezone?: string;
}

View File

@@ -0,0 +1,7 @@
import { Ad } from './ad';
export class AdCompleter {
complete = async (ad: Ad): Promise<Ad> => {
return ad;
};
}

View File

@@ -1,6 +1,6 @@
import { AutoMap } from '@automapper/classes';
import { PointType } from '../../../geography/domain/types/point-type.enum';
import { Coordinates } from '../../../geography/domain/entities/coordinates';
import { Frequency } from '../types/frequency.enum';
export class Ad {
@AutoMap()
@@ -13,7 +13,7 @@ export class Ad {
passenger: boolean;
@AutoMap()
frequency: number;
frequency: Frequency;
@AutoMap()
fromDate: Date;
@@ -64,22 +64,16 @@ export class Ad {
sunMargin: number;
@AutoMap()
driverDuration: number;
driverDuration?: number;
@AutoMap()
driverDistance: number;
driverDistance?: number;
@AutoMap()
passengerDuration: number;
passengerDuration?: number;
@AutoMap()
passengerDistance: number;
@AutoMap()
originType: PointType;
@AutoMap()
destinationType: PointType;
passengerDistance?: number;
@AutoMap(() => [Coordinates])
waypoints: Coordinates[];
@@ -102,6 +96,9 @@ export class Ad {
@AutoMap()
seatsUsed: number;
@AutoMap()
strict: boolean;
@AutoMap()
createdAt: Date;

View File

@@ -0,0 +1,98 @@
import { Coordinates } from '../../../geography/domain/entities/coordinates';
import { Route } from '../../../geography/domain/entities/route';
import { IFindTimezone } from '../../../geography/domain/interfaces/timezone-finder.interface';
import { Role } from '../types/role.enum';
import { IGeorouter } from '../../../geography/domain/interfaces/georouter.interface';
import { Path } from '../../../geography/domain/types/path.type';
import { Timezoner } from '../../../geography/domain/types/timezoner';
export class Geography {
private points: Coordinates[];
timezones: string[];
driverRoute: Route;
passengerRoute: Route;
timezoneFinder: IFindTimezone;
constructor(points: Coordinates[], timezoner: Timezoner) {
this.points = points;
this.timezones = [timezoner.timezone];
this.timezoneFinder = timezoner.finder;
this.setTimezones();
}
createRoutes = async (
roles: Role[],
georouter: IGeorouter,
): Promise<void> => {
const paths: Path[] = [];
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, {
withDistance: false,
withPoints: false,
withTime: false,
});
if (routes.some((route) => route.key == RouteKey.COMMON)) {
this.driverRoute = routes.find(
(route) => route.key == RouteKey.COMMON,
).route;
this.passengerRoute = routes.find(
(route) => route.key == RouteKey.COMMON,
).route;
} else {
if (routes.some((route) => route.key == RouteKey.DRIVER)) {
this.driverRoute = routes.find(
(route) => route.key == RouteKey.DRIVER,
).route;
}
if (routes.some((route) => route.key == RouteKey.PASSENGER)) {
this.passengerRoute = routes.find(
(route) => route.key == RouteKey.PASSENGER,
).route;
}
}
};
private setTimezones = (): void => {
this.timezones = this.timezoneFinder.timezones(
this.points[0].lat,
this.points[0].lon,
);
};
}
export enum RouteKey {
COMMON = 'common',
DRIVER = 'driver',
PASSENGER = 'passenger',
}

View File

@@ -1,14 +1,14 @@
import { DateTime, TimeZone } from 'timezonecomplete';
export class TimeConverter {
static toUtcDatetime = (
date: string,
time: string,
timezone: string,
): Date => {
static toUtcDatetime = (date: Date, time: string, timezone: string): Date => {
try {
if (!date || !time || !timezone) throw new Error();
return new Date(
new DateTime(`${date}T${time}:00`, TimeZone.zone(timezone, false))
new DateTime(
`${date.toISOString().split('T')[0]}T${time}:00`,
TimeZone.zone(timezone, false),
)
.convert(TimeZone.zone('UTC'))
.toIsoString(),
);

View File

@@ -0,0 +1,5 @@
export type IDefaultParams = {
DEFAULT_TIMEZONE: string;
GEOROUTER_TYPE: string;
GEOROUTER_URL: string;
};

View File

@@ -1,4 +1,4 @@
export enum Frequency {
PUNCTUAL = 1,
RECURRENT = 2,
PUNCTUAL = 'PUNCTUAL',
RECURRENT = 'RECURRENT',
}

View File

@@ -0,0 +1,4 @@
export enum Role {
DRIVER = 'DRIVER',
PASSENGER = 'PASSENGER',
}

View File

@@ -20,6 +20,16 @@ export class CreateAdUseCase {
CreateAdRequest,
Ad,
);
adToCreate.driverDistance = command.geography.driverRoute?.distance;
adToCreate.driverDuration = command.geography.driverRoute?.duration;
adToCreate.passengerDistance = command.geography.passengerRoute?.distance;
adToCreate.passengerDuration = command.geography.passengerRoute?.duration;
adToCreate.fwdAzimuth = command.geography.driverRoute
? command.geography.driverRoute.fwdAzimuth
: command.geography.passengerRoute.fwdAzimuth;
adToCreate.backAzimuth = command.geography.driverRoute
? command.geography.driverRoute.backAzimuth
: command.geography.passengerRoute.backAzimuth;
return adToCreate;
// return await this.adRepository.createAd(adToCreate);
} catch (error) {

View File

@@ -4,7 +4,6 @@ import { Injectable } from '@nestjs/common';
import { Ad } from '../domain/entities/ad';
import { AdPresenter } from '../adapters/primaries/ad.presenter';
import { CreateAdRequest } from '../domain/dtos/create-ad.request';
import { Coordinates } from '../../geography/domain/entities/coordinates';
import { TimeConverter } from '../domain/entities/time-converter';
@Injectable()
@@ -16,43 +15,10 @@ export class AdProfile extends AutomapperProfile {
override get profile() {
return (mapper: any) => {
createMap(mapper, Ad, AdPresenter);
createMap(
mapper,
CreateAdRequest,
CreateAdRequest,
forMember(
(dest) => dest.waypoints,
mapFrom((source) =>
source.waypoints.map(
(waypoint) =>
new Coordinates(
waypoint.lon ?? undefined,
waypoint.lat ?? undefined,
),
),
),
),
);
createMap(
mapper,
CreateAdRequest,
Ad,
forMember(
(dest) => dest.fromDate,
mapFrom((source) => new Date(source.fromDate)),
),
forMember(
(dest) => dest.toDate,
mapFrom((source) => new Date(source.toDate)),
),
forMember(
(dest) => dest.createdAt,
mapFrom((source) => new Date(source.createdAt)),
),
forMember(
(dest) => dest.updatedAt,
mapFrom((source) => new Date(source.updatedAt)),
),
forMember(
(dest) => dest.monTime,
mapFrom(({ monTime: time, fromDate: date, timezone }) =>

View File

@@ -0,0 +1,38 @@
import { ConfigService } from '@nestjs/config';
import { Test, TestingModule } from '@nestjs/testing';
import { DefaultParamsProvider } from '../../../../adapters/secondaries/default-params.provider';
import { IDefaultParams } from '../../../../domain/types/default-params.type';
const mockConfigService = {
get: jest.fn().mockImplementation(() => 'some_default_value'),
};
describe('DefaultParamsProvider', () => {
let defaultParamsProvider: DefaultParamsProvider;
beforeAll(async () => {
const module: TestingModule = await Test.createTestingModule({
imports: [],
providers: [
DefaultParamsProvider,
{
provide: ConfigService,
useValue: mockConfigService,
},
],
}).compile();
defaultParamsProvider = module.get<DefaultParamsProvider>(
DefaultParamsProvider,
);
});
it('should be defined', () => {
expect(defaultParamsProvider).toBeDefined();
});
it('should provide default params', async () => {
const params: IDefaultParams = defaultParamsProvider.getParams();
expect(params.GEOROUTER_URL).toBe('some_default_value');
});
});

View File

@@ -0,0 +1,42 @@
import { Frequency } from '../../../domain/types/frequency.enum';
import { Ad } from '../../../domain/entities/ad';
import { AdCompleter } from '../../../domain/entities/ad.completer';
import { Coordinates } from '../../../../geography/domain/entities/coordinates';
const ad: Ad = new Ad();
ad.driver = true;
ad.passenger = false;
ad.frequency = Frequency.RECURRENT;
ad.fromDate = new Date('2023-05-01');
ad.toDate = new Date('2024-05-01');
ad.monTime = new Date('2023-05-01T06:00:00.000Z');
ad.tueTime = new Date('2023-05-01T06:00:00.000Z');
ad.wedTime = new Date('2023-05-01T06:00:00.000Z');
ad.thuTime = new Date('2023-05-01T06:00:00.000Z');
ad.friTime = new Date('2023-05-01T06:00:00.000Z');
ad.monMargin = 900;
ad.tueMargin = 900;
ad.wedMargin = 900;
ad.thuMargin = 900;
ad.friMargin = 900;
ad.satMargin = 900;
ad.sunMargin = 900;
ad.waypoints = [new Coordinates(6.18, 48.69), new Coordinates(6.44, 48.85)];
ad.seatsDriver = 3;
ad.seatsPassenger = 1;
ad.strict = false;
describe('AdCompleter', () => {
it('should be defined', () => {
expect(new AdCompleter()).toBeDefined();
});
describe('complete', () => {
it('should complete an ad', async () => {
const ad: Ad = new Ad();
const adCompleter: AdCompleter = new AdCompleter();
const completedAd: Ad = await adCompleter.complete(ad);
expect(completedAd.fwdAzimuth).toBe(45);
});
});
});

View File

@@ -1,5 +1,4 @@
import { CreateAdRequest } from '../../../domain/dtos/create-ad.request';
import { PointType } from '../../../../geography/domain/types/point-type.enum';
import { CreateAdUseCase } from '../../../domain/usecases/create-ad.usecase';
import { Test, TestingModule } from '@nestjs/testing';
import { AutomapperModule } from '@automapper/nestjs';
@@ -8,16 +7,28 @@ import { AdRepository } from '../../../adapters/secondaries/ad.repository';
import { CreateAdCommand } from '../../../commands/create-ad.command';
import { Ad } from '../../../domain/entities/ad';
import { AdProfile } from '../../../mappers/ad.profile';
import { Frequency } from '../../../domain/types/frequency.enum';
import { IDefaultParams } from '../../../domain/types/default-params.type';
const mockAdRepository = {};
const mockGeorouterCreator = {
create: jest.fn().mockImplementation(),
};
const defaultParams: IDefaultParams = {
GEOROUTER_TYPE: 'graphhopper',
GEOROUTER_URL: 'http://localhost',
};
const createAdRequest: CreateAdRequest = {
uuid: '77c55dfc-c28b-4026-942e-f94e95401fb1',
userUuid: 'dfd993f6-7889-4876-9570-5e1d7b6e3f42',
driver: true,
passenger: false,
frequency: 2,
fromDate: '2023-04-26',
toDate: '2024-04-25',
frequency: Frequency.RECURRENT,
fromDate: new Date('2023-04-26'),
toDate: new Date('2024-04-25'),
monTime: '07:00',
tueTime: '07:00',
wedTime: '07:00',
@@ -32,12 +43,9 @@ const createAdRequest: CreateAdRequest = {
friMargin: 900,
satMargin: 900,
sunMargin: 900,
originType: PointType.OTHER,
destinationType: PointType.OTHER,
seatsDriver: 3,
seatsPassenger: 1,
createdAt: '2023-04-01 12:45',
updatedAt: '2023-04-01 12:45',
strict: false,
waypoints: [
{ lon: 6, lat: 45 },
{ lon: 6.5, lat: 45.5 },
@@ -70,7 +78,11 @@ describe('CreateAdUseCase', () => {
describe('execute', () => {
it('should create an ad', async () => {
const ad = await createAdUseCase.execute(
new CreateAdCommand(createAdRequest),
new CreateAdCommand(
createAdRequest,
defaultParams,
mockGeorouterCreator,
),
);
expect(ad).toBeInstanceOf(Ad);
});

View File

@@ -8,7 +8,7 @@ describe('TimeConverter', () => {
it('should convert a Europe/Paris datetime to utc datetime', () => {
expect(
TimeConverter.toUtcDatetime(
'2023-05-01',
new Date('2023-05-01'),
'07:00',
'Europe/Paris',
).getUTCHours(),
@@ -20,16 +20,28 @@ describe('TimeConverter', () => {
TimeConverter.toUtcDatetime(undefined, '07:00', 'Europe/Paris'),
).toBeUndefined();
expect(
TimeConverter.toUtcDatetime('2023-13-01', '07:00', 'Europe/Paris'),
TimeConverter.toUtcDatetime(
new Date('2023-13-01'),
'07:00',
'Europe/Paris',
),
).toBeUndefined();
expect(
TimeConverter.toUtcDatetime('2023-05-01', undefined, 'Europe/Paris'),
TimeConverter.toUtcDatetime(
new Date('2023-05-01'),
undefined,
'Europe/Paris',
),
).toBeUndefined();
expect(
TimeConverter.toUtcDatetime('2023-05-01', 'a', 'Europe/Paris'),
TimeConverter.toUtcDatetime(new Date('2023-05-01'), 'a', 'Europe/Paris'),
).toBeUndefined();
expect(
TimeConverter.toUtcDatetime('2023-13-01', '07:00', 'OlympusMons/Mars'),
TimeConverter.toUtcDatetime(
new Date('2023-13-01'),
'07:00',
'OlympusMons/Mars',
),
).toBeUndefined();
});
});