WIP: Implemented tests & processed tracking information
This commit is contained in:
parent
ad84d1f837
commit
af9ab1a483
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -15,4 +15,8 @@ describe('ShipmentsController', () => {
|
|||
it('should be defined', () => {
|
||||
expect(controller).toBeDefined();
|
||||
});
|
||||
|
||||
it('should', () => {
|
||||
expect(true).toBe(true);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -22,6 +22,6 @@ export class ShipmentsController {
|
|||
},
|
||||
})
|
||||
getShipments() {
|
||||
return this.shipmentsService.getShipments();
|
||||
return this.shipmentsService.getAll();
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -16,5 +16,6 @@ import { ShipmentEntity } from '@/entities/shipment.entity';
|
|||
],
|
||||
controllers: [ShipmentsController],
|
||||
providers: [ShipmentsService],
|
||||
exports: [ShipmentsService],
|
||||
})
|
||||
export class ShipmentsModule {}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>(NotificationsService);
|
||||
});
|
||||
|
||||
it('should be defined', () => {
|
||||
expect(service).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
|
@ -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<void> {
|
||||
const notifier = this.notifierFactory(shipment, trackingInfo);
|
||||
await notifier.send();
|
||||
}
|
||||
}
|
||||
|
|
@ -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<boolean> {
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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<boolean>;
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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<boolean>;
|
||||
}
|
||||
|
||||
export const SupportedNotifiers = {
|
||||
debug: ConsoleNotifier,
|
||||
email: ConsoleNotifier,
|
||||
} as const;
|
||||
|
||||
export type SupportedNotifiersNames = keyof typeof SupportedNotifiers;
|
||||
|
|
@ -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>(TrackingController);
|
||||
trackingService = module.get<TrackingService>(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');
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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<unknown> {
|
||||
return this.trackingService
|
||||
.track(body)
|
||||
|
||||
.then(() => ({
|
||||
message: 'Ok',
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 {}
|
||||
|
|
|
|||
|
|
@ -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<ContainerEntity>;
|
||||
|
||||
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);
|
||||
trackingService = module.get<TrackingService>(TrackingService);
|
||||
shipmentsService = module.get<ShipmentsService>(ShipmentsService);
|
||||
notificationsService =
|
||||
module.get<NotificationsService>(NotificationsService);
|
||||
containerEntityRepository =
|
||||
module.get<Repository<ContainerEntity>>(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);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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<ContainerEntity>,
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
Loading…
Reference in New Issue