diff --git a/src/modules/ad/ad.di-tokens.ts b/src/modules/ad/ad.di-tokens.ts index b2942e0..5d06236 100644 --- a/src/modules/ad/ad.di-tokens.ts +++ b/src/modules/ad/ad.di-tokens.ts @@ -1,3 +1,4 @@ export const PARAMS_PROVIDER = Symbol('PARAMS_PROVIDER'); export const TIMEZONE_FINDER = Symbol('TIMEZONE_FINDER'); +export const TIME_CONVERTER = Symbol('TIME_CONVERTER'); export const AD_REPOSITORY = Symbol('AD_REPOSITORY'); diff --git a/src/modules/ad/ad.mapper.ts b/src/modules/ad/ad.mapper.ts index 5cc8ad0..571cdc9 100644 --- a/src/modules/ad/ad.mapper.ts +++ b/src/modules/ad/ad.mapper.ts @@ -10,12 +10,15 @@ import { import { Frequency } from './core/ad.types'; import { WaypointProps } from './core/value-objects/waypoint.value-object'; import { v4 } from 'uuid'; -import { PARAMS_PROVIDER, TIMEZONE_FINDER } from './ad.di-tokens'; +import { + PARAMS_PROVIDER, + TIMEZONE_FINDER, + TIME_CONVERTER, +} from './ad.di-tokens'; import { TimezoneFinderPort } from './core/ports/timezone-finder.port'; -import { Coordinates } from './core/types/coordinates'; import { DefaultParamsProviderPort } from './core/ports/default-params-provider.port'; import { DefaultParams } from './core/ports/default-params.type'; -import { DateTime, TimeZone } from 'timezonecomplete'; +import { TimeConverterPort } from './core/ports/time-converter.port'; /** * Mapper constructs objects that are used in different layers: @@ -35,13 +38,20 @@ export class AdMapper private readonly defaultParamsProvider: DefaultParamsProviderPort, @Inject(TIMEZONE_FINDER) private readonly timezoneFinder: TimezoneFinderPort, + @Inject(TIME_CONVERTER) + private readonly timeConverter: TimeConverterPort, ) { this.defaultParams = defaultParamsProvider.getParams(); } toPersistence = (entity: AdEntity): AdWriteModel => { const copy = entity.getProps(); - const timezone = this.getTimezone(copy.waypoints[0].address.coordinates); + const { lon, lat } = copy.waypoints[0].address.coordinates; + const timezone = this.timezoneFinder.timezones( + lon, + lat, + this.defaultParams.DEFAULT_TIMEZONE, + )[0]; const now = new Date(); const record: AdWriteModel = { uuid: copy.id, @@ -52,50 +62,50 @@ export class AdMapper fromDate: new Date(copy.fromDate), toDate: new Date(copy.toDate), monTime: copy.schedule.mon - ? AdMapper.toUtcDatetime( - new Date(copy.fromDate), + ? this.timeConverter.dateTimeToUtc( + copy.fromDate, copy.schedule.mon, timezone, ) : undefined, tueTime: copy.schedule.tue - ? AdMapper.toUtcDatetime( - new Date(copy.fromDate), + ? this.timeConverter.dateTimeToUtc( + copy.fromDate, copy.schedule.tue, timezone, ) : undefined, wedTime: copy.schedule.wed - ? AdMapper.toUtcDatetime( - new Date(copy.fromDate), + ? this.timeConverter.dateTimeToUtc( + copy.fromDate, copy.schedule.wed, timezone, ) : undefined, thuTime: copy.schedule.thu - ? AdMapper.toUtcDatetime( - new Date(copy.fromDate), + ? this.timeConverter.dateTimeToUtc( + copy.fromDate, copy.schedule.thu, timezone, ) : undefined, friTime: copy.schedule.fri - ? AdMapper.toUtcDatetime( - new Date(copy.fromDate), + ? this.timeConverter.dateTimeToUtc( + copy.fromDate, copy.schedule.fri, timezone, ) : undefined, satTime: copy.schedule.sat - ? AdMapper.toUtcDatetime( - new Date(copy.fromDate), + ? this.timeConverter.dateTimeToUtc( + copy.fromDate, copy.schedule.sat, timezone, ) : undefined, sunTime: copy.schedule.sun - ? AdMapper.toUtcDatetime( - new Date(copy.fromDate), + ? this.timeConverter.dateTimeToUtc( + copy.fromDate, copy.schedule.sun, timezone, ) @@ -199,35 +209,4 @@ export class AdMapper (avoid blacklisting, which will return everything but blacklisted items, which can lead to a data leak). */ - - private getTimezone = (coordinates: Coordinates): string => { - try { - const timezones = this.timezoneFinder.timezones( - coordinates.lon, - coordinates.lat, - ); - if (timezones.length > 0) return timezones[0]; - } catch (e) {} - return this.defaultParams.DEFAULT_TIMEZONE; - }; - - private static toUtcDatetime = ( - date: Date, - time: string, - timezone: string, - ): Date => { - try { - if (!date || !time || !timezone) throw new Error(); - return new Date( - new DateTime( - `${date.toISOString().split('T')[0]}T${time}:00`, - TimeZone.zone(timezone, false), - ) - .convert(TimeZone.zone('UTC')) - .toIsoString(), - ); - } catch (e) { - return undefined; - } - }; } diff --git a/src/modules/ad/ad.module.ts b/src/modules/ad/ad.module.ts index 2211535..602d973 100644 --- a/src/modules/ad/ad.module.ts +++ b/src/modules/ad/ad.module.ts @@ -5,6 +5,7 @@ import { AD_REPOSITORY, PARAMS_PROVIDER, TIMEZONE_FINDER, + TIME_CONVERTER, } from './ad.di-tokens'; import { MESSAGE_BROKER_PUBLISHER, @@ -18,6 +19,7 @@ import { AdMapper } from './ad.mapper'; import { CreateAdService } from './core/commands/create-ad/create-ad.service'; import { TimezoneFinder } from './infrastructure/timezone-finder'; import { PrismaService } from '@libs/db/prisma.service'; +import { TimeConverter } from './infrastructure/time-converter'; @Module({ imports: [CqrsModule], @@ -46,6 +48,10 @@ import { PrismaService } from '@libs/db/prisma.service'; provide: TIMEZONE_FINDER, useClass: TimezoneFinder, }, + { + provide: TIME_CONVERTER, + useClass: TimeConverter, + }, ], exports: [ PrismaService, diff --git a/src/modules/ad/core/ports/time-converter.port.ts b/src/modules/ad/core/ports/time-converter.port.ts new file mode 100644 index 0000000..feb4d2c --- /dev/null +++ b/src/modules/ad/core/ports/time-converter.port.ts @@ -0,0 +1,8 @@ +export interface TimeConverterPort { + dateTimeToUtc( + date: string, + time: string, + timezone: string, + dst?: boolean, + ): Date; +} diff --git a/src/modules/ad/core/ports/timezone-finder.port.ts b/src/modules/ad/core/ports/timezone-finder.port.ts index ddfbc8b..72ba115 100644 --- a/src/modules/ad/core/ports/timezone-finder.port.ts +++ b/src/modules/ad/core/ports/timezone-finder.port.ts @@ -1,3 +1,3 @@ export interface TimezoneFinderPort { - timezones(lon: number, lat: number): string[]; + timezones(lon: number, lat: number, defaultTimezone?: string): string[]; } diff --git a/src/modules/ad/infrastructure/time-converter.ts b/src/modules/ad/infrastructure/time-converter.ts new file mode 100644 index 0000000..ef41158 --- /dev/null +++ b/src/modules/ad/infrastructure/time-converter.ts @@ -0,0 +1,24 @@ +import { Injectable } from '@nestjs/common'; +import { TimeConverterPort } from '../core/ports/time-converter.port'; +import { DateTime, TimeZone } from 'timezonecomplete'; + +@Injectable() +export class TimeConverter implements TimeConverterPort { + dateTimeToUtc = ( + date: string, + time: string, + timezone: string, + dst?: boolean, + ): Date => { + try { + if (!date || !time || !timezone) throw new Error(); + return new Date( + new DateTime(`${date}T${time}`, TimeZone.zone(timezone, dst)) + .convert(TimeZone.zone('UTC')) + .toIsoString(), + ); + } catch (e) { + return undefined; + } + }; +} diff --git a/src/modules/ad/infrastructure/timezone-finder.ts b/src/modules/ad/infrastructure/timezone-finder.ts index 7c1ec9a..be990b3 100644 --- a/src/modules/ad/infrastructure/timezone-finder.ts +++ b/src/modules/ad/infrastructure/timezone-finder.ts @@ -4,5 +4,13 @@ import { find } from 'geo-tz'; @Injectable() export class TimezoneFinder implements TimezoneFinderPort { - timezones = (lon: number, lat: number): string[] => find(lat, lon); + timezones = ( + lon: number, + lat: number, + defaultTimezone?: string, + ): string[] => { + const foundTimezones = find(lat, lon); + if (defaultTimezone && foundTimezones.length == 0) return [defaultTimezone]; + return foundTimezones; + }; } diff --git a/src/modules/ad/tests/unit/ad.mapper.spec.ts b/src/modules/ad/tests/unit/ad.mapper.spec.ts index f7e76ed..78e659d 100644 --- a/src/modules/ad/tests/unit/ad.mapper.spec.ts +++ b/src/modules/ad/tests/unit/ad.mapper.spec.ts @@ -1,13 +1,19 @@ -import { PARAMS_PROVIDER, TIMEZONE_FINDER } from '@modules/ad/ad.di-tokens'; +import { + PARAMS_PROVIDER, + TIMEZONE_FINDER, + TIME_CONVERTER, +} from '@modules/ad/ad.di-tokens'; import { AdMapper } from '@modules/ad/ad.mapper'; import { AdEntity } from '@modules/ad/core/ad.entity'; import { Frequency } from '@modules/ad/core/ad.types'; import { DefaultParamsProviderPort } from '@modules/ad/core/ports/default-params-provider.port'; +import { TimeConverterPort } from '@modules/ad/core/ports/time-converter.port'; import { TimezoneFinderPort } from '@modules/ad/core/ports/timezone-finder.port'; import { AdReadModel, AdWriteModel, } from '@modules/ad/infrastructure/ad.repository'; +import { AdResponseDto } from '@modules/ad/interface/dtos/ad.response.dto'; import { Test } from '@nestjs/testing'; const now = new Date('2023-06-21 06:00:00'); @@ -21,7 +27,13 @@ const adEntity: AdEntity = new AdEntity({ fromDate: '2023-06-21', toDate: '2023-06-21', schedule: { + mon: '07:15', + tue: '07:15', wed: '07:15', + thu: '07:15', + fri: '07:15', + sat: '07:15', + sun: '07:15', }, waypoints: [ { @@ -33,8 +45,8 @@ const adEntity: AdEntity = new AdEntity({ postalCode: '54000', country: 'France', coordinates: { - lon: 48.68944505415954, - lat: 6.176510296462267, + lat: 48.68944505415954, + lon: 6.176510296462267, }, }, }, @@ -45,8 +57,8 @@ const adEntity: AdEntity = new AdEntity({ postalCode: '75000', country: 'France', coordinates: { - lon: 48.8566, - lat: 2.3522, + lat: 48.8566, + lon: 2.3522, }, }, }, @@ -91,8 +103,8 @@ const adReadModel: AdReadModel = { locality: 'Nancy', postalCode: '54000', country: 'France', - lon: 48.68944505415954, - lat: 6.176510296462267, + lat: 48.68944505415954, + lon: 6.176510296462267, createdAt: now, updatedAt: now, }, @@ -102,8 +114,8 @@ const adReadModel: AdReadModel = { locality: 'Paris', postalCode: '75000', country: 'France', - lon: 48.8566, - lat: 2.3522, + lat: 48.8566, + lon: 2.3522, createdAt: now, updatedAt: now, }, @@ -143,7 +155,20 @@ const mockDefaultParamsProvider: DefaultParamsProviderPort = { }; const mockTimezoneFinder: TimezoneFinderPort = { - timezones: jest.fn(), + // eslint-disable-next-line @typescript-eslint/no-unused-vars + timezones: jest.fn().mockImplementation((lon: number, lat: number) => { + if (lon < 60) return 'Europe/Paris'; + return 'America/New_York'; + }), +}; + +const mockTimeConverter: TimeConverterPort = { + dateTimeToUtc: jest + .fn() + // eslint-disable-next-line @typescript-eslint/no-unused-vars + .mockImplementation((datetime: Date, timezone: string, dst?: boolean) => { + return datetime; + }), }; describe('Ad Mapper', () => { @@ -161,6 +186,10 @@ describe('Ad Mapper', () => { provide: TIMEZONE_FINDER, useValue: mockTimezoneFinder, }, + { + provide: TIME_CONVERTER, + useValue: mockTimeConverter, + }, ], }).compile(); adMapper = module.get(AdMapper); @@ -178,9 +207,14 @@ describe('Ad Mapper', () => { it('should map persisted data to domain entity', async () => { const mapped: AdEntity = adMapper.toDomain(adReadModel); - expect(mapped.getProps().waypoints[0].address.coordinates.lon).toBe( + expect(mapped.getProps().waypoints[0].address.coordinates.lat).toBe( 48.68944505415954, ); - expect(mapped.getProps().waypoints[1].address.coordinates.lat).toBe(2.3522); + expect(mapped.getProps().waypoints[1].address.coordinates.lon).toBe(2.3522); + }); + + it('should map domain entity to response', async () => { + const mapped: AdResponseDto = adMapper.toResponse(adEntity); + expect(mapped.uuid).toBe('c160cf8c-f057-4962-841f-3ad68346df44'); }); }); diff --git a/src/modules/ad/tests/unit/infrastructure/time-converter.spec.ts b/src/modules/ad/tests/unit/infrastructure/time-converter.spec.ts new file mode 100644 index 0000000..4bd8a4d --- /dev/null +++ b/src/modules/ad/tests/unit/infrastructure/time-converter.spec.ts @@ -0,0 +1,52 @@ +import { TimeConverter } from '@modules/ad/infrastructure/time-converter'; + +describe('Time Converter', () => { + it('should be defined', () => { + const timeConverter: TimeConverter = new TimeConverter(); + expect(timeConverter).toBeDefined(); + }); + it('should convert a paris datetime to utc', () => { + const timeConverter: TimeConverter = new TimeConverter(); + const parisDate = '2023-06-22'; + const parisTime = '08:00'; + const utcDatetime = timeConverter.dateTimeToUtc( + parisDate, + parisTime, + 'Europe/Paris', + ); + expect(utcDatetime.toISOString()).toEqual('2023-06-22T06:00:00.000Z'); + }); + it('should return undefined if date is invalid', () => { + const timeConverter: TimeConverter = new TimeConverter(); + const parisDate = '2023-16-22'; + const parisTime = '08:00'; + const utcDatetime = timeConverter.dateTimeToUtc( + parisDate, + parisTime, + 'Europe/Paris', + ); + expect(utcDatetime).toBeUndefined(); + }); + it('should return undefined if time is invalid', () => { + const timeConverter: TimeConverter = new TimeConverter(); + const parisDate = '2023-06-22'; + const parisTime = '28:00'; + const utcDatetime = timeConverter.dateTimeToUtc( + parisDate, + parisTime, + 'Europe/Paris', + ); + expect(utcDatetime).toBeUndefined(); + }); + it('should return undefined if timezone is invalid', () => { + const timeConverter: TimeConverter = new TimeConverter(); + const parisDate = '2023-06-22'; + const parisTime = '08:00'; + const utcDatetime = timeConverter.dateTimeToUtc( + parisDate, + parisTime, + 'Foo/Bar', + ); + expect(utcDatetime).toBeUndefined(); + }); +});