WIP: Implemented tests & processed tracking information

This commit is contained in:
Thom Werring 2024-03-26 18:23:40 +01:00
parent ad84d1f837
commit af9ab1a483
27 changed files with 483 additions and 81 deletions

View File

@ -15,7 +15,7 @@ module.exports = {
node: true, node: true,
jest: true, jest: true,
}, },
ignorePatterns: ['.eslintrc.js'], ignorePatterns: ['.eslintrc.js', 'database/'],
rules: { rules: {
'@typescript-eslint/interface-name-prefix': 'off', '@typescript-eslint/interface-name-prefix': 'off',
'@typescript-eslint/explicit-function-return-type': 'off', '@typescript-eslint/explicit-function-return-type': 'off',

26
package-lock.json generated
View File

@ -16,6 +16,7 @@
"@nestjs/typeorm": "^10.0.2", "@nestjs/typeorm": "^10.0.2",
"class-transformer": "^0.5.1", "class-transformer": "^0.5.1",
"class-validator": "^0.14.1", "class-validator": "^0.14.1",
"jest-mock": "^29.7.0",
"mysql2": "^3.9.2", "mysql2": "^3.9.2",
"reflect-metadata": "^0.2.1", "reflect-metadata": "^0.2.1",
"rxjs": "^7.8.1", "rxjs": "^7.8.1",
@ -1431,7 +1432,6 @@
"version": "29.6.3", "version": "29.6.3",
"resolved": "https://registry.npmjs.org/@jest/schemas/-/schemas-29.6.3.tgz", "resolved": "https://registry.npmjs.org/@jest/schemas/-/schemas-29.6.3.tgz",
"integrity": "sha512-mo5j5X+jIZmJQveBKeS/clAueipV7KgiX1vMgCxam1RNYiqE1w62n0/tJJnHtjW8ZHcQco5gY85jA3mi0L+nSA==", "integrity": "sha512-mo5j5X+jIZmJQveBKeS/clAueipV7KgiX1vMgCxam1RNYiqE1w62n0/tJJnHtjW8ZHcQco5gY85jA3mi0L+nSA==",
"dev": true,
"dependencies": { "dependencies": {
"@sinclair/typebox": "^0.27.8" "@sinclair/typebox": "^0.27.8"
}, },
@ -1513,7 +1513,6 @@
"version": "29.6.3", "version": "29.6.3",
"resolved": "https://registry.npmjs.org/@jest/types/-/types-29.6.3.tgz", "resolved": "https://registry.npmjs.org/@jest/types/-/types-29.6.3.tgz",
"integrity": "sha512-u3UPsIilWKOM3F9CXtrG8LEJmNxwoCQC/XVj4IKYXvvpx7QIi/Kg1LI5uDmDpKlac62NUtX7eLjRh+jVZcLOzw==", "integrity": "sha512-u3UPsIilWKOM3F9CXtrG8LEJmNxwoCQC/XVj4IKYXvvpx7QIi/Kg1LI5uDmDpKlac62NUtX7eLjRh+jVZcLOzw==",
"dev": true,
"dependencies": { "dependencies": {
"@jest/schemas": "^29.6.3", "@jest/schemas": "^29.6.3",
"@types/istanbul-lib-coverage": "^2.0.0", "@types/istanbul-lib-coverage": "^2.0.0",
@ -2015,8 +2014,7 @@
"node_modules/@sinclair/typebox": { "node_modules/@sinclair/typebox": {
"version": "0.27.8", "version": "0.27.8",
"resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.27.8.tgz", "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.27.8.tgz",
"integrity": "sha512-+Fj43pSMwJs4KRrH/938Uf+uAELIgVBmQzg/q1YG10djyfA3TnrU8N8XzqCh/okZdszqBQTZf96idMfE5lnwTA==", "integrity": "sha512-+Fj43pSMwJs4KRrH/938Uf+uAELIgVBmQzg/q1YG10djyfA3TnrU8N8XzqCh/okZdszqBQTZf96idMfE5lnwTA=="
"dev": true
}, },
"node_modules/@sinonjs/commons": { "node_modules/@sinonjs/commons": {
"version": "3.0.1", "version": "3.0.1",
@ -2199,14 +2197,12 @@
"node_modules/@types/istanbul-lib-coverage": { "node_modules/@types/istanbul-lib-coverage": {
"version": "2.0.6", "version": "2.0.6",
"resolved": "https://registry.npmjs.org/@types/istanbul-lib-coverage/-/istanbul-lib-coverage-2.0.6.tgz", "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==", "integrity": "sha512-2QF/t/auWm0lsy8XtKVPG19v3sSOQlJe/YHZgfjb/KBBHOGSV+J2q/S671rcq9uTBrLAXmZpqJiaQbMT+zNU1w=="
"dev": true
}, },
"node_modules/@types/istanbul-lib-report": { "node_modules/@types/istanbul-lib-report": {
"version": "3.0.3", "version": "3.0.3",
"resolved": "https://registry.npmjs.org/@types/istanbul-lib-report/-/istanbul-lib-report-3.0.3.tgz", "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==", "integrity": "sha512-NQn7AHQnk/RSLOxrBbGyJM/aVQ+pjj5HCgasFxc0K/KhoATfQ/47AyUl15I2yBUpihjmas+a+VJBOqecrFH+uA==",
"dev": true,
"dependencies": { "dependencies": {
"@types/istanbul-lib-coverage": "*" "@types/istanbul-lib-coverage": "*"
} }
@ -2215,7 +2211,6 @@
"version": "3.0.4", "version": "3.0.4",
"resolved": "https://registry.npmjs.org/@types/istanbul-reports/-/istanbul-reports-3.0.4.tgz", "resolved": "https://registry.npmjs.org/@types/istanbul-reports/-/istanbul-reports-3.0.4.tgz",
"integrity": "sha512-pk2B1NWalF9toCRu6gjBzR69syFjP4Od8WRAX+0mmf9lAjCRicLOWc+ZrxZHx/0XRjotgkF9t6iaMJ+aXcOdZQ==", "integrity": "sha512-pk2B1NWalF9toCRu6gjBzR69syFjP4Od8WRAX+0mmf9lAjCRicLOWc+ZrxZHx/0XRjotgkF9t6iaMJ+aXcOdZQ==",
"dev": true,
"dependencies": { "dependencies": {
"@types/istanbul-lib-report": "*" "@types/istanbul-lib-report": "*"
} }
@ -2252,7 +2247,6 @@
"version": "20.11.30", "version": "20.11.30",
"resolved": "https://registry.npmjs.org/@types/node/-/node-20.11.30.tgz", "resolved": "https://registry.npmjs.org/@types/node/-/node-20.11.30.tgz",
"integrity": "sha512-dHM6ZxwlmuZaRmUPfv1p+KrdD1Dci04FbdEm/9wEMouFqxYoFl5aMkt0VMAUtYRQDyYvD41WJLukhq/ha3YuTw==", "integrity": "sha512-dHM6ZxwlmuZaRmUPfv1p+KrdD1Dci04FbdEm/9wEMouFqxYoFl5aMkt0VMAUtYRQDyYvD41WJLukhq/ha3YuTw==",
"devOptional": true,
"dependencies": { "dependencies": {
"undici-types": "~5.26.4" "undici-types": "~5.26.4"
} }
@ -2338,7 +2332,6 @@
"version": "17.0.32", "version": "17.0.32",
"resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-17.0.32.tgz", "resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-17.0.32.tgz",
"integrity": "sha512-xQ67Yc/laOG5uMfX/093MRlGGCIBzZMarVa+gfNKJxWAIgykYpVGkBdbqEzGDDfCrVUj6Hiff4mTZ5BA6TmAog==", "integrity": "sha512-xQ67Yc/laOG5uMfX/093MRlGGCIBzZMarVa+gfNKJxWAIgykYpVGkBdbqEzGDDfCrVUj6Hiff4mTZ5BA6TmAog==",
"dev": true,
"dependencies": { "dependencies": {
"@types/yargs-parser": "*" "@types/yargs-parser": "*"
} }
@ -2346,8 +2339,7 @@
"node_modules/@types/yargs-parser": { "node_modules/@types/yargs-parser": {
"version": "21.0.3", "version": "21.0.3",
"resolved": "https://registry.npmjs.org/@types/yargs-parser/-/yargs-parser-21.0.3.tgz", "resolved": "https://registry.npmjs.org/@types/yargs-parser/-/yargs-parser-21.0.3.tgz",
"integrity": "sha512-I4q9QU9MQv4oEOz4tAHJtNz1cwuLxn2F3xcc2iV5WdqLPpUnj30aUuxt1mAxYTG+oe8CZMV/+6rU4S4gRDzqtQ==", "integrity": "sha512-I4q9QU9MQv4oEOz4tAHJtNz1cwuLxn2F3xcc2iV5WdqLPpUnj30aUuxt1mAxYTG+oe8CZMV/+6rU4S4gRDzqtQ=="
"dev": true
}, },
"node_modules/@typescript-eslint/eslint-plugin": { "node_modules/@typescript-eslint/eslint-plugin": {
"version": "7.3.1", "version": "7.3.1",
@ -3388,7 +3380,6 @@
"version": "3.9.0", "version": "3.9.0",
"resolved": "https://registry.npmjs.org/ci-info/-/ci-info-3.9.0.tgz", "resolved": "https://registry.npmjs.org/ci-info/-/ci-info-3.9.0.tgz",
"integrity": "sha512-NIxF55hv4nSqQswkAeiOi1r83xy8JldOFDTWiug55KBu9Jnblncd2U6ViHmYgHf01TPZS77NJBhBMKdWj9HQMQ==", "integrity": "sha512-NIxF55hv4nSqQswkAeiOi1r83xy8JldOFDTWiug55KBu9Jnblncd2U6ViHmYgHf01TPZS77NJBhBMKdWj9HQMQ==",
"dev": true,
"funding": [ "funding": [
{ {
"type": "github", "type": "github",
@ -5135,8 +5126,7 @@
"node_modules/graceful-fs": { "node_modules/graceful-fs": {
"version": "4.2.11", "version": "4.2.11",
"resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz",
"integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ=="
"dev": true
}, },
"node_modules/graphemer": { "node_modules/graphemer": {
"version": "1.4.0", "version": "1.4.0",
@ -5983,7 +5973,6 @@
"version": "29.7.0", "version": "29.7.0",
"resolved": "https://registry.npmjs.org/jest-mock/-/jest-mock-29.7.0.tgz", "resolved": "https://registry.npmjs.org/jest-mock/-/jest-mock-29.7.0.tgz",
"integrity": "sha512-ITOMZn+UkYS4ZFh83xYAOzWStloNzJFO2s8DWrE4lhtGD+AorgnbkiKERe4wQVBydIGPx059g6riW5Btp6Llnw==", "integrity": "sha512-ITOMZn+UkYS4ZFh83xYAOzWStloNzJFO2s8DWrE4lhtGD+AorgnbkiKERe4wQVBydIGPx059g6riW5Btp6Llnw==",
"dev": true,
"dependencies": { "dependencies": {
"@jest/types": "^29.6.3", "@jest/types": "^29.6.3",
"@types/node": "*", "@types/node": "*",
@ -6213,7 +6202,6 @@
"version": "29.7.0", "version": "29.7.0",
"resolved": "https://registry.npmjs.org/jest-util/-/jest-util-29.7.0.tgz", "resolved": "https://registry.npmjs.org/jest-util/-/jest-util-29.7.0.tgz",
"integrity": "sha512-z6EbKajIpqGKU56y5KBUgy1dt1ihhQJgWzUlZHArA/+X2ad7Cb5iF+AK1EWVL/Bo7Rz9uurpqw6SiBCefUbCGA==", "integrity": "sha512-z6EbKajIpqGKU56y5KBUgy1dt1ihhQJgWzUlZHArA/+X2ad7Cb5iF+AK1EWVL/Bo7Rz9uurpqw6SiBCefUbCGA==",
"dev": true,
"dependencies": { "dependencies": {
"@jest/types": "^29.6.3", "@jest/types": "^29.6.3",
"@types/node": "*", "@types/node": "*",
@ -6230,7 +6218,6 @@
"version": "2.3.1", "version": "2.3.1",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz",
"integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==",
"dev": true,
"engines": { "engines": {
"node": ">=8.6" "node": ">=8.6"
}, },
@ -9495,8 +9482,7 @@
"node_modules/undici-types": { "node_modules/undici-types": {
"version": "5.26.5", "version": "5.26.5",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-5.26.5.tgz", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-5.26.5.tgz",
"integrity": "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==", "integrity": "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA=="
"devOptional": true
}, },
"node_modules/universalify": { "node_modules/universalify": {
"version": "2.0.1", "version": "2.0.1",

View File

@ -28,6 +28,7 @@
"@nestjs/typeorm": "^10.0.2", "@nestjs/typeorm": "^10.0.2",
"class-transformer": "^0.5.1", "class-transformer": "^0.5.1",
"class-validator": "^0.14.1", "class-validator": "^0.14.1",
"jest-mock": "^29.7.0",
"mysql2": "^3.9.2", "mysql2": "^3.9.2",
"reflect-metadata": "^0.2.1", "reflect-metadata": "^0.2.1",
"rxjs": "^7.8.1", "rxjs": "^7.8.1",

View File

@ -1,13 +1,11 @@
import { import {
Body,
ClassSerializerInterceptor, ClassSerializerInterceptor,
Controller, Controller,
Get, Get,
Post,
UseInterceptors, UseInterceptors,
} from '@nestjs/common'; } from '@nestjs/common';
import { AppService } from './app.service'; import { AppService } from './app.service';
import { ApiBody, ApiExtraModels, getSchemaPath } from '@nestjs/swagger'; import { ApiExtraModels } from '@nestjs/swagger';
import { TrackingDto } from '@/dtos/tracking.dto'; import { TrackingDto } from '@/dtos/tracking.dto';
@Controller() @Controller()
@ -20,15 +18,4 @@ export class AppController {
debug(): unknown { debug(): unknown {
return this.appService.debug(); return this.appService.debug();
} }
@Post()
@ApiBody({
schema: {
$ref: getSchemaPath(TrackingDto),
},
})
test(@Body() body: TrackingDto): string {
console.log(body);
return 'ok';
}
} }

View File

@ -1,6 +1,5 @@
import { ApiProperty } from '@nestjs/swagger'; import { ApiProperty } from '@nestjs/swagger';
import { ContainerEntity } from '@/entities/container.entity'; import { ContainerEntity } from '@/entities/container.entity';
import { instanceToPlain, plainToClass } from 'class-transformer';
export class ContainerDto { export class ContainerDto {
@ApiProperty() @ApiProperty()
@ -17,14 +16,4 @@ export class ContainerDto {
this.containerNumber = entity.containerNumber; this.containerNumber = entity.containerNumber;
this.shipmentId = entity.shipmentId; 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);
}
} }

View File

@ -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 { uuidTransformer } from '@/utils/uuidTransformer';
import { ShipmentEntity } from '@/entities/shipment.entity'; import { ShipmentEntity } from '@/entities/shipment.entity';
@Entity({ @Entity({
name: 'containers', name: 'containers',
}) })
export class ContainerEntity extends BaseEntity { export class ContainerEntity {
@PrimaryColumn({ @PrimaryColumn({
type: 'binary', type: 'binary',
length: 16, length: 16,

View File

@ -1,18 +1,11 @@
import { import { Column, Entity, JoinColumn, OneToOne, PrimaryColumn } from 'typeorm';
BaseEntity,
Column,
Entity,
JoinColumn,
OneToOne,
PrimaryColumn,
} from 'typeorm';
import { uuidTransformer } from '@/utils/uuidTransformer'; import { uuidTransformer } from '@/utils/uuidTransformer';
import { ShipmentEntity } from '@/entities/shipment.entity'; import { ShipmentEntity } from '@/entities/shipment.entity';
@Entity({ @Entity({
name: 'live_tracking', name: 'live_tracking',
}) })
export class LiveTrackingEntity extends BaseEntity { export class LiveTrackingEntity {
@PrimaryColumn({ @PrimaryColumn({
type: 'binary', type: 'binary',
length: 16, length: 16,

View File

@ -1,11 +1,4 @@
import { import { Column, Entity, OneToMany, OneToOne, PrimaryColumn } from 'typeorm';
BaseEntity,
Column,
Entity,
OneToMany,
OneToOne,
PrimaryColumn,
} from 'typeorm';
import { uuidTransformer } from '@/utils/uuidTransformer'; import { uuidTransformer } from '@/utils/uuidTransformer';
import { ContainerEntity } from '@/entities/container.entity'; import { ContainerEntity } from '@/entities/container.entity';
import { LiveTrackingEntity } from '@/entities/liveTracking.entity'; import { LiveTrackingEntity } from '@/entities/liveTracking.entity';
@ -13,7 +6,7 @@ import { LiveTrackingEntity } from '@/entities/liveTracking.entity';
@Entity({ @Entity({
name: 'shipments', name: 'shipments',
}) })
export class ShipmentEntity extends BaseEntity { export class ShipmentEntity {
@PrimaryColumn({ @PrimaryColumn({
type: 'binary', type: 'binary',
length: 16, length: 16,

View File

@ -15,4 +15,8 @@ describe('ShipmentsController', () => {
it('should be defined', () => { it('should be defined', () => {
expect(controller).toBeDefined(); expect(controller).toBeDefined();
}); });
it('should', () => {
expect(true).toBe(true);
});
}); });

View File

@ -22,6 +22,6 @@ export class ShipmentsController {
}, },
}) })
getShipments() { getShipments() {
return this.shipmentsService.getShipments(); return this.shipmentsService.getAll();
} }
} }

View File

@ -16,5 +16,6 @@ import { ShipmentEntity } from '@/entities/shipment.entity';
], ],
controllers: [ShipmentsController], controllers: [ShipmentsController],
providers: [ShipmentsService], providers: [ShipmentsService],
exports: [ShipmentsService],
}) })
export class ShipmentsModule {} export class ShipmentsModule {}

View File

@ -11,9 +11,13 @@ export class ShipmentsService {
) {} ) {}
// @todo add pagination // @todo add pagination
getShipments() { getAll() {
return this.shipmentRepository.find({ return this.shipmentRepository.find({
relations: ['liveTracking', 'containers'], relations: ['liveTracking', 'containers'],
}); });
} }
update(shipment: ShipmentEntity) {
return this.shipmentRepository.save(shipment);
}
} }

View File

@ -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();
});
});

View File

@ -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();
}
}

View File

@ -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;
}
}

View File

@ -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>;
}

View File

@ -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);
}
}

View File

@ -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;

View File

@ -1,18 +1,56 @@
import { Test, TestingModule } from '@nestjs/testing'; import { Test, TestingModule } from '@nestjs/testing';
import { TrackingController } from './tracking.controller'; import { TrackingController } from './tracking.controller';
import { TrackingService } from '@/tracking/tracking.service';
import { TrackingDto } from '@/dtos/tracking.dto';
describe('TrackingController', () => { describe('TrackingController', () => {
let trackingInfo: TrackingDto;
let controller: TrackingController; let controller: TrackingController;
let trackingService: TrackingService;
beforeEach(async () => { beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({ const module: TestingModule = await Test.createTestingModule({
controllers: [TrackingController], controllers: [TrackingController],
}).compile(); })
.useMocker((token) => {
if (token === TrackingService) {
return { track: jest.fn().mockResolvedValue(true) };
}
})
.compile();
controller = module.get<TrackingController>(TrackingController); 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', () => { it('should be defined', () => {
expect(controller).toBeDefined(); 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');
});
}); });

View File

@ -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') @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',
}));
}
}

View File

@ -1,9 +1,41 @@
import { Module } from '@nestjs/common'; import { Module } from '@nestjs/common';
import { TrackingController } from './tracking.controller'; import { TrackingController } from './tracking.controller';
import { TrackingService } from './tracking.service'; 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({ @Module({
imports: [
TypeOrmModule.forFeature([ContainerEntity, ShipmentEntity]),
ShipmentsModule,
],
controllers: [TrackingController], 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 {} export class TrackingModule {}

View File

@ -1,18 +1,126 @@
import { Test, TestingModule } from '@nestjs/testing'; import { Test, TestingModule } from '@nestjs/testing';
import { TrackingService } from './tracking.service'; 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', () => { 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 () => { beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({ const module: TestingModule = await Test.createTestingModule({
providers: [TrackingService], 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', () => { 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);
}); });
}); });

View File

@ -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() @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;
}
}

View File

@ -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 // custom implementation of stringify, as provided uuid's are not valid according to RFC4122
const stringify = (buffer: Buffer | null): string => { 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)}`; 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 = { export const uuidTransformer = {
to(uuid: string): Buffer { to(uuid: string): Buffer {
return Buffer.from(parse(uuid)); return parseUuid(uuid);
}, },
from(bin: Buffer | null): string { from(bin: Buffer | null): string {
return stringify(bin); return stringify(bin);

10
track.http Normal file
View File

@ -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"
}