Compare commits

...

56 Commits

Author SHA1 Message Date
Romain Thouvenin 12c237b980 Update the README doc 2024-05-16 17:13:34 +02:00
Romain Thouvenin f6f9696620 Add a zero-value to the Frequency GRPC enum (required by protobuf specs) 2024-05-16 17:13:34 +02:00
Romain Thouvenin 5aa4d9e568 Unit tests for the update-ad service 2024-05-16 17:13:34 +02:00
Romain Thouvenin f6c3204708 Emit the AdUpdated domain event from the service instead of the repository
This is to avoid storing the event in the entity, which prevents serializing it into JSON
(because it has a circular dependency to AdEntity)
2024-05-16 17:13:34 +02:00
Romain Thouvenin 659c1baea8 Implement the GRPC controller to update ads 2024-05-16 17:13:34 +02:00
Romain Thouvenin 7a84bff260 Implement update ad command 2024-05-16 17:13:34 +02:00
Romain Thouvenin 3d4ff00066 publish integration event when an ad is updated 2024-05-16 17:13:34 +02:00
Romain Thouvenin 3ff5277d5f Add update method to Ad entity 2024-05-16 17:13:34 +02:00
Romain Thouvenin 62e5fd56d9 Upgrade ddd-library 2024-05-16 17:13:34 +02:00
Romain Thouvenin c7d4792893 Consistent and DRY declarations of ScheduleItem types 2024-05-07 10:43:49 +02:00
Romain Thouvenin 5e449ad69a Prepare release 2.6 2024-05-07 10:42:54 +02:00
Romain Thouvenin 51ca6cf9c4 Update documentation about the delete command 2024-04-29 08:34:40 +02:00
Romain Thouvenin be2af64f60 Update the documentation about integration events 2024-04-29 08:29:50 +02:00
Romain Thouvenin 9fb7ef2eac Listen to user.deleted events to delete the corresponding user ads 2024-04-26 12:31:16 +02:00
Romain Thouvenin 492bb3ca44 Expose the debugger in the dev container 2024-04-26 12:29:06 +02:00
Romain Thouvenin e8903099d7 Implement the delete GRPC command 2024-04-26 10:58:44 +02:00
Romain Thouvenin b17fc32a12 Fix tests path in build config 2024-04-26 10:57:45 +02:00
Romain Thouvenin 8c7512b6c3 Use common test and build jobs 2024-04-03 08:51:29 +02:00
Romain Thouvenin 15236904e3 Prepare release 2.5 2024-04-03 08:48:51 +02:00
Romain Thouvenin a7b342c049 Add optional comment to Ad type and records #7409 2024-02-29 14:47:53 +01:00
Sylvain Briat da4b30350b fix coverage results path in package.json 2024-02-26 15:07:42 +01:00
Sylvain Briat 55c7e2b11c removed local vscode settings 2024-02-20 16:44:28 +01:00
Sylvain Briat c52afbb243 update readme 2024-02-16 16:52:20 +01:00
Sylvain Briat 98d2b521ab add controller in module 2024-02-16 16:52:20 +01:00
Sylvain Briat bbb96cfd36 find ads by user id query, dto and controller 2024-02-16 16:52:14 +01:00
Sylvain Briat 909ef04e69 move tests folder to the root 2024-02-16 16:02:04 +01:00
Fanch 540c63d297 copy file from v3 gitlab template repo 2024-02-05 19:16:00 +01:00
Sylvain Briat c72c64e6da Merge branch 'use_prisma_deploy' into 'main'
use prisma deploy as default for migrate, add migrate:dev command

See merge request v3/service/ad!37
2024-01-31 14:18:11 +00:00
Fanch 8f57dc2c7a use prisma deploy as default for migrate, add migrate:dev command 2024-01-31 12:47:27 +01:00
Fanch 41073539bf Merge branch 'fix_test_install' into 'main'
Fix test install

See merge request v3/service/ad!34
2024-01-24 14:15:51 +00:00
Fanch 483e947d92 use node lts image for docker 2024-01-23 14:50:32 +01:00
Fanch b13df86745 use full registry path for docker image 2024-01-23 14:50:32 +01:00
Fanch 61c1d6ffcb Merge branch 'fixAdValidation' into 'main'
Fix ad validation

See merge request v3/service/ad!36
2024-01-19 14:02:19 +00:00
Sylvain Briat fbc0ae2a33 2.4.5 2024-01-18 15:53:57 +01:00
Sylvain Briat b039dbb3bd fix ad validation : remove schedule and waypoints duplication 2024-01-18 15:53:48 +01:00
Sylvain Briat 2009355b18 2.4.4 2024-01-17 12:00:00 +01:00
Sylvain Briat 5d6547a184 fix bad validation : wrong cascade update after ad status change 2024-01-17 11:59:51 +01:00
Sylvain Briat 40e8b5f733 Merge branch 'updatePackages' into 'main'
Update packages

See merge request v3/service/ad!35
2024-01-17 08:03:01 +00:00
Sylvain Briat 98068d021f pretty 2024-01-17 08:56:34 +01:00
Sylvain Briat 4e236551ae 2.4.3 2024-01-17 08:51:40 +01:00
Sylvain Briat 4bd7ca64de update packages 2024-01-17 08:51:34 +01:00
Sylvain Briat 3e1c4afce3 Merge branch 'secureBroker' into 'main'
Secure broker

See merge request v3/service/ad!33
2023-12-18 14:36:48 +00:00
Sylvain Briat d4a37b237e 2.4.2 2023-12-18 15:32:39 +01:00
Sylvain Briat b2cf66139a secure broker 2023-12-18 15:31:39 +01:00
Sylvain Briat 99017b0e55 Merge branch 'fixAdValidation' into 'main'
Fix ad validation

See merge request v3/service/ad!32
2023-12-07 10:16:04 +00:00
Sylvain Briat ee0a2cb386 2.4.1 2023-12-07 11:11:59 +01:00
Sylvain Briat f69e8a95f1 fix ad validation after matcher ad creation 2023-12-07 11:11:52 +01:00
Sylvain Briat 976a3c3779 Merge branch 'adStatus' into 'main'
Handle matcher messages, handle ad statuses

See merge request v3/service/ad!31
2023-12-06 15:13:02 +00:00
Sylvain Briat c85d6fb756 2.4.0 2023-12-06 15:51:50 +01:00
Sylvain Briat e0a4b07733 handle matcher messages 2023-12-06 15:51:46 +01:00
Sylvain Briat dfe4db8276 Merge branch 'findAllByIds' into 'main'
Find all by ids

See merge request v3/service/ad!30
2023-11-22 16:23:49 +00:00
Sylvain Briat 88a975a8a1 2.3.0 2023-11-22 17:19:58 +01:00
Sylvain Briat 263133ec30 find all ads by ids 2023-11-22 17:19:53 +01:00
Sylvain Briat 3d29eb4517 Merge branch 'security' into 'main'
Improve security : add sast and secret detection in gitlab ci

See merge request v3/service/ad!29
2023-11-06 07:53:07 +00:00
Sylvain Briat d3c305dbce Improve security : add sast and secret detection in gitlab ci 2023-11-06 08:48:56 +01:00
Sylvain Briat 4844f07e08 Merge branch 'updatePackages' into 'main'
Update packages

See merge request v3/service/ad!28
2023-10-31 09:05:51 +00:00
109 changed files with 3889 additions and 1506 deletions

View File

@ -8,7 +8,7 @@ HEALTH_SERVICE_PORT=6006
DATABASE_URL="postgresql://mobicoop:mobicoop@v3-db:5432/mobicoop?schema=ad"
# MESSAGE BROKER
MESSAGE_BROKER_URI=amqp://v3-broker:5672
MESSAGE_BROKER_URI=amqp://mobicoop:mobicoop@v3-broker:5672
MESSAGE_BROKER_EXCHANGE=mobicoop
MESSAGE_BROKER_EXCHANGE_DURABILITY=true

View File

@ -4,51 +4,10 @@ stages:
- test
- build
##############
# TEST STAGE #
##############
test:
stage: test
image: docker/compose:latest
variables:
DOCKER_TLS_CERTDIR: ''
services:
- docker:dind
script:
- docker-compose -f docker-compose.ci.tools.yml -p ad-tools --env-file ci/.env.ci up -d
- sh ci/wait-up.sh
- docker-compose -f docker-compose.ci.service.yml -p ad-service --env-file ci/.env.ci up -d
- docker exec -t v3-ad-api sh -c "npm run test:integration:ci"
coverage: /All files[^|]*\|[^|]*\s+([\d\.]+)/
rules:
- if: '$CI_MERGE_REQUEST_TARGET_BRANCH_NAME == $CI_DEFAULT_BRANCH || $CI_COMMIT_MESSAGE =~ /--check/ || $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH'
when: always
###############
# BUILD STAGE #
###############
build:
stage: build
image: docker:20.10.22
variables:
DOCKER_TLS_CERTDIR: ''
services:
- docker:dind
before_script:
- echo -n $CI_REGISTRY_PASSWORD | docker login -u $CI_REGISTRY_USER --password-stdin $CI_REGISTRY
script:
- export VERSION=$(docker run --rm -v "$PWD":/usr/src/app:ro -w /usr/src/app node:slim node -p "require('./package.json').version")
- docker pull $CI_REGISTRY_IMAGE:latest || true
- >
docker build
--pull
--cache-from $CI_REGISTRY_IMAGE:latest
--tag $CI_REGISTRY_IMAGE:$VERSION
--tag $CI_REGISTRY_IMAGE:latest
.
- docker push $CI_REGISTRY_IMAGE:$VERSION
- docker push $CI_REGISTRY_IMAGE:latest
only:
- main
include:
- template: Security/SAST.gitlab-ci.yml
- template: Security/Secret-Detection.gitlab-ci.yml
- project: mobicoop/v3/gitlab-templates
file:
- /ci/service.test-job.yml
- /ci/release.build-job.yml

View File

@ -0,0 +1,55 @@
_Replace italic text by your own description_
## Feature Merge Request
### Why this Merge Request
_This merge request addresses, and describe the problem or user story being addressed._
### What is implemented, what is the chosen solution
_Explain the fix or solution implemented. Which other solution have been envisaged._
### Related issues and impact on other project in codebase
_Provide links to the related issues, feature requests and merge request (from Gitlab and Redmine)._
_And Link to other project Impacted._
### Other Information
_Include any extra information or considerations for reviewers._
## Checklists
### Merge Request
- [ ] Target branch identified.
- [ ] Code based on last version of target branch.
- [ ] Description filled.
- [ ] Impact on other project codebase identified.
- [ ] Documentation reflects the changes made.
- [ ] Test run in gitlab pipeline and locally.
- [ ] One or more reviewer is defined
### Code Review
- [ ] Code follows project coding guidelines.
- [ ] Code follows project designed architecture.
- [ ] Code is easily readable.
- [ ] Everything new have an explicit and pertinent name (variable, method, file ...)
- [ ] No redundant/duplicate code (unless explain by architecture choice)
- [ ] Commit are all related to MR and well written (Atomic commit).
- [ ] New code is tested and covered by automated test.
- [ ] No useless logging or debugging code.
- [ ] No code can be replaced by library or framework code.
### TODO before merge
- [ ] _add any task here_
- [ ] ...
### TODO after merge
- [ ] _add any task here_
- [ ] ...

View File

@ -0,0 +1,62 @@
_Replace italic text by your own description_
## Release Merge Request
### Why this Merge Request
_This merge request addresses, and describe the problem or user story being addressed._
### What is implemented, what is the chosen solution
_Explain the fix or solution implemented. Which other solution have been envisaged._
### Related issues and impact on other project in codebase
_Provide links to the related issues, feature requests and merge request (from Gitlab and Redmine)._
_And Link to other project Impacted._
### Other Information
_Include any extra information or considerations for reviewers._
## Checklists
### Merge Request
- [ ] Target branch identified.
- [ ] Code based on last version of target branch.
- [ ] Description filled.
- [ ] Impact on other project codebase identified.
- [ ] Documentation reflects the changes made.
- [ ] Test run in gitlab pipeline and locally.
- [ ] One or more reviewer is defined
### Code Review
- [ ] Code follows project coding guidelines.
- [ ] Code follows project designed architecture.
- [ ] Code is easily readable.
- [ ] Everything new have an explicit and pertinent name (variable, method, file ...)
- [ ] No redundant/duplicate code (unless explain by architecture choice)
- [ ] Commit are all related to MR and well written (Atomic commit).
- [ ] New code is tested and covered by automated test.
- [ ] No useless logging or debugging code.
- [ ] No code can be replaced by library or framework code.
### Change Management
- [ ] Release is planned
- [ ] Merge Request to be included are identified
- [ ] Concerned Team are aware of the change
- [ ] No other change on the same day (if possible)
### TODO before merge
- [ ] _add any task here_
- [ ] ...
### TODO after merge
- [ ] _add any task here_
- [ ] ...

View File

@ -0,0 +1,37 @@
_Replace italic text by your own description_
## Small Fix Merge Request
### Why this Merge Request
_This merge request addresses, and describe the problem or user story being addressed._
### What is implemented, what is the chosen solution
_Explain the fix or solution implemented. Which other solution have been envisaged._
### Related issues and impact on other project in codebase
_Provide links to the related issues, feature requests and merge request (from Gitlab and Redmine)._
_And Link to other project Impacted._
### Other Information
_Include any extra information or considerations for reviewers._
## Checklists
### Merge Request
- [ ] Target branch identified.
- [ ] Code based on last version of target branch.
- [ ] Description filled.
- [ ] Impact on other project codebase identified.
- [ ] Test run in gitlab pipeline and locally.
### Code Review
- [ ] Code is easily readable.
- [ ] Commit are all related to MR and well written (Atomic commit).
- [ ] No useless logging or debugging code.

View File

@ -4,3 +4,4 @@ node_modules
dist
coverage
.prettierrc.json
.gitlab

View File

@ -2,7 +2,7 @@
# BUILD FOR LOCAL DEVELOPMENT
###################
FROM node:18-alpine3.16 As development
FROM docker.io/node:lts-hydrogen As development
# Create app directory
WORKDIR /usr/src/app
@ -29,7 +29,7 @@ USER node
# BUILD FOR PRODUCTION
###################
FROM node:18-alpine3.16 As build
FROM docker.io/node:lts-hydrogen As build
WORKDIR /usr/src/app
@ -63,7 +63,7 @@ USER node
# PRODUCTION
###################
FROM node:18-alpine3.16 As production
FROM docker.io/node:lts-hydrogen As production
# Copy package.json to be able to execute migration command
COPY --chown=node:node package*.json ./

View File

@ -56,6 +56,26 @@ The app exposes the following [gRPC](https://grpc.io/) services :
}
```
- **FindAllByIds** : find all ads for the given ids
```json
{
"ids": [
"80126a61-d128-4f96-afdb-92e33c75a3e1",
"80126a61-d128-4f96-afdb-92e33c75a3e2",
"80126a61-d128-4f96-afdb-92e33c75a3e3"
]
}
```
- **FindAllByUserId** : find all ads for the given user id
```json
{
"id": "80c9bb02-0931-4a1d-bea6-22d358992245"
}
```
- **Create** : create an ad
Punctual driver ad :
@ -95,7 +115,8 @@ The app exposes the following [gRPC](https://grpc.io/) services :
"postalCode": "75000",
"country": "France"
}
]
],
"comment": "I'm flexible with the departure time"
}
```
@ -137,7 +158,8 @@ The app exposes the following [gRPC](https://grpc.io/) services :
"postalCode": "75000",
"country": "France"
}
]
],
"comment": "I'm flexible with the departure time"
}
```
@ -187,7 +209,8 @@ The app exposes the following [gRPC](https://grpc.io/) services :
"postalCode": "75000",
"country": "France"
}
]
],
"comment": "I'm flexible with the departure time"
}
```
@ -207,12 +230,36 @@ The app exposes the following [gRPC](https://grpc.io/) services :
- seatsRequested: number of seats requested as passenger (required if `passenger` is true)
- strict (boolean): if set to true, allow matching only with similar frequency ads
- waypoints: an array of addresses that represent the waypoints of the journey (only first and last waypoints are used for passenger ads). Note that positions are **required** and **must** be consecutives
- comment: optional freetext comment / description about the ad
- **Update** : Replace the content of an ad
Accepts the same data as the `Create` function + an ad id, and replace the given ad with the given data.
- **Delete** : Delete permanently an ad
```json
{
"id": "80126a61-d128-4f96-afdb-92e33c75a3e1"
}
```
## Messages
### Handled
The service listens to these RabbitMQ messages:
- **matcher-ad.created** (to update the status of pending ads)
- **matcher-ad.creation-failed** (to update the status of pending ads)
- **user.deleted** (to delete the associated ads)
### Emitted
As mentionned earlier, RabbitMQ messages are sent after these events :
- **Create** (message : the created ad informations)
- **ad.created** (message: the created ad information)
- **ad.updated** (message: the updated ad information)
- **ad.deleted** (message: the id of the deleted ad)
## Tests / ESLint / Prettier

View File

@ -9,8 +9,7 @@ DATABASE_URL="postgresql://mobicoop:mobicoop@v3-db:5432/mobicoop?schema=public"
RMQ_URI=amqp://v3-ad-broker:5672
# MESSAGE BROKER
BROKER_IMAGE=rabbitmq:3-alpine
BROKER_IMAGE=docker.io/rabbitmq:3-alpine
# POSTGRES
POSTGRES_IMAGE=postgres:15.0
POSTGRES_IMAGE=docker.io/postgres:15.0

View File

@ -2,7 +2,7 @@
# BUILD FOR CI TESTING
###################
FROM node:18-alpine3.16
FROM docker.io/node:lts-hydrogen
# Create app directory
WORKDIR /usr/src/app

View File

@ -11,10 +11,11 @@ services:
- .:/usr/src/app
env_file:
- .env
command: npm run start:dev
command: npm run start:debug
ports:
- ${SERVICE_PORT:-5006}:${SERVICE_PORT:-5006}
- ${HEALTH_SERVICE_PORT:-6006}:${HEALTH_SERVICE_PORT:-6006}
- 9226:9229
networks:
v3-network:
aliases:

2328
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -1,6 +1,6 @@
{
"name": "@mobicoop/ad",
"version": "2.2.5",
"version": "2.6.0",
"description": "Mobicoop V3 Ad",
"author": "sbriat",
"private": true,
@ -11,7 +11,7 @@
"format": "prettier --write \"src/**/*.ts\" \"test/**/*.ts\"",
"start": "nest start",
"start:dev": "nest start --watch",
"start:debug": "nest start --debug --watch",
"start:debug": "nest start --debug 0.0.0.0:9229 --watch",
"start:prod": "node dist/main",
"lint": "eslint \"{src,apps,libs,test}/**/*.ts\" --fix",
"lint:check": "eslint \"{src,apps,libs,test}/**/*.ts\" --fix-dry-run --ignore-path .gitignore",
@ -24,61 +24,61 @@
"test:integration:ci": "npm run migrate:test:ci && dotenv -e ci/.env.ci -- jest --testPathPattern 'tests/integration/' --runInBand",
"test:cov": "jest --testPathPattern 'tests/unit/' --coverage",
"test:e2e": "jest --config ./test/jest-e2e.json",
"migrate": "docker exec v3-ad-api sh -c 'npx prisma migrate dev'",
"migrate": "docker exec v3-ad-api sh -c 'npx prisma migrate deploy'",
"migrate:dev": "docker exec v3-ad-api sh -c 'npx prisma migrate dev'",
"migrate:test": "dotenv -e .env.test -- npx prisma migrate deploy",
"migrate:test:ci": "dotenv -e ci/.env.ci -- npx prisma migrate deploy",
"migrate:deploy": "npx prisma migrate deploy"
},
"dependencies": {
"@grpc/grpc-js": "^1.9.9",
"@grpc/grpc-js": "^1.9.14",
"@grpc/proto-loader": "^0.7.10",
"@songkeys/nestjs-redis": "^10.0.0",
"@mobicoop/ddd-library": "^2.1.1",
"@mobicoop/health-module": "^2.3.1",
"@mobicoop/message-broker-module": "^2.1.1",
"@nestjs/common": "^10.2.7",
"@mobicoop/ddd-library": "^2.5.0",
"@mobicoop/health-module": "^2.3.2",
"@mobicoop/message-broker-module": "^2.1.2",
"@nestjs/common": "^10.3.0",
"@nestjs/config": "^3.1.1",
"@nestjs/core": "^10.2.7",
"@nestjs/core": "^10.3.0",
"@nestjs/cqrs": "^10.2.6",
"@nestjs/event-emitter": "^2.0.2",
"@nestjs/microservices": "^10.2.7",
"@nestjs/platform-express": "^10.2.7",
"@nestjs/terminus": "^10.1.1",
"@prisma/client": "^5.5.2",
"@nestjs/event-emitter": "^2.0.3",
"@nestjs/microservices": "^10.3.0",
"@nestjs/platform-express": "^10.3.0",
"@nestjs/terminus": "^10.2.0",
"@prisma/client": "^5.8.1",
"class-transformer": "^0.5.1",
"class-validator": "^0.14.0",
"geo-tz": "^7.0.7",
"class-validator": "^0.14.1",
"geo-tz": "^8.0.0",
"ioredis": "^5.3.2",
"nestjs-request-context": "^3.0.0",
"reflect-metadata": "^0.1.13",
"rxjs": "^7.8.1",
"timezonecomplete": "^5.12.4"
},
"devDependencies": {
"@nestjs/cli": "^10.2.1",
"@nestjs/schematics": "^10.0.3",
"@nestjs/testing": "^10.2.7",
"@types/express": "^4.17.20",
"@types/jest": "29.5.7",
"@types/node": "20.8.10",
"@types/supertest": "^2.0.15",
"@types/uuid": "^9.0.6",
"@typescript-eslint/eslint-plugin": "^6.9.1",
"@typescript-eslint/parser": "^6.9.1",
"@nestjs/cli": "^10.3.0",
"@nestjs/schematics": "^10.1.0",
"@nestjs/testing": "^10.3.0",
"@types/express": "^4.17.21",
"@types/jest": "29.5.11",
"@types/node": "20.11.5",
"@types/supertest": "^6.0.2",
"@types/uuid": "^9.0.7",
"@typescript-eslint/eslint-plugin": "^6.19.0",
"@typescript-eslint/parser": "^6.19.0",
"dotenv-cli": "^7.3.0",
"eslint": "^8.52.0",
"eslint-config-prettier": "^9.0.0",
"eslint-plugin-prettier": "^5.0.1",
"eslint": "^8.56.0",
"eslint-config-prettier": "^9.1.0",
"eslint-plugin-prettier": "^5.1.3",
"jest": "29.7.0",
"prettier": "^3.0.3",
"prisma": "^5.5.2",
"prettier": "^3.2.3",
"prisma": "^5.8.1",
"source-map-support": "^0.5.21",
"supertest": "^6.3.3",
"supertest": "^6.3.4",
"ts-jest": "29.1.1",
"ts-loader": "^9.5.0",
"ts-node": "^10.9.1",
"ts-loader": "^9.5.1",
"ts-node": "^10.9.2",
"tsconfig-paths": "4.2.0",
"typescript": "^5.2.2"
"typescript": "^5.3.3"
},
"jest": {
"moduleFileExtensions": [
@ -96,13 +96,13 @@
"prisma.service.ts",
"main.ts"
],
"rootDir": "src",
"rootDir": ".",
"testRegex": ".*\\.spec\\.ts$",
"transform": {
"^.+\\.(t|j)s$": "ts-jest"
},
"collectCoverageFrom": [
"**/*.(t|j)s"
"./src/**/*.(t|j)s"
],
"coveragePathIgnorePatterns": [
".constants.ts",
@ -114,10 +114,10 @@
"prisma.service.ts",
"main.ts"
],
"coverageDirectory": "../coverage",
"coverageDirectory": "coverage",
"moduleNameMapper": {
"^@modules(.*)": "<rootDir>/modules/$1",
"^@src(.*)": "<rootDir>$1"
"^@modules(.*)": "<rootDir>/src/modules/$1",
"^@src(.*)": "<rootDir>/src/$1"
},
"testEnvironment": "node"
}

View File

@ -0,0 +1,5 @@
-- CreateEnum
CREATE TYPE "Status" AS ENUM ('PENDING', 'VALID', 'INVALID', 'SUSPENDED');
-- AlterTable
ALTER TABLE "ad" ADD COLUMN "status" "Status" NOT NULL DEFAULT 'PENDING';

View File

@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "ad" ADD COLUMN "comment" TEXT;

View File

@ -14,6 +14,7 @@ datasource db {
model Ad {
uuid String @id @default(uuid()) @db.Uuid
userUuid String @db.Uuid
status Status @default(PENDING)
driver Boolean
passenger Boolean
frequency Frequency
@ -26,6 +27,7 @@ model Ad {
createdAt DateTime @default(now())
updatedAt DateTime @default(now()) @updatedAt
waypoints Waypoint[]
comment String?
@@map("ad")
}
@ -66,3 +68,10 @@ enum Frequency {
PUNCTUAL
RECURRENT
}
enum Status {
PENDING
VALID
INVALID
SUSPENDED
}

View File

@ -5,8 +5,24 @@ export const SERVICE_NAME = 'ad';
export const GRPC_PACKAGE_NAME = 'ad';
export const GRPC_SERVICE_NAME = 'AdService';
// messaging
// messaging output
export const AD_CREATED_ROUTING_KEY = 'ad.created';
export const AD_UPDATED_ROUTING_KEY = 'ad.updated';
export const AD_DELETED_ROUTING_KEY = 'ad.deleted';
// messaging input
export const MATCHER_AD_CREATED_MESSAGE_HANDLER = 'matcherAdCreated';
export const MATCHER_AD_CREATED_ROUTING_KEY = 'matcher-ad.created';
export const MATCHER_AD_CREATED_QUEUE = 'ad.matcher-ad.created';
export const MATCHER_AD_CREATION_FAILED_MESSAGE_HANDLER =
'matcherAdCreationFailed';
export const MATCHER_AD_CREATION_FAILED_ROUTING_KEY =
'matcher-ad.creation-failed';
export const MATCHER_AD_CREATION_FAILED_QUEUE = 'ad.matcher-ad.creation-failed';
export const USER_DELETED_MESSAGE_HANDLER = 'userDeleted';
export const USER_DELETED_ROUTING_KEY = 'user.deleted';
export const USER_DELETED_QUEUE = 'ad.user.deleted';
// configuration
export const SERVICE_CONFIGURATION_SET_QUEUE = 'ad-configuration-set';

View File

@ -1,19 +1,21 @@
import { Mapper } from '@mobicoop/ddd-library';
import { AdResponseDto } from './interface/dtos/ad.response.dto';
import { Inject, Injectable } from '@nestjs/common';
import { AdEntity } from './core/domain/ad.entity';
import {
AdWriteModel,
AdReadModel,
WaypointModel,
ScheduleItemModel,
} from './infrastructure/ad.repository';
import { Frequency } from './core/domain/ad.types';
import { WaypointProps } from './core/domain/value-objects/waypoint.value-object';
import { v4 } from 'uuid';
import { ScheduleItemProps } from './core/domain/value-objects/schedule-item.value-object';
import { OUTPUT_DATETIME_TRANSFORMER } from './ad.di-tokens';
import { DateTimeTransformerPort } from './core/application/ports/datetime-transformer.port';
import { AdEntity } from './core/domain/ad.entity';
import { Frequency, Status } from './core/domain/ad.types';
import { ScheduleItemProps } from './core/domain/value-objects/schedule-item.value-object';
import { WaypointProps } from './core/domain/value-objects/waypoint.value-object';
import {
AdReadModel,
AdWriteModel,
ScheduleItemModel,
ScheduleWriteModel,
WaypointModel,
WaypointWriteModel,
} from './infrastructure/ad.repository';
import { AdResponseDto } from './interface/dtos/ad.response.dto';
/**
* Mapper constructs objects that are used in different layers:
@ -31,21 +33,39 @@ export class AdMapper
private readonly outputDatetimeTransformer: DateTimeTransformerPort,
) {}
toPersistence = (entity: AdEntity): AdWriteModel => {
toPersistence = (entity: AdEntity, update?: boolean): AdWriteModel => {
const copy = entity.getProps();
const now = new Date();
const record: AdWriteModel = {
uuid: copy.id,
userUuid: copy.userId,
driver: copy.driver as boolean,
passenger: copy.passenger as boolean,
status: copy.status,
frequency: copy.frequency,
fromDate: new Date(copy.fromDate),
toDate: new Date(copy.toDate),
schedule: {
create: copy.schedule.map((scheduleItem: ScheduleItemProps) => ({
schedule: this.toScheduleItemWriteModel(copy.schedule, update),
seatsProposed: copy.seatsProposed as number,
seatsRequested: copy.seatsRequested as number,
strict: copy.strict as boolean,
waypoints: this.toWaypointWriteModel(copy.waypoints, update),
comment: copy.comment,
};
return record;
};
toScheduleItemWriteModel = (
schedule: ScheduleItemProps[],
update?: boolean,
): ScheduleWriteModel | undefined => {
if (!schedule) {
return undefined;
}
const now = new Date();
const record: ScheduleWriteModel = {
create: schedule.map((scheduleItem: ScheduleItemProps) => ({
uuid: v4(),
day: scheduleItem.day as number,
day: scheduleItem.day,
time: new Date(
1970,
0,
@ -53,16 +73,29 @@ export class AdMapper
parseInt(scheduleItem.time.split(':')[0]),
parseInt(scheduleItem.time.split(':')[1]),
),
margin: scheduleItem.margin as number,
margin: scheduleItem.margin,
createdAt: now,
updatedAt: now,
})),
},
seatsProposed: copy.seatsProposed as number,
seatsRequested: copy.seatsRequested as number,
strict: copy.strict as boolean,
waypoints: {
create: copy.waypoints.map((waypoint: WaypointProps) => ({
};
if (update) {
record.deleteMany = {
createdAt: { lt: now },
};
}
return record;
};
toWaypointWriteModel = (
waypoints: WaypointProps[],
update?: boolean,
): WaypointWriteModel | undefined => {
if (!waypoints) {
return undefined;
}
const now = new Date();
const record: WaypointWriteModel = {
create: waypoints.map((waypoint: WaypointProps) => ({
uuid: v4(),
position: waypoint.position,
name: waypoint.address.name,
@ -76,10 +109,12 @@ export class AdMapper
createdAt: now,
updatedAt: now,
})),
},
createdAt: copy.createdAt,
updatedAt: copy.updatedAt,
};
if (update) {
record.deleteMany = {
createdAt: { lt: now },
};
}
return record;
};
@ -92,10 +127,11 @@ export class AdMapper
userId: record.userUuid,
driver: record.driver,
passenger: record.passenger,
status: record.status as Status,
frequency: record.frequency as Frequency,
fromDate: record.fromDate.toISOString().split('T')[0],
toDate: record.toDate.toISOString().split('T')[0],
schedule: record.schedule.map((scheduleItem: ScheduleItemModel) => ({
schedule: record.schedule?.map((scheduleItem: ScheduleItemModel) => ({
day: scheduleItem.day,
time: `${scheduleItem.time
.getUTCHours()
@ -109,7 +145,7 @@ export class AdMapper
seatsProposed: record.seatsProposed,
seatsRequested: record.seatsRequested,
strict: record.strict,
waypoints: record.waypoints.map((waypoint: WaypointModel) => ({
waypoints: record.waypoints?.map((waypoint: WaypointModel) => ({
position: waypoint.position,
address: {
name: waypoint.name,
@ -124,6 +160,7 @@ export class AdMapper
},
},
})),
comment: record.comment,
},
});
return entity;
@ -135,6 +172,8 @@ export class AdMapper
response.userId = props.userId;
response.driver = props.driver as boolean;
response.passenger = props.passenger as boolean;
response.strict = props.strict;
response.status = props.status;
response.frequency = props.frequency;
response.fromDate = this.outputDatetimeTransformer.fromDate(
{
@ -156,7 +195,7 @@ export class AdMapper
response.schedule = props.schedule.map(
(scheduleItem: ScheduleItemProps) => ({
day: this.outputDatetimeTransformer.day(
scheduleItem.day as number,
scheduleItem.day,
{
date: props.fromDate,
time: scheduleItem.time,
@ -172,7 +211,7 @@ export class AdMapper
},
props.frequency,
),
margin: scheduleItem.margin as number,
margin: scheduleItem.margin,
}),
);
response.seatsProposed = props.seatsProposed as number;
@ -188,6 +227,7 @@ export class AdMapper
lon: waypoint.address.coordinates.lon,
lat: waypoint.address.coordinates.lat,
}));
response.comment = props.comment;
return response;
};
}

View File

@ -1,5 +1,5 @@
import { MessageBrokerPublisher } from '@mobicoop/message-broker-module';
import { Module, Provider } from '@nestjs/common';
import { CreateAdGrpcController } from './interface/grpc-controllers/create-ad.grpc.controller';
import { CqrsModule } from '@nestjs/cqrs';
import {
AD_MESSAGE_PUBLISHER,
@ -9,28 +9,70 @@ import {
TIMEZONE_FINDER,
TIME_CONVERTER,
} from './ad.di-tokens';
import { AdRepository } from './infrastructure/ad.repository';
import { AdMapper } from './ad.mapper';
import { CreateAdService } from './core/application/commands/create-ad/create-ad.service';
import { TimezoneFinder } from './infrastructure/timezone-finder';
import { TimeConverter } from './infrastructure/time-converter';
import { FindAdByIdGrpcController } from './interface/grpc-controllers/find-ad-by-id.grpc.controller';
import { FindAdByIdQueryHandler } from './core/application/queries/find-ad-by-id/find-ad-by-id.query-handler';
import { DeleteAdService } from './core/application/commands/delete-ad/delete-ad.service';
import { DeleteUserAdsService } from './core/application/commands/delete-user-ads/delete-user-ads.service';
import { InvalidateAdService } from './core/application/commands/invalidate-ad/invalidate-ad.service';
import { UpdateAdService } from './core/application/commands/update-ad/update-ad.service';
import { ValidateAdService } from './core/application/commands/validate-ad/validate-ad.service';
import { PublishMessageWhenAdIsDeletedDomainEventHandler } from './core/application/event-handlers/publish-message-when-ad-deleted.domain-event-handler';
import { PublishMessageWhenAdIsCreatedDomainEventHandler } from './core/application/event-handlers/publish-message-when-ad-is-created.domain-event-handler';
import { PrismaService } from './infrastructure/prisma.service';
import { MessageBrokerPublisher } from '@mobicoop/message-broker-module';
import { PublishMessageWhenAdIsUpdatedDomainEventHandler } from './core/application/event-handlers/publish-message-when-ad-is-updated.domain-event-handler';
import { FindAdByIdQueryHandler } from './core/application/queries/find-ad-by-id/find-ad-by-id.query-handler';
import { FindAdsByIdsQueryHandler } from './core/application/queries/find-ads-by-ids/find-ads-by-ids.query-handler';
import { FindAdsByUserIdQueryHandler } from './core/application/queries/find-ads-by-user-id/find-ads-by-user-id.query-handler';
import { AdRepository } from './infrastructure/ad.repository';
import { InputDateTimeTransformer } from './infrastructure/input-datetime-transformer';
import { OutputDateTimeTransformer } from './infrastructure/output-datetime-transformer';
import { PrismaService } from './infrastructure/prisma.service';
import { TimeConverter } from './infrastructure/time-converter';
import { TimezoneFinder } from './infrastructure/timezone-finder';
import { CreateAdGrpcController } from './interface/grpc-controllers/create-ad.grpc.controller';
import { DeleteAdGrpcController } from './interface/grpc-controllers/delete-ad.grpc.controller';
import { FindAdByIdGrpcController } from './interface/grpc-controllers/find-ad-by-id.grpc.controller';
import { FindAdsByIdsGrpcController } from './interface/grpc-controllers/find-ads-by-ids.grpc.controller';
import { FindAdsByUserIdGrpcController } from './interface/grpc-controllers/find-ads-by-user-id.grpc.controller';
import { UpdateAdGrpcController } from './interface/grpc-controllers/update-ad.grpc.controller';
import { MatcherAdCreatedMessageHandler } from './interface/message-handlers/matcher-ad-created.message-handler';
import { MatcherAdCreationFailedMessageHandler } from './interface/message-handlers/matcher-ad-creation-failed.message-handler';
import { UserDeletedMessageHandler } from './interface/message-handlers/user-deleted.message-handler';
const grpcControllers = [CreateAdGrpcController, FindAdByIdGrpcController];
const grpcControllers = [
CreateAdGrpcController,
UpdateAdGrpcController,
DeleteAdGrpcController,
FindAdByIdGrpcController,
FindAdsByIdsGrpcController,
FindAdsByUserIdGrpcController,
];
const messageHandlers = [
MatcherAdCreatedMessageHandler,
MatcherAdCreationFailedMessageHandler,
UserDeletedMessageHandler,
];
const eventHandlers: Provider[] = [
PublishMessageWhenAdIsCreatedDomainEventHandler,
PublishMessageWhenAdIsUpdatedDomainEventHandler,
PublishMessageWhenAdIsDeletedDomainEventHandler,
];
const commandHandlers: Provider[] = [CreateAdService];
const commandHandlers: Provider[] = [
CreateAdService,
UpdateAdService,
DeleteAdService,
DeleteUserAdsService,
ValidateAdService,
InvalidateAdService,
];
const queryHandlers: Provider[] = [FindAdByIdQueryHandler];
const queryHandlers: Provider[] = [
FindAdByIdQueryHandler,
FindAdsByIdsQueryHandler,
FindAdsByUserIdQueryHandler,
];
const mappers: Provider[] = [AdMapper];
@ -72,6 +114,7 @@ const adapters: Provider[] = [
imports: [CqrsModule],
controllers: [...grpcControllers],
providers: [
...messageHandlers,
...eventHandlers,
...commandHandlers,
...queryHandlers,

View File

@ -1,7 +1,7 @@
import { ScheduleItem } from '../../types/schedule-item';
import { Waypoint } from '../../types/waypoint';
import { Frequency } from '@modules/ad/core/domain/ad.types';
import { Command, CommandProps } from '@mobicoop/ddd-library';
import { Frequency } from '@modules/ad/core/domain/ad.types';
import { ScheduleItemProps } from '@modules/ad/core/domain/value-objects/schedule-item.value-object';
import { Waypoint } from '../../types/waypoint';
export class CreateAdCommand extends Command {
readonly userId: string;
@ -10,11 +10,12 @@ export class CreateAdCommand extends Command {
readonly frequency: Frequency;
readonly fromDate: string;
readonly toDate: string;
readonly schedule: ScheduleItem[];
readonly schedule: ScheduleItemProps[];
readonly seatsProposed?: number;
readonly seatsRequested?: number;
readonly strict: boolean;
readonly waypoints: Waypoint[];
readonly comment?: string;
constructor(props: CommandProps<CreateAdCommand>) {
super(props);
@ -29,5 +30,6 @@ export class CreateAdCommand extends Command {
this.seatsRequested = props.seatsRequested;
this.strict = props.strict;
this.waypoints = props.waypoints;
this.comment = props.comment;
}
}

View File

@ -1,34 +1,29 @@
import { CommandHandler, ICommandHandler } from '@nestjs/cqrs';
import { CreateAdCommand } from './create-ad.command';
import { Inject } from '@nestjs/common';
import { AggregateID, ConflictException } from '@mobicoop/ddd-library';
import {
AD_REPOSITORY,
INPUT_DATETIME_TRANSFORMER,
} from '@modules/ad/ad.di-tokens';
import { AdEntity } from '@modules/ad/core/domain/ad.entity';
import { Waypoint } from '../../types/waypoint';
import { AdRepositoryPort } from '../../ports/ad.repository.port';
import { AdAlreadyExistsException } from '@modules/ad/core/domain/ad.errors';
import { AggregateID, ConflictException } from '@mobicoop/ddd-library';
import { ScheduleItem } from '../../types/schedule-item';
import { ScheduleItemProps } from '@modules/ad/core/domain/value-objects/schedule-item.value-object';
import { Inject } from '@nestjs/common';
import { CommandHandler, ICommandHandler } from '@nestjs/cqrs';
import { AdRepositoryPort } from '../../ports/ad.repository.port';
import { DateTimeTransformerPort } from '../../ports/datetime-transformer.port';
import { Waypoint } from '../../types/waypoint';
import { CreateAdCommand } from './create-ad.command';
@CommandHandler(CreateAdCommand)
export class CreateAdService implements ICommandHandler {
constructor(
@Inject(AD_REPOSITORY)
private readonly repository: AdRepositoryPort,
@Inject(INPUT_DATETIME_TRANSFORMER)
private readonly datetimeTransformer: DateTimeTransformerPort,
) {}
async execute(command: CreateAdCommand): Promise<AggregateID> {
const ad = AdEntity.create({
export function createPropsFromCommand(
command: CreateAdCommand,
datetimeTransformer: DateTimeTransformerPort,
) {
return {
userId: command.userId,
driver: command.driver,
passenger: command.passenger,
frequency: command.frequency,
fromDate: this.datetimeTransformer.fromDate(
//TODO Shouldn't that kind of logic be in the domain layer?
fromDate: datetimeTransformer.fromDate(
{
date: command.fromDate,
time: command.schedule[0].time,
@ -39,7 +34,7 @@ export class CreateAdService implements ICommandHandler {
},
command.frequency,
),
toDate: this.datetimeTransformer.toDate(
toDate: datetimeTransformer.toDate(
command.toDate,
{
date: command.fromDate,
@ -51,8 +46,8 @@ export class CreateAdService implements ICommandHandler {
},
command.frequency,
),
schedule: command.schedule.map((scheduleItem: ScheduleItem) => ({
day: this.datetimeTransformer.day(
schedule: command.schedule.map((scheduleItem: ScheduleItemProps) => ({
day: datetimeTransformer.day(
scheduleItem.day,
{
date: command.fromDate,
@ -64,7 +59,7 @@ export class CreateAdService implements ICommandHandler {
},
command.frequency,
),
time: this.datetimeTransformer.time(
time: datetimeTransformer.time(
{
date: command.fromDate,
time: scheduleItem.time,
@ -95,7 +90,23 @@ export class CreateAdService implements ICommandHandler {
},
},
})),
});
comment: command.comment,
};
}
@CommandHandler(CreateAdCommand)
export class CreateAdService implements ICommandHandler {
constructor(
@Inject(AD_REPOSITORY)
private readonly repository: AdRepositoryPort,
@Inject(INPUT_DATETIME_TRANSFORMER)
private readonly datetimeTransformer: DateTimeTransformerPort,
) {}
async execute(command: CreateAdCommand): Promise<AggregateID> {
const ad = AdEntity.create(
createPropsFromCommand(command, this.datetimeTransformer),
);
try {
await this.repository.insert(ad);

View File

@ -0,0 +1,7 @@
import { Command, CommandProps } from '@mobicoop/ddd-library';
export class DeleteAdCommand extends Command {
constructor(props: CommandProps<DeleteAdCommand>) {
super(props);
}
}

View File

@ -0,0 +1,18 @@
import { AD_REPOSITORY } from '@modules/ad/ad.di-tokens';
import { Inject } from '@nestjs/common';
import { CommandHandler, ICommandHandler } from '@nestjs/cqrs';
import { AdRepositoryPort } from '../../ports/ad.repository.port';
import { DeleteAdCommand } from './delete-ad.command';
@CommandHandler(DeleteAdCommand)
export class DeleteAdService implements ICommandHandler {
constructor(
@Inject(AD_REPOSITORY) private readonly adRepository: AdRepositoryPort,
) {}
async execute(command: DeleteAdCommand): Promise<boolean> {
const ad = await this.adRepository.findOneById(command.id);
ad.delete();
return this.adRepository.delete(ad);
}
}

View File

@ -0,0 +1,7 @@
import { Command, CommandProps } from '@mobicoop/ddd-library';
export class DeleteUserAdsCommand extends Command {
constructor(props: CommandProps<DeleteUserAdsCommand>) {
super(props);
}
}

View File

@ -0,0 +1,29 @@
import { AdEntity } from '@modules/ad/core/domain/ad.entity';
import {
CommandBus,
CommandHandler,
ICommandHandler,
QueryBus,
} from '@nestjs/cqrs';
import { FindAdsByUserIdQuery } from '../../queries/find-ads-by-user-id/find-ads-by-user-id.query';
import { DeleteAdCommand } from '../delete-ad/delete-ad.command';
import { DeleteUserAdsCommand } from './delete-user-ads.command';
@CommandHandler(DeleteUserAdsCommand)
export class DeleteUserAdsService implements ICommandHandler {
constructor(
private readonly queryBus: QueryBus,
private readonly commandBus: CommandBus,
) {}
async execute(command: DeleteUserAdsCommand): Promise<void> {
const ads: AdEntity[] = await this.queryBus.execute(
new FindAdsByUserIdQuery(command.id),
);
await Promise.all(
ads.map((ad) =>
this.commandBus.execute(new DeleteAdCommand({ id: ad.id })),
),
);
}
}

View File

@ -0,0 +1,7 @@
import { Command, CommandProps } from '@mobicoop/ddd-library';
export class InvalidateAdCommand extends Command {
constructor(props: CommandProps<InvalidateAdCommand>) {
super(props);
}
}

View File

@ -0,0 +1,25 @@
import { CommandHandler, ICommandHandler } from '@nestjs/cqrs';
import { InvalidateAdCommand } from './invalidate-ad.command';
import { AD_REPOSITORY } from '@modules/ad/ad.di-tokens';
import { Inject } from '@nestjs/common';
import { AdRepositoryPort } from '../../ports/ad.repository.port';
import { AggregateID } from '@mobicoop/ddd-library';
import { AdEntity } from '@modules/ad/core/domain/ad.entity';
@CommandHandler(InvalidateAdCommand)
export class InvalidateAdService implements ICommandHandler {
constructor(
@Inject(AD_REPOSITORY)
private readonly repository: AdRepositoryPort,
) {}
async execute(command: InvalidateAdCommand): Promise<AggregateID> {
const ad: AdEntity = await this.repository.findOneById(command.id, {
waypoints: true,
schedule: true,
});
ad.invalid();
await this.repository.update(ad.id, ad);
return ad.id;
}
}

View File

@ -0,0 +1,16 @@
import { CommandProps } from '@mobicoop/ddd-library';
import { CreateAdCommand } from '../create-ad/create-ad.command';
/**
* Ad updates follow the PUT semantics: they replace the entire object.
* Therefore the update command extends the create command to inherit the same properties
* and re-use the data transformation logic.
*/
export class UpdateAdCommand extends CreateAdCommand {
public adId: string;
constructor(props: CommandProps<UpdateAdCommand>) {
super(props);
this.adId = props.adId;
}
}

View File

@ -0,0 +1,36 @@
import {
AD_REPOSITORY,
INPUT_DATETIME_TRANSFORMER,
} from '@modules/ad/ad.di-tokens';
import { AdUpdatedDomainEvent } from '@modules/ad/core/domain/events/ad.domain-event';
import { Inject } from '@nestjs/common';
import { CommandHandler, ICommandHandler } from '@nestjs/cqrs';
import { EventEmitter2 } from '@nestjs/event-emitter';
import { AdRepositoryPort } from '../../ports/ad.repository.port';
import { DateTimeTransformerPort } from '../../ports/datetime-transformer.port';
import { createPropsFromCommand } from '../create-ad/create-ad.service';
import { UpdateAdCommand } from './update-ad.command';
@CommandHandler(UpdateAdCommand)
export class UpdateAdService implements ICommandHandler {
constructor(
@Inject(AD_REPOSITORY)
private readonly repository: AdRepositoryPort,
@Inject(INPUT_DATETIME_TRANSFORMER)
private readonly datetimeTransformer: DateTimeTransformerPort,
private readonly eventEmitter: EventEmitter2,
) {}
async execute(command: UpdateAdCommand): Promise<void> {
const ad = await this.repository.findOneById(command.adId, {
waypoints: true,
schedule: true,
});
ad.update(createPropsFromCommand(command, this.datetimeTransformer));
await this.repository.update(ad.id, ad);
this.eventEmitter.emitAsync(
AdUpdatedDomainEvent.name,
new AdUpdatedDomainEvent(ad),
);
}
}

View File

@ -0,0 +1,7 @@
import { Command, CommandProps } from '@mobicoop/ddd-library';
export class ValidateAdCommand extends Command {
constructor(props: CommandProps<ValidateAdCommand>) {
super(props);
}
}

View File

@ -0,0 +1,25 @@
import { CommandHandler, ICommandHandler } from '@nestjs/cqrs';
import { ValidateAdCommand } from './validate-ad.command';
import { AD_REPOSITORY } from '@modules/ad/ad.di-tokens';
import { Inject } from '@nestjs/common';
import { AdRepositoryPort } from '../../ports/ad.repository.port';
import { AggregateID } from '@mobicoop/ddd-library';
import { AdEntity } from '@modules/ad/core/domain/ad.entity';
@CommandHandler(ValidateAdCommand)
export class ValidateAdService implements ICommandHandler {
constructor(
@Inject(AD_REPOSITORY)
private readonly repository: AdRepositoryPort,
) {}
async execute(command: ValidateAdCommand): Promise<AggregateID> {
const ad: AdEntity = await this.repository.findOneById(command.id, {
waypoints: false,
schedule: false,
});
ad.valid();
await this.repository.update(ad.id, ad);
return ad.id;
}
}

View File

@ -0,0 +1,22 @@
import { MessagePublisherPort } from '@mobicoop/ddd-library';
import { AD_MESSAGE_PUBLISHER } from '@modules/ad/ad.di-tokens';
import { Inject, Injectable } from '@nestjs/common';
import { OnEvent } from '@nestjs/event-emitter';
import { AD_DELETED_ROUTING_KEY } from '@src/app.constants';
import { AdDeletedDomainEvent } from '../../domain/events/ad-delete.domain-event';
@Injectable()
export class PublishMessageWhenAdIsDeletedDomainEventHandler {
constructor(
@Inject(AD_MESSAGE_PUBLISHER)
private readonly messagePublisher: MessagePublisherPort,
) {}
@OnEvent(AdDeletedDomainEvent.name, { async: true, promisify: true })
async handle(event: AdDeletedDomainEvent): Promise<void> {
this.messagePublisher.publish(
AD_DELETED_ROUTING_KEY,
JSON.stringify(event),
);
}
}

View File

@ -1,6 +1,6 @@
import { Inject, Injectable } from '@nestjs/common';
import { OnEvent } from '@nestjs/event-emitter';
import { AdCreatedDomainEvent } from '../../domain/events/ad-created.domain-events';
import { AdCreatedDomainEvent } from '../../domain/events/ad-created.domain-event';
import { MessagePublisherPort } from '@mobicoop/ddd-library';
import { AD_MESSAGE_PUBLISHER } from '@modules/ad/ad.di-tokens';
import { AD_CREATED_ROUTING_KEY } from '@src/app.constants';

View File

@ -0,0 +1,44 @@
import {
IntegrationEvent,
IntegrationEventProps,
MessagePublisherPort,
} from '@mobicoop/ddd-library';
import { AD_MESSAGE_PUBLISHER } from '@modules/ad/ad.di-tokens';
import { AdMapper } from '@modules/ad/ad.mapper';
import { AdResponseDto } from '@modules/ad/interface/dtos/ad.response.dto';
import { Inject, Injectable } from '@nestjs/common';
import { OnEvent } from '@nestjs/event-emitter';
import { AD_UPDATED_ROUTING_KEY } from '@src/app.constants';
import { v4 } from 'uuid';
import { AdUpdatedDomainEvent } from '../../domain/events/ad.domain-event';
class AdIntegrationEvent extends IntegrationEvent {
readonly data: AdResponseDto;
constructor(props: IntegrationEventProps<unknown>, data: AdResponseDto) {
super(props);
this.data = data;
}
}
@Injectable()
export class PublishMessageWhenAdIsUpdatedDomainEventHandler {
constructor(
@Inject(AD_MESSAGE_PUBLISHER)
private readonly messagePublisher: MessagePublisherPort,
private readonly mapper: AdMapper,
) {}
@OnEvent(AdUpdatedDomainEvent.name, { async: true, promisify: true })
async handle(event: AdUpdatedDomainEvent): Promise<void> {
this.messagePublisher.publish(
AD_UPDATED_ROUTING_KEY,
JSON.stringify(
new AdIntegrationEvent(
{ id: v4(), metadata: event.metadata },
this.mapper.toResponse(event.ad),
),
),
);
}
}

View File

@ -0,0 +1,20 @@
import { IQueryHandler, QueryHandler } from '@nestjs/cqrs';
import { AD_REPOSITORY } from '@modules/ad/ad.di-tokens';
import { AdRepositoryPort } from '../../ports/ad.repository.port';
import { Inject } from '@nestjs/common';
import { AdEntity } from '../../../domain/ad.entity';
import { FindAdsByIdsQuery } from './find-ads-by-ids.query';
@QueryHandler(FindAdsByIdsQuery)
export class FindAdsByIdsQueryHandler implements IQueryHandler {
constructor(
@Inject(AD_REPOSITORY)
private readonly repository: AdRepositoryPort,
) {}
async execute(query: FindAdsByIdsQuery): Promise<AdEntity[]> {
return await this.repository.findAllByIds(query.ids, {
waypoints: true,
schedule: true,
});
}
}

View File

@ -0,0 +1,10 @@
import { QueryBase } from '@mobicoop/ddd-library';
export class FindAdsByIdsQuery extends QueryBase {
readonly ids: string[];
constructor(ids: string[]) {
super();
this.ids = ids;
}
}

View File

@ -0,0 +1,25 @@
import { IQueryHandler, QueryHandler } from '@nestjs/cqrs';
import { AD_REPOSITORY } from '@modules/ad/ad.di-tokens';
import { AdRepositoryPort } from '../../ports/ad.repository.port';
import { Inject } from '@nestjs/common';
import { AdEntity } from '../../../domain/ad.entity';
import { FindAdsByUserIdQuery } from './find-ads-by-user-id.query';
@QueryHandler(FindAdsByUserIdQuery)
export class FindAdsByUserIdQueryHandler implements IQueryHandler {
constructor(
@Inject(AD_REPOSITORY)
private readonly repository: AdRepositoryPort,
) {}
async execute(query: FindAdsByUserIdQuery): Promise<AdEntity[]> {
return await this.repository.findAll(
{
userUuid: query.userId,
},
{
waypoints: true,
schedule: true,
},
);
}
}

View File

@ -0,0 +1,10 @@
import { QueryBase } from '@mobicoop/ddd-library';
export class FindAdsByUserIdQuery extends QueryBase {
readonly userId: string;
constructor(userId: string) {
super();
this.userId = userId;
}
}

View File

@ -1,5 +0,0 @@
export type ScheduleItem = {
day: number;
time: string;
margin: number;
};

View File

@ -1,5 +1,6 @@
import { Address } from './address';
//TODO Why not use the Waypoint value-object from the domain?
export type Waypoint = {
position: number;
} & Address;

View File

@ -1,7 +1,11 @@
import { AggregateRoot, AggregateID } from '@mobicoop/ddd-library';
import { AggregateID, AggregateRoot } from '@mobicoop/ddd-library';
import { v4 } from 'uuid';
import { AdCreatedDomainEvent } from './events/ad-created.domain-events';
import { AdProps, CreateAdProps } from './ad.types';
import { AdProps, CreateAdProps, Status } from './ad.types';
import { AdCreatedDomainEvent } from './events/ad-created.domain-event';
import { AdDeletedDomainEvent } from './events/ad-delete.domain-event';
import { AdInvalidatedDomainEvent } from './events/ad-invalidated.domain-event';
import { AdSuspendedDomainEvent } from './events/ad-suspended.domain-event';
import { AdValidatedDomainEvent } from './events/ad-validated.domain-event';
import { ScheduleItemProps } from './value-objects/schedule-item.value-object';
import { WaypointProps } from './value-objects/waypoint.value-object';
@ -10,10 +14,14 @@ export class AdEntity extends AggregateRoot<AdProps> {
static create = (create: CreateAdProps): AdEntity => {
const id = v4();
const props: AdProps = { ...create };
const props: AdProps = { ...create, status: Status.PENDING };
const ad = new AdEntity({ id, props });
ad.addEvent(
new AdCreatedDomainEvent({
metadata: {
correlationId: id,
timestamp: Date.now(),
},
aggregateId: id,
userId: props.userId,
driver: props.driver,
@ -22,9 +30,9 @@ export class AdEntity extends AggregateRoot<AdProps> {
fromDate: props.fromDate,
toDate: props.toDate,
schedule: props.schedule.map((day: ScheduleItemProps) => ({
day: day.day as number,
day: day.day,
time: day.time,
margin: day.margin as number,
margin: day.margin,
})),
seatsProposed: props.seatsProposed,
seatsRequested: props.seatsRequested,
@ -40,11 +48,80 @@ export class AdEntity extends AggregateRoot<AdProps> {
lon: waypoint.address.coordinates.lon,
lat: waypoint.address.coordinates.lat,
})),
comment: props.comment,
}),
);
return ad;
};
valid = (): AdEntity => {
this.props.status = Status.VALID;
this.addEvent(
new AdValidatedDomainEvent({
metadata: {
correlationId: this.id,
timestamp: Date.now(),
},
aggregateId: this.id,
}),
);
return this;
};
invalid = (): AdEntity => {
this.props.status = Status.INVALID;
this.addEvent(
new AdInvalidatedDomainEvent({
metadata: {
correlationId: this.id,
timestamp: Date.now(),
},
aggregateId: this.id,
}),
);
return this;
};
suspend = (): AdEntity => {
this.props.status = Status.SUSPENDED;
this.addEvent(
new AdSuspendedDomainEvent({
metadata: {
correlationId: this.id,
timestamp: Date.now(),
},
aggregateId: this.id,
}),
);
return this;
};
update = (newProps: CreateAdProps): AdEntity => {
this.props.driver = newProps.driver;
this.props.passenger = newProps.passenger;
this.props.frequency = newProps.frequency;
this.props.fromDate = newProps.fromDate;
this.props.toDate = newProps.toDate;
this.props.seatsProposed = newProps.seatsProposed;
this.props.seatsRequested = newProps.seatsRequested;
this.props.strict = newProps.strict;
this.props.comment = newProps.comment;
this.props.schedule = newProps.schedule.map((item) => ({ ...item }));
this.props.waypoints = newProps.waypoints.map((wp) => ({ ...wp }));
//The ad goes back to pending status until it is validated again
this.props.status = Status.PENDING;
this.validate();
return this;
};
delete(): void {
this.addEvent(
new AdDeletedDomainEvent({
aggregateId: this.id,
}),
);
}
validate(): void {
// entity business rules validation to protect it's invariant before saving entity to a database
}

View File

@ -1,21 +1,6 @@
import { ScheduleItemProps } from './value-objects/schedule-item.value-object';
import { WaypointProps } from './value-objects/waypoint.value-object';
// All properties that an Ad has
export interface AdProps {
userId: string;
driver: boolean;
passenger: boolean;
frequency: Frequency;
fromDate: string;
toDate: string;
schedule: ScheduleItemProps[];
seatsProposed: number;
seatsRequested: number;
strict: boolean;
waypoints: WaypointProps[];
}
// Properties that are needed for an Ad creation
export interface CreateAdProps {
userId: string;
@ -29,9 +14,22 @@ export interface CreateAdProps {
seatsRequested: number;
strict: boolean;
waypoints: WaypointProps[];
comment?: string;
}
// All properties that an Ad has
export interface AdProps extends CreateAdProps {
status: Status;
}
export enum Frequency {
PUNCTUAL = 'PUNCTUAL',
RECURRENT = 'RECURRENT',
}
export enum Status {
PENDING = 'PENDING',
VALID = 'VALID',
INVALID = 'INVALID',
SUSPENDED = 'SUSPENDED',
}

View File

@ -1,4 +1,5 @@
import { DomainEvent, DomainEventProps } from '@mobicoop/ddd-library';
import { ScheduleItemProps } from '../value-objects/schedule-item.value-object';
export class AdCreatedDomainEvent extends DomainEvent {
readonly userId: string;
@ -7,11 +8,12 @@ export class AdCreatedDomainEvent extends DomainEvent {
readonly frequency: string;
readonly fromDate: string;
readonly toDate: string;
readonly schedule: ScheduleItem[];
readonly schedule: ScheduleItemProps[];
readonly seatsProposed: number;
readonly seatsRequested: number;
readonly strict: boolean;
readonly waypoints: Waypoint[];
readonly comment?: string;
constructor(props: DomainEventProps<AdCreatedDomainEvent>) {
super(props);
@ -26,15 +28,10 @@ export class AdCreatedDomainEvent extends DomainEvent {
this.seatsRequested = props.seatsRequested;
this.strict = props.strict;
this.waypoints = props.waypoints;
this.comment = props.comment;
}
}
export class ScheduleItem {
day: number;
time: string;
margin: number;
}
export class Waypoint {
position: number;
name?: string;

View File

@ -0,0 +1,7 @@
import { DomainEvent, DomainEventProps } from '@mobicoop/ddd-library';
export class AdDeletedDomainEvent extends DomainEvent {
constructor(props: DomainEventProps<AdDeletedDomainEvent>) {
super(props);
}
}

View File

@ -0,0 +1,7 @@
import { DomainEvent, DomainEventProps } from '@mobicoop/ddd-library';
export class AdInvalidatedDomainEvent extends DomainEvent {
constructor(props: DomainEventProps<AdInvalidatedDomainEvent>) {
super(props);
}
}

View File

@ -0,0 +1,7 @@
import { DomainEvent, DomainEventProps } from '@mobicoop/ddd-library';
export class AdSuspendedDomainEvent extends DomainEvent {
constructor(props: DomainEventProps<AdSuspendedDomainEvent>) {
super(props);
}
}

View File

@ -0,0 +1,7 @@
import { DomainEvent, DomainEventProps } from '@mobicoop/ddd-library';
export class AdValidatedDomainEvent extends DomainEvent {
constructor(props: DomainEventProps<AdValidatedDomainEvent>) {
super(props);
}
}

View File

@ -0,0 +1,23 @@
import { DomainEvent } from '@mobicoop/ddd-library';
import { AdEntity } from '../ad.entity';
export abstract class AdDomainEvent extends DomainEvent {
readonly ad: AdEntity;
constructor(ad: AdEntity) {
super({
metadata: {
correlationId: ad.id,
timestamp: Date.now(),
},
aggregateId: ad.id,
});
this.ad = ad;
}
}
export class AdUpdatedDomainEvent extends AdDomainEvent {
constructor(ad: AdEntity) {
super(ad);
}
}

View File

@ -6,13 +6,13 @@ import { ValueObject } from '@mobicoop/ddd-library';
* */
export interface ScheduleItemProps {
day?: number;
day: number;
time: string;
margin?: number;
margin: number;
}
export class ScheduleItem extends ValueObject<ScheduleItemProps> {
get day(): number | undefined {
get day(): number {
return this.props.day;
}
@ -20,7 +20,7 @@ export class ScheduleItem extends ValueObject<ScheduleItemProps> {
return this.props.time;
}
get margin(): number | undefined {
get margin(): number {
return this.props.margin;
}

View File

@ -1,44 +1,58 @@
import { Inject, Injectable, Logger } from '@nestjs/common';
import { EventEmitter2 } from '@nestjs/event-emitter';
import { AdEntity } from '../core/domain/ad.entity';
import { AdMapper } from '../ad.mapper';
import { AdRepositoryPort } from '../core/application/ports/ad.repository.port';
import {
LoggerBase,
MessagePublisherPort,
PrismaRepositoryBase,
} from '@mobicoop/ddd-library';
import { PrismaService } from './prisma.service';
import { AD_MESSAGE_PUBLISHER } from '../ad.di-tokens';
import { Inject, Injectable, Logger } from '@nestjs/common';
import { EventEmitter2 } from '@nestjs/event-emitter';
import { SERVICE_NAME } from '@src/app.constants';
import { AD_MESSAGE_PUBLISHER } from '../ad.di-tokens';
import { AdMapper } from '../ad.mapper';
import { AdRepositoryPort } from '../core/application/ports/ad.repository.port';
import { AdEntity } from '../core/domain/ad.entity';
import { PrismaService } from './prisma.service';
export type AdBaseModel = {
uuid: string;
userUuid: string;
driver: boolean;
passenger: boolean;
status: string;
frequency: string;
fromDate: Date;
toDate: Date;
seatsProposed: number;
seatsRequested: number;
strict: boolean;
createdAt: Date;
updatedAt: Date;
comment?: string;
};
export type AdReadModel = AdBaseModel & {
waypoints: WaypointModel[];
schedule: ScheduleItemModel[];
createdAt: Date;
updatedAt: Date;
};
export type AdWriteModel = AdBaseModel & {
waypoints: {
create: WaypointModel[];
schedule: ScheduleWriteModel | undefined;
waypoints: WaypointWriteModel | undefined;
};
schedule: {
export type ScheduleWriteModel = {
deleteMany?: PastCreatedFilter;
create: ScheduleItemModel[];
};
export type WaypointWriteModel = {
deleteMany?: PastCreatedFilter;
create: WaypointModel[];
};
// used to delete records created in the past,
// because the order of `create` and `deleteMany` is not guaranteed
export type PastCreatedFilter = {
createdAt: { lt: Date };
};
export type ScheduleItemModel = {

View File

@ -1,10 +1,11 @@
import { ResponseBase } from '@mobicoop/ddd-library';
import { Frequency } from '@modules/ad/core/domain/ad.types';
import { Frequency, Status } from '@modules/ad/core/domain/ad.types';
export class AdResponseDto extends ResponseBase {
userId: string;
driver: boolean;
passenger: boolean;
status: Status;
frequency: Frequency;
fromDate: string;
toDate: string;
@ -27,4 +28,5 @@ export class AdResponseDto extends ResponseBase {
lon: number;
lat: number;
}[];
comment?: string;
}

View File

@ -0,0 +1,5 @@
import { AdResponseDto } from './ad.response.dto';
export class AdsResponseDto {
readonly ads: readonly AdResponseDto[];
}

View File

@ -4,9 +4,10 @@ package ad;
service AdService {
rpc FindOneById(AdById) returns (Ad);
rpc FindAll(AdFilter) returns (Ads);
rpc FindAllByIds(AdsById) returns (Ads);
rpc FindAllByUserId(UserById) returns (Ads);
rpc Create(Ad) returns (AdById);
rpc Update(Ad) returns (Ad);
rpc Update(Ad) returns (Empty);
rpc Delete(AdById) returns (Empty);
}
@ -14,6 +15,14 @@ message AdById {
string id = 1;
}
message UserById {
string id = 1;
}
message AdsById {
repeated string ids = 1;
}
message Ad {
string id = 1;
string userId = 2;
@ -27,6 +36,7 @@ message Ad {
int32 seatsRequested = 10;
bool strict = 11;
repeated Waypoint waypoints = 12;
optional string comment = 13;
}
message ScheduleItem {
@ -48,18 +58,13 @@ message Waypoint {
}
enum Frequency {
UNSPECIFIED = 0;
PUNCTUAL = 1;
RECURRENT = 2;
}
message AdFilter {
int32 page = 1;
int32 perPage = 2;
}
message Ads {
repeated Ad data = 1;
int32 total = 2;
repeated Ad ads = 1;
}
message Empty {}

View File

@ -0,0 +1,45 @@
import {
DatabaseErrorException,
NotFoundException,
RpcExceptionCode,
RpcValidationPipe,
} from '@mobicoop/ddd-library';
import { DeleteAdCommand } from '@modules/ad/core/application/commands/delete-ad/delete-ad.command';
import { Controller, UsePipes } from '@nestjs/common';
import { CommandBus } from '@nestjs/cqrs';
import { GrpcMethod, RpcException } from '@nestjs/microservices';
import { GRPC_SERVICE_NAME } from '@src/app.constants';
import { DeleteAdRequestDto } from './dtos/delete-ad.request.dto';
@UsePipes(
new RpcValidationPipe({
whitelist: false,
forbidUnknownValues: false,
}),
)
@Controller()
export class DeleteAdGrpcController {
constructor(private readonly commandBus: CommandBus) {}
@GrpcMethod(GRPC_SERVICE_NAME, 'Delete')
async delete(data: DeleteAdRequestDto): Promise<void> {
try {
await this.commandBus.execute(new DeleteAdCommand(data));
} catch (error: any) {
if (error instanceof NotFoundException)
throw new RpcException({
code: RpcExceptionCode.NOT_FOUND,
message: error.message,
});
if (error instanceof DatabaseErrorException)
throw new RpcException({
code: RpcExceptionCode.INTERNAL,
message: error.message,
});
throw new RpcException({
code: RpcExceptionCode.UNKNOWN,
message: error.message,
});
}
}
}

View File

@ -3,8 +3,10 @@ import {
IsBoolean,
IsInt,
IsEnum,
IsString,
ValidateNested,
ArrayMinSize,
Length,
IsUUID,
IsArray,
IsISO8601,
@ -78,4 +80,9 @@ export class CreateAdRequestDto {
@HasValidPositionIndexes()
@ValidateNested({ each: true })
waypoints: WaypointDto[];
@Length(0, 2000)
@IsString()
@IsOptional()
comment?: string;
}

View File

@ -0,0 +1,7 @@
import { IsNotEmpty, IsString } from 'class-validator';
export class DeleteAdRequestDto {
@IsString()
@IsNotEmpty()
id: string;
}

View File

@ -0,0 +1,7 @@
import { ArrayMinSize, IsArray } from 'class-validator';
export class FindAdsByIdsRequestDto {
@IsArray()
@ArrayMinSize(1)
ids: string[];
}

View File

@ -0,0 +1,6 @@
import { IsString } from 'class-validator';
export class FindAdsByUserIdRequestDto {
@IsString()
id: string;
}

View File

@ -0,0 +1,7 @@
import { IsUUID } from 'class-validator';
import { CreateAdRequestDto } from './create-ad.request.dto';
export class UpdateAdRequestDto extends CreateAdRequestDto {
@IsUUID(4)
id: string;
}

View File

@ -0,0 +1,42 @@
import { Controller, UsePipes } from '@nestjs/common';
import { QueryBus } from '@nestjs/cqrs';
import { GrpcMethod, RpcException } from '@nestjs/microservices';
import { AdEntity } from '@modules/ad/core/domain/ad.entity';
import { AdMapper } from '@modules/ad/ad.mapper';
import { RpcExceptionCode } from '@mobicoop/ddd-library';
import { RpcValidationPipe } from '@mobicoop/ddd-library';
import { GRPC_SERVICE_NAME } from '@src/app.constants';
import { FindAdsByIdsRequestDto } from './dtos/find-ads-by-ids.request.dto';
import { AdsResponseDto } from '../dtos/ads.response.dto';
import { FindAdsByIdsQuery } from '@modules/ad/core/application/queries/find-ads-by-ids/find-ads-by-ids.query';
@UsePipes(
new RpcValidationPipe({
whitelist: false,
forbidUnknownValues: false,
}),
)
@Controller()
export class FindAdsByIdsGrpcController {
constructor(
protected readonly mapper: AdMapper,
private readonly queryBus: QueryBus,
) {}
@GrpcMethod(GRPC_SERVICE_NAME, 'FindAllByIds')
async findAllByIds(data: FindAdsByIdsRequestDto): Promise<AdsResponseDto> {
try {
const ads: AdEntity[] = await this.queryBus.execute(
new FindAdsByIdsQuery(data.ids),
);
return {
ads: ads.map((ad: AdEntity) => this.mapper.toResponse(ad)),
};
} catch (e) {
throw new RpcException({
code: RpcExceptionCode.UNKNOWN,
message: e.message,
});
}
}
}

View File

@ -0,0 +1,44 @@
import { Controller, UsePipes } from '@nestjs/common';
import { QueryBus } from '@nestjs/cqrs';
import { GrpcMethod, RpcException } from '@nestjs/microservices';
import { AdEntity } from '@modules/ad/core/domain/ad.entity';
import { AdMapper } from '@modules/ad/ad.mapper';
import { RpcExceptionCode } from '@mobicoop/ddd-library';
import { RpcValidationPipe } from '@mobicoop/ddd-library';
import { GRPC_SERVICE_NAME } from '@src/app.constants';
import { AdsResponseDto } from '../dtos/ads.response.dto';
import { FindAdsByUserIdQuery } from '@modules/ad/core/application/queries/find-ads-by-user-id/find-ads-by-user-id.query';
import { FindAdsByUserIdRequestDto } from './dtos/find-ads-by-user-id.request.dto';
@UsePipes(
new RpcValidationPipe({
whitelist: false,
forbidUnknownValues: false,
}),
)
@Controller()
export class FindAdsByUserIdGrpcController {
constructor(
protected readonly mapper: AdMapper,
private readonly queryBus: QueryBus,
) {}
@GrpcMethod(GRPC_SERVICE_NAME, 'FindAllByUserId')
async findAllByUserId(
data: FindAdsByUserIdRequestDto,
): Promise<AdsResponseDto> {
try {
const ads: AdEntity[] = await this.queryBus.execute(
new FindAdsByUserIdQuery(data.id),
);
return {
ads: ads.map((ad: AdEntity) => this.mapper.toResponse(ad)),
};
} catch (e) {
throw new RpcException({
code: RpcExceptionCode.UNKNOWN,
message: e.message,
});
}
}
}

View File

@ -0,0 +1,43 @@
import {
NotFoundException,
RpcExceptionCode,
RpcValidationPipe,
} from '@mobicoop/ddd-library';
import { UpdateAdCommand } from '@modules/ad/core/application/commands/update-ad/update-ad.command';
import { Controller, UsePipes } from '@nestjs/common';
import { CommandBus } from '@nestjs/cqrs';
import { GrpcMethod, RpcException } from '@nestjs/microservices';
import { GRPC_SERVICE_NAME } from '@src/app.constants';
import { UpdateAdRequestDto } from './dtos/update-ad.request.dto';
@UsePipes(
new RpcValidationPipe({
whitelist: false,
forbidUnknownValues: false,
}),
)
@Controller()
export class UpdateAdGrpcController {
constructor(private readonly commandBus: CommandBus) {}
@GrpcMethod(GRPC_SERVICE_NAME, 'Update')
async update(data: UpdateAdRequestDto): Promise<void> {
try {
const cmdProps = {
adId: data.id,
...data,
};
delete (cmdProps as { id?: string }).id;
await this.commandBus.execute(new UpdateAdCommand(cmdProps));
} catch (error) {
if (error instanceof NotFoundException) {
throw new RpcException({
code: RpcExceptionCode.NOT_FOUND,
message: error.message,
});
}
throw error;
}
}
}

View File

@ -0,0 +1,29 @@
import { Injectable } from '@nestjs/common';
import { RabbitSubscribe } from '@mobicoop/message-broker-module';
import { CommandBus } from '@nestjs/cqrs';
import { MATCHER_AD_CREATED_MESSAGE_HANDLER } from '@src/app.constants';
import { MatcherAdCreatedIntegrationEvent } from './matcher-ad.types';
import { ValidateAdCommand } from '@modules/ad/core/application/commands/validate-ad/validate-ad.command';
@Injectable()
export class MatcherAdCreatedMessageHandler {
constructor(private readonly commandBus: CommandBus) {}
@RabbitSubscribe({
name: MATCHER_AD_CREATED_MESSAGE_HANDLER,
})
public async matcherAdCreated(message: string) {
try {
const matcherAdCreatedIntegrationEvent: MatcherAdCreatedIntegrationEvent =
JSON.parse(message);
await this.commandBus.execute(
new ValidateAdCommand({
id: matcherAdCreatedIntegrationEvent.id,
}),
);
} catch (error: any) {
// do not throw error to acknowledge incoming message
// error handling should be done in the command handler, if relevant
}
}
}

View File

@ -0,0 +1,29 @@
import { Injectable } from '@nestjs/common';
import { RabbitSubscribe } from '@mobicoop/message-broker-module';
import { CommandBus } from '@nestjs/cqrs';
import { MATCHER_AD_CREATION_FAILED_MESSAGE_HANDLER } from '@src/app.constants';
import { MatcherAdCreationFailedIntegrationEvent } from './matcher-ad.types';
import { InvalidateAdCommand } from '@modules/ad/core/application/commands/invalidate-ad/invalidate-ad.command';
@Injectable()
export class MatcherAdCreationFailedMessageHandler {
constructor(private readonly commandBus: CommandBus) {}
@RabbitSubscribe({
name: MATCHER_AD_CREATION_FAILED_MESSAGE_HANDLER,
})
public async matcherAdCreationFailed(message: string) {
try {
const matcherAdCreationFailedIntegrationEvent: MatcherAdCreationFailedIntegrationEvent =
JSON.parse(message);
await this.commandBus.execute(
new InvalidateAdCommand({
id: matcherAdCreationFailedIntegrationEvent.id,
}),
);
} catch (error: any) {
// do not throw error to acknowledge incoming message
// error handling should be done in the command handler, if relevant
}
}
}

View File

@ -0,0 +1,4 @@
import { IntegrationEvent } from '@mobicoop/ddd-library';
export type MatcherAdCreatedIntegrationEvent = IntegrationEvent;
export type MatcherAdCreationFailedIntegrationEvent = IntegrationEvent;

View File

@ -0,0 +1,23 @@
import { IntegrationEvent } from '@mobicoop/ddd-library';
import { RabbitSubscribe } from '@mobicoop/message-broker-module';
import { DeleteUserAdsCommand } from '@modules/ad/core/application/commands/delete-user-ads/delete-user-ads.command';
import { Injectable } from '@nestjs/common';
import { CommandBus } from '@nestjs/cqrs';
import { USER_DELETED_MESSAGE_HANDLER } from '@src/app.constants';
type UserDeletedEvent = IntegrationEvent;
@Injectable()
export class UserDeletedMessageHandler {
constructor(private readonly commandBus: CommandBus) {}
@RabbitSubscribe({
name: USER_DELETED_MESSAGE_HANDLER,
})
public async userDeleted(message: string) {
const deletedUser: UserDeletedEvent = JSON.parse(message);
await this.commandBus.execute(
new DeleteUserAdsCommand({ id: deletedUser.id }),
);
}
}

View File

@ -1,13 +1,24 @@
import { Module, Provider } from '@nestjs/common';
import { MESSAGE_PUBLISHER } from './messager.di-tokens';
import { ConfigModule, ConfigService } from '@nestjs/config';
import { SERVICE_NAME } from '@src/app.constants';
import {
MessageBrokerModule,
MessageBrokerModuleOptions,
MessageBrokerPublisher,
} from '@mobicoop/message-broker-module';
import { ConfigModule, ConfigService } from '@nestjs/config';
import {
MATCHER_AD_CREATED_MESSAGE_HANDLER,
MATCHER_AD_CREATED_QUEUE,
MATCHER_AD_CREATED_ROUTING_KEY,
MATCHER_AD_CREATION_FAILED_MESSAGE_HANDLER,
MATCHER_AD_CREATION_FAILED_QUEUE,
MATCHER_AD_CREATION_FAILED_ROUTING_KEY,
SERVICE_NAME,
USER_DELETED_MESSAGE_HANDLER,
USER_DELETED_QUEUE,
USER_DELETED_ROUTING_KEY,
} from '@src/app.constants';
const imports = [
MessageBrokerModule.forRootAsync({
@ -24,6 +35,20 @@ const imports = [
) as boolean,
},
name: SERVICE_NAME,
handlers: {
[MATCHER_AD_CREATED_MESSAGE_HANDLER]: {
routingKey: MATCHER_AD_CREATED_ROUTING_KEY,
queue: MATCHER_AD_CREATED_QUEUE,
},
[MATCHER_AD_CREATION_FAILED_MESSAGE_HANDLER]: {
routingKey: MATCHER_AD_CREATION_FAILED_ROUTING_KEY,
queue: MATCHER_AD_CREATION_FAILED_QUEUE,
},
[USER_DELETED_MESSAGE_HANDLER]: {
routingKey: USER_DELETED_ROUTING_KEY,
queue: USER_DELETED_QUEUE,
},
},
}),
}),
];

View File

@ -2,7 +2,7 @@ import { OUTPUT_DATETIME_TRANSFORMER } from '@modules/ad/ad.di-tokens';
import { AdMapper } from '@modules/ad/ad.mapper';
import { DateTimeTransformerPort } from '@modules/ad/core/application/ports/datetime-transformer.port';
import { AdEntity } from '@modules/ad/core/domain/ad.entity';
import { Frequency } from '@modules/ad/core/domain/ad.types';
import { Frequency, Status } from '@modules/ad/core/domain/ad.types';
import {
AdReadModel,
AdWriteModel,
@ -17,6 +17,7 @@ const adEntity: AdEntity = new AdEntity({
userId: '7ca2490b-d04d-4ac5-8d6c-5c416fab922e',
driver: false,
passenger: true,
status: Status.PENDING,
frequency: Frequency.PUNCTUAL,
fromDate: '2023-06-21',
toDate: '2023-06-21',
@ -67,6 +68,7 @@ const adReadModel: AdReadModel = {
userUuid: '7ca2490b-d04d-4ac5-8d6c-5c416fab922e',
driver: false,
passenger: true,
status: Status.PENDING,
frequency: Frequency.PUNCTUAL,
fromDate: new Date('2023-06-21'),
toDate: new Date('2023-06-21'),
@ -109,6 +111,7 @@ const adReadModel: AdReadModel = {
strict: false,
seatsProposed: 3,
seatsRequested: 1,
comment: '',
createdAt: now,
updatedAt: now,
};
@ -142,9 +145,9 @@ describe('Ad Mapper', () => {
it('should map domain entity to persistence data', async () => {
const mapped: AdWriteModel = adMapper.toPersistence(adEntity);
expect(mapped.waypoints.create[0].uuid.length).toBe(36);
expect(mapped.waypoints.create[1].uuid.length).toBe(36);
expect(mapped.schedule.create.length).toBe(1);
expect(mapped.waypoints?.create[0].uuid.length).toBe(36);
expect(mapped.waypoints?.create[1].uuid.length).toBe(36);
expect(mapped.schedule?.create.length).toBe(1);
});
it('should map persisted data to domain entity', async () => {

10
tests/unit/ad/ad.mocks.ts Normal file
View File

@ -0,0 +1,10 @@
import { DateTimeTransformerPort } from '@modules/ad/core/application/ports/datetime-transformer.port';
export function mockInputDateTimeTransformer(): DateTimeTransformerPort {
return {
fromDate: jest.fn(),
toDate: jest.fn(),
day: jest.fn(),
time: jest.fn(),
};
}

View File

@ -1,5 +1,9 @@
import { AdEntity } from '@modules/ad/core/domain/ad.entity';
import { CreateAdProps, Frequency } from '@modules/ad/core/domain/ad.types';
import {
CreateAdProps,
Frequency,
Status,
} from '@modules/ad/core/domain/ad.types';
import { WaypointProps } from '@modules/ad/core/domain/value-objects/waypoint.value-object';
const originWaypointProps: WaypointProps = {
@ -35,6 +39,7 @@ const baseCreateAdProps = {
seatsRequested: 1,
strict: false,
waypoints: [originWaypointProps, destinationWaypointProps],
comment: "J'accepte les chiens mais pas les chats",
};
const punctualCreateAdProps = {
fromDate: '2023-06-21',
@ -124,11 +129,15 @@ describe('Ad entity create', () => {
punctualPassengerCreateAdProps,
);
expect(punctualPassengerAd.id.length).toBe(36);
expect(punctualPassengerAd.getProps().status).toBe(Status.PENDING);
expect(punctualPassengerAd.getProps().schedule.length).toBe(1);
expect(punctualPassengerAd.getProps().schedule[0].day).toBe(3);
expect(punctualPassengerAd.getProps().schedule[0].time).toBe('08:30');
expect(punctualPassengerAd.getProps().driver).toBeFalsy();
expect(punctualPassengerAd.getProps().passenger).toBeTruthy();
expect(punctualPassengerAd.getProps().comment).toBe(
"J'accepte les chiens mais pas les chats",
);
});
it('should create a new punctual driver ad entity', async () => {
const punctualDriverAd: AdEntity = AdEntity.create(
@ -191,3 +200,33 @@ describe('Ad entity create', () => {
});
});
});
describe('Ad entity validate status', () => {
it('should validate status of a pending ad entity', async () => {
const punctualPassengerAd: AdEntity = AdEntity.create(
punctualPassengerCreateAdProps,
);
punctualPassengerAd.valid();
expect(punctualPassengerAd.getProps().status).toBe(Status.VALID);
});
});
describe('Ad entity invalidate status', () => {
it('should invalidate status of a pending ad entity', async () => {
const punctualPassengerAd: AdEntity = AdEntity.create(
punctualPassengerCreateAdProps,
);
punctualPassengerAd.invalid();
expect(punctualPassengerAd.getProps().status).toBe(Status.INVALID);
});
});
describe('Ad entity suspend status', () => {
it('should suspend status of a pending ad entity', async () => {
const punctualPassengerAd: AdEntity = AdEntity.create(
punctualPassengerCreateAdProps,
);
punctualPassengerAd.suspend();
expect(punctualPassengerAd.getProps().status).toBe(Status.SUSPENDED);
});
});

View File

@ -0,0 +1,57 @@
import { CreateAdProps, Frequency } from '@modules/ad/core/domain/ad.types';
import { WaypointProps } from '@modules/ad/core/domain/value-objects/waypoint.value-object';
const originWaypointProps: WaypointProps = {
position: 0,
address: {
houseNumber: '5',
street: 'Avenue Foch',
locality: 'Nancy',
postalCode: '54000',
country: 'France',
coordinates: {
lat: 48.689445,
lon: 6.17651,
},
},
};
const destinationWaypointProps: WaypointProps = {
position: 1,
address: {
locality: 'Paris',
postalCode: '75000',
country: 'France',
coordinates: {
lat: 48.8566,
lon: 2.3522,
},
},
};
const baseCreateAdProps = {
userId: 'e8fe64b1-4c33-49e1-9f69-4db48b21df36',
seatsProposed: 3,
seatsRequested: 1,
strict: false,
waypoints: [originWaypointProps, destinationWaypointProps],
};
const punctualCreateAdProps = {
fromDate: '2023-06-22',
toDate: '2023-06-22',
schedule: [
{
day: 4,
time: '08:30',
margin: 900,
},
],
frequency: Frequency.PUNCTUAL,
};
export function punctualPassengerCreateAdProps(): CreateAdProps {
return {
...baseCreateAdProps,
...punctualCreateAdProps,
driver: false,
passenger: true,
};
}

View File

@ -1,18 +1,17 @@
import { Test, TestingModule } from '@nestjs/testing';
import { AggregateID, ConflictException } from '@mobicoop/ddd-library';
import {
AD_REPOSITORY,
INPUT_DATETIME_TRANSFORMER,
} from '@modules/ad/ad.di-tokens';
import { WaypointDto } from '@modules/ad/interface/grpc-controllers/dtos/waypoint.dto';
import { CreateAdRequestDto } from '@modules/ad/interface/grpc-controllers/dtos/create-ad.request.dto';
import { AggregateID } from '@mobicoop/ddd-library';
import { AdEntity } from '@modules/ad/core/domain/ad.entity';
import { ConflictException } from '@mobicoop/ddd-library';
import { Frequency } from '@modules/ad/core/domain/ad.types';
import { CreateAdService } from '@modules/ad/core/application/commands/create-ad/create-ad.service';
import { CreateAdCommand } from '@modules/ad/core/application/commands/create-ad/create-ad.command';
import { CreateAdService } from '@modules/ad/core/application/commands/create-ad/create-ad.service';
import { AdEntity } from '@modules/ad/core/domain/ad.entity';
import { AdAlreadyExistsException } from '@modules/ad/core/domain/ad.errors';
import { DateTimeTransformerPort } from '@modules/ad/core/application/ports/datetime-transformer.port';
import { Frequency } from '@modules/ad/core/domain/ad.types';
import { CreateAdRequestDto } from '@modules/ad/interface/grpc-controllers/dtos/create-ad.request.dto';
import { WaypointDto } from '@modules/ad/interface/grpc-controllers/dtos/waypoint.dto';
import { Test, TestingModule } from '@nestjs/testing';
import { mockInputDateTimeTransformer } from '../ad.mocks';
const originWaypoint: WaypointDto = {
position: 0,
@ -64,13 +63,6 @@ const mockAdRepository = {
}),
};
const mockInputDateTimeTransformer: DateTimeTransformerPort = {
fromDate: jest.fn(),
toDate: jest.fn(),
day: jest.fn(),
time: jest.fn(),
};
describe('create-ad.service', () => {
let createAdService: CreateAdService;
@ -83,7 +75,7 @@ describe('create-ad.service', () => {
},
{
provide: INPUT_DATETIME_TRANSFORMER,
useValue: mockInputDateTimeTransformer,
useValue: mockInputDateTimeTransformer(),
},
CreateAdService,
],

View File

@ -0,0 +1,42 @@
import { AD_REPOSITORY } from '@modules/ad/ad.di-tokens';
import { DeleteAdCommand } from '@modules/ad/core/application/commands/delete-ad/delete-ad.command';
import { DeleteAdService } from '@modules/ad/core/application/commands/delete-ad/delete-ad.service';
import { AdEntity } from '@modules/ad/core/domain/ad.entity';
import { Test, TestingModule } from '@nestjs/testing';
import { punctualPassengerCreateAdProps } from './ad.fixtures';
const ad: AdEntity = AdEntity.create(punctualPassengerCreateAdProps());
jest.spyOn(ad, 'delete');
const mockAdRepository = {
findOneById: jest.fn().mockImplementation(() => ad),
delete: jest.fn(),
};
describe('delete-ad.service', () => {
let deleteAdService: DeleteAdService;
beforeAll(async () => {
const module: TestingModule = await Test.createTestingModule({
providers: [
{
provide: AD_REPOSITORY,
useValue: mockAdRepository,
},
DeleteAdService,
],
}).compile();
deleteAdService = module.get<DeleteAdService>(DeleteAdService);
});
it('should be defined', () => {
expect(deleteAdService).toBeDefined();
});
it('should trigger the delete logic and delete the ad from the repository', async () => {
await deleteAdService.execute(new DeleteAdCommand({ id: ad.id }));
expect(ad.delete).toHaveBeenCalled();
expect(mockAdRepository.delete).toHaveBeenCalledWith(ad);
});
});

View File

@ -0,0 +1,53 @@
import { DeleteUserAdsCommand } from '@modules/ad/core/application/commands/delete-user-ads/delete-user-ads.command';
import { DeleteUserAdsService } from '@modules/ad/core/application/commands/delete-user-ads/delete-user-ads.service';
import { AdEntity } from '@modules/ad/core/domain/ad.entity';
import { CommandBus, QueryBus } from '@nestjs/cqrs';
import { Test, TestingModule } from '@nestjs/testing';
import { punctualPassengerCreateAdProps } from './ad.fixtures';
const userAds = [
AdEntity.create(punctualPassengerCreateAdProps()),
AdEntity.create(punctualPassengerCreateAdProps()),
];
const mockQueryBus = {
execute: jest.fn().mockImplementation(() => userAds),
};
const mockCommandBus = {
execute: jest.fn(),
};
describe('delete-user-ads.service', () => {
let deleteUserAdsService: DeleteUserAdsService;
beforeAll(async () => {
const module: TestingModule = await Test.createTestingModule({
providers: [
{
provide: QueryBus,
useValue: mockQueryBus,
},
{
provide: CommandBus,
useValue: mockCommandBus,
},
DeleteUserAdsService,
],
}).compile();
deleteUserAdsService =
module.get<DeleteUserAdsService>(DeleteUserAdsService);
});
it('should be defined', () => {
expect(deleteUserAdsService).toBeDefined();
});
it('should call the delete command for each ad returned by the query', async () => {
await deleteUserAdsService.execute(
new DeleteUserAdsCommand({ id: userAds[0].getProps().userId }),
);
expect(mockCommandBus.execute).toHaveBeenCalledTimes(userAds.length);
});
});

View File

@ -0,0 +1,47 @@
import { AD_REPOSITORY } from '@modules/ad/ad.di-tokens';
import { FindAdByIdQuery } from '@modules/ad/core/application/queries/find-ad-by-id/find-ad-by-id.query';
import { FindAdByIdQueryHandler } from '@modules/ad/core/application/queries/find-ad-by-id/find-ad-by-id.query-handler';
import { AdEntity } from '@modules/ad/core/domain/ad.entity';
import { Test, TestingModule } from '@nestjs/testing';
import { punctualPassengerCreateAdProps } from './ad.fixtures';
const ad: AdEntity = AdEntity.create(punctualPassengerCreateAdProps());
const mockAdRepository = {
findOneById: jest.fn().mockImplementation(() => ad),
};
describe('find-ad-by-id.query-handler', () => {
let findAdByIdQueryHandler: FindAdByIdQueryHandler;
beforeAll(async () => {
const module: TestingModule = await Test.createTestingModule({
providers: [
{
provide: AD_REPOSITORY,
useValue: mockAdRepository,
},
FindAdByIdQueryHandler,
],
}).compile();
findAdByIdQueryHandler = module.get<FindAdByIdQueryHandler>(
FindAdByIdQueryHandler,
);
});
it('should be defined', () => {
expect(findAdByIdQueryHandler).toBeDefined();
});
describe('execution', () => {
it('should return an ad', async () => {
const findAdbyIdQuery = new FindAdByIdQuery(
'dd264806-13b4-4226-9b18-87adf0ad5dd1',
);
const ad: AdEntity =
await findAdByIdQueryHandler.execute(findAdbyIdQuery);
expect(ad.getProps().fromDate).toBe('2023-06-22');
});
});
});

View File

@ -0,0 +1,107 @@
import { AD_REPOSITORY } from '@modules/ad/ad.di-tokens';
import { FindAdsByIdsQuery } from '@modules/ad/core/application/queries/find-ads-by-ids/find-ads-by-ids.query';
import { FindAdsByIdsQueryHandler } from '@modules/ad/core/application/queries/find-ads-by-ids/find-ads-by-ids.query-handler';
import { AdEntity } from '@modules/ad/core/domain/ad.entity';
import { CreateAdProps, Frequency } from '@modules/ad/core/domain/ad.types';
import { WaypointProps } from '@modules/ad/core/domain/value-objects/waypoint.value-object';
import { Test, TestingModule } from '@nestjs/testing';
const originWaypointProps: WaypointProps = {
position: 0,
address: {
houseNumber: '5',
street: 'Avenue Foch',
locality: 'Nancy',
postalCode: '54000',
country: 'France',
coordinates: {
lat: 48.689445,
lon: 6.17651,
},
},
};
const destinationWaypointProps: WaypointProps = {
position: 1,
address: {
locality: 'Paris',
postalCode: '75000',
country: 'France',
coordinates: {
lat: 48.8566,
lon: 2.3522,
},
},
};
const baseCreateAdProps = {
userId: 'e8fe64b1-4c33-49e1-9f69-4db48b21df36',
seatsProposed: 3,
seatsRequested: 1,
strict: false,
waypoints: [originWaypointProps, destinationWaypointProps],
};
const punctualCreateAdProps = {
fromDate: '2023-06-22',
toDate: '2023-06-22',
schedule: [
{
day: 4,
time: '08:30',
margin: 900,
},
],
frequency: Frequency.PUNCTUAL,
};
const punctualPassengerCreateAdProps: CreateAdProps = {
...baseCreateAdProps,
...punctualCreateAdProps,
driver: false,
passenger: true,
};
const ads: AdEntity[] = [
AdEntity.create(punctualPassengerCreateAdProps),
AdEntity.create(punctualPassengerCreateAdProps),
AdEntity.create(punctualPassengerCreateAdProps),
];
const mockAdRepository = {
findAllByIds: jest.fn().mockImplementation(() => ads),
};
describe('Find Ads By Ids Query Handler', () => {
let findAdsByIdsQueryHandler: FindAdsByIdsQueryHandler;
beforeAll(async () => {
const module: TestingModule = await Test.createTestingModule({
providers: [
{
provide: AD_REPOSITORY,
useValue: mockAdRepository,
},
FindAdsByIdsQueryHandler,
],
}).compile();
findAdsByIdsQueryHandler = module.get<FindAdsByIdsQueryHandler>(
FindAdsByIdsQueryHandler,
);
});
it('should be defined', () => {
expect(findAdsByIdsQueryHandler).toBeDefined();
});
describe('execution', () => {
it('should return an ad', async () => {
const findAdsByIdsQuery = new FindAdsByIdsQuery([
'dd264806-13b4-4226-9b18-87adf0ad5dd1',
'dd264806-13b4-4226-9b18-87adf0ad5dd2',
'dd264806-13b4-4226-9b18-87adf0ad5dd3',
]);
const ads: AdEntity[] =
await findAdsByIdsQueryHandler.execute(findAdsByIdsQuery);
expect(ads).toHaveLength(3);
expect(ads[1].getProps().fromDate).toBe('2023-06-22');
});
});
});

View File

@ -0,0 +1,105 @@
import { AD_REPOSITORY } from '@modules/ad/ad.di-tokens';
import { FindAdsByUserIdQuery } from '@modules/ad/core/application/queries/find-ads-by-user-id/find-ads-by-user-id.query';
import { FindAdsByUserIdQueryHandler } from '@modules/ad/core/application/queries/find-ads-by-user-id/find-ads-by-user-id.query-handler';
import { AdEntity } from '@modules/ad/core/domain/ad.entity';
import { CreateAdProps, Frequency } from '@modules/ad/core/domain/ad.types';
import { WaypointProps } from '@modules/ad/core/domain/value-objects/waypoint.value-object';
import { Test, TestingModule } from '@nestjs/testing';
const originWaypointProps: WaypointProps = {
position: 0,
address: {
houseNumber: '5',
street: 'Avenue Foch',
locality: 'Nancy',
postalCode: '54000',
country: 'France',
coordinates: {
lat: 48.689445,
lon: 6.17651,
},
},
};
const destinationWaypointProps: WaypointProps = {
position: 1,
address: {
locality: 'Paris',
postalCode: '75000',
country: 'France',
coordinates: {
lat: 48.8566,
lon: 2.3522,
},
},
};
const baseCreateAdProps = {
userId: 'e8fe64b1-4c33-49e1-9f69-4db48b21df36',
seatsProposed: 3,
seatsRequested: 1,
strict: false,
waypoints: [originWaypointProps, destinationWaypointProps],
};
const punctualCreateAdProps = {
fromDate: '2023-06-22',
toDate: '2023-06-22',
schedule: [
{
day: 4,
time: '08:30',
margin: 900,
},
],
frequency: Frequency.PUNCTUAL,
};
const punctualPassengerCreateAdProps: CreateAdProps = {
...baseCreateAdProps,
...punctualCreateAdProps,
driver: false,
passenger: true,
};
const ads: AdEntity[] = [
AdEntity.create(punctualPassengerCreateAdProps),
AdEntity.create(punctualPassengerCreateAdProps),
AdEntity.create(punctualPassengerCreateAdProps),
];
const mockAdRepository = {
findAll: jest.fn().mockImplementation(() => ads),
};
describe('Find Ads By User Id Query Handler', () => {
let findAdsByUserIdQueryHandler: FindAdsByUserIdQueryHandler;
beforeAll(async () => {
const module: TestingModule = await Test.createTestingModule({
providers: [
{
provide: AD_REPOSITORY,
useValue: mockAdRepository,
},
FindAdsByUserIdQueryHandler,
],
}).compile();
findAdsByUserIdQueryHandler = module.get<FindAdsByUserIdQueryHandler>(
FindAdsByUserIdQueryHandler,
);
});
it('should be defined', () => {
expect(findAdsByUserIdQueryHandler).toBeDefined();
});
describe('execution', () => {
it('should return an ad', async () => {
const findAdsByIdsQuery = new FindAdsByUserIdQuery(
'e8fe64b1-4c33-49e1-9f69-4db48b21df36',
);
const ads: AdEntity[] =
await findAdsByUserIdQueryHandler.execute(findAdsByIdsQuery);
expect(ads).toHaveLength(3);
expect(ads[1].getProps().fromDate).toBe('2023-06-22');
});
});
});

View File

@ -0,0 +1,100 @@
import { AggregateID } from '@mobicoop/ddd-library';
import { AD_REPOSITORY } from '@modules/ad/ad.di-tokens';
import { InvalidateAdCommand } from '@modules/ad/core/application/commands/invalidate-ad/invalidate-ad.command';
import { InvalidateAdService } from '@modules/ad/core/application/commands/invalidate-ad/invalidate-ad.service';
import { AdEntity } from '@modules/ad/core/domain/ad.entity';
import { CreateAdProps, Frequency } from '@modules/ad/core/domain/ad.types';
import { WaypointProps } from '@modules/ad/core/domain/value-objects/waypoint.value-object';
import { Test, TestingModule } from '@nestjs/testing';
const originWaypointProps: WaypointProps = {
position: 0,
address: {
houseNumber: '5',
street: 'Avenue Foch',
locality: 'Nancy',
postalCode: '54000',
country: 'France',
coordinates: {
lat: 48.689445,
lon: 6.17651,
},
},
};
const destinationWaypointProps: WaypointProps = {
position: 1,
address: {
locality: 'Paris',
postalCode: '75000',
country: 'France',
coordinates: {
lat: 48.8566,
lon: 2.3522,
},
},
};
const baseCreateAdProps = {
userId: 'e8fe64b1-4c33-49e1-9f69-4db48b21df36',
seatsProposed: 3,
seatsRequested: 1,
strict: false,
waypoints: [originWaypointProps, destinationWaypointProps],
};
const punctualCreateAdProps = {
fromDate: '2023-06-22',
toDate: '2023-06-22',
schedule: [
{
day: 4,
time: '08:30',
margin: 900,
},
],
frequency: Frequency.PUNCTUAL,
};
const punctualPassengerCreateAdProps: CreateAdProps = {
...baseCreateAdProps,
...punctualCreateAdProps,
driver: false,
passenger: true,
};
const ad: AdEntity = AdEntity.create(punctualPassengerCreateAdProps);
const mockAdRepository = {
findOneById: jest.fn().mockImplementation(() => ad),
update: jest.fn().mockImplementation(() => ad.id),
};
describe('Invalidate Ad Service', () => {
let invalidateAdService: InvalidateAdService;
beforeAll(async () => {
const module: TestingModule = await Test.createTestingModule({
providers: [
{
provide: AD_REPOSITORY,
useValue: mockAdRepository,
},
InvalidateAdService,
],
}).compile();
invalidateAdService = module.get<InvalidateAdService>(InvalidateAdService);
});
it('should be defined', () => {
expect(invalidateAdService).toBeDefined();
});
describe('execution', () => {
it('should invalidate an ad', async () => {
jest.spyOn(ad, 'invalid');
const invalidateAdCommand = new InvalidateAdCommand(ad.id);
const result: AggregateID =
await invalidateAdService.execute(invalidateAdCommand);
expect(result).toBe(ad.id);
expect(ad.invalid).toHaveBeenCalledTimes(1);
});
});
});

View File

@ -1,6 +1,6 @@
import { Frequency } from '@modules/ad/core/domain/ad.types';
import { PublishMessageWhenAdIsCreatedDomainEventHandler } from '@modules/ad/core/application/event-handlers/publish-message-when-ad-is-created.domain-event-handler';
import { AdCreatedDomainEvent } from '@modules/ad/core/domain/events/ad-created.domain-events';
import { AdCreatedDomainEvent } from '@modules/ad/core/domain/events/ad-created.domain-event';
import { Test, TestingModule } from '@nestjs/testing';
import { AD_MESSAGE_PUBLISHER } from '@modules/ad/ad.di-tokens';
import { AD_CREATED_ROUTING_KEY } from '@src/app.constants';

View File

@ -0,0 +1,61 @@
import {
AD_MESSAGE_PUBLISHER,
OUTPUT_DATETIME_TRANSFORMER,
TIMEZONE_FINDER,
TIME_CONVERTER,
} from '@modules/ad/ad.di-tokens';
import { AdMapper } from '@modules/ad/ad.mapper';
import { PublishMessageWhenAdIsUpdatedDomainEventHandler } from '@modules/ad/core/application/event-handlers/publish-message-when-ad-is-updated.domain-event-handler';
import { AdEntity } from '@modules/ad/core/domain/ad.entity';
import { AdUpdatedDomainEvent } from '@modules/ad/core/domain/events/ad.domain-event';
import { OutputDateTimeTransformer } from '@modules/ad/infrastructure/output-datetime-transformer';
import { TimeConverter } from '@modules/ad/infrastructure/time-converter';
import { TimezoneFinder } from '@modules/ad/infrastructure/timezone-finder';
import { Test, TestingModule } from '@nestjs/testing';
import { punctualPassengerCreateAdProps } from './ad.fixtures';
const mockMessagePublisher = {
publish: jest.fn(),
};
describe('Publish message when ad is updated domain event handler', () => {
let updatedDomainEventHandler: PublishMessageWhenAdIsUpdatedDomainEventHandler;
beforeAll(async () => {
const module: TestingModule = await Test.createTestingModule({
providers: [
{
provide: AD_MESSAGE_PUBLISHER,
useValue: mockMessagePublisher,
},
{
provide: OUTPUT_DATETIME_TRANSFORMER,
useClass: OutputDateTimeTransformer,
},
{
provide: TIMEZONE_FINDER,
useClass: TimezoneFinder,
},
{
provide: TIME_CONVERTER,
useClass: TimeConverter,
},
AdMapper,
PublishMessageWhenAdIsUpdatedDomainEventHandler,
],
}).compile();
updatedDomainEventHandler =
module.get<PublishMessageWhenAdIsUpdatedDomainEventHandler>(
PublishMessageWhenAdIsUpdatedDomainEventHandler,
);
});
it('should publish a message', () => {
expect(updatedDomainEventHandler).toBeDefined();
const ad = AdEntity.create(punctualPassengerCreateAdProps());
const adUpdatedDomainEvent = new AdUpdatedDomainEvent(ad);
updatedDomainEventHandler.handle(adUpdatedDomainEvent);
expect(mockMessagePublisher.publish).toHaveBeenCalledTimes(1);
});
});

View File

@ -0,0 +1,71 @@
import {
AD_REPOSITORY,
INPUT_DATETIME_TRANSFORMER,
} from '@modules/ad/ad.di-tokens';
import { UpdateAdCommand } from '@modules/ad/core/application/commands/update-ad/update-ad.command';
import { UpdateAdService } from '@modules/ad/core/application/commands/update-ad/update-ad.service';
import { AdEntity } from '@modules/ad/core/domain/ad.entity';
import { Status } from '@modules/ad/core/domain/ad.types';
import { EventEmitter2 } from '@nestjs/event-emitter';
import { Test, TestingModule } from '@nestjs/testing';
import { mockInputDateTimeTransformer } from '../ad.mocks';
import { punctualCreateAdRequest } from '../interface/ad.fixtures';
import { punctualPassengerCreateAdProps } from './ad.fixtures';
const mockAdRepository = {
findOneById: jest.fn().mockImplementation(
async (id) =>
new AdEntity({
id,
props: { ...punctualPassengerCreateAdProps(), status: Status.VALID },
}),
),
update: jest.fn(),
};
const mockEventEmitter = {
emitAsync: jest.fn(),
};
describe('create-ad.service', () => {
let updateAdService: UpdateAdService;
beforeAll(async () => {
const module: TestingModule = await Test.createTestingModule({
providers: [
{
provide: AD_REPOSITORY,
useValue: mockAdRepository,
},
{
provide: INPUT_DATETIME_TRANSFORMER,
useValue: mockInputDateTimeTransformer(),
},
{
provide: EventEmitter2,
useValue: mockEventEmitter,
},
UpdateAdService,
],
}).compile();
updateAdService = module.get<UpdateAdService>(UpdateAdService);
});
it('should be defined', () => {
expect(updateAdService).toBeDefined();
});
describe('execute', () => {
it('should update the ad in the repository and emit an event', async () => {
const command = new UpdateAdCommand({
adId: '200d61a8-d878-4378-a609-c19ea71633d2',
...punctualCreateAdRequest(),
});
await updateAdService.execute(command);
expect(mockAdRepository.update).toHaveBeenCalled();
expect(mockEventEmitter.emitAsync).toHaveBeenCalled();
});
});
});

View File

@ -1,8 +1,9 @@
import { AggregateID } from '@mobicoop/ddd-library';
import { AD_REPOSITORY } from '@modules/ad/ad.di-tokens';
import { ValidateAdCommand } from '@modules/ad/core/application/commands/validate-ad/validate-ad.command';
import { ValidateAdService } from '@modules/ad/core/application/commands/validate-ad/validate-ad.service';
import { AdEntity } from '@modules/ad/core/domain/ad.entity';
import { CreateAdProps, Frequency } from '@modules/ad/core/domain/ad.types';
import { FindAdByIdQuery } from '@modules/ad/core/application/queries/find-ad-by-id/find-ad-by-id.query';
import { FindAdByIdQueryHandler } from '@modules/ad/core/application/queries/find-ad-by-id/find-ad-by-id.query-handler';
import { WaypointProps } from '@modules/ad/core/domain/value-objects/waypoint.value-object';
import { Test, TestingModule } from '@nestjs/testing';
@ -44,7 +45,9 @@ const punctualCreateAdProps = {
toDate: '2023-06-22',
schedule: [
{
day: 4,
time: '08:30',
margin: 900,
},
],
frequency: Frequency.PUNCTUAL,
@ -60,10 +63,11 @@ const ad: AdEntity = AdEntity.create(punctualPassengerCreateAdProps);
const mockAdRepository = {
findOneById: jest.fn().mockImplementation(() => ad),
update: jest.fn().mockImplementation(() => ad.id),
};
describe('find-ad-by-id.query-handler', () => {
let findAdByIdQueryHandler: FindAdByIdQueryHandler;
describe('Validate Ad Service', () => {
let validateAdService: ValidateAdService;
beforeAll(async () => {
const module: TestingModule = await Test.createTestingModule({
@ -72,27 +76,25 @@ describe('find-ad-by-id.query-handler', () => {
provide: AD_REPOSITORY,
useValue: mockAdRepository,
},
FindAdByIdQueryHandler,
ValidateAdService,
],
}).compile();
findAdByIdQueryHandler = module.get<FindAdByIdQueryHandler>(
FindAdByIdQueryHandler,
);
validateAdService = module.get<ValidateAdService>(ValidateAdService);
});
it('should be defined', () => {
expect(findAdByIdQueryHandler).toBeDefined();
expect(validateAdService).toBeDefined();
});
describe('execution', () => {
it('should return an ad', async () => {
const findAdbyIdQuery = new FindAdByIdQuery(
'dd264806-13b4-4226-9b18-87adf0ad5dd1',
);
const ad: AdEntity =
await findAdByIdQueryHandler.execute(findAdbyIdQuery);
expect(ad.getProps().fromDate).toBe('2023-06-22');
it('should validate an ad', async () => {
jest.spyOn(ad, 'valid');
const validateAdCommand = new ValidateAdCommand(ad.id);
const result: AggregateID =
await validateAdService.execute(validateAdCommand);
expect(result).toBe(ad.id);
expect(ad.valid).toHaveBeenCalledTimes(1);
});
});
});

View File

@ -0,0 +1,43 @@
import { Frequency } from '@modules/ad/core/domain/ad.types';
import { CreateAdRequestDto } from '@modules/ad/interface/grpc-controllers/dtos/create-ad.request.dto';
import { WaypointDto } from '@modules/ad/interface/grpc-controllers/dtos/waypoint.dto';
const originWaypoint: WaypointDto = {
position: 0,
lat: 48.689445,
lon: 6.17651,
houseNumber: '5',
street: 'Avenue Foch',
locality: 'Nancy',
postalCode: '54000',
country: 'France',
};
const destinationWaypoint: WaypointDto = {
position: 1,
lat: 48.8566,
lon: 2.3522,
locality: 'Paris',
postalCode: '75000',
country: 'France',
};
export function punctualCreateAdRequest(): CreateAdRequestDto {
return {
userId: '4eb6a6af-ecfd-41c3-9118-473a507014d4',
fromDate: '2023-12-21',
toDate: '2023-12-21',
schedule: [
{
time: '08:15',
day: 4,
margin: 600,
},
],
driver: false,
passenger: true,
seatsRequested: 1,
seatsProposed: 3,
strict: false,
frequency: Frequency.PUNCTUAL,
waypoints: [originWaypoint, destinationWaypoint],
};
}

View File

@ -1,51 +1,10 @@
import { IdResponse } from '@mobicoop/ddd-library';
import { RpcExceptionCode } from '@mobicoop/ddd-library';
import { IdResponse, RpcExceptionCode } from '@mobicoop/ddd-library';
import { AdAlreadyExistsException } from '@modules/ad/core/domain/ad.errors';
import { Frequency } from '@modules/ad/core/domain/ad.types';
import { CreateAdGrpcController } from '@modules/ad/interface/grpc-controllers/create-ad.grpc.controller';
import { CreateAdRequestDto } from '@modules/ad/interface/grpc-controllers/dtos/create-ad.request.dto';
import { WaypointDto } from '@modules/ad/interface/grpc-controllers/dtos/waypoint.dto';
import { CommandBus } from '@nestjs/cqrs';
import { RpcException } from '@nestjs/microservices';
import { Test, TestingModule } from '@nestjs/testing';
const originWaypoint: WaypointDto = {
position: 0,
lat: 48.689445,
lon: 6.17651,
houseNumber: '5',
street: 'Avenue Foch',
locality: 'Nancy',
postalCode: '54000',
country: 'France',
};
const destinationWaypoint: WaypointDto = {
position: 1,
lat: 48.8566,
lon: 2.3522,
locality: 'Paris',
postalCode: '75000',
country: 'France',
};
const punctualCreateAdRequest: CreateAdRequestDto = {
userId: '4eb6a6af-ecfd-41c3-9118-473a507014d4',
fromDate: '2023-12-21',
toDate: '2023-12-21',
schedule: [
{
time: '08:15',
day: 4,
margin: 600,
},
],
driver: false,
passenger: true,
seatsRequested: 1,
seatsProposed: 3,
strict: false,
frequency: Frequency.PUNCTUAL,
waypoints: [originWaypoint, destinationWaypoint],
};
import { punctualCreateAdRequest } from './ad.fixtures';
const mockCommandBus = {
execute: jest
@ -89,7 +48,7 @@ describe('Create Ad Grpc Controller', () => {
it('should create a new ad', async () => {
jest.spyOn(mockCommandBus, 'execute');
const result: IdResponse = await createAdGrpcController.create(
punctualCreateAdRequest,
punctualCreateAdRequest(),
);
expect(result).toBeInstanceOf(IdResponse);
expect(result.id).toBe('200d61a8-d878-4378-a609-c19ea71633d2');
@ -100,7 +59,7 @@ describe('Create Ad Grpc Controller', () => {
jest.spyOn(mockCommandBus, 'execute');
expect.assertions(3);
try {
await createAdGrpcController.create(punctualCreateAdRequest);
await createAdGrpcController.create(punctualCreateAdRequest());
} catch (e: any) {
expect(e).toBeInstanceOf(RpcException);
expect(e.error.code).toBe(RpcExceptionCode.ALREADY_EXISTS);
@ -112,7 +71,7 @@ describe('Create Ad Grpc Controller', () => {
jest.spyOn(mockCommandBus, 'execute');
expect.assertions(3);
try {
await createAdGrpcController.create(punctualCreateAdRequest);
await createAdGrpcController.create(punctualCreateAdRequest());
} catch (e: any) {
expect(e).toBeInstanceOf(RpcException);
expect(e.error.code).toBe(RpcExceptionCode.UNKNOWN);

View File

@ -0,0 +1,42 @@
import { DeleteAdGrpcController } from '@modules/ad/interface/grpc-controllers/delete-ad.grpc.controller';
import { CommandBus } from '@nestjs/cqrs';
import { Test, TestingModule } from '@nestjs/testing';
const mockCommandBus = {
execute: jest.fn(),
};
describe('Delete Ad Grpc Controller', () => {
let deleteAdGrpcController: DeleteAdGrpcController;
beforeAll(async () => {
const module: TestingModule = await Test.createTestingModule({
providers: [
{
provide: CommandBus,
useValue: mockCommandBus,
},
DeleteAdGrpcController,
],
}).compile();
deleteAdGrpcController = module.get<DeleteAdGrpcController>(
DeleteAdGrpcController,
);
});
afterEach(async () => {
jest.clearAllMocks();
});
it('should be defined', () => {
expect(deleteAdGrpcController).toBeDefined();
});
it('should execute the delete ad command', async () => {
await deleteAdGrpcController.delete({
id: '200d61a8-d878-4378-a609-c19ea71633d2',
});
expect(mockCommandBus.execute).toHaveBeenCalledTimes(1);
});
});

View File

@ -0,0 +1,132 @@
import { RpcExceptionCode } from '@mobicoop/ddd-library';
import { AdMapper } from '@modules/ad/ad.mapper';
import { Frequency } from '@modules/ad/core/domain/ad.types';
import { FindAdsByIdsGrpcController } from '@modules/ad/interface/grpc-controllers/find-ads-by-ids.grpc.controller';
import { QueryBus } from '@nestjs/cqrs';
import { RpcException } from '@nestjs/microservices';
import { Test, TestingModule } from '@nestjs/testing';
const mockQueryBus = {
execute: jest
.fn()
.mockImplementationOnce(() => [
'200d61a8-d878-4378-a609-c19ea71633d2',
'200d61a8-d878-4378-a609-c19ea71633d3',
'200d61a8-d878-4378-a609-c19ea71633d4',
])
.mockImplementationOnce(() => {
throw new Error();
}),
};
const mockAdMapper = {
toResponse: jest.fn().mockImplementationOnce(() => ({
userId: '8cc90d1a-4a59-4289-a7d8-078f9db7857f',
driver: true,
passenger: true,
frequency: Frequency.PUNCTUAL,
fromDate: '2023-06-27',
toDate: '2023-06-27',
schedule: {
tue: '07:15',
},
marginDurations: {
mon: 900,
tue: 900,
wed: 900,
thu: 900,
fri: 900,
sat: 900,
sun: 900,
},
seatsProposed: 3,
seatsRequested: 1,
waypoints: [
{
position: 0,
lat: 48.689445,
lon: 6.17651,
houseNumber: '5',
street: 'Avenue Foch',
locality: 'Nancy',
postalCode: '54000',
country: 'France',
},
{
position: 1,
lat: 48.8566,
lon: 2.3522,
locality: 'Paris',
postalCode: '75000',
country: 'France',
},
],
})),
};
describe('Find Ads By Ids Grpc Controller', () => {
let findAdsByIdsGrpcController: FindAdsByIdsGrpcController;
beforeAll(async () => {
const module: TestingModule = await Test.createTestingModule({
providers: [
{
provide: QueryBus,
useValue: mockQueryBus,
},
{
provide: AdMapper,
useValue: mockAdMapper,
},
FindAdsByIdsGrpcController,
],
}).compile();
findAdsByIdsGrpcController = module.get<FindAdsByIdsGrpcController>(
FindAdsByIdsGrpcController,
);
});
afterEach(async () => {
jest.clearAllMocks();
});
it('should be defined', () => {
expect(findAdsByIdsGrpcController).toBeDefined();
});
it('should return ads', async () => {
jest.spyOn(mockQueryBus, 'execute');
jest.spyOn(mockAdMapper, 'toResponse');
const response = await findAdsByIdsGrpcController.findAllByIds({
ids: [
'200d61a8-d878-4378-a609-c19ea71633d2',
'200d61a8-d878-4378-a609-c19ea71633d3',
'200d61a8-d878-4378-a609-c19ea71633d4',
],
});
expect(response.ads).toHaveLength(3);
expect(mockQueryBus.execute).toHaveBeenCalledTimes(1);
expect(mockAdMapper.toResponse).toHaveBeenCalledTimes(3);
});
it('should throw a generic RpcException', async () => {
jest.spyOn(mockQueryBus, 'execute');
jest.spyOn(mockAdMapper, 'toResponse');
expect.assertions(4);
try {
await findAdsByIdsGrpcController.findAllByIds({
ids: [
'200d61a8-d878-4378-a609-c19ea71633d2',
'200d61a8-d878-4378-a609-c19ea71633d3',
'200d61a8-d878-4378-a609-c19ea71633d4',
],
});
} catch (e: any) {
expect(e).toBeInstanceOf(RpcException);
expect(e.error.code).toBe(RpcExceptionCode.UNKNOWN);
}
expect(mockQueryBus.execute).toHaveBeenCalledTimes(1);
expect(mockAdMapper.toResponse).toHaveBeenCalledTimes(0);
});
});

View File

@ -0,0 +1,124 @@
import { RpcExceptionCode } from '@mobicoop/ddd-library';
import { AdMapper } from '@modules/ad/ad.mapper';
import { Frequency } from '@modules/ad/core/domain/ad.types';
import { FindAdsByUserIdGrpcController } from '@modules/ad/interface/grpc-controllers/find-ads-by-user-id.grpc.controller';
import { QueryBus } from '@nestjs/cqrs';
import { RpcException } from '@nestjs/microservices';
import { Test, TestingModule } from '@nestjs/testing';
const mockQueryBus = {
execute: jest
.fn()
.mockImplementationOnce(() => [
'200d61a8-d878-4378-a609-c19ea71633d2',
'200d61a8-d878-4378-a609-c19ea71633d3',
'200d61a8-d878-4378-a609-c19ea71633d4',
])
.mockImplementationOnce(() => {
throw new Error();
}),
};
const mockAdMapper = {
toResponse: jest.fn().mockImplementationOnce(() => ({
userId: '8cc90d1a-4a59-4289-a7d8-078f9db7857f',
driver: true,
passenger: true,
frequency: Frequency.PUNCTUAL,
fromDate: '2023-06-27',
toDate: '2023-06-27',
schedule: {
tue: '07:15',
},
marginDurations: {
mon: 900,
tue: 900,
wed: 900,
thu: 900,
fri: 900,
sat: 900,
sun: 900,
},
seatsProposed: 3,
seatsRequested: 1,
waypoints: [
{
position: 0,
lat: 48.689445,
lon: 6.17651,
houseNumber: '5',
street: 'Avenue Foch',
locality: 'Nancy',
postalCode: '54000',
country: 'France',
},
{
position: 1,
lat: 48.8566,
lon: 2.3522,
locality: 'Paris',
postalCode: '75000',
country: 'France',
},
],
})),
};
describe('Find Ads By User Id Grpc Controller', () => {
let findAdsByUserIdGrpcController: FindAdsByUserIdGrpcController;
beforeAll(async () => {
const module: TestingModule = await Test.createTestingModule({
providers: [
{
provide: QueryBus,
useValue: mockQueryBus,
},
{
provide: AdMapper,
useValue: mockAdMapper,
},
FindAdsByUserIdGrpcController,
],
}).compile();
findAdsByUserIdGrpcController = module.get<FindAdsByUserIdGrpcController>(
FindAdsByUserIdGrpcController,
);
});
afterEach(async () => {
jest.clearAllMocks();
});
it('should be defined', () => {
expect(findAdsByUserIdGrpcController).toBeDefined();
});
it('should return ads', async () => {
jest.spyOn(mockQueryBus, 'execute');
jest.spyOn(mockAdMapper, 'toResponse');
const response = await findAdsByUserIdGrpcController.findAllByUserId({
id: '8cc90d1a-4a59-4289-a7d8-078f9db7857f',
});
expect(response.ads).toHaveLength(3);
expect(mockQueryBus.execute).toHaveBeenCalledTimes(1);
expect(mockAdMapper.toResponse).toHaveBeenCalledTimes(3);
});
it('should throw a generic RpcException', async () => {
jest.spyOn(mockQueryBus, 'execute');
jest.spyOn(mockAdMapper, 'toResponse');
expect.assertions(4);
try {
await findAdsByUserIdGrpcController.findAllByUserId({
id: '8cc90d1a-4a59-4289-a7d8-078f9db7857f',
});
} catch (e: any) {
expect(e).toBeInstanceOf(RpcException);
expect(e.error.code).toBe(RpcExceptionCode.UNKNOWN);
}
expect(mockQueryBus.execute).toHaveBeenCalledTimes(1);
expect(mockAdMapper.toResponse).toHaveBeenCalledTimes(0);
});
});

Some files were not shown because too many files have changed in this diff Show More