diff --git a/.eslintrc.js b/.eslintrc.js index fe00111..aa5b3b8 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -15,7 +15,7 @@ module.exports = { node: true, jest: true, }, - ignorePatterns: ['.eslintrc.js'], + ignorePatterns: ['.eslintrc.js', 'database/'], rules: { '@typescript-eslint/interface-name-prefix': 'off', '@typescript-eslint/explicit-function-return-type': 'off', diff --git a/package-lock.json b/package-lock.json index c57bcbf..129db5a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -16,6 +16,7 @@ "@nestjs/typeorm": "^10.0.2", "class-transformer": "^0.5.1", "class-validator": "^0.14.1", + "jest-mock": "^29.7.0", "mysql2": "^3.9.2", "reflect-metadata": "^0.2.1", "rxjs": "^7.8.1", @@ -1431,7 +1432,6 @@ "version": "29.6.3", "resolved": "https://registry.npmjs.org/@jest/schemas/-/schemas-29.6.3.tgz", "integrity": "sha512-mo5j5X+jIZmJQveBKeS/clAueipV7KgiX1vMgCxam1RNYiqE1w62n0/tJJnHtjW8ZHcQco5gY85jA3mi0L+nSA==", - "dev": true, "dependencies": { "@sinclair/typebox": "^0.27.8" }, @@ -1513,7 +1513,6 @@ "version": "29.6.3", "resolved": "https://registry.npmjs.org/@jest/types/-/types-29.6.3.tgz", "integrity": "sha512-u3UPsIilWKOM3F9CXtrG8LEJmNxwoCQC/XVj4IKYXvvpx7QIi/Kg1LI5uDmDpKlac62NUtX7eLjRh+jVZcLOzw==", - "dev": true, "dependencies": { "@jest/schemas": "^29.6.3", "@types/istanbul-lib-coverage": "^2.0.0", @@ -2015,8 +2014,7 @@ "node_modules/@sinclair/typebox": { "version": "0.27.8", "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.27.8.tgz", - "integrity": "sha512-+Fj43pSMwJs4KRrH/938Uf+uAELIgVBmQzg/q1YG10djyfA3TnrU8N8XzqCh/okZdszqBQTZf96idMfE5lnwTA==", - "dev": true + "integrity": "sha512-+Fj43pSMwJs4KRrH/938Uf+uAELIgVBmQzg/q1YG10djyfA3TnrU8N8XzqCh/okZdszqBQTZf96idMfE5lnwTA==" }, "node_modules/@sinonjs/commons": { "version": "3.0.1", @@ -2199,14 +2197,12 @@ "node_modules/@types/istanbul-lib-coverage": { "version": "2.0.6", "resolved": "https://registry.npmjs.org/@types/istanbul-lib-coverage/-/istanbul-lib-coverage-2.0.6.tgz", - "integrity": "sha512-2QF/t/auWm0lsy8XtKVPG19v3sSOQlJe/YHZgfjb/KBBHOGSV+J2q/S671rcq9uTBrLAXmZpqJiaQbMT+zNU1w==", - "dev": true + "integrity": "sha512-2QF/t/auWm0lsy8XtKVPG19v3sSOQlJe/YHZgfjb/KBBHOGSV+J2q/S671rcq9uTBrLAXmZpqJiaQbMT+zNU1w==" }, "node_modules/@types/istanbul-lib-report": { "version": "3.0.3", "resolved": "https://registry.npmjs.org/@types/istanbul-lib-report/-/istanbul-lib-report-3.0.3.tgz", "integrity": "sha512-NQn7AHQnk/RSLOxrBbGyJM/aVQ+pjj5HCgasFxc0K/KhoATfQ/47AyUl15I2yBUpihjmas+a+VJBOqecrFH+uA==", - "dev": true, "dependencies": { "@types/istanbul-lib-coverage": "*" } @@ -2215,7 +2211,6 @@ "version": "3.0.4", "resolved": "https://registry.npmjs.org/@types/istanbul-reports/-/istanbul-reports-3.0.4.tgz", "integrity": "sha512-pk2B1NWalF9toCRu6gjBzR69syFjP4Od8WRAX+0mmf9lAjCRicLOWc+ZrxZHx/0XRjotgkF9t6iaMJ+aXcOdZQ==", - "dev": true, "dependencies": { "@types/istanbul-lib-report": "*" } @@ -2252,7 +2247,6 @@ "version": "20.11.30", "resolved": "https://registry.npmjs.org/@types/node/-/node-20.11.30.tgz", "integrity": "sha512-dHM6ZxwlmuZaRmUPfv1p+KrdD1Dci04FbdEm/9wEMouFqxYoFl5aMkt0VMAUtYRQDyYvD41WJLukhq/ha3YuTw==", - "devOptional": true, "dependencies": { "undici-types": "~5.26.4" } @@ -2338,7 +2332,6 @@ "version": "17.0.32", "resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-17.0.32.tgz", "integrity": "sha512-xQ67Yc/laOG5uMfX/093MRlGGCIBzZMarVa+gfNKJxWAIgykYpVGkBdbqEzGDDfCrVUj6Hiff4mTZ5BA6TmAog==", - "dev": true, "dependencies": { "@types/yargs-parser": "*" } @@ -2346,8 +2339,7 @@ "node_modules/@types/yargs-parser": { "version": "21.0.3", "resolved": "https://registry.npmjs.org/@types/yargs-parser/-/yargs-parser-21.0.3.tgz", - "integrity": "sha512-I4q9QU9MQv4oEOz4tAHJtNz1cwuLxn2F3xcc2iV5WdqLPpUnj30aUuxt1mAxYTG+oe8CZMV/+6rU4S4gRDzqtQ==", - "dev": true + "integrity": "sha512-I4q9QU9MQv4oEOz4tAHJtNz1cwuLxn2F3xcc2iV5WdqLPpUnj30aUuxt1mAxYTG+oe8CZMV/+6rU4S4gRDzqtQ==" }, "node_modules/@typescript-eslint/eslint-plugin": { "version": "7.3.1", @@ -3388,7 +3380,6 @@ "version": "3.9.0", "resolved": "https://registry.npmjs.org/ci-info/-/ci-info-3.9.0.tgz", "integrity": "sha512-NIxF55hv4nSqQswkAeiOi1r83xy8JldOFDTWiug55KBu9Jnblncd2U6ViHmYgHf01TPZS77NJBhBMKdWj9HQMQ==", - "dev": true, "funding": [ { "type": "github", @@ -5135,8 +5126,7 @@ "node_modules/graceful-fs": { "version": "4.2.11", "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", - "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", - "dev": true + "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==" }, "node_modules/graphemer": { "version": "1.4.0", @@ -5983,7 +5973,6 @@ "version": "29.7.0", "resolved": "https://registry.npmjs.org/jest-mock/-/jest-mock-29.7.0.tgz", "integrity": "sha512-ITOMZn+UkYS4ZFh83xYAOzWStloNzJFO2s8DWrE4lhtGD+AorgnbkiKERe4wQVBydIGPx059g6riW5Btp6Llnw==", - "dev": true, "dependencies": { "@jest/types": "^29.6.3", "@types/node": "*", @@ -6213,7 +6202,6 @@ "version": "29.7.0", "resolved": "https://registry.npmjs.org/jest-util/-/jest-util-29.7.0.tgz", "integrity": "sha512-z6EbKajIpqGKU56y5KBUgy1dt1ihhQJgWzUlZHArA/+X2ad7Cb5iF+AK1EWVL/Bo7Rz9uurpqw6SiBCefUbCGA==", - "dev": true, "dependencies": { "@jest/types": "^29.6.3", "@types/node": "*", @@ -6230,7 +6218,6 @@ "version": "2.3.1", "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", - "dev": true, "engines": { "node": ">=8.6" }, @@ -9495,8 +9482,7 @@ "node_modules/undici-types": { "version": "5.26.5", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-5.26.5.tgz", - "integrity": "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==", - "devOptional": true + "integrity": "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==" }, "node_modules/universalify": { "version": "2.0.1", diff --git a/package.json b/package.json index e8c9e15..f5c3bc1 100644 --- a/package.json +++ b/package.json @@ -28,6 +28,7 @@ "@nestjs/typeorm": "^10.0.2", "class-transformer": "^0.5.1", "class-validator": "^0.14.1", + "jest-mock": "^29.7.0", "mysql2": "^3.9.2", "reflect-metadata": "^0.2.1", "rxjs": "^7.8.1", diff --git a/src/app.controller.ts b/src/app.controller.ts index 3833980..7bf5dce 100644 --- a/src/app.controller.ts +++ b/src/app.controller.ts @@ -1,13 +1,11 @@ import { - Body, ClassSerializerInterceptor, Controller, Get, - Post, UseInterceptors, } from '@nestjs/common'; import { AppService } from './app.service'; -import { ApiBody, ApiExtraModels, getSchemaPath } from '@nestjs/swagger'; +import { ApiExtraModels } from '@nestjs/swagger'; import { TrackingDto } from '@/dtos/tracking.dto'; @Controller() @@ -20,15 +18,4 @@ export class AppController { debug(): unknown { return this.appService.debug(); } - - @Post() - @ApiBody({ - schema: { - $ref: getSchemaPath(TrackingDto), - }, - }) - test(@Body() body: TrackingDto): string { - console.log(body); - return 'ok'; - } } diff --git a/src/dtos/container.dto.ts b/src/dtos/container.dto.ts index 991a69c..885f74e 100644 --- a/src/dtos/container.dto.ts +++ b/src/dtos/container.dto.ts @@ -1,6 +1,5 @@ import { ApiProperty } from '@nestjs/swagger'; import { ContainerEntity } from '@/entities/container.entity'; -import { instanceToPlain, plainToClass } from 'class-transformer'; export class ContainerDto { @ApiProperty() @@ -17,14 +16,4 @@ export class ContainerDto { this.containerNumber = entity.containerNumber; this.shipmentId = entity.shipmentId; } - - static fromEntity(entity: ContainerEntity): ContainerDto { - const data = instanceToPlain(entity); - return plainToClass(ContainerDto, data); - } - - toEntity(): ContainerEntity { - const data = instanceToPlain(this); - return plainToClass(ContainerEntity, data); - } } diff --git a/src/entities/container.entity.ts b/src/entities/container.entity.ts index 0daf713..2b77177 100644 --- a/src/entities/container.entity.ts +++ b/src/entities/container.entity.ts @@ -1,11 +1,11 @@ -import { BaseEntity, Column, Entity, ManyToOne, PrimaryColumn } from 'typeorm'; +import { Column, Entity, ManyToOne, PrimaryColumn } from 'typeorm'; import { uuidTransformer } from '@/utils/uuidTransformer'; import { ShipmentEntity } from '@/entities/shipment.entity'; @Entity({ name: 'containers', }) -export class ContainerEntity extends BaseEntity { +export class ContainerEntity { @PrimaryColumn({ type: 'binary', length: 16, diff --git a/src/entities/liveTracking.entity.ts b/src/entities/liveTracking.entity.ts index 662d05d..da1ab4b 100644 --- a/src/entities/liveTracking.entity.ts +++ b/src/entities/liveTracking.entity.ts @@ -1,18 +1,11 @@ -import { - BaseEntity, - Column, - Entity, - JoinColumn, - OneToOne, - PrimaryColumn, -} from 'typeorm'; +import { Column, Entity, JoinColumn, OneToOne, PrimaryColumn } from 'typeorm'; import { uuidTransformer } from '@/utils/uuidTransformer'; import { ShipmentEntity } from '@/entities/shipment.entity'; @Entity({ name: 'live_tracking', }) -export class LiveTrackingEntity extends BaseEntity { +export class LiveTrackingEntity { @PrimaryColumn({ type: 'binary', length: 16, diff --git a/src/entities/shipment.entity.ts b/src/entities/shipment.entity.ts index 8f2e2f1..ba86585 100644 --- a/src/entities/shipment.entity.ts +++ b/src/entities/shipment.entity.ts @@ -1,11 +1,4 @@ -import { - BaseEntity, - Column, - Entity, - OneToMany, - OneToOne, - PrimaryColumn, -} from 'typeorm'; +import { Column, Entity, OneToMany, OneToOne, PrimaryColumn } from 'typeorm'; import { uuidTransformer } from '@/utils/uuidTransformer'; import { ContainerEntity } from '@/entities/container.entity'; import { LiveTrackingEntity } from '@/entities/liveTracking.entity'; @@ -13,7 +6,7 @@ import { LiveTrackingEntity } from '@/entities/liveTracking.entity'; @Entity({ name: 'shipments', }) -export class ShipmentEntity extends BaseEntity { +export class ShipmentEntity { @PrimaryColumn({ type: 'binary', length: 16, diff --git a/src/shipments/shipments.controller.spec.ts b/src/shipments/shipments.controller.spec.ts index 49f3ce4..f49fad0 100644 --- a/src/shipments/shipments.controller.spec.ts +++ b/src/shipments/shipments.controller.spec.ts @@ -15,4 +15,8 @@ describe('ShipmentsController', () => { it('should be defined', () => { expect(controller).toBeDefined(); }); + + it('should', () => { + expect(true).toBe(true); + }); }); diff --git a/src/shipments/shipments.controller.ts b/src/shipments/shipments.controller.ts index 2682e63..1a979c7 100644 --- a/src/shipments/shipments.controller.ts +++ b/src/shipments/shipments.controller.ts @@ -22,6 +22,6 @@ export class ShipmentsController { }, }) getShipments() { - return this.shipmentsService.getShipments(); + return this.shipmentsService.getAll(); } } diff --git a/src/shipments/shipments.module.ts b/src/shipments/shipments.module.ts index acc6c5f..7985a41 100644 --- a/src/shipments/shipments.module.ts +++ b/src/shipments/shipments.module.ts @@ -16,5 +16,6 @@ import { ShipmentEntity } from '@/entities/shipment.entity'; ], controllers: [ShipmentsController], providers: [ShipmentsService], + exports: [ShipmentsService], }) export class ShipmentsModule {} diff --git a/src/shipments/shipments.service.ts b/src/shipments/shipments.service.ts index bdbd1a5..e56535c 100644 --- a/src/shipments/shipments.service.ts +++ b/src/shipments/shipments.service.ts @@ -11,9 +11,13 @@ export class ShipmentsService { ) {} // @todo add pagination - getShipments() { + getAll() { return this.shipmentRepository.find({ relations: ['liveTracking', 'containers'], }); } + + update(shipment: ShipmentEntity) { + return this.shipmentRepository.save(shipment); + } } diff --git a/src/tracking/notifications.service.spec.ts b/src/tracking/notifications.service.spec.ts new file mode 100644 index 0000000..4711910 --- /dev/null +++ b/src/tracking/notifications.service.spec.ts @@ -0,0 +1,18 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { NotificationsService } from './notifications.service'; + +describe('NotificationsService', () => { + let service: NotificationsService; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [NotificationsService], + }).compile(); + + service = module.get(NotificationsService); + }); + + it('should be defined', () => { + expect(service).toBeDefined(); + }); +}); diff --git a/src/tracking/notifications.service.ts b/src/tracking/notifications.service.ts new file mode 100644 index 0000000..96076e8 --- /dev/null +++ b/src/tracking/notifications.service.ts @@ -0,0 +1,26 @@ +import { Inject, Injectable } from '@nestjs/common'; +import { ShipmentEntity } from '@/entities/shipment.entity'; +import { TrackingDto } from '@/dtos/tracking.dto'; +import { + INotifier, + SupportedNotifiersNames, +} from '@/tracking/notifications/notifier.types'; + +@Injectable() +export class NotificationsService { + constructor( + @Inject('NOTIFIER_FACTORY') + private readonly notifierFactory: ( + shipment: ShipmentEntity, + trackingInfo: TrackingDto, + type?: SupportedNotifiersNames, + ) => INotifier, + ) {} + public async send( + shipment: ShipmentEntity, + trackingInfo: TrackingDto, + ): Promise { + const notifier = this.notifierFactory(shipment, trackingInfo); + await notifier.send(); + } +} diff --git a/src/tracking/notifications/console.notifier.spec.ts b/src/tracking/notifications/console.notifier.spec.ts new file mode 100644 index 0000000..e69de29 diff --git a/src/tracking/notifications/console.notifier.ts b/src/tracking/notifications/console.notifier.ts new file mode 100644 index 0000000..e5e2704 --- /dev/null +++ b/src/tracking/notifications/console.notifier.ts @@ -0,0 +1,35 @@ +import { NotifierAbstract } from '@/tracking/notifications/notifier.abstract'; +import { INotifier } from '@/tracking/notifications/notifier.types'; + +export class ConsoleNotifier extends NotifierAbstract implements INotifier { + async send(): Promise { + const messages = this.generateMessages(); + + if (!messages.length) { + console.log('Dont send email'); + return false; + } + + console.log( + `Send email with the following messages: ${messages.join('\n')}`, + ); + + return true; + } + + private generateMessages(): string[] { + const messages: string[] = []; + if (this.isDelayed) { + messages.push('Your shipment has been delayed.'); + } + + if (this.daysToArrival <= 0) { + messages.push('Your shipment has arrived'); + } + if (this.daysToArrival > 0 && this.daysToArrival <= 1) { + messages.push('Your shipment will arrive soon'); + } + + return messages; + } +} diff --git a/src/tracking/notifications/notifier.abstract.ts b/src/tracking/notifications/notifier.abstract.ts new file mode 100644 index 0000000..f178595 --- /dev/null +++ b/src/tracking/notifications/notifier.abstract.ts @@ -0,0 +1,18 @@ +import { ShipmentEntity } from '@/entities/shipment.entity'; +import { TrackingDto } from '@/dtos/tracking.dto'; +import { INotifier } from '@/tracking/notifications/notifier.types'; + +export abstract class NotifierAbstract implements INotifier { + readonly isDelayed: boolean; + readonly daysToArrival: number; + constructor(shipment: ShipmentEntity, trackingInfo: TrackingDto) { + const delayedCutoffDate = new Date(shipment.arrivalDateOriginal); + delayedCutoffDate.setDate(delayedCutoffDate.getDate() + 1); + + this.isDelayed = trackingInfo.arrivalDateEstimate > delayedCutoffDate; + this.daysToArrival = + (trackingInfo.arrivalDateEstimate.getTime() - Date.now()) / 86_400_000; + } + + public abstract send(): Promise; +} diff --git a/src/tracking/notifications/notifier.factory.spec.ts b/src/tracking/notifications/notifier.factory.spec.ts new file mode 100644 index 0000000..e69de29 diff --git a/src/tracking/notifications/notifier.factory.ts b/src/tracking/notifications/notifier.factory.ts new file mode 100644 index 0000000..93c8de9 --- /dev/null +++ b/src/tracking/notifications/notifier.factory.ts @@ -0,0 +1,25 @@ +import { ShipmentEntity } from '@/entities/shipment.entity'; +import { TrackingDto } from '@/dtos/tracking.dto'; +import { + SupportedNotifiers, + SupportedNotifiersNames, +} from '@/tracking/notifications/notifier.types'; + +export class NotifierFactory { + constructor( + private readonly shipment: ShipmentEntity, + private readonly trackingInfo: TrackingDto, + private readonly type: SupportedNotifiersNames = 'debug', + ) {} + + createNotifier(type?: SupportedNotifiersNames) { + type = type ?? this.type; + const notifier = SupportedNotifiers[type]; + + if (!notifier) { + throw new Error(`Notifier ${type} not supported`); + } + + return new notifier(this.shipment, this.trackingInfo); + } +} diff --git a/src/tracking/notifications/notifier.types.ts b/src/tracking/notifications/notifier.types.ts new file mode 100644 index 0000000..96b3c00 --- /dev/null +++ b/src/tracking/notifications/notifier.types.ts @@ -0,0 +1,21 @@ +import { ConsoleNotifier } from '@/tracking/notifications/console.notifier'; + +export type ShipmentStatus = + | 'now' + | 'soon' + | 'delayed' + | 'delayed+now' + | 'delayed+soon'; + +export interface INotifier { + isDelayed: boolean; + daysToArrival: number; + send(): Promise; +} + +export const SupportedNotifiers = { + debug: ConsoleNotifier, + email: ConsoleNotifier, +} as const; + +export type SupportedNotifiersNames = keyof typeof SupportedNotifiers; diff --git a/src/tracking/tracking.controller.spec.ts b/src/tracking/tracking.controller.spec.ts index 3dffc2a..b4bc653 100644 --- a/src/tracking/tracking.controller.spec.ts +++ b/src/tracking/tracking.controller.spec.ts @@ -1,18 +1,56 @@ import { Test, TestingModule } from '@nestjs/testing'; import { TrackingController } from './tracking.controller'; +import { TrackingService } from '@/tracking/tracking.service'; +import { TrackingDto } from '@/dtos/tracking.dto'; describe('TrackingController', () => { + let trackingInfo: TrackingDto; let controller: TrackingController; + let trackingService: TrackingService; beforeEach(async () => { const module: TestingModule = await Test.createTestingModule({ controllers: [TrackingController], - }).compile(); + }) + .useMocker((token) => { + if (token === TrackingService) { + return { track: jest.fn().mockResolvedValue(true) }; + } + }) + + .compile(); controller = module.get(TrackingController); + trackingService = module.get(TrackingService); + + trackingInfo = { + carrierName: 'Evergreen', + destinationUnLoCode: 'CNNBO', + departureDate: new Date('2023-05-14T11:00:00.000Z'), + arrivalDateEstimate: new Date('2023-02-14T15:00:00.000Z'), + containerNumber: 'EGLU866139144', + }; }); it('should be defined', () => { expect(controller).toBeDefined(); }); + + it('should have a method track', () => { + expect(controller.track).toBeDefined(); + }); + + it('should return an object with message: ok', async () => { + await expect(controller.track(trackingInfo)).resolves.toEqual({ + message: 'Ok', + }); + }); + + it('should pass on any exception thrown from tracking service', async () => { + jest + .spyOn(trackingService, 'track') + .mockRejectedValue(new Error('Test Error')); + + await expect(controller.track(trackingInfo)).rejects.toThrow('Test Error'); + }); }); diff --git a/src/tracking/tracking.controller.ts b/src/tracking/tracking.controller.ts index ef91aae..2fd8c5e 100644 --- a/src/tracking/tracking.controller.ts +++ b/src/tracking/tracking.controller.ts @@ -1,4 +1,42 @@ -import { Controller } from '@nestjs/common'; +import { Body, Controller, HttpCode, Post } from '@nestjs/common'; +import { + ApiBody, + ApiExtraModels, + ApiNotFoundResponse, + ApiOkResponse, + ApiUnprocessableEntityResponse, + getSchemaPath, +} from '@nestjs/swagger'; +import { TrackingDto } from '@/dtos/tracking.dto'; +import { TrackingService } from '@/tracking/tracking.service'; @Controller('tracking') -export class TrackingController {} +@ApiExtraModels(TrackingDto) +export class TrackingController { + constructor(private readonly trackingService: TrackingService) {} + @Post() + @ApiBody({ + schema: { + $ref: getSchemaPath(TrackingDto), + }, + }) + @ApiOkResponse({ + description: 'Processed', + }) + @ApiUnprocessableEntityResponse({ + description: + 'The shipment has been found, but the shipment data does not match.', + }) + @ApiNotFoundResponse({ + description: 'We could not find any shipment matching the request.', + }) + @HttpCode(200) + track(@Body() body: TrackingDto): Promise { + return this.trackingService + .track(body) + + .then(() => ({ + message: 'Ok', + })); + } +} diff --git a/src/tracking/tracking.module.ts b/src/tracking/tracking.module.ts index a18f2fe..1e52cd3 100644 --- a/src/tracking/tracking.module.ts +++ b/src/tracking/tracking.module.ts @@ -1,9 +1,41 @@ import { Module } from '@nestjs/common'; import { TrackingController } from './tracking.controller'; import { TrackingService } from './tracking.service'; +import { TypeOrmModule } from '@nestjs/typeorm'; +import { ContainerEntity } from '@/entities/container.entity'; +import { ShipmentEntity } from '@/entities/shipment.entity'; +import { ShipmentsModule } from '@/shipments/shipments.module'; +import { NotificationsService } from '@/tracking/notifications.service'; +import { NotifierFactory } from '@/tracking/notifications/notifier.factory'; +import { TrackingDto } from '@/dtos/tracking.dto'; +import { + INotifier, + SupportedNotifiersNames, +} from '@/tracking/notifications/notifier.types'; @Module({ + imports: [ + TypeOrmModule.forFeature([ContainerEntity, ShipmentEntity]), + ShipmentsModule, + ], controllers: [TrackingController], - providers: [TrackingService], + providers: [ + TrackingService, + NotificationsService, + { + provide: 'NOTIFIER_FACTORY', + useFactory(): ( + shipment: ShipmentEntity, + trackingInfo: TrackingDto, + type: SupportedNotifiersNames, + ) => INotifier { + return ( + shipment: ShipmentEntity, + trackingInfo: TrackingDto, + type?: SupportedNotifiersNames, + ) => new NotifierFactory(shipment, trackingInfo, type).createNotifier(); + }, + }, + ], }) export class TrackingModule {} diff --git a/src/tracking/tracking.service.spec.ts b/src/tracking/tracking.service.spec.ts index f916200..5ff5887 100644 --- a/src/tracking/tracking.service.spec.ts +++ b/src/tracking/tracking.service.spec.ts @@ -1,18 +1,126 @@ import { Test, TestingModule } from '@nestjs/testing'; import { TrackingService } from './tracking.service'; - +import { Repository } from 'typeorm'; +import { ContainerEntity } from '@/entities/container.entity'; +import { ShipmentsService } from '@/shipments/shipments.service'; +import { NotificationsService } from '@/tracking/notifications.service'; +import { getRepositoryToken } from '@nestjs/typeorm'; +import * as uuid from 'uuid'; +import { TrackingDto } from '@/dtos/tracking.dto'; +import { ShipmentEntity } from '@/entities/shipment.entity'; describe('TrackingService', () => { - let service: TrackingService; + const mockTrackingInfo: TrackingDto = { + carrierName: 'Evergreen', + destinationUnLoCode: 'NLAMS', + departureDate: new Date('2023-05-14T11:00:00.000Z'), + arrivalDateEstimate: new Date('2023-02-14T15:00:00.000Z'), + containerNumber: 'AAAA000000000', + }; + + const mockShipment: ShipmentEntity = { + id: uuid.NIL, + createdAt: new Date('2023-05-01T11:00:00.000Z'), + carrierName: 'Evergreen', + originPort: 'NLAMS', + destinationPort: 'NLAMS', + departureDate: new Date('2023-05-14T11:00:00.000Z'), + arrivalDateOriginal: new Date('2023-02-14T15:00:00.000Z'), + arrivalDateEstimate: new Date('2023-02-14T15:00:00.000Z'), + }; + + const mockContainerEntity: ContainerEntity = { + id: uuid.NIL, + shipmentId: uuid.NIL, + containerNumber: 'AAAA000000000', + shipment: mockShipment, + }; + + const containerEntityToken = getRepositoryToken(ContainerEntity); + let trackingService: TrackingService; + let shipmentsService: ShipmentsService; + let notificationsService: NotificationsService; + let containerEntityRepository: Repository; beforeEach(async () => { const module: TestingModule = await Test.createTestingModule({ providers: [TrackingService], - }).compile(); + }) + .useMocker((token) => { + switch (token) { + case containerEntityToken: + return { + findOneOrFail: jest.fn().mockResolvedValue(mockContainerEntity), + }; + case ShipmentsService: + return { + update: jest.fn().mockResolvedValue(mockShipment), + }; + case NotificationsService: + return { + send: jest.fn().mockResolvedValue(true), + }; + } + }) + .compile(); - service = module.get(TrackingService); + trackingService = module.get(TrackingService); + shipmentsService = module.get(ShipmentsService); + notificationsService = + module.get(NotificationsService); + containerEntityRepository = + module.get>(containerEntityToken); }); it('should be defined', () => { - expect(service).toBeDefined(); + expect(trackingService).toBeDefined(); + expect(trackingService.track).toBeDefined(); + }); + + it('should have a track method that returns true', async () => { + await expect(trackingService.track(mockTrackingInfo)).resolves.toBe(true); + expect(containerEntityRepository.findOneOrFail).toHaveBeenCalledWith({ + relations: ['shipment'], + where: { + containerNumber: mockTrackingInfo.containerNumber, + }, + }); + expect(shipmentsService.update).toHaveBeenCalledWith({ + ...mockShipment, + arrivalDateEstimate: mockTrackingInfo.arrivalDateEstimate, + departureDate: mockTrackingInfo.departureDate, + }); + expect(notificationsService.send).toHaveBeenCalledWith( + mockShipment, + mockTrackingInfo, + ); + }); + + it('should have a track method that validates the tracking information with the returned container information', async () => { + const mockShipmentNotFound = { + ...mockContainerEntity, + shipment: undefined, + }; + const mockShipmentCarrierNameMismatch = { + ...mockContainerEntity, + shipment: { ...mockShipment, carrierName: 'incorrect' }, + }; + + jest + .spyOn(containerEntityRepository, 'findOneOrFail') + .mockResolvedValueOnce(mockShipmentNotFound); + await expect(trackingService.track(mockTrackingInfo)).rejects.toThrow( + 'Shipment not found', + ); + expect(notificationsService.send).toHaveBeenCalledTimes(0); + expect(shipmentsService.update).toHaveBeenCalledTimes(0); + + jest + .spyOn(containerEntityRepository, 'findOneOrFail') + .mockResolvedValueOnce(mockShipmentCarrierNameMismatch); + await expect(trackingService.track(mockTrackingInfo)).rejects.toThrow( + 'CarrierName does not match', + ); + expect(notificationsService.send).toHaveBeenCalledTimes(0); + expect(shipmentsService.update).toHaveBeenCalledTimes(0); }); }); diff --git a/src/tracking/tracking.service.ts b/src/tracking/tracking.service.ts index d768c99..277ce5b 100644 --- a/src/tracking/tracking.service.ts +++ b/src/tracking/tracking.service.ts @@ -1,4 +1,68 @@ -import { Injectable } from '@nestjs/common'; +import { + Injectable, + NotFoundException, + UnprocessableEntityException, +} from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { ContainerEntity } from '@/entities/container.entity'; +import { Repository } from 'typeorm'; +import { ShipmentEntity } from '@/entities/shipment.entity'; +import { TrackingDto } from '@/dtos/tracking.dto'; +import { ShipmentsService } from '@/shipments/shipments.service'; +import { NotificationsService } from '@/tracking/notifications.service'; @Injectable() -export class TrackingService {} +export class TrackingService { + constructor( + @InjectRepository(ContainerEntity) + private readonly containerRepository: Repository, + private readonly shipmentsService: ShipmentsService, + private readonly notificationsService: NotificationsService, + ) {} + + public async track(trackingInfo: TrackingDto) { + const container = await this.containerRepository.findOneOrFail({ + relations: ['shipment'], + where: { + containerNumber: trackingInfo.containerNumber, + }, + }); + + const shipment = this.validate(container, trackingInfo); + + await this.updateShipment(shipment, trackingInfo); + + await this.notificationsService.send(shipment, trackingInfo); + + return true; + } + + private validate( + { shipment }: ContainerEntity, + trackingInfo: TrackingDto, + ): ShipmentEntity { + if (!shipment) { + throw new NotFoundException('Shipment not found'); + } + + if (shipment.carrierName !== trackingInfo.carrierName) { + throw new UnprocessableEntityException('CarrierName does not match'); + } + + return shipment; + } + + private async updateShipment( + shipment: ShipmentEntity, + trackingInfo: TrackingDto, + ) { + const updatedShipment: ShipmentEntity = { + ...shipment, + arrivalDateEstimate: trackingInfo.arrivalDateEstimate, + departureDate: trackingInfo.departureDate, + }; + await this.shipmentsService.update(updatedShipment); + + return updatedShipment; + } +} diff --git a/src/utils/uuidTransformer.ts b/src/utils/uuidTransformer.ts index e2a80ee..796b787 100644 --- a/src/utils/uuidTransformer.ts +++ b/src/utils/uuidTransformer.ts @@ -1,4 +1,4 @@ -import { NIL, parse } from 'uuid'; +import { NIL } from 'uuid'; // custom implementation of stringify, as provided uuid's are not valid according to RFC4122 const stringify = (buffer: Buffer | null): string => { @@ -16,9 +16,20 @@ const stringify = (buffer: Buffer | null): string => { return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`; }; +const parseUuid = (uuid: string): Buffer => { + const uuidTester = + /^[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}$/i; + + if (!uuidTester.test(uuid)) { + throw new Error('Invalid UUID'); + } + + return Buffer.from(uuid.replaceAll('-', ''), 'hex'); +}; + export const uuidTransformer = { to(uuid: string): Buffer { - return Buffer.from(parse(uuid)); + return parseUuid(uuid); }, from(bin: Buffer | null): string { return stringify(bin); diff --git a/track.http b/track.http new file mode 100644 index 0000000..41a17c3 --- /dev/null +++ b/track.http @@ -0,0 +1,10 @@ +POST http://localhost:3000/tracking +Content-Type: application/json + +{ + "carrierName": "Evergreen", + "destinationUnLoCode": "CNNBO", + "departureDate": "2023-05-14T11:00:00.000Z", + "arrivalDateEstimate": "2023-02-14T15:00:00.000Z", + "containerNumber": "EGLU866139144" +}