Compare commits
10 Commits
afebfc9150
...
75b459b10f
| Author | SHA1 | Date |
|---|---|---|
|
|
75b459b10f | |
|
|
ea3d29ac68 | |
|
|
a15e24e901 | |
|
|
0e4716b7b8 | |
|
|
8c1ed4d086 | |
|
|
f1ac705204 | |
|
|
73db243099 | |
|
|
64e7fd66fc | |
|
|
d7728f1ca4 | |
|
|
c33cc366d6 |
|
|
@ -1,2 +1,4 @@
|
||||||
|
database
|
||||||
|
coverage
|
||||||
tech-assignment-data
|
tech-assignment-data
|
||||||
node_modules
|
node_modules
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,5 @@
|
||||||
|
DB_HOST=database
|
||||||
|
DB_USER=root
|
||||||
|
DB_PASS=myfreight
|
||||||
|
DB_PORT=3306
|
||||||
|
DB_DABA=myfreight
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
/database
|
/database/lib
|
||||||
.eslintcache
|
.eslintcache
|
||||||
|
|
||||||
# compiled output
|
# compiled output
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,7 @@ WORKDIR /usr/src/app
|
||||||
|
|
||||||
COPY package*.json .
|
COPY package*.json .
|
||||||
|
|
||||||
RUN npm ci
|
RUN npm ci --no-auditdc po
|
||||||
|
|
||||||
COPY . .
|
COPY . .
|
||||||
|
|
||||||
|
|
|
||||||
34
README.md
34
README.md
|
|
@ -1,3 +1,21 @@
|
||||||
|
# MyFreight Assessment
|
||||||
|
|
||||||
|
## How to run
|
||||||
|
Easiest way to run this project is using docker compose:
|
||||||
|
`docker compose up`
|
||||||
|
|
||||||
|
It is possible that this will give some database connection issues on the first try, but after a couple of seconds, it should automatically reconnect.
|
||||||
|
|
||||||
|
Alternatively, it is possible to use your own mysql database, and run the code locally:
|
||||||
|
|
||||||
|
1. Import the `database/entrypoint/init.sql` into your own database
|
||||||
|
2. Update the `.env` file with the database credentials
|
||||||
|
3. Run `npm ci` to install all npm packages
|
||||||
|
4. Run `npm run build` to build the nestjs application
|
||||||
|
5. Run `npm run start:prod` to start the nestjs application
|
||||||
|
|
||||||
|
## Receiving Tracking information
|
||||||
|
When receiving Tracking information, the following steps need to be completed to process the data
|
||||||
```mermaid
|
```mermaid
|
||||||
flowchart TD
|
flowchart TD
|
||||||
receiveTracking(["Receive Tracking"])
|
receiveTracking(["Receive Tracking"])
|
||||||
|
|
@ -26,8 +44,16 @@ containerNumber:**string**
|
||||||
%% isArrivingSoon -->|yes| sendArriveSoon
|
%% isArrivingSoon -->|yes| sendArriveSoon
|
||||||
```
|
```
|
||||||
|
|
||||||
```mermaid
|
|
||||||
flowchart
|
|
||||||
getShipments([Get all shipments])
|
|
||||||
|
|
||||||
```
|
## Possible improvements
|
||||||
|
|
||||||
|
- Sending the actual notification, there should be a more general way to setup notifiers
|
||||||
|
- Moving some variables to config files
|
||||||
|
- when is a shipment "arriving soon"
|
||||||
|
- mock date
|
||||||
|
- Use the uuid package
|
||||||
|
- was not possible since the uuid's from the sample data were invalid uuid's
|
||||||
|
- Add end-to-end tests as well
|
||||||
|
- Get shipments should be paginated
|
||||||
|
- Could use a package like typeorm-crud
|
||||||
|
- Cleanup duplicate OpenApi decorators, perhaps consolidate them in a custom decorator
|
||||||
|
|
|
||||||
File diff suppressed because one or more lines are too long
|
|
@ -3,7 +3,11 @@ services:
|
||||||
build: .
|
build: .
|
||||||
ports:
|
ports:
|
||||||
- "3001:3000"
|
- "3001:3000"
|
||||||
command: "npm run start:dev"
|
command: "npm run start:prod"
|
||||||
|
depends_on:
|
||||||
|
- database
|
||||||
|
volumes:
|
||||||
|
- ".:/usr/src/app"
|
||||||
database:
|
database:
|
||||||
image: "mysql:8"
|
image: "mysql:8"
|
||||||
command: --default-authentication-plugin=mysql_native_password
|
command: --default-authentication-plugin=mysql_native_password
|
||||||
|
|
|
||||||
|
|
@ -1,24 +0,0 @@
|
||||||
import { Test, TestingModule } from '@nestjs/testing';
|
|
||||||
import { INestApplication } from '@nestjs/common';
|
|
||||||
import * as request from 'supertest';
|
|
||||||
import { AppModule } from './../src/app.module';
|
|
||||||
|
|
||||||
describe('AppController (e2e)', () => {
|
|
||||||
let app: INestApplication;
|
|
||||||
|
|
||||||
beforeEach(async () => {
|
|
||||||
const moduleFixture: TestingModule = await Test.createTestingModule({
|
|
||||||
imports: [AppModule],
|
|
||||||
}).compile();
|
|
||||||
|
|
||||||
app = moduleFixture.createNestApplication();
|
|
||||||
await app.init();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('/ (GET)', () => {
|
|
||||||
return request(app.getHttpServer())
|
|
||||||
.get('/')
|
|
||||||
.expect(200)
|
|
||||||
.expect('Hello World!');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
@ -1,9 +0,0 @@
|
||||||
{
|
|
||||||
"moduleFileExtensions": ["js", "json", "ts"],
|
|
||||||
"rootDir": ".",
|
|
||||||
"testEnvironment": "node",
|
|
||||||
"testRegex": ".e2e-spec.ts$",
|
|
||||||
"transform": {
|
|
||||||
"^.+\\.(t|j)s$": "ts-jest"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -10,13 +10,13 @@
|
||||||
"license": "UNLICENSED",
|
"license": "UNLICENSED",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@nestjs/common": "^10.3.5",
|
"@nestjs/common": "^10.3.5",
|
||||||
|
"@nestjs/config": "^3.2.1",
|
||||||
"@nestjs/core": "^10.3.5",
|
"@nestjs/core": "^10.3.5",
|
||||||
"@nestjs/platform-express": "^10.3.5",
|
"@nestjs/platform-express": "^10.3.5",
|
||||||
"@nestjs/swagger": "^7.3.0",
|
"@nestjs/swagger": "^7.3.0",
|
||||||
"@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",
|
||||||
|
|
@ -1432,6 +1432,7 @@
|
||||||
"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,6 +1514,7 @@
|
||||||
"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",
|
||||||
|
|
@ -1766,6 +1768,21 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@nestjs/config": {
|
||||||
|
"version": "3.2.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@nestjs/config/-/config-3.2.1.tgz",
|
||||||
|
"integrity": "sha512-tFZyLJKanSAu51ygQ6ZBSpx95pRcwS6qSpJDW6FFgRQzkOaOUXpL8qD8yMNoYoYxuJCxph+waiBaWKgFWxn3sw==",
|
||||||
|
"dependencies": {
|
||||||
|
"dotenv": "16.4.5",
|
||||||
|
"dotenv-expand": "10.0.0",
|
||||||
|
"lodash": "4.17.21",
|
||||||
|
"uuid": "9.0.1"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@nestjs/common": "^8.0.0 || ^9.0.0 || ^10.0.0",
|
||||||
|
"rxjs": "^7.1.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@nestjs/core": {
|
"node_modules/@nestjs/core": {
|
||||||
"version": "10.3.5",
|
"version": "10.3.5",
|
||||||
"resolved": "https://registry.npmjs.org/@nestjs/core/-/core-10.3.5.tgz",
|
"resolved": "https://registry.npmjs.org/@nestjs/core/-/core-10.3.5.tgz",
|
||||||
|
|
@ -2014,7 +2031,8 @@
|
||||||
"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",
|
||||||
|
|
@ -2197,12 +2215,14 @@
|
||||||
"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": "*"
|
||||||
}
|
}
|
||||||
|
|
@ -2211,6 +2231,7 @@
|
||||||
"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": "*"
|
||||||
}
|
}
|
||||||
|
|
@ -2247,6 +2268,7 @@
|
||||||
"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"
|
||||||
}
|
}
|
||||||
|
|
@ -2332,6 +2354,7 @@
|
||||||
"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": "*"
|
||||||
}
|
}
|
||||||
|
|
@ -2339,7 +2362,8 @@
|
||||||
"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",
|
||||||
|
|
@ -3380,6 +3404,7 @@
|
||||||
"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",
|
||||||
|
|
@ -4054,6 +4079,14 @@
|
||||||
"url": "https://dotenvx.com"
|
"url": "https://dotenvx.com"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/dotenv-expand": {
|
||||||
|
"version": "10.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/dotenv-expand/-/dotenv-expand-10.0.0.tgz",
|
||||||
|
"integrity": "sha512-GopVGCpVS1UKH75VKHGuQFqS1Gusej0z4FyQkPdwjil2gNIv+LNsqBlboOzpJFZKVT95GkCyWJbBSdFEFUWI2A==",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/eastasianwidth": {
|
"node_modules/eastasianwidth": {
|
||||||
"version": "0.2.0",
|
"version": "0.2.0",
|
||||||
"resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz",
|
"resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz",
|
||||||
|
|
@ -5126,7 +5159,8 @@
|
||||||
"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",
|
||||||
|
|
@ -5973,6 +6007,7 @@
|
||||||
"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": "*",
|
||||||
|
|
@ -6202,6 +6237,7 @@
|
||||||
"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": "*",
|
||||||
|
|
@ -6218,6 +6254,7 @@
|
||||||
"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"
|
||||||
},
|
},
|
||||||
|
|
@ -9482,7 +9519,8 @@
|
||||||
"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",
|
||||||
|
|
|
||||||
15
package.json
15
package.json
|
|
@ -11,24 +11,23 @@
|
||||||
"start": "nest start",
|
"start": "nest start",
|
||||||
"start:dev": "nest start --watch",
|
"start:dev": "nest start --watch",
|
||||||
"start:debug": "nest start --debug --watch",
|
"start:debug": "nest start --debug --watch",
|
||||||
"start:prod": "node dist/main",
|
"start:prod": "node dist/src/main",
|
||||||
"lint": "eslint \"{src,apps,libs,test}/**/*.ts\" --fix",
|
"lint": "eslint \"{src,apps,libs,test}/**/*.ts\" --fix",
|
||||||
"test": "jest",
|
"test": "jest",
|
||||||
"test:watch": "jest --watch",
|
"test:watch": "jest --watch",
|
||||||
"test:cov": "jest --coverage",
|
"test:cov": "jest --coverage",
|
||||||
"test:debug": "node --inspect-brk -r tsconfig-paths/register -r ts-node/register node_modules/.bin/jest --runInBand",
|
"test:debug": "node --inspect-brk -r tsconfig-paths/register -r ts-node/register node_modules/.bin/jest --runInBand",
|
||||||
"test:e2e": "jest --config e2e-tests/jest-e2e.json",
|
|
||||||
"prepare": "husky install"
|
"prepare": "husky install"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@nestjs/common": "^10.3.5",
|
"@nestjs/common": "^10.3.5",
|
||||||
|
"@nestjs/config": "^3.2.1",
|
||||||
"@nestjs/core": "^10.3.5",
|
"@nestjs/core": "^10.3.5",
|
||||||
"@nestjs/platform-express": "^10.3.5",
|
"@nestjs/platform-express": "^10.3.5",
|
||||||
"@nestjs/swagger": "^7.3.0",
|
"@nestjs/swagger": "^7.3.0",
|
||||||
"@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",
|
||||||
|
|
@ -68,8 +67,12 @@
|
||||||
"ts"
|
"ts"
|
||||||
],
|
],
|
||||||
"rootDir": ".",
|
"rootDir": ".",
|
||||||
"roots": ["<rootDir>/tests"],
|
"roots": [
|
||||||
"testPathIgnorePatterns": ["./database"],
|
"<rootDir>/tests"
|
||||||
|
],
|
||||||
|
"testPathIgnorePatterns": [
|
||||||
|
"./database"
|
||||||
|
],
|
||||||
"testRegex": ".*\\.spec\\.ts$",
|
"testRegex": ".*\\.spec\\.ts$",
|
||||||
"transform": {
|
"transform": {
|
||||||
"^.+\\.(t|j)s$": "ts-jest"
|
"^.+\\.(t|j)s$": "ts-jest"
|
||||||
|
|
@ -77,7 +80,7 @@
|
||||||
"collectCoverageFrom": [
|
"collectCoverageFrom": [
|
||||||
"**/*.(t|j)s"
|
"**/*.(t|j)s"
|
||||||
],
|
],
|
||||||
"coverageDirectory": "../coverage",
|
"coverageDirectory": "<rootDir>/coverage",
|
||||||
"testEnvironment": "node",
|
"testEnvironment": "node",
|
||||||
"moduleNameMapper": {
|
"moduleNameMapper": {
|
||||||
"@/(.*)": "<rootDir>/src/$1",
|
"@/(.*)": "<rootDir>/src/$1",
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,7 @@
|
||||||
POST http://localhost:3000/tracking
|
POST http://localhost:3000/tracking
|
||||||
|
Accept: application/json
|
||||||
Content-Type: application/json
|
Content-Type: application/json
|
||||||
|
Authorization: eG5nVnZGaWtCMFN6NjRRaWQ2TUJsUTJWaDpjeGg1R3FKRUl0UTlzSGRQdElGMGg0bFJp
|
||||||
|
|
||||||
{
|
{
|
||||||
"carrierName": "Evergreen",
|
"carrierName": "Evergreen",
|
||||||
|
|
@ -8,3 +10,9 @@ Content-Type: application/json
|
||||||
"arrivalDateEstimate": "2023-02-14T15:00:00.000Z",
|
"arrivalDateEstimate": "2023-02-14T15:00:00.000Z",
|
||||||
"containerNumber": "EGLU866139144"
|
"containerNumber": "EGLU866139144"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
###
|
||||||
|
|
||||||
|
GET http://localhost:3000/shipments
|
||||||
|
Accept: application/json
|
||||||
|
Authorization: eG5nVnZGaWtCMFN6NjRRaWQ2TUJsUTJWaDpjeGg1R3FKRUl0UTlzSGRQdElGMGg0bFJp
|
||||||
|
|
@ -1,21 +0,0 @@
|
||||||
import {
|
|
||||||
ClassSerializerInterceptor,
|
|
||||||
Controller,
|
|
||||||
Get,
|
|
||||||
UseInterceptors,
|
|
||||||
} from '@nestjs/common';
|
|
||||||
import { AppService } from './app.service';
|
|
||||||
import { ApiExtraModels } from '@nestjs/swagger';
|
|
||||||
import { TrackingDto } from '@/dtos/tracking.dto';
|
|
||||||
|
|
||||||
@Controller()
|
|
||||||
@ApiExtraModels(TrackingDto)
|
|
||||||
@UseInterceptors(ClassSerializerInterceptor)
|
|
||||||
export class AppController {
|
|
||||||
constructor(private readonly appService: AppService) {}
|
|
||||||
|
|
||||||
@Get()
|
|
||||||
debug(): unknown {
|
|
||||||
return this.appService.debug();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,35 +1,51 @@
|
||||||
import { Module } from '@nestjs/common';
|
import { Module } from '@nestjs/common';
|
||||||
import { AppController } from './app.controller';
|
|
||||||
import { AppService } from './app.service';
|
|
||||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||||
import { ContainerEntity } from '@/entities/container.entity';
|
import { ContainerEntity } from '@/entities/container.entity';
|
||||||
import { LiveTrackingEntity } from '@/entities/liveTracking.entity';
|
import { LiveTrackingEntity } from '@/entities/liveTracking.entity';
|
||||||
import { ShipmentEntity } from '@/entities/shipment.entity';
|
import { ShipmentEntity } from '@/entities/shipment.entity';
|
||||||
import { TrackingModule } from './tracking/tracking.module';
|
import { TrackingModule } from './tracking/tracking.module';
|
||||||
import { ShipmentsModule } from './shipments/shipments.module';
|
import { ShipmentsModule } from './shipments/shipments.module';
|
||||||
|
import { APP_GUARD } from '@nestjs/core';
|
||||||
|
import { AuthGuard } from '@/guards/auth.guard';
|
||||||
|
import { ApiKeyEntity } from '@/entities/apiKey.entity';
|
||||||
|
import { AuthService } from '@/auth.service';
|
||||||
|
import { ConfigModule, ConfigService } from '@nestjs/config';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [
|
imports: [
|
||||||
TypeOrmModule.forRoot({
|
TypeOrmModule.forRootAsync({
|
||||||
type: 'mysql',
|
imports: [ConfigModule.forRoot()],
|
||||||
host: 'localhost',
|
useFactory: async (configService: ConfigService) => {
|
||||||
port: 13306,
|
return {
|
||||||
username: 'root',
|
type: 'mysql',
|
||||||
password: 'myfreight',
|
host: configService.get('DB_HOST'),
|
||||||
database: 'myfreight',
|
port: Number(configService.get('DB_PORT')),
|
||||||
entities: [ContainerEntity, LiveTrackingEntity, ShipmentEntity],
|
username: configService.get('DB_USER'),
|
||||||
synchronize: true,
|
password: configService.get('DB_PASS'),
|
||||||
logging: false,
|
database: configService.get('DB_DABA'),
|
||||||
|
entities: [
|
||||||
|
ContainerEntity,
|
||||||
|
LiveTrackingEntity,
|
||||||
|
ShipmentEntity,
|
||||||
|
ApiKeyEntity,
|
||||||
|
],
|
||||||
|
synchronize: true,
|
||||||
|
logging: false,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
inject: [ConfigService],
|
||||||
}),
|
}),
|
||||||
TypeOrmModule.forFeature([
|
TypeOrmModule.forFeature([ApiKeyEntity]),
|
||||||
ContainerEntity,
|
|
||||||
LiveTrackingEntity,
|
|
||||||
ShipmentEntity,
|
|
||||||
]),
|
|
||||||
TrackingModule,
|
TrackingModule,
|
||||||
ShipmentsModule,
|
ShipmentsModule,
|
||||||
],
|
],
|
||||||
controllers: [AppController],
|
controllers: [],
|
||||||
providers: [AppService],
|
providers: [
|
||||||
|
AuthService,
|
||||||
|
{
|
||||||
|
provide: APP_GUARD,
|
||||||
|
useClass: AuthGuard,
|
||||||
|
},
|
||||||
|
],
|
||||||
})
|
})
|
||||||
export class AppModule {}
|
export class AppModule {}
|
||||||
|
|
|
||||||
|
|
@ -1,19 +0,0 @@
|
||||||
import { Injectable } from '@nestjs/common';
|
|
||||||
import { ContainerEntity } from '@/entities/container.entity';
|
|
||||||
import { Repository } from 'typeorm';
|
|
||||||
import { InjectRepository } from '@nestjs/typeorm';
|
|
||||||
|
|
||||||
@Injectable()
|
|
||||||
export class AppService {
|
|
||||||
constructor(
|
|
||||||
@InjectRepository(ContainerEntity)
|
|
||||||
private readonly containerRepository: Repository<ContainerEntity>,
|
|
||||||
) {}
|
|
||||||
async debug(): Promise<unknown> {
|
|
||||||
return this.containerRepository.find({
|
|
||||||
take: 5,
|
|
||||||
});
|
|
||||||
|
|
||||||
// return 'Hello World!';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -0,0 +1,28 @@
|
||||||
|
import { Injectable } from '@nestjs/common';
|
||||||
|
import { InjectRepository } from '@nestjs/typeorm';
|
||||||
|
import { Repository } from 'typeorm';
|
||||||
|
import { ApiKeyEntity } from '@/entities/apiKey.entity';
|
||||||
|
import * as crypto from 'crypto';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class AuthService {
|
||||||
|
constructor(
|
||||||
|
@InjectRepository(ApiKeyEntity)
|
||||||
|
private readonly apiKeyRepository: Repository<ApiKeyEntity>,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
async isAllowed(apiKey: string, apiSecret: string): Promise<boolean> {
|
||||||
|
const apiKeyEntity = await this.apiKeyRepository.findOneBy({
|
||||||
|
apiKey,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!apiKeyEntity || !apiKeyEntity.enabled) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const hmac = crypto.createHmac('sha256', apiSecret);
|
||||||
|
const digest = hmac.update(apiKey).digest('hex');
|
||||||
|
|
||||||
|
return digest === apiKeyEntity.digest;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,23 @@
|
||||||
|
import { Column, Entity, PrimaryColumn } from 'typeorm';
|
||||||
|
|
||||||
|
@Entity({
|
||||||
|
name: 'apiKeys',
|
||||||
|
})
|
||||||
|
export class ApiKeyEntity {
|
||||||
|
@PrimaryColumn({
|
||||||
|
type: 'char',
|
||||||
|
length: 25,
|
||||||
|
})
|
||||||
|
apiKey!: string;
|
||||||
|
|
||||||
|
@Column({
|
||||||
|
type: 'char',
|
||||||
|
length: 64,
|
||||||
|
})
|
||||||
|
digest!: string;
|
||||||
|
|
||||||
|
@Column({
|
||||||
|
type: 'boolean',
|
||||||
|
})
|
||||||
|
enabled!: boolean;
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,47 @@
|
||||||
|
import { CanActivate, ExecutionContext, Injectable } from '@nestjs/common';
|
||||||
|
import { Request } from 'express';
|
||||||
|
import { AuthService } from '@/auth.service';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class AuthGuard implements CanActivate {
|
||||||
|
constructor(private readonly authService: AuthService) {}
|
||||||
|
async canActivate(context: ExecutionContext): Promise<boolean> {
|
||||||
|
const request = context.switchToHttp().getRequest<Request>();
|
||||||
|
const apiCredentials = this.extractCredentialsFromRequestApiKey(request);
|
||||||
|
|
||||||
|
if (!apiCredentials) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.authService.isAllowed(
|
||||||
|
apiCredentials.key,
|
||||||
|
apiCredentials.secret,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private extractCredentialsFromRequestApiKey(request: Request):
|
||||||
|
| {
|
||||||
|
key: string;
|
||||||
|
secret: string;
|
||||||
|
}
|
||||||
|
| false {
|
||||||
|
const authorization = request.headers.authorization;
|
||||||
|
|
||||||
|
if (!authorization) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const [key, secret] = Buffer.from(authorization, 'base64')
|
||||||
|
.toString('utf8')
|
||||||
|
.split(':', 2);
|
||||||
|
|
||||||
|
if (!key || !secret) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
key,
|
||||||
|
secret,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,22 +0,0 @@
|
||||||
import { Test, TestingModule } from '@nestjs/testing';
|
|
||||||
import { ShipmentsController } from './shipments.controller';
|
|
||||||
|
|
||||||
describe('ShipmentsController', () => {
|
|
||||||
let controller: ShipmentsController;
|
|
||||||
|
|
||||||
beforeEach(async () => {
|
|
||||||
const module: TestingModule = await Test.createTestingModule({
|
|
||||||
controllers: [ShipmentsController],
|
|
||||||
}).compile();
|
|
||||||
|
|
||||||
controller = module.get<ShipmentsController>(ShipmentsController);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should be defined', () => {
|
|
||||||
expect(controller).toBeDefined();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should', () => {
|
|
||||||
expect(true).toBe(true);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
@ -1,6 +1,13 @@
|
||||||
import { Controller, Get } from '@nestjs/common';
|
import { Controller, Get } from '@nestjs/common';
|
||||||
import { ShipmentsService } from '@/shipments/shipments.service';
|
import { ShipmentsService } from '@/shipments/shipments.service';
|
||||||
import { ApiExtraModels, ApiOkResponse, getSchemaPath } from '@nestjs/swagger';
|
import {
|
||||||
|
ApiExtraModels,
|
||||||
|
ApiOkResponse,
|
||||||
|
ApiSecurity,
|
||||||
|
ApiTags,
|
||||||
|
ApiUnauthorizedResponse,
|
||||||
|
getSchemaPath,
|
||||||
|
} from '@nestjs/swagger';
|
||||||
import {
|
import {
|
||||||
ShipmentContainerDto,
|
ShipmentContainerDto,
|
||||||
ShipmentDto,
|
ShipmentDto,
|
||||||
|
|
@ -13,15 +20,41 @@ export class ShipmentsController {
|
||||||
constructor(private readonly shipmentsService: ShipmentsService) {}
|
constructor(private readonly shipmentsService: ShipmentsService) {}
|
||||||
|
|
||||||
@Get()
|
@Get()
|
||||||
|
@ApiTags('Shipping')
|
||||||
@ApiOkResponse({
|
@ApiOkResponse({
|
||||||
description: 'Returns a list of shipments',
|
description: 'Returns a list of shipments',
|
||||||
schema: {
|
schema: {
|
||||||
items: {
|
type: 'object',
|
||||||
$ref: getSchemaPath(ShipmentDto),
|
properties: {
|
||||||
|
data: {
|
||||||
|
items: {
|
||||||
|
$ref: getSchemaPath(ShipmentDto),
|
||||||
|
},
|
||||||
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
getShipments() {
|
@ApiUnauthorizedResponse({
|
||||||
return this.shipmentsService.getAll();
|
description: 'Unauthorized request',
|
||||||
|
schema: {
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
message: {
|
||||||
|
type: 'string',
|
||||||
|
},
|
||||||
|
error: {
|
||||||
|
type: 'string',
|
||||||
|
},
|
||||||
|
statusCode: {
|
||||||
|
type: 'number',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
@ApiSecurity('API key')
|
||||||
|
async getShipments() {
|
||||||
|
return {
|
||||||
|
data: await this.shipmentsService.getAll(),
|
||||||
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,18 +0,0 @@
|
||||||
import { Test, TestingModule } from '@nestjs/testing';
|
|
||||||
import { ShipmentsService } from './shipments.service';
|
|
||||||
|
|
||||||
describe('ShipmentsService', () => {
|
|
||||||
let service: ShipmentsService;
|
|
||||||
|
|
||||||
beforeEach(async () => {
|
|
||||||
const module: TestingModule = await Test.createTestingModule({
|
|
||||||
providers: [ShipmentsService],
|
|
||||||
}).compile();
|
|
||||||
|
|
||||||
service = module.get<ShipmentsService>(ShipmentsService);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should be defined', () => {
|
|
||||||
expect(service).toBeDefined();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
@ -10,7 +10,6 @@ export class ShipmentsService {
|
||||||
private readonly shipmentRepository: Repository<ShipmentEntity>,
|
private readonly shipmentRepository: Repository<ShipmentEntity>,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
// @todo add pagination
|
|
||||||
getAll() {
|
getAll() {
|
||||||
return this.shipmentRepository.find({
|
return this.shipmentRepository.find({
|
||||||
relations: ['liveTracking', 'containers'],
|
relations: ['liveTracking', 'containers'],
|
||||||
|
|
|
||||||
|
|
@ -20,7 +20,7 @@ export class ConsoleNotifier extends NotifierAbstract implements INotifier {
|
||||||
private generateMessages(): string[] {
|
private generateMessages(): string[] {
|
||||||
const messages: string[] = [];
|
const messages: string[] = [];
|
||||||
if (this.isDelayed) {
|
if (this.isDelayed) {
|
||||||
messages.push('Your shipment has been delayed.');
|
messages.push('Your shipment has been delayed');
|
||||||
}
|
}
|
||||||
|
|
||||||
if (this.daysToArrival <= 0) {
|
if (this.daysToArrival <= 0) {
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,9 @@ import {
|
||||||
ApiExtraModels,
|
ApiExtraModels,
|
||||||
ApiNotFoundResponse,
|
ApiNotFoundResponse,
|
||||||
ApiOkResponse,
|
ApiOkResponse,
|
||||||
|
ApiSecurity,
|
||||||
|
ApiTags,
|
||||||
|
ApiUnauthorizedResponse,
|
||||||
ApiUnprocessableEntityResponse,
|
ApiUnprocessableEntityResponse,
|
||||||
getSchemaPath,
|
getSchemaPath,
|
||||||
} from '@nestjs/swagger';
|
} from '@nestjs/swagger';
|
||||||
|
|
@ -26,11 +29,58 @@ export class TrackingController {
|
||||||
@ApiUnprocessableEntityResponse({
|
@ApiUnprocessableEntityResponse({
|
||||||
description:
|
description:
|
||||||
'The shipment has been found, but the shipment data does not match.',
|
'The shipment has been found, but the shipment data does not match.',
|
||||||
|
schema: {
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
message: {
|
||||||
|
type: 'string',
|
||||||
|
},
|
||||||
|
error: {
|
||||||
|
type: 'string',
|
||||||
|
},
|
||||||
|
statusCode: {
|
||||||
|
type: 'number',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
})
|
})
|
||||||
@ApiNotFoundResponse({
|
@ApiNotFoundResponse({
|
||||||
description: 'We could not find any shipment matching the request.',
|
description: 'We could not find any shipment matching the request.',
|
||||||
|
schema: {
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
message: {
|
||||||
|
type: 'string',
|
||||||
|
},
|
||||||
|
error: {
|
||||||
|
type: 'string',
|
||||||
|
},
|
||||||
|
statusCode: {
|
||||||
|
type: 'number',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
@ApiUnauthorizedResponse({
|
||||||
|
description: 'Unauthorized request',
|
||||||
|
schema: {
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
message: {
|
||||||
|
type: 'string',
|
||||||
|
},
|
||||||
|
error: {
|
||||||
|
type: 'string',
|
||||||
|
},
|
||||||
|
statusCode: {
|
||||||
|
type: 'number',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
})
|
})
|
||||||
@HttpCode(200)
|
@HttpCode(200)
|
||||||
|
@ApiSecurity('API key')
|
||||||
|
@ApiTags('Tracking')
|
||||||
track(@Body() body: TrackingDto): Promise<unknown> {
|
track(@Body() body: TrackingDto): Promise<unknown> {
|
||||||
return this.trackingService
|
return this.trackingService
|
||||||
.track(body)
|
.track(body)
|
||||||
|
|
|
||||||
|
|
@ -21,7 +21,7 @@ export class TrackingService {
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
public async track(trackingInfo: TrackingDto) {
|
public async track(trackingInfo: TrackingDto) {
|
||||||
const container = await this.containerRepository.findOneOrFail({
|
const container = await this.containerRepository.findOne({
|
||||||
relations: ['shipment'],
|
relations: ['shipment'],
|
||||||
where: {
|
where: {
|
||||||
containerNumber: trackingInfo.containerNumber,
|
containerNumber: trackingInfo.containerNumber,
|
||||||
|
|
@ -38,18 +38,22 @@ export class TrackingService {
|
||||||
}
|
}
|
||||||
|
|
||||||
private validate(
|
private validate(
|
||||||
{ shipment }: ContainerEntity,
|
container: ContainerEntity | null,
|
||||||
trackingInfo: TrackingDto,
|
trackingInfo: TrackingDto,
|
||||||
): ShipmentEntity {
|
): ShipmentEntity {
|
||||||
if (!shipment) {
|
if (!container) {
|
||||||
|
throw new NotFoundException('Container not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!container.shipment) {
|
||||||
throw new NotFoundException('Shipment not found');
|
throw new NotFoundException('Shipment not found');
|
||||||
}
|
}
|
||||||
|
|
||||||
if (shipment.carrierName !== trackingInfo.carrierName) {
|
if (container.shipment.carrierName !== trackingInfo.carrierName) {
|
||||||
throw new UnprocessableEntityException('CarrierName does not match');
|
throw new UnprocessableEntityException('CarrierName does not match');
|
||||||
}
|
}
|
||||||
|
|
||||||
return shipment;
|
return container.shipment;
|
||||||
}
|
}
|
||||||
|
|
||||||
private async updateShipment(
|
private async updateShipment(
|
||||||
|
|
|
||||||
|
|
@ -6,7 +6,11 @@ export function openApi(app: INestApplication) {
|
||||||
.setTitle('MyFreight Assessment')
|
.setTitle('MyFreight Assessment')
|
||||||
.setDescription('Backend assessment for MyFreight')
|
.setDescription('Backend assessment for MyFreight')
|
||||||
.setVersion('1.0')
|
.setVersion('1.0')
|
||||||
.addTag('MyFreight')
|
.addSecurity('API key', {
|
||||||
|
type: 'apiKey',
|
||||||
|
in: 'header',
|
||||||
|
name: 'Authorization',
|
||||||
|
})
|
||||||
.build();
|
.build();
|
||||||
const document = SwaggerModule.createDocument(app, config);
|
const document = SwaggerModule.createDocument(app, config);
|
||||||
SwaggerModule.setup('api', app, document);
|
SwaggerModule.setup('api', app, document);
|
||||||
|
|
|
||||||
|
|
@ -1 +1 @@
|
||||||
Subproject commit b753a440a945ba260c1c583d6f0a0361591d0fc8
|
Subproject commit 89136dcd85c5de7b8a927c9cfaa21bee61506254
|
||||||
|
|
@ -6,7 +6,7 @@ import { ContainerEntity } from '@/entities/container.entity';
|
||||||
export const mockTrackingInfo: TrackingDto = {
|
export const mockTrackingInfo: TrackingDto = {
|
||||||
carrierName: 'Evergreen',
|
carrierName: 'Evergreen',
|
||||||
destinationUnLoCode: 'NLAMS',
|
destinationUnLoCode: 'NLAMS',
|
||||||
departureDate: new Date('2023-05-14T11:00:00.000Z'),
|
departureDate: new Date('2023-01-14T11:00:00.000Z'),
|
||||||
arrivalDateEstimate: new Date('2023-02-14T15:00:00.000Z'),
|
arrivalDateEstimate: new Date('2023-02-14T15:00:00.000Z'),
|
||||||
containerNumber: 'AAAA000000000',
|
containerNumber: 'AAAA000000000',
|
||||||
};
|
};
|
||||||
|
|
@ -17,7 +17,7 @@ export const mockShipment: ShipmentEntity = {
|
||||||
carrierName: 'Evergreen',
|
carrierName: 'Evergreen',
|
||||||
originPort: 'NLAMS',
|
originPort: 'NLAMS',
|
||||||
destinationPort: 'NLAMS',
|
destinationPort: 'NLAMS',
|
||||||
departureDate: new Date('2023-05-14T11:00:00.000Z'),
|
departureDate: new Date('2023-01-14T11:00:00.000Z'),
|
||||||
arrivalDateOriginal: new Date('2023-02-14T15:00:00.000Z'),
|
arrivalDateOriginal: new Date('2023-02-14T15:00:00.000Z'),
|
||||||
arrivalDateEstimate: new Date('2023-02-14T15:00:00.000Z'),
|
arrivalDateEstimate: new Date('2023-02-14T15:00:00.000Z'),
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,36 @@
|
||||||
|
import { Test, TestingModule } from '@nestjs/testing';
|
||||||
|
import { ShipmentsController } from '@/shipments/shipments.controller';
|
||||||
|
import { ShipmentsService } from '@/shipments/shipments.service';
|
||||||
|
|
||||||
|
describe('ShipmentsController', () => {
|
||||||
|
let controller: ShipmentsController;
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
const module: TestingModule = await Test.createTestingModule({
|
||||||
|
controllers: [ShipmentsController],
|
||||||
|
})
|
||||||
|
.useMocker((token) => {
|
||||||
|
if (token === ShipmentsService) {
|
||||||
|
return {
|
||||||
|
getAll: jest.fn().mockResolvedValue([]),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
.compile();
|
||||||
|
|
||||||
|
controller = module.get<ShipmentsController>(ShipmentsController);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should be defined', () => {
|
||||||
|
expect(controller).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should have a method getShipments', () => {
|
||||||
|
expect(controller.getShipments).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return an object with message: ok', async () => {
|
||||||
|
await expect(controller.getShipments()).resolves.toEqual({ data: [] });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -0,0 +1,59 @@
|
||||||
|
import { Test, TestingModule } from '@nestjs/testing';
|
||||||
|
import { ShipmentsService } from '@/shipments/shipments.service';
|
||||||
|
import { getRepositoryToken } from '@nestjs/typeorm';
|
||||||
|
import { ShipmentEntity } from '@/entities/shipment.entity';
|
||||||
|
import { Repository } from 'typeorm';
|
||||||
|
import { mockShipment } from '@tests/data/tracking.mock';
|
||||||
|
|
||||||
|
describe('ShipmentsService', () => {
|
||||||
|
const shipmentEntityToken = getRepositoryToken(ShipmentEntity);
|
||||||
|
let service: ShipmentsService;
|
||||||
|
let repository: Repository<ShipmentEntity>;
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
const module: TestingModule = await Test.createTestingModule({
|
||||||
|
providers: [ShipmentsService],
|
||||||
|
})
|
||||||
|
.useMocker((token) => {
|
||||||
|
switch (token) {
|
||||||
|
case shipmentEntityToken:
|
||||||
|
return {
|
||||||
|
find: jest.fn().mockResolvedValue([]),
|
||||||
|
save: jest
|
||||||
|
.fn()
|
||||||
|
.mockImplementation((shipmentEntity: ShipmentEntity) =>
|
||||||
|
Promise.resolve(shipmentEntity),
|
||||||
|
),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.compile();
|
||||||
|
|
||||||
|
service = module.get<ShipmentsService>(ShipmentsService);
|
||||||
|
repository = module.get<Repository<ShipmentEntity>>(shipmentEntityToken);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should be defined', () => {
|
||||||
|
expect(service).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should have a getAll method', () => {
|
||||||
|
expect(service.getAll).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should fetch data with relations liveTracking and containers', async () => {
|
||||||
|
await expect(service.getAll()).resolves.toEqual([]);
|
||||||
|
expect(repository.find).toHaveBeenCalledWith({
|
||||||
|
relations: ['liveTracking', 'containers'],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should have an update method', () => {
|
||||||
|
expect(service.update).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should fetch data with relations liveTracking and containers', async () => {
|
||||||
|
await expect(service.update(mockShipment)).resolves.toEqual(mockShipment);
|
||||||
|
expect(repository.save).toHaveBeenCalledWith(mockShipment);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -29,6 +29,6 @@ describe('NotificationsService', () => {
|
||||||
it('should have a method send that resolves', async () => {
|
it('should have a method send that resolves', async () => {
|
||||||
await expect(
|
await expect(
|
||||||
service.send(mockShipment, mockTrackingInfo),
|
service.send(mockShipment, mockTrackingInfo),
|
||||||
).resolves.toBeDefined();
|
).resolves.not.toThrow();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,118 @@
|
||||||
|
import { mockShipment, mockTrackingInfo } from '@tests/data/tracking.mock';
|
||||||
|
import { ConsoleNotifier } from '@/tracking/notifications/console.notifier';
|
||||||
|
import { resetDate, setDate } from '@/utils/fakeDate';
|
||||||
|
import { TrackingDto } from '@/dtos/tracking.dto';
|
||||||
|
import { ShipmentEntity } from '@/entities/shipment.entity';
|
||||||
|
|
||||||
|
describe('ConsoleNotifier', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
jest.clearAllMocks();
|
||||||
|
setDate('2023-02-14 14:00:00');
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(() => {
|
||||||
|
resetDate();
|
||||||
|
jest.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should be defined', () => {
|
||||||
|
expect(ConsoleNotifier).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should be arriving soon if arrivalEstimate is between 0 and 1 days of today', async () => {
|
||||||
|
const logSpy = jest.spyOn(global.console, 'log').mockImplementation();
|
||||||
|
const consoleNotifier = new ConsoleNotifier(mockShipment, mockTrackingInfo);
|
||||||
|
expect(consoleNotifier.isDelayed).toBe(false);
|
||||||
|
expect(consoleNotifier.daysToArrival).toBeGreaterThan(0);
|
||||||
|
expect(consoleNotifier.daysToArrival).toBeLessThan(1);
|
||||||
|
await expect(consoleNotifier.send()).resolves.toBe(true);
|
||||||
|
expect(logSpy).toHaveBeenCalledWith(
|
||||||
|
'Send email with the following messages: Your shipment will arrive soon',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
it('should be arriving soon if arrivalEstimate is less than or equal to today', async () => {
|
||||||
|
const logSpy = jest.spyOn(global.console, 'log').mockImplementation();
|
||||||
|
const arrivesTodayTrackingInfo: TrackingDto = {
|
||||||
|
...mockTrackingInfo,
|
||||||
|
arrivalDateEstimate: new Date(),
|
||||||
|
};
|
||||||
|
|
||||||
|
const consoleNotifier = new ConsoleNotifier(
|
||||||
|
mockShipment,
|
||||||
|
arrivesTodayTrackingInfo,
|
||||||
|
);
|
||||||
|
expect(consoleNotifier.isDelayed).toBe(false);
|
||||||
|
expect(consoleNotifier.daysToArrival).toBeLessThanOrEqual(0);
|
||||||
|
await expect(consoleNotifier.send()).resolves.toBe(true);
|
||||||
|
expect(logSpy).toHaveBeenCalledWith(
|
||||||
|
'Send email with the following messages: Your shipment has arrived',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should be delayed if arrivalDateEstimate is more than 1 day after arrivalDateOriginal', async () => {
|
||||||
|
const logSpy = jest.spyOn(global.console, 'log').mockImplementation();
|
||||||
|
const trackingDelay = 2;
|
||||||
|
const newArrivalDate = new Date();
|
||||||
|
newArrivalDate.setDate(
|
||||||
|
mockShipment.arrivalDateOriginal.getDate() + trackingDelay,
|
||||||
|
);
|
||||||
|
const delayedTrackingInfo: TrackingDto = {
|
||||||
|
...mockTrackingInfo,
|
||||||
|
arrivalDateEstimate: newArrivalDate,
|
||||||
|
};
|
||||||
|
|
||||||
|
delayedTrackingInfo.arrivalDateEstimate;
|
||||||
|
|
||||||
|
const consoleNotifier = new ConsoleNotifier(
|
||||||
|
mockShipment,
|
||||||
|
delayedTrackingInfo,
|
||||||
|
);
|
||||||
|
expect(consoleNotifier.isDelayed).toBe(true);
|
||||||
|
expect(consoleNotifier.daysToArrival).toBe(trackingDelay);
|
||||||
|
await expect(consoleNotifier.send()).resolves.toBe(true);
|
||||||
|
expect(logSpy).toHaveBeenCalledTimes(1);
|
||||||
|
expect(logSpy).toHaveBeenCalledWith(
|
||||||
|
'Send email with the following messages: Your shipment has been delayed',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should log multiple messages if shipment has arrived but original time has been exceeded by more than 1 day', async () => {
|
||||||
|
const logSpy = jest.spyOn(global.console, 'log').mockImplementation();
|
||||||
|
const shipment: ShipmentEntity = {
|
||||||
|
...mockShipment,
|
||||||
|
arrivalDateOriginal: new Date('2023-02-12 14:00:00'),
|
||||||
|
};
|
||||||
|
const trackingInfo: TrackingDto = {
|
||||||
|
...mockTrackingInfo,
|
||||||
|
arrivalDateEstimate: new Date('2023-02-14 14:00:00'),
|
||||||
|
};
|
||||||
|
const consoleNotifier = new ConsoleNotifier(shipment, trackingInfo);
|
||||||
|
expect(consoleNotifier.isDelayed).toBe(true);
|
||||||
|
expect(consoleNotifier.daysToArrival).toBe(0);
|
||||||
|
await expect(consoleNotifier.send()).resolves.toBe(true);
|
||||||
|
|
||||||
|
expect(logSpy).toHaveBeenCalledWith(
|
||||||
|
'Send email with the following messages: Your shipment has been delayed\nYour shipment has arrived',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should log multiple messages if shipment will arrive soon but original time has been exceeded by more than 1 day', async () => {
|
||||||
|
const logSpy = jest.spyOn(global.console, 'log').mockImplementation();
|
||||||
|
const shipment: ShipmentEntity = {
|
||||||
|
...mockShipment,
|
||||||
|
arrivalDateOriginal: new Date('2023-02-12 14:00:00'),
|
||||||
|
};
|
||||||
|
const trackingInfo: TrackingDto = {
|
||||||
|
...mockTrackingInfo,
|
||||||
|
arrivalDateEstimate: new Date('2023-02-14 15:00:00'),
|
||||||
|
};
|
||||||
|
const consoleNotifier = new ConsoleNotifier(shipment, trackingInfo);
|
||||||
|
expect(consoleNotifier.isDelayed).toBe(true);
|
||||||
|
expect(consoleNotifier.daysToArrival).toBeGreaterThan(0);
|
||||||
|
expect(consoleNotifier.daysToArrival).toBeLessThanOrEqual(1);
|
||||||
|
await expect(consoleNotifier.send()).resolves.toBe(true);
|
||||||
|
expect(logSpy).toHaveBeenCalledWith(
|
||||||
|
'Send email with the following messages: Your shipment has been delayed\nYour shipment will arrive soon',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -0,0 +1,14 @@
|
||||||
|
import { mockShipment, mockTrackingInfo } from '@tests/data/tracking.mock';
|
||||||
|
import { NotifierFactory } from '@/tracking/notifications/notifier.factory';
|
||||||
|
import { ConsoleNotifier } from '@/tracking/notifications/console.notifier';
|
||||||
|
|
||||||
|
describe('NotifierFactory', () => {
|
||||||
|
it('should be defined', () => {
|
||||||
|
expect(NotifierFactory).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should create a notifier instance', () => {
|
||||||
|
const factory = new NotifierFactory(mockShipment, mockTrackingInfo);
|
||||||
|
expect(factory.createNotifier()).toBeInstanceOf(ConsoleNotifier);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -26,7 +26,7 @@ describe('TrackingService', () => {
|
||||||
switch (token) {
|
switch (token) {
|
||||||
case containerEntityToken:
|
case containerEntityToken:
|
||||||
return {
|
return {
|
||||||
findOneOrFail: jest.fn().mockResolvedValue(mockContainerEntity),
|
findOne: jest.fn().mockResolvedValue(mockContainerEntity),
|
||||||
};
|
};
|
||||||
case ShipmentsService:
|
case ShipmentsService:
|
||||||
return {
|
return {
|
||||||
|
|
@ -55,7 +55,7 @@ describe('TrackingService', () => {
|
||||||
|
|
||||||
it('should have a track method that returns true', async () => {
|
it('should have a track method that returns true', async () => {
|
||||||
await expect(trackingService.track(mockTrackingInfo)).resolves.toBe(true);
|
await expect(trackingService.track(mockTrackingInfo)).resolves.toBe(true);
|
||||||
expect(containerEntityRepository.findOneOrFail).toHaveBeenCalledWith({
|
expect(containerEntityRepository.findOne).toHaveBeenCalledWith({
|
||||||
relations: ['shipment'],
|
relations: ['shipment'],
|
||||||
where: {
|
where: {
|
||||||
containerNumber: mockTrackingInfo.containerNumber,
|
containerNumber: mockTrackingInfo.containerNumber,
|
||||||
|
|
@ -83,16 +83,21 @@ describe('TrackingService', () => {
|
||||||
};
|
};
|
||||||
|
|
||||||
jest
|
jest
|
||||||
.spyOn(containerEntityRepository, 'findOneOrFail')
|
.spyOn(containerEntityRepository, 'findOne')
|
||||||
|
.mockResolvedValueOnce(null);
|
||||||
|
await expect(trackingService.track(mockTrackingInfo)).rejects.toThrow(
|
||||||
|
'Container not found',
|
||||||
|
);
|
||||||
|
|
||||||
|
jest
|
||||||
|
.spyOn(containerEntityRepository, 'findOne')
|
||||||
.mockResolvedValueOnce(mockShipmentNotFound);
|
.mockResolvedValueOnce(mockShipmentNotFound);
|
||||||
await expect(trackingService.track(mockTrackingInfo)).rejects.toThrow(
|
await expect(trackingService.track(mockTrackingInfo)).rejects.toThrow(
|
||||||
'Shipment not found',
|
'Shipment not found',
|
||||||
);
|
);
|
||||||
expect(notificationsService.send).toHaveBeenCalledTimes(0);
|
|
||||||
expect(shipmentsService.update).toHaveBeenCalledTimes(0);
|
|
||||||
|
|
||||||
jest
|
jest
|
||||||
.spyOn(containerEntityRepository, 'findOneOrFail')
|
.spyOn(containerEntityRepository, 'findOne')
|
||||||
.mockResolvedValueOnce(mockShipmentCarrierNameMismatch);
|
.mockResolvedValueOnce(mockShipmentCarrierNameMismatch);
|
||||||
await expect(trackingService.track(mockTrackingInfo)).rejects.toThrow(
|
await expect(trackingService.track(mockTrackingInfo)).rejects.toThrow(
|
||||||
'CarrierName does not match',
|
'CarrierName does not match',
|
||||||
|
|
|
||||||
|
|
@ -17,11 +17,24 @@ describe('uuidTransformer util', () => {
|
||||||
expect(transformer.from(null)).toBe(NIL);
|
expect(transformer.from(null)).toBe(NIL);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('From method should throw an error if paring an invalid length buffer', () => {
|
||||||
|
expect(() => transformer.from(Buffer.alloc(15))).toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
it('From method should return a nil uuid if empty 16 length buffer is passed', () => {
|
it('From method should return a nil uuid if empty 16 length buffer is passed', () => {
|
||||||
expect(transformer.from(Buffer.alloc(16))).toBe(NIL);
|
expect(transformer.from(Buffer.alloc(16))).toBe(NIL);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('From and To methods should be each others inverse', () => {
|
||||||
|
const uuid = 'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa';
|
||||||
|
expect(transformer.from(transformer.to(uuid))).toBe(uuid);
|
||||||
|
});
|
||||||
|
|
||||||
it('To method should return a buffer matching the uuid', () => {
|
it('To method should return a buffer matching the uuid', () => {
|
||||||
expect(transformer.to(NIL)).toEqual(Buffer.alloc(16));
|
expect(transformer.to(NIL)).toEqual(Buffer.alloc(16));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('To method throw an error if passing a string that does not match a uuid', () => {
|
||||||
|
expect(() => transformer.to('test')).toThrow();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue