Merge branch 'useRedis' into 'main'

Switch to redis

See merge request v3/service/configuration!25
This commit is contained in:
Sylvain Briat 2023-10-25 08:22:37 +00:00
commit efb35d6d2b
65 changed files with 636 additions and 1449 deletions

View File

@ -3,10 +3,29 @@ SERVICE_URL=0.0.0.0
SERVICE_PORT=5003 SERVICE_PORT=5003
HEALTH_SERVICE_PORT=6003 HEALTH_SERVICE_PORT=6003
# PRISMA
DATABASE_URL="postgresql://mobicoop:mobicoop@v3-db:5432/mobicoop?schema=configuration"
# MESSAGE BROKER # MESSAGE BROKER
MESSAGE_BROKER_URI=amqp://v3-broker:5672 MESSAGE_BROKER_URI=amqp://v3-broker:5672
MESSAGE_BROKER_EXCHANGE=mobicoop MESSAGE_BROKER_EXCHANGE=mobicoop
MESSAGE_BROKER_EXCHANGE_DURABILITY=true MESSAGE_BROKER_EXCHANGE_DURABILITY=true
# REDIS
REDIS_HOST=v3-redis
REDIS_PASSWORD=redis
REDIS_PORT=6379
# DEFAULT CONFIGURATION
# CARPOOL
# default carpool departure time margin (in seconds)
DEPARTURE_TIME_MARGIN=900
# default role
ROLE=passenger
# seats proposed as driver / requested as passenger
SEATS_PROPOSED=3
SEATS_REQUESTED=1
# accept only same frequency requests
STRICT_FREQUENCY=false
# PAGINATION
# number of results per page
PER_PAGE=10

View File

@ -2,5 +2,7 @@
SERVICE_URL=0.0.0.0 SERVICE_URL=0.0.0.0
SERVICE_PORT=5003 SERVICE_PORT=5003
# PRISMA # REDIS
DATABASE_URL="postgresql://mobicoop:mobicoop@localhost:5432/mobicoop-test?schema=configuration" REDIS_HOST=v3-redis-test
REDIS_PASSWORD=redis
REDIS_PORT=6380

View File

@ -19,7 +19,7 @@ test:
- docker-compose -f docker-compose.ci.tools.yml -p configuration-tools --env-file ci/.env.ci up -d - docker-compose -f docker-compose.ci.tools.yml -p configuration-tools --env-file ci/.env.ci up -d
- sh ci/wait-up.sh - sh ci/wait-up.sh
- docker-compose -f docker-compose.ci.service.yml -p configuration-service --env-file ci/.env.ci up -d - docker-compose -f docker-compose.ci.service.yml -p configuration-service --env-file ci/.env.ci up -d
- docker exec -t v3-configuration-api sh -c "npm run test:integration:ci" # - docker exec -t v3-configuration-api sh -c "npm run test:integration:ci"
coverage: /All files[^|]*\|[^|]*\s+([\d\.]+)/ coverage: /All files[^|]*\|[^|]*\s+([\d\.]+)/
rules: rules:
- if: '$CI_MERGE_REQUEST_TARGET_BRANCH_NAME == $CI_DEFAULT_BRANCH || $CI_COMMIT_MESSAGE =~ /--check/ || $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH' - if: '$CI_MERGE_REQUEST_TARGET_BRANCH_NAME == $CI_DEFAULT_BRANCH || $CI_COMMIT_MESSAGE =~ /--check/ || $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH'

View File

@ -12,12 +12,8 @@ WORKDIR /usr/src/app
# Copying this first prevents re-running npm install on every code change. # Copying this first prevents re-running npm install on every code change.
COPY --chown=node:node package*.json ./ COPY --chown=node:node package*.json ./
# Copy prisma (needed for prisma error types)
COPY --chown=node:node ./prisma prisma
# Install app dependencies using the `npm ci` command instead of `npm install` # Install app dependencies using the `npm ci` command instead of `npm install`
RUN npm ci RUN npm ci
RUN npx prisma generate
# Bundle app source # Bundle app source
COPY --chown=node:node . . COPY --chown=node:node . .
@ -43,9 +39,6 @@ COPY --chown=node:node --from=development /usr/src/app/node_modules ./node_modul
COPY --chown=node:node . . COPY --chown=node:node . .
# Copy prisma (needed for migrations)
COPY --chown=node:node ./prisma prisma
# Run the build command which creates the production bundle # Run the build command which creates the production bundle
RUN npm run build RUN npm run build
@ -70,7 +63,6 @@ COPY --chown=node:node package*.json ./
# Copy the bundled code from the build stage to the production image # Copy the bundled code from the build stage to the production image
COPY --chown=node:node --from=build /usr/src/app/node_modules ./node_modules COPY --chown=node:node --from=build /usr/src/app/node_modules ./node_modules
COPY --chown=node:node --from=build /usr/src/app/prisma ./prisma
COPY --chown=node:node --from=build /usr/src/app/dist ./dist COPY --chown=node:node --from=build /usr/src/app/dist ./dist
# Start the server using the production build # Start the server using the production build

View File

@ -1,12 +1,9 @@
# Mobicoop V3 - Configuration Service # Mobicoop V3 - Configuration Service
Configuration items management. Used to configure all services using a broker to disseminate the configuration items. Configuration items management. Used to configure and store all services using redis as database.
This service handles the persistence of the configuration items of all services in a database, and sends values _via_ the broker.
Each item consists in : Each item consists in :
- a **uuid** : a unique identifier for the configuration item
- a **domain** : each service is associated with one or more domains, represented by an uppercase string (eg. _USER_) - a **domain** : each service is associated with one or more domains, represented by an uppercase string (eg. _USER_)
- a **key** : the key of the configuration item (a string) - a **key** : the key of the configuration item (a string)
- a **value** : the value of the configuration item (always a string, each service must cast the value if needed) - a **value** : the value of the configuration item (always a string, each service must cast the value if needed)
@ -17,9 +14,10 @@ Practically, it's the other way round as it's easier to use this configuration s
## Available domains ## Available domains
- **AD** : ad related configuration items - **CARPOOL** : carpool related configuration items (eg. default number of seats proposed as a driver)
- **MATCHER** : matching algotithm related configuration items - **PAGINATION** : pagination related configuration items (eg. default number of results per page)
- **USER** : user related configuration items
New domains will be added in the future depending on the needs !
## Requirements ## Requirements
@ -57,11 +55,7 @@ A RabbitMQ instance is also required to send / receive messages when data has be
## Database migration ## Database migration
Before using the app, you need to launch the database migration (it will be launched inside the container) : Redis database is automatically populated at the start of the service. If keys already exists in the database, items are preserved.
```bash
npm run migrate
```
## Usage ## Usage
@ -71,52 +65,24 @@ The app exposes the following [gRPC](https://grpc.io/) services :
```json ```json
{ {
"domain": "AD", "domain": "CARPOOL",
"key": "seatsProposed" "key": "seatsProposed"
} }
``` ```
- **Set** : create or update a configuration item - **Set** : update a configuration item
```json ```json
{ {
"domain": "USER", "domain": "CARPOOL",
"key": "key1", "key": "seatsProposed",
"value": "value1" "value": "3"
} }
``` ```
- **Delete** : delete a configuration item by its domain and key
```json
{
"domain": "AD",
"key": "seatsProposed"
}
```
- **Propagate** : propagate all configuration items using the message broker
```json
{}
```
## Messages
As mentionned earlier, RabbitMQ messages are sent after these events :
- **Set** (message : the created / updated configuration item informations)
- **Delete** (message : the uuid of the deleted configuration item)
- **Propagate** (message : all the configuration items)
Various messages are also sent for logging purpose.
## Tests / ESLint / Prettier ## Tests / ESLint / Prettier
Tests are run outside the container for ease of use (switching between different environments inside containers using prisma is complicated and error prone). Tests are run outside the container for ease of use.
The integration tests use a dedicated database (see _db-test_ section of _docker-compose.yml_).
```bash ```bash
# run all tests (unit + integration) # run all tests (unit + integration)

View File

@ -2,14 +2,6 @@
SERVICE_URL=0.0.0.0 SERVICE_URL=0.0.0.0
SERVICE_PORT=5003 SERVICE_PORT=5003
# PRISMA # REDIS
DATABASE_URL="postgresql://mobicoop:mobicoop@v3-db:5432/mobicoop?schema=public" REDIS_IMAGE=redis:7.0-alpine
REDIS_PASSWORD=redis
# RABBIT MQ
RMQ_URI=amqp://v3-broker:5672
# MESSAGE BROKER
BROKER_IMAGE=rabbitmq:3-alpine
# POSTGRES
POSTGRES_IMAGE=postgres:15.0

View File

@ -16,9 +16,6 @@ RUN npm ci
# Bundle app source # Bundle app source
COPY . . COPY . .
# Generate prisma client
RUN npx prisma generate
# Create a "dist" folder # Create a "dist" folder
RUN npm run build RUN npm run build

View File

@ -1,12 +1,12 @@
#!/bin/bash #!/bin/bash
testlog() { testlog() {
docker logs v3-db | grep -q "database system is ready to accept connections" docker logs v3-redis | grep -q "Ready to accept connections"
} }
testlog 2> /dev/null testlog 2> /dev/null
while [ $? -ne 0 ]; while [ $? -ne 0 ];
do do
sleep 5 sleep 5
echo "Waiting for Test DB to be up..." echo "Waiting for Test Redis to be up..."
testlog 2> /dev/null testlog 2> /dev/null
done done

View File

@ -1,23 +1,12 @@
version: '3.8' version: '3.8'
services: services:
db: redis:
container_name: v3-db container_name: v3-redis
image: ${POSTGRES_IMAGE} image: ${REDIS_IMAGE}
environment: command: redis-server --requirepass ${REDIS_PASSWORD}
POSTGRES_DB: mobicoop
POSTGRES_USER: mobicoop
POSTGRES_PASSWORD: mobicoop
ports: ports:
- 5432:5432 - 6379:6379
networks:
- v3-network
broker:
container_name: v3-broker
image: ${BROKER_IMAGE}
ports:
- 5672:5672
networks: networks:
- v3-network - v3-network

119
package-lock.json generated
View File

@ -1,12 +1,12 @@
{ {
"name": "@mobicoop/configuration", "name": "@mobicoop/configuration",
"version": "1.0.0", "version": "2.0.0",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "@mobicoop/configuration", "name": "@mobicoop/configuration",
"version": "1.0.0", "version": "2.0.0",
"license": "AGPL", "license": "AGPL",
"dependencies": { "dependencies": {
"@grpc/grpc-js": "^1.9.6", "@grpc/grpc-js": "^1.9.6",
@ -23,8 +23,10 @@
"@nestjs/platform-express": "^10.2.7", "@nestjs/platform-express": "^10.2.7",
"@nestjs/terminus": "^10.1.1", "@nestjs/terminus": "^10.1.1",
"@prisma/client": "^5.4.2", "@prisma/client": "^5.4.2",
"@songkeys/nestjs-redis": "^10.0.0",
"class-transformer": "^0.5.1", "class-transformer": "^0.5.1",
"class-validator": "^0.14.0", "class-validator": "^0.14.0",
"ioredis": "^5.3.2",
"reflect-metadata": "^0.1.13", "reflect-metadata": "^0.1.13",
"rimraf": "^5.0.5", "rimraf": "^5.0.5",
"rxjs": "^7.8.1", "rxjs": "^7.8.1",
@ -1096,6 +1098,11 @@
"integrity": "sha512-ZnQMnLV4e7hDlUvw8H+U8ASL02SS2Gn6+9Ac3wGGLIe7+je2AeAOxPY+izIPJDfFDb7eDjev0Us8MO1iFRN8hA==", "integrity": "sha512-ZnQMnLV4e7hDlUvw8H+U8ASL02SS2Gn6+9Ac3wGGLIe7+je2AeAOxPY+izIPJDfFDb7eDjev0Us8MO1iFRN8hA==",
"dev": true "dev": true
}, },
"node_modules/@ioredis/commands": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/@ioredis/commands/-/commands-1.2.0.tgz",
"integrity": "sha512-Sx1pU8EM64o2BrqNpEO1CNLtKQwyhuXuqyfH7oGKCk+1a33d2r5saW8zNwm3j6BTExtjrv2BxTgzzkMwts6vGg=="
},
"node_modules/@isaacs/cliui": { "node_modules/@isaacs/cliui": {
"version": "8.0.2", "version": "8.0.2",
"resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz", "resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz",
@ -2490,6 +2497,27 @@
"@sinonjs/commons": "^3.0.0" "@sinonjs/commons": "^3.0.0"
} }
}, },
"node_modules/@songkeys/nestjs-redis": {
"version": "10.0.0",
"resolved": "https://registry.npmjs.org/@songkeys/nestjs-redis/-/nestjs-redis-10.0.0.tgz",
"integrity": "sha512-s56+NECuJXzcaPLYzpvA2xjL0e/1Zy55UE0q6b1UqpbQSKI06TFPFCWCMUadJigiuB26O1hxi+lmDbzahKvcLg==",
"dependencies": {
"tslib": "2.6.0"
},
"engines": {
"node": ">=16.0.0"
},
"peerDependencies": {
"@nestjs/common": "^10.0.0",
"@nestjs/core": "^10.0.0",
"ioredis": "^5.0.0"
}
},
"node_modules/@songkeys/nestjs-redis/node_modules/tslib": {
"version": "2.6.0",
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.6.0.tgz",
"integrity": "sha512-7At1WUettjcSRHXCyYtTselblcHl9PJFFVKiCAy/bY97+BPZXSQ2wbq0P9s8tK2G7dFQfNnlJnPAiArVBVBsfA=="
},
"node_modules/@tsconfig/node10": { "node_modules/@tsconfig/node10": {
"version": "1.0.9", "version": "1.0.9",
"resolved": "https://registry.npmjs.org/@tsconfig/node10/-/node10-1.0.9.tgz", "resolved": "https://registry.npmjs.org/@tsconfig/node10/-/node10-1.0.9.tgz",
@ -4098,6 +4126,14 @@
"node": ">=0.8" "node": ">=0.8"
} }
}, },
"node_modules/cluster-key-slot": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/cluster-key-slot/-/cluster-key-slot-1.1.2.tgz",
"integrity": "sha512-RMr0FhtfXemyinomL4hrWcYJxmX6deFdCxpJzhDttxgO1+bcCnkk+9drydLVDmAMG7NE6aN/fl4F7ucU/90gAA==",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/co": { "node_modules/co": {
"version": "4.6.0", "version": "4.6.0",
"resolved": "https://registry.npmjs.org/co/-/co-4.6.0.tgz", "resolved": "https://registry.npmjs.org/co/-/co-4.6.0.tgz",
@ -4581,6 +4617,14 @@
"node": ">=0.4.0" "node": ">=0.4.0"
} }
}, },
"node_modules/denque": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/denque/-/denque-2.1.0.tgz",
"integrity": "sha512-HVQE3AAb/pxF8fQAoiqpvg9i3evqug3hoiwakOyZAwJm+6vZehbkYXZ0l4JxS+I3QxM97v5aaRNhj8v5oBhekw==",
"engines": {
"node": ">=0.10"
}
},
"node_modules/depd": { "node_modules/depd": {
"version": "2.0.0", "version": "2.0.0",
"resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz",
@ -5559,20 +5603,6 @@
"integrity": "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==", "integrity": "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==",
"dev": true "dev": true
}, },
"node_modules/fsevents": {
"version": "2.3.3",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
"integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
"dev": true,
"hasInstallScript": true,
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
}
},
"node_modules/function-bind": { "node_modules/function-bind": {
"version": "1.1.2", "version": "1.1.2",
"resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz",
@ -5952,6 +5982,29 @@
"node": ">= 0.10" "node": ">= 0.10"
} }
}, },
"node_modules/ioredis": {
"version": "5.3.2",
"resolved": "https://registry.npmjs.org/ioredis/-/ioredis-5.3.2.tgz",
"integrity": "sha512-1DKMMzlIHM02eBBVOFQ1+AolGjs6+xEcM4PDL7NqOS6szq7H9jSaEkIUH6/a5Hl241LzW6JLSiAbNvTQjUupUA==",
"dependencies": {
"@ioredis/commands": "^1.1.1",
"cluster-key-slot": "^1.1.0",
"debug": "^4.3.4",
"denque": "^2.1.0",
"lodash.defaults": "^4.2.0",
"lodash.isarguments": "^3.1.0",
"redis-errors": "^1.2.0",
"redis-parser": "^3.0.0",
"standard-as-callback": "^2.1.0"
},
"engines": {
"node": ">=12.22.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/ioredis"
}
},
"node_modules/ipaddr.js": { "node_modules/ipaddr.js": {
"version": "1.9.1", "version": "1.9.1",
"resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz",
@ -6979,6 +7032,16 @@
"resolved": "https://registry.npmjs.org/lodash.camelcase/-/lodash.camelcase-4.3.0.tgz", "resolved": "https://registry.npmjs.org/lodash.camelcase/-/lodash.camelcase-4.3.0.tgz",
"integrity": "sha512-TwuEnCnxbc3rAvhf/LbG7tJUDzhqXyFnv3dtzLOPgCG/hODL7WFnsbwktkD7yUV0RrreP/l1PALq/YSg6VvjlA==" "integrity": "sha512-TwuEnCnxbc3rAvhf/LbG7tJUDzhqXyFnv3dtzLOPgCG/hODL7WFnsbwktkD7yUV0RrreP/l1PALq/YSg6VvjlA=="
}, },
"node_modules/lodash.defaults": {
"version": "4.2.0",
"resolved": "https://registry.npmjs.org/lodash.defaults/-/lodash.defaults-4.2.0.tgz",
"integrity": "sha512-qjxPLHd3r5DnsdGacqOMU6pb/avJzdh9tFX2ymgoZE27BmjXrNy/y4LoaiTeAb+O3gL8AfpJGtqfX/ae2leYYQ=="
},
"node_modules/lodash.isarguments": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/lodash.isarguments/-/lodash.isarguments-3.1.0.tgz",
"integrity": "sha512-chi4NHZlZqZD18a0imDHnZPrDeBbTtVN7GXMwuGdRH9qotxAjYs3aVLKc7zNOG9eddR5Ksd8rvFEBc9SsggPpg=="
},
"node_modules/lodash.memoize": { "node_modules/lodash.memoize": {
"version": "4.1.2", "version": "4.1.2",
"resolved": "https://registry.npmjs.org/lodash.memoize/-/lodash.memoize-4.1.2.tgz", "resolved": "https://registry.npmjs.org/lodash.memoize/-/lodash.memoize-4.1.2.tgz",
@ -8022,6 +8085,25 @@
"node": ">= 0.10" "node": ">= 0.10"
} }
}, },
"node_modules/redis-errors": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/redis-errors/-/redis-errors-1.2.0.tgz",
"integrity": "sha512-1qny3OExCf0UvUV/5wpYKf2YwPcOqXzkwKKSmKHiE6ZMQs5heeE/c8eXK+PNllPvmjgAbfnsbpkGZWy8cBpn9w==",
"engines": {
"node": ">=4"
}
},
"node_modules/redis-parser": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/redis-parser/-/redis-parser-3.0.0.tgz",
"integrity": "sha512-DJnGAeenTdpMEH6uAJRK/uiyEIH9WVsUmoLwzudwGJUwZPp80PDBWPHXSAGNPwNvIXAbe7MSUB1zQFugFml66A==",
"dependencies": {
"redis-errors": "^1.0.0"
},
"engines": {
"node": ">=4"
}
},
"node_modules/reflect-metadata": { "node_modules/reflect-metadata": {
"version": "0.1.13", "version": "0.1.13",
"resolved": "https://registry.npmjs.org/reflect-metadata/-/reflect-metadata-0.1.13.tgz", "resolved": "https://registry.npmjs.org/reflect-metadata/-/reflect-metadata-0.1.13.tgz",
@ -8546,6 +8628,11 @@
"node": ">=8" "node": ">=8"
} }
}, },
"node_modules/standard-as-callback": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/standard-as-callback/-/standard-as-callback-2.1.0.tgz",
"integrity": "sha512-qoRRSyROncaz1z0mvYqIE4lCd9p2R90i6GxW3uZv5ucSu8tU7B5HXUP1gG8pVZsYNVaXjk8ClXHPttLyxAL48A=="
},
"node_modules/statuses": { "node_modules/statuses": {
"version": "2.0.1", "version": "2.0.1",
"resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.1.tgz", "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.1.tgz",

View File

@ -1,6 +1,6 @@
{ {
"name": "@mobicoop/configuration", "name": "@mobicoop/configuration",
"version": "1.0.0", "version": "2.0.0",
"description": "Mobicoop V3 Configuration Service", "description": "Mobicoop V3 Configuration Service",
"author": "sbriat", "author": "sbriat",
"private": true, "private": true,
@ -17,18 +17,11 @@
"lint:check": "eslint \"{src,apps,libs,test}/**/*.ts\" --fix-dry-run --ignore-path .gitignore", "lint:check": "eslint \"{src,apps,libs,test}/**/*.ts\" --fix-dry-run --ignore-path .gitignore",
"pretty:check": "./node_modules/.bin/prettier --check .", "pretty:check": "./node_modules/.bin/prettier --check .",
"pretty": "./node_modules/.bin/prettier --write .", "pretty": "./node_modules/.bin/prettier --write .",
"test": "npm run migrate:test && dotenv -e .env.test jest", "test": "dotenv -e .env.test jest",
"test:unit": "jest --testPathPattern 'tests/unit/' --verbose", "test:unit": "jest --testPathPattern 'tests/unit/' --verbose",
"test:unit:ci": "jest --testPathPattern 'tests/unit/' --coverage", "test:unit:ci": "jest --testPathPattern 'tests/unit/' --coverage",
"test:integration": "npm run migrate:test && dotenv -e .env.test -- jest --testPathPattern 'tests/integration/' --verbose",
"test:integration:ci": "npm run migrate:test:ci && dotenv -e ci/.env.ci -- jest --testPathPattern 'tests/integration/'",
"test:cov": "jest --testPathPattern 'tests/unit/' --coverage", "test:cov": "jest --testPathPattern 'tests/unit/' --coverage",
"test:e2e": "jest --config ./test/jest-e2e.json", "test:e2e": "jest --config ./test/jest-e2e.json"
"generate": "docker exec v3-configuration-api sh -c 'npx prisma generate'",
"migrate": "docker exec v3-configuration-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": { "dependencies": {
"@grpc/grpc-js": "^1.9.6", "@grpc/grpc-js": "^1.9.6",
@ -44,9 +37,10 @@
"@nestjs/microservices": "^10.2.7", "@nestjs/microservices": "^10.2.7",
"@nestjs/platform-express": "^10.2.7", "@nestjs/platform-express": "^10.2.7",
"@nestjs/terminus": "^10.1.1", "@nestjs/terminus": "^10.1.1",
"@prisma/client": "^5.4.2", "@songkeys/nestjs-redis": "^10.0.0",
"class-transformer": "^0.5.1", "class-transformer": "^0.5.1",
"class-validator": "^0.14.0", "class-validator": "^0.14.0",
"ioredis": "^5.3.2",
"reflect-metadata": "^0.1.13", "reflect-metadata": "^0.1.13",
"rimraf": "^5.0.5", "rimraf": "^5.0.5",
"rxjs": "^7.8.1", "rxjs": "^7.8.1",
@ -69,7 +63,6 @@
"eslint-plugin-prettier": "^5.0.1", "eslint-plugin-prettier": "^5.0.1",
"jest": "29.7.0", "jest": "29.7.0",
"prettier": "^3.0.3", "prettier": "^3.0.3",
"prisma": "^5.4.2",
"source-map-support": "^0.5.21", "source-map-support": "^0.5.21",
"supertest": "^6.3.3", "supertest": "^6.3.3",
"ts-jest": "29.1.1", "ts-jest": "29.1.1",
@ -91,6 +84,7 @@
".di-tokens.ts", ".di-tokens.ts",
".response.ts", ".response.ts",
".port.ts", ".port.ts",
".config.ts",
"prisma.service.ts", "prisma.service.ts",
"main.ts" "main.ts"
], ],
@ -109,6 +103,7 @@
".di-tokens.ts", ".di-tokens.ts",
".response.ts", ".response.ts",
".port.ts", ".port.ts",
".config.ts",
"prisma.service.ts", "prisma.service.ts",
"main.ts" "main.ts"
], ],

View File

@ -1,14 +0,0 @@
-- CreateTable
CREATE TABLE "configuration" (
"uuid" UUID NOT NULL,
"domain" TEXT NOT NULL,
"key" TEXT NOT NULL,
"value" TEXT NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "configuration_pkey" PRIMARY KEY ("uuid")
);
-- CreateIndex
CREATE UNIQUE INDEX "configuration_domain_key_key" ON "configuration"("domain", "key");

View File

@ -1,3 +0,0 @@
# Please do not edit this file manually
# It should be added in your version-control system (i.e. Git)
provider = "postgresql"

View File

@ -1,24 +0,0 @@
// This is your Prisma schema file,
// learn more about it in the docs: https://pris.ly/d/prisma-schema
generator client {
provider = "prisma-client-js"
binaryTargets = ["linux-musl", "debian-openssl-3.0.x"]
}
datasource db {
provider = "postgresql"
url = env("DATABASE_URL")
}
model Configuration {
uuid String @id @default(uuid()) @db.Uuid
domain String
key String
value String
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@unique([domain, key])
@@map("configuration")
}

View File

@ -5,12 +5,6 @@ export const SERVICE_NAME = 'configuration';
export const GRPC_PACKAGE_NAME = 'configuration'; export const GRPC_PACKAGE_NAME = 'configuration';
export const GRPC_SERVICE_NAME = 'ConfigurationService'; export const GRPC_SERVICE_NAME = 'ConfigurationService';
// messaging
export const CONFIGURATION_SET_ROUTING_KEY = 'configuration.set';
export const CONFIGURATION_DELETED_ROUTING_KEY = 'configuration.deleted';
export const CONFIGURATION_PROPAGATED_ROUTING_KEY = 'configuration.propagated';
// health // health
export const GRPC_HEALTH_PACKAGE_NAME = 'health'; export const GRPC_HEALTH_PACKAGE_NAME = 'health';
export const HEALTH_CONFIGURATION_REPOSITORY = 'ConfigurationRepository';
export const HEALTH_CRITICAL_LOGGING_KEY = 'logging.configuration.health.crit'; export const HEALTH_CRITICAL_LOGGING_KEY = 'logging.configuration.health.crit';

View File

@ -1,44 +1,69 @@
import { import { HealthModule, HealthModuleOptions } from '@mobicoop/health-module';
HealthModule,
HealthModuleOptions,
HealthRepositoryPort,
} from '@mobicoop/health-module';
import { MessagerModule } from '@modules/messager/messager.module'; import { MessagerModule } from '@modules/messager/messager.module';
import { Module } from '@nestjs/common'; import { Module } from '@nestjs/common';
import { ConfigModule } from '@nestjs/config'; import { ConfigModule, ConfigService } from '@nestjs/config';
import { import { HEALTH_CRITICAL_LOGGING_KEY, SERVICE_NAME } from './app.constants';
HEALTH_CONFIGURATION_REPOSITORY,
HEALTH_CRITICAL_LOGGING_KEY,
SERVICE_NAME,
} from './app.constants';
import { MessagePublisherPort } from '@mobicoop/ddd-library'; import { MessagePublisherPort } from '@mobicoop/ddd-library';
import { MESSAGE_PUBLISHER } from '@modules/messager/messager.di-tokens'; import { MESSAGE_PUBLISHER } from '@modules/messager/messager.di-tokens';
import { CONFIGURATION_REPOSITORY } from '@modules/configuration/configuration.di-tokens';
import { ConfigurationModule } from '@modules/configuration/configuration.module'; import { ConfigurationModule } from '@modules/configuration/configuration.module';
import { EventEmitterModule } from '@nestjs/event-emitter'; import { EventEmitterModule } from '@nestjs/event-emitter';
import brokerConfig from './config/broker.config';
import carpoolConfig from './config/carpool.config';
import paginationConfig from './config/pagination.config';
import serviceConfig from './config/service.config';
import { RedisModule, RedisModuleOptions } from '@songkeys/nestjs-redis';
import redisConfig from './config/redis.config';
import { Transport } from '@nestjs/microservices';
@Module({ @Module({
imports: [ imports: [
ConfigModule.forRoot({ isGlobal: true }), ConfigModule.forRoot({
isGlobal: true,
load: [
brokerConfig,
carpoolConfig,
paginationConfig,
redisConfig,
serviceConfig,
],
}),
EventEmitterModule.forRoot(), EventEmitterModule.forRoot(),
HealthModule.forRootAsync({ HealthModule.forRootAsync({
imports: [ConfigurationModule, MessagerModule], imports: [MessagerModule, ConfigModule],
inject: [CONFIGURATION_REPOSITORY, MESSAGE_PUBLISHER], inject: [MESSAGE_PUBLISHER, ConfigService],
useFactory: async ( useFactory: async (
configurationRepository: HealthRepositoryPort,
messagePublisher: MessagePublisherPort, messagePublisher: MessagePublisherPort,
configService: ConfigService,
): Promise<HealthModuleOptions> => ({ ): Promise<HealthModuleOptions> => ({
serviceName: SERVICE_NAME, serviceName: SERVICE_NAME,
criticalLoggingKey: HEALTH_CRITICAL_LOGGING_KEY, criticalLoggingKey: HEALTH_CRITICAL_LOGGING_KEY,
checkRepositories: [ messagePublisher,
checkMicroservices: [
{ {
name: HEALTH_CONFIGURATION_REPOSITORY, host: configService.get<string>('redis.host') as string,
repository: configurationRepository, port: configService.get<string>('redis.port') as string,
password: configService.get<string>('redis.password'),
name: 'Redis',
transport: Transport.REDIS,
}, },
], ],
messagePublisher,
}), }),
}), }),
RedisModule.forRootAsync({
imports: [ConfigModule],
inject: [ConfigService],
useFactory: async (
configService: ConfigService,
): Promise<RedisModuleOptions> => {
return {
config: {
host: configService.get<string>('redis.host') as string,
port: configService.get<number>('redis.port') as number,
password: configService.get<string>('redis.password'),
},
};
},
}),
ConfigurationModule, ConfigurationModule,
MessagerModule, MessagerModule,
], ],

View File

@ -0,0 +1,11 @@
import { registerAs } from '@nestjs/config';
export default registerAs('broker', () => ({
uri: process.env.MESSAGE_BROKER_URI ?? 'amqp://v3-broker:5672',
exchange: process.env.MESSAGE_BROKER_EXCHANGE ?? 'mobicoop',
durability: process.env.MESSAGE_BROKER_EXCHANGE_DURABILITY
? process.env.MESSAGE_BROKER_EXCHANGE_DURABILITY === 'false'
? false
: true
: true,
}));

View File

@ -0,0 +1,19 @@
import { registerAs } from '@nestjs/config';
export default registerAs('carpool', () => ({
departureTimeMargin: process.env.DEPARTURE_TIME_MARGIN
? parseInt(process.env.DEPARTURE_TIME_MARGIN, 10)
: 900,
role: process.env.ROLE ?? 'passenger',
seatsProposed: process.env.SEATS_PROPOSED
? parseInt(process.env.SEATS_PROPOSED, 10)
: 3,
seatsRequested: process.env.SEATS_REQUESTED
? parseInt(process.env.SEATS_REQUESTED, 10)
: 1,
strictFrequency: process.env.STRICT_FREQUENCY
? process.env.STRICT_FREQUENCY === 'false'
? false
: true
: false,
}));

View File

@ -0,0 +1,5 @@
import { registerAs } from '@nestjs/config';
export default registerAs('pagination', () => ({
perPage: process.env.PER_PAGE ? parseInt(process.env.PER_PAGE, 10) : 10,
}));

View File

@ -0,0 +1,7 @@
import { registerAs } from '@nestjs/config';
export default registerAs('redis', () => ({
host: process.env.REDIS_HOST ?? 'v3-redis',
port: process.env.PORT ? parseInt(process.env.PORT, 10) : 6379,
password: process.env.REDIS_PASSWORD ?? 'redis',
}));

View File

@ -0,0 +1,8 @@
import { registerAs } from '@nestjs/config';
export default registerAs('service', () => ({
url: process.env.SERVICE_URL ?? '0.0.0.0',
port: process.env.SERVICE_PORT
? parseInt(process.env.SERVICE_PORT, 10)
: 5003,
}));

View File

@ -1,4 +1 @@
export const CONFIGURATION_MESSAGE_PUBLISHER = Symbol(
'CONFIGURATION_MESSAGE_PUBLISHER',
);
export const CONFIGURATION_REPOSITORY = Symbol('CONFIGURATION_REPOSITORY'); export const CONFIGURATION_REPOSITORY = Symbol('CONFIGURATION_REPOSITORY');

View File

@ -1,18 +1,13 @@
import { Mapper } from '@mobicoop/ddd-library'; import { Mapper } from '@mobicoop/ddd-library';
import { Injectable } from '@nestjs/common'; import { Injectable } from '@nestjs/common';
import { ConfigurationEntity } from './core/domain/configuration.entity'; import { ConfigurationEntity } from './core/domain/configuration.entity';
import { ConfigurationResponseDto } from './interface/dtos/configuration.response.dto';
import { ConfigurationDomain } from './core/domain/configuration.types';
import { import {
ConfigurationReadModel, ConfigurationReadModel,
ConfigurationWriteModel, ConfigurationWriteModel,
} from './infrastructure/configuration.repository'; } from './infrastructure/configuration.repository';
import { ConfigurationResponseDto } from './interface/dtos/configuration.response.dto'; import { v4 } from 'uuid';
/**
* Mapper constructs objects that are used in different layers:
* Record is an object that is stored in a database,
* Entity is an object that is used in application domain layer,
* and a ResponseDTO is an object returned to a user (usually as json).
*/
@Injectable() @Injectable()
export class ConfigurationMapper export class ConfigurationMapper
@ -27,9 +22,7 @@ export class ConfigurationMapper
toPersistence = (entity: ConfigurationEntity): ConfigurationWriteModel => { toPersistence = (entity: ConfigurationEntity): ConfigurationWriteModel => {
const copy = entity.getProps(); const copy = entity.getProps();
const record: ConfigurationWriteModel = { const record: ConfigurationWriteModel = {
uuid: entity.id, key: `${copy.identifier.domain}:${copy.identifier.key}`,
domain: copy.domain,
key: copy.key,
value: copy.value, value: copy.value,
}; };
return record; return record;
@ -37,12 +30,14 @@ export class ConfigurationMapper
toDomain = (record: ConfigurationReadModel): ConfigurationEntity => { toDomain = (record: ConfigurationReadModel): ConfigurationEntity => {
const entity = new ConfigurationEntity({ const entity = new ConfigurationEntity({
id: record.uuid, id: v4(),
createdAt: new Date(record.createdAt), createdAt: new Date(),
updatedAt: new Date(record.updatedAt), updatedAt: new Date(),
props: { props: {
domain: record.domain, identifier: {
key: record.key, domain: record.key.split(':')[0] as ConfigurationDomain,
key: record.key.split(':')[1],
},
value: record.value, value: record.value,
}, },
}); });
@ -52,8 +47,8 @@ export class ConfigurationMapper
toResponse = (entity: ConfigurationEntity): ConfigurationResponseDto => { toResponse = (entity: ConfigurationEntity): ConfigurationResponseDto => {
const props = entity.getProps(); const props = entity.getProps();
const response = new ConfigurationResponseDto(entity); const response = new ConfigurationResponseDto(entity);
response.domain = props.domain; response.domain = props.identifier.domain;
response.key = props.key; response.key = props.identifier.key;
response.value = props.value; response.value = props.value;
return response; return response;
}; };

View File

@ -2,45 +2,26 @@ import { Module, Provider } from '@nestjs/common';
import { CqrsModule } from '@nestjs/cqrs'; import { CqrsModule } from '@nestjs/cqrs';
import { GetConfigurationGrpcController } from './interface/grpc-controllers/get-configuration.grpc.controller'; import { GetConfigurationGrpcController } from './interface/grpc-controllers/get-configuration.grpc.controller';
import { SetConfigurationGrpcController } from './interface/grpc-controllers/set-configuration.grpc.controller'; import { SetConfigurationGrpcController } from './interface/grpc-controllers/set-configuration.grpc.controller';
import { DeleteConfigurationGrpcController } from './interface/grpc-controllers/delete-configuration.grpc.controller';
import { PropagateConfigurationsGrpcController } from './interface/grpc-controllers/propagate-configurations.grpc.controller';
import { PublishMessageWhenConfigurationIsSetDomainEventHandler } from './core/application/event-handlers/publish-message-when-configuration-is-set.domain-event-handler';
import { PublishMessageWhenConfigurationIsDeletedDomainEventHandler } from './core/application/event-handlers/publish-message-when-configuration-is-deleted.domain-event-handler';
import { SetConfigurationService } from './core/application/commands/set-configuration/set-configuration.service'; import { SetConfigurationService } from './core/application/commands/set-configuration/set-configuration.service';
import { DeleteConfigurationService } from './core/application/commands/delete-configuration/delete-configuration.service';
import { GetConfigurationQueryHandler } from './core/application/queries/get-configuration/get-configuration.query-handler'; import { GetConfigurationQueryHandler } from './core/application/queries/get-configuration/get-configuration.query-handler';
import { ConfigurationMapper } from './configuration.mapper'; import { ConfigurationMapper } from './configuration.mapper';
import { import { CONFIGURATION_REPOSITORY } from './configuration.di-tokens';
CONFIGURATION_MESSAGE_PUBLISHER, import { PopulateService } from './core/application/services/populate.service';
CONFIGURATION_REPOSITORY,
} from './configuration.di-tokens';
import { ConfigurationRepository } from './infrastructure/configuration.repository'; import { ConfigurationRepository } from './infrastructure/configuration.repository';
import { MessageBrokerPublisher } from '@mobicoop/message-broker-module';
import { PrismaService } from './infrastructure/prisma.service';
import { PropagateConfigurationsService } from './core/application/commands/propagate-configurations/propagate-configurations.service';
const grpcControllers = [ const grpcControllers = [
GetConfigurationGrpcController, GetConfigurationGrpcController,
SetConfigurationGrpcController, SetConfigurationGrpcController,
DeleteConfigurationGrpcController,
PropagateConfigurationsGrpcController,
]; ];
const eventHandlers: Provider[] = [ const commandHandlers: Provider[] = [SetConfigurationService];
PublishMessageWhenConfigurationIsSetDomainEventHandler,
PublishMessageWhenConfigurationIsDeletedDomainEventHandler,
];
const commandHandlers: Provider[] = [
SetConfigurationService,
DeleteConfigurationService,
PropagateConfigurationsService,
];
const queryHandlers: Provider[] = [GetConfigurationQueryHandler]; const queryHandlers: Provider[] = [GetConfigurationQueryHandler];
const mappers: Provider[] = [ConfigurationMapper]; const mappers: Provider[] = [ConfigurationMapper];
const providers: Provider[] = [PopulateService];
const repositories: Provider[] = [ const repositories: Provider[] = [
{ {
provide: CONFIGURATION_REPOSITORY, provide: CONFIGURATION_REPOSITORY,
@ -48,26 +29,16 @@ const repositories: Provider[] = [
}, },
]; ];
const messagePublishers: Provider[] = [
{
provide: CONFIGURATION_MESSAGE_PUBLISHER,
useExisting: MessageBrokerPublisher,
},
];
const orms: Provider[] = [PrismaService];
@Module({ @Module({
imports: [CqrsModule], imports: [CqrsModule],
controllers: [...grpcControllers], controllers: [...grpcControllers],
providers: [ providers: [
...eventHandlers,
...commandHandlers, ...commandHandlers,
...queryHandlers, ...queryHandlers,
...mappers, ...mappers,
...providers,
...repositories, ...repositories,
...messagePublishers,
...orms,
], ],
exports: [PrismaService, ConfigurationMapper, CONFIGURATION_REPOSITORY], exports: [ConfigurationMapper, CONFIGURATION_REPOSITORY],
}) })
export class ConfigurationModule {} export class ConfigurationModule {}

View File

@ -1,12 +0,0 @@
import { Command, CommandProps } from '@mobicoop/ddd-library';
export class DeleteConfigurationCommand extends Command {
readonly domain: string;
readonly key: string;
constructor(props: CommandProps<DeleteConfigurationCommand>) {
super(props);
this.domain = props.domain;
this.key = props.key;
}
}

View File

@ -1,26 +0,0 @@
import { Inject } from '@nestjs/common';
import { CommandHandler, ICommandHandler } from '@nestjs/cqrs';
import { DeleteConfigurationCommand } from './delete-configuration.command';
import { CONFIGURATION_REPOSITORY } from '@modules/configuration/configuration.di-tokens';
import { ConfigurationRepositoryPort } from '../../ports/configuration.repository.port';
import { ConfigurationEntity } from '@modules/configuration/core/domain/configuration.entity';
@CommandHandler(DeleteConfigurationCommand)
export class DeleteConfigurationService implements ICommandHandler {
constructor(
@Inject(CONFIGURATION_REPOSITORY)
private readonly configurationRepository: ConfigurationRepositoryPort,
) {}
async execute(command: DeleteConfigurationCommand): Promise<boolean> {
const configuration: ConfigurationEntity =
await this.configurationRepository.findOne({
domain: command.domain,
key: command.key,
});
configuration.delete();
const isDeleted: boolean =
await this.configurationRepository.delete(configuration);
return isDeleted;
}
}

View File

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

View File

@ -1,36 +0,0 @@
import { CommandHandler, ICommandHandler } from '@nestjs/cqrs';
import { Inject } from '@nestjs/common';
import {
CONFIGURATION_MESSAGE_PUBLISHER,
CONFIGURATION_REPOSITORY,
} from '@modules/configuration/configuration.di-tokens';
import { ConfigurationRepositoryPort } from '../../ports/configuration.repository.port';
import { PropagateConfigurationsCommand } from './propagate-configurations.command';
import { ConfigurationEntity } from '@modules/configuration/core/domain/configuration.entity';
import { MessagePublisherPort } from '@mobicoop/ddd-library';
import { CONFIGURATION_PROPAGATED_ROUTING_KEY } from '@src/app.constants';
import { ConfigurationMapper } from '@modules/configuration/configuration.mapper';
@CommandHandler(PropagateConfigurationsCommand)
export class PropagateConfigurationsService implements ICommandHandler {
constructor(
@Inject(CONFIGURATION_REPOSITORY)
private readonly repository: ConfigurationRepositoryPort,
@Inject(CONFIGURATION_MESSAGE_PUBLISHER)
private readonly messagePublisher: MessagePublisherPort,
private readonly configurationMapper: ConfigurationMapper,
) {}
async execute(): Promise<void> {
const configurationItems: ConfigurationEntity[] =
await this.repository.findAll({});
this.messagePublisher.publish(
CONFIGURATION_PROPAGATED_ROUTING_KEY,
JSON.stringify(
configurationItems.map((configuration: ConfigurationEntity) =>
this.configurationMapper.toResponse(configuration),
),
),
);
}
}

View File

@ -1,42 +1,24 @@
import { CommandHandler, ICommandHandler } from '@nestjs/cqrs'; import { CommandHandler, ICommandHandler } from '@nestjs/cqrs';
import { Inject } from '@nestjs/common'; import { Inject } from '@nestjs/common';
import { AggregateID, NotFoundException } from '@mobicoop/ddd-library';
import { SetConfigurationCommand } from './set-configuration.command'; import { SetConfigurationCommand } from './set-configuration.command';
import { CONFIGURATION_REPOSITORY } from '@modules/configuration/configuration.di-tokens'; import { CONFIGURATION_REPOSITORY } from '@modules/configuration/configuration.di-tokens';
import { ConfigurationDomain } from '@modules/configuration/core/domain/configuration.types';
import { ConfigurationRepositoryPort } from '../../ports/configuration.repository.port'; import { ConfigurationRepositoryPort } from '../../ports/configuration.repository.port';
import { ConfigurationEntity } from '@modules/configuration/core/domain/configuration.entity';
@CommandHandler(SetConfigurationCommand) @CommandHandler(SetConfigurationCommand)
export class SetConfigurationService implements ICommandHandler { export class SetConfigurationService implements ICommandHandler {
constructor( constructor(
@Inject(CONFIGURATION_REPOSITORY) @Inject(CONFIGURATION_REPOSITORY)
private readonly repository: ConfigurationRepositoryPort, private readonly configurationRepository: ConfigurationRepositoryPort,
) {} ) {}
async execute(command: SetConfigurationCommand): Promise<AggregateID> { async execute(command: SetConfigurationCommand): Promise<void> {
try { await this.configurationRepository.set(
const existingConfiguration: ConfigurationEntity = {
await this.repository.findOne({ domain: command.domain as ConfigurationDomain,
domain: command.domain, key: command.key,
key: command.key, },
}); command.value,
existingConfiguration.update(command); );
await this.repository.update(
existingConfiguration.id,
existingConfiguration,
);
return existingConfiguration.id;
} catch (error: any) {
if (error instanceof NotFoundException) {
try {
const newConfiguration = ConfigurationEntity.create(command);
await this.repository.insert(newConfiguration);
return newConfiguration.id;
} catch (error: any) {
throw error;
}
}
throw error;
}
} }
} }

View File

@ -1,33 +0,0 @@
import { Inject, Injectable } from '@nestjs/common';
import { OnEvent } from '@nestjs/event-emitter';
import { MessagePublisherPort } from '@mobicoop/ddd-library';
import { CONFIGURATION_MESSAGE_PUBLISHER } from '@modules/configuration/configuration.di-tokens';
import { ConfigurationDeletedDomainEvent } from '../../domain/events/configuration-deleted.domain-event';
import { ConfigurationDeletedIntegrationEvent } from '../events/configuration-deleted.integration-event';
import { CONFIGURATION_DELETED_ROUTING_KEY } from '@src/app.constants';
@Injectable()
export class PublishMessageWhenConfigurationIsDeletedDomainEventHandler {
constructor(
@Inject(CONFIGURATION_MESSAGE_PUBLISHER)
private readonly messagePublisher: MessagePublisherPort,
) {}
@OnEvent(ConfigurationDeletedDomainEvent.name, {
async: true,
promisify: true,
})
async handle(event: ConfigurationDeletedDomainEvent): Promise<any> {
const configurationDeletedIntegrationEvent =
new ConfigurationDeletedIntegrationEvent({
id: event.aggregateId,
domain: event.domain,
key: event.key,
metadata: event.metadata,
});
this.messagePublisher.publish(
CONFIGURATION_DELETED_ROUTING_KEY,
JSON.stringify(configurationDeletedIntegrationEvent),
);
}
}

View File

@ -1,31 +0,0 @@
import { Inject, Injectable } from '@nestjs/common';
import { OnEvent } from '@nestjs/event-emitter';
import { MessagePublisherPort } from '@mobicoop/ddd-library';
import { ConfigurationSetIntegrationEvent } from '../events/configuration-set.integration-event';
import { CONFIGURATION_MESSAGE_PUBLISHER } from '@modules/configuration/configuration.di-tokens';
import { ConfigurationSetDomainEvent } from '../../domain/events/configuration-set.domain-event';
import { CONFIGURATION_SET_ROUTING_KEY } from '@src/app.constants';
@Injectable()
export class PublishMessageWhenConfigurationIsSetDomainEventHandler {
constructor(
@Inject(CONFIGURATION_MESSAGE_PUBLISHER)
private readonly messagePublisher: MessagePublisherPort,
) {}
@OnEvent(ConfigurationSetDomainEvent.name, { async: true, promisify: true })
async handle(event: ConfigurationSetDomainEvent): Promise<any> {
const configurationSetIntegrationEvent =
new ConfigurationSetIntegrationEvent({
id: event.aggregateId,
domain: event.domain,
key: event.key,
value: event.value,
metadata: event.metadata,
});
this.messagePublisher.publish(
CONFIGURATION_SET_ROUTING_KEY,
JSON.stringify(configurationSetIntegrationEvent),
);
}
}

View File

@ -1,14 +0,0 @@
import { IntegrationEvent, IntegrationEventProps } from '@mobicoop/ddd-library';
export class ConfigurationDeletedIntegrationEvent extends IntegrationEvent {
readonly domain: string;
readonly key: string;
constructor(
props: IntegrationEventProps<ConfigurationDeletedIntegrationEvent>,
) {
super(props);
this.domain = props.domain;
this.key = props.key;
}
}

View File

@ -1,14 +0,0 @@
import { IntegrationEvent, IntegrationEventProps } from '@mobicoop/ddd-library';
export class ConfigurationSetIntegrationEvent extends IntegrationEvent {
readonly domain: string;
readonly key: string;
readonly value: string;
constructor(props: IntegrationEventProps<ConfigurationSetIntegrationEvent>) {
super(props);
this.domain = props.domain;
this.key = props.key;
this.value = props.value;
}
}

View File

@ -1,4 +1,13 @@
import { RepositoryPort } from '@mobicoop/ddd-library';
import { ConfigurationEntity } from '../../domain/configuration.entity'; import { ConfigurationEntity } from '../../domain/configuration.entity';
import {
ConfigurationIdentifier,
ConfigurationValue,
} from '../../domain/configuration.types';
export type ConfigurationRepositoryPort = RepositoryPort<ConfigurationEntity>; export interface ConfigurationRepositoryPort {
get(identifier: ConfigurationIdentifier): Promise<ConfigurationEntity>;
set(
identifier: ConfigurationIdentifier,
value: ConfigurationValue,
): Promise<void>;
}

View File

@ -2,8 +2,9 @@ import { IQueryHandler, QueryHandler } from '@nestjs/cqrs';
import { GetConfigurationQuery } from './get-configuration.query'; import { GetConfigurationQuery } from './get-configuration.query';
import { Inject } from '@nestjs/common'; import { Inject } from '@nestjs/common';
import { CONFIGURATION_REPOSITORY } from '@modules/configuration/configuration.di-tokens'; import { CONFIGURATION_REPOSITORY } from '@modules/configuration/configuration.di-tokens';
import { ConfigurationRepositoryPort } from '../../ports/configuration.repository.port';
import { ConfigurationEntity } from '@modules/configuration/core/domain/configuration.entity'; import { ConfigurationEntity } from '@modules/configuration/core/domain/configuration.entity';
import { ConfigurationRepositoryPort } from '../../ports/configuration.repository.port';
import { ConfigurationDomain } from '@modules/configuration/core/domain/configuration.types';
@QueryHandler(GetConfigurationQuery) @QueryHandler(GetConfigurationQuery)
export class GetConfigurationQueryHandler implements IQueryHandler { export class GetConfigurationQueryHandler implements IQueryHandler {
@ -12,6 +13,9 @@ export class GetConfigurationQueryHandler implements IQueryHandler {
private readonly configurationRepository: ConfigurationRepositoryPort, private readonly configurationRepository: ConfigurationRepositoryPort,
) {} ) {}
async execute(query: GetConfigurationQuery): Promise<ConfigurationEntity> { async execute(query: GetConfigurationQuery): Promise<ConfigurationEntity> {
return await this.configurationRepository.findOne(query); return await this.configurationRepository.get({
domain: query.domain as ConfigurationDomain,
key: query.key,
});
} }
} }

View File

@ -0,0 +1,62 @@
import { CONFIGURATION_REPOSITORY } from '@modules/configuration/configuration.di-tokens';
import { Inject, Injectable, OnApplicationBootstrap } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { ConfigurationRepositoryPort } from '../ports/configuration.repository.port';
import {
CarpoolConfig,
ConfigurationDomain,
PaginationConfig,
} from '../../domain/configuration.types';
import { NotFoundException } from '@mobicoop/ddd-library';
@Injectable()
export class PopulateService implements OnApplicationBootstrap {
constructor(
@Inject(CONFIGURATION_REPOSITORY)
private readonly configurationRepository: ConfigurationRepositoryPort,
private readonly configService: ConfigService,
) {}
onApplicationBootstrap() {
this._populate();
}
private _populate = async (): Promise<void> => {
const carpoolConfig: CarpoolConfig = this.configService.get<CarpoolConfig>(
'carpool',
) as CarpoolConfig;
const paginationConfig: PaginationConfig =
this.configService.get<PaginationConfig>(
'pagination',
) as PaginationConfig;
await Promise.all([
this._populateConfig(ConfigurationDomain.CARPOOL, carpoolConfig),
this._populateConfig(ConfigurationDomain.PAGINATION, paginationConfig),
]);
};
private _populateConfig = async <T>(
domain: ConfigurationDomain,
config: T,
): Promise<void> => {
let key: keyof typeof config;
for (key in config) {
try {
await this.configurationRepository.get({
domain,
key,
});
} catch (error: any) {
if (error instanceof NotFoundException) {
this.configurationRepository.set(
{
domain,
key,
},
`${config[key]}`,
);
}
}
}
};
}

View File

@ -3,10 +3,7 @@ import { v4 } from 'uuid';
import { import {
ConfigurationProps, ConfigurationProps,
CreateConfigurationProps, CreateConfigurationProps,
UpdateConfigurationProps,
} from './configuration.types'; } from './configuration.types';
import { ConfigurationSetDomainEvent } from './events/configuration-set.domain-event';
import { ConfigurationDeletedDomainEvent } from './events/configuration-deleted.domain-event';
export class ConfigurationEntity extends AggregateRoot<ConfigurationProps> { export class ConfigurationEntity extends AggregateRoot<ConfigurationProps> {
protected readonly _id: AggregateID; protected readonly _id: AggregateID;
@ -15,39 +12,9 @@ export class ConfigurationEntity extends AggregateRoot<ConfigurationProps> {
const id = v4(); const id = v4();
const props: ConfigurationProps = { ...create }; const props: ConfigurationProps = { ...create };
const configuration = new ConfigurationEntity({ id, props }); const configuration = new ConfigurationEntity({ id, props });
configuration.addEvent(
new ConfigurationSetDomainEvent({
aggregateId: id,
domain: props.domain,
key: props.key,
value: props.value,
}),
);
return configuration; return configuration;
}; };
update(props: UpdateConfigurationProps): void {
this.props.value = props.value;
this.addEvent(
new ConfigurationSetDomainEvent({
aggregateId: this._id,
domain: this.props.domain,
key: this.props.key,
value: props.value,
}),
);
}
delete(): void {
this.addEvent(
new ConfigurationDeletedDomainEvent({
aggregateId: this.id,
domain: this.props.domain,
key: this.props.key,
}),
);
}
validate(): void { validate(): void {
// entity business rules validation to protect it's invariant before saving entity to a database // entity business rules validation to protect it's invariant before saving entity to a database
} }

View File

@ -1,23 +1,36 @@
// All properties that a Configuration has // All properties that a Configuration has
export interface ConfigurationProps { export interface ConfigurationProps {
domain: string; identifier: ConfigurationIdentifier;
key: string; value: ConfigurationValue;
value: string;
} }
// Properties that are needed for a Configuration creation // Properties that are needed for a Configuration creation
export interface CreateConfigurationProps { export interface CreateConfigurationProps {
domain: string; identifier: ConfigurationIdentifier;
key: string; value: ConfigurationValue;
value: string;
} }
export interface UpdateConfigurationProps { export enum ConfigurationDomain {
value: string; CARPOOL = 'CARPOOL',
PAGINATION = 'PAGINATION',
} }
export enum Domain { export type ConfigurationIdentifier = {
AD = 'AD', domain: ConfigurationDomain;
MATCHER = 'MATCHER', key: ConfigurationKey;
USER = 'USER', };
export type ConfigurationKey = string;
export type ConfigurationValue = string;
export interface CarpoolConfig {
departureTimeMargin: number;
role: string;
seatsProposed: number;
seatsRequested: number;
strictFrequency: boolean;
}
export interface PaginationConfig {
perPage: number;
} }

View File

@ -1,12 +0,0 @@
import { DomainEvent, DomainEventProps } from '@mobicoop/ddd-library';
export class ConfigurationDeletedDomainEvent extends DomainEvent {
readonly domain: string;
readonly key: string;
constructor(props: DomainEventProps<ConfigurationDeletedDomainEvent>) {
super(props);
this.domain = props.domain;
this.key = props.key;
}
}

View File

@ -1,14 +0,0 @@
import { DomainEvent, DomainEventProps } from '@mobicoop/ddd-library';
export class ConfigurationSetDomainEvent extends DomainEvent {
readonly domain: string;
readonly key: string;
readonly value: string;
constructor(props: DomainEventProps<ConfigurationSetDomainEvent>) {
super(props);
this.domain = props.domain;
this.key = props.key;
this.value = props.value;
}
}

View File

@ -1,60 +1,47 @@
import { Inject, Injectable, Logger } from '@nestjs/common'; import { Injectable } from '@nestjs/common';
import { EventEmitter2 } from '@nestjs/event-emitter';
import {
LoggerBase,
MessagePublisherPort,
PrismaRepositoryBase,
} from '@mobicoop/ddd-library';
import { SERVICE_NAME } from '@src/app.constants';
import { ConfigurationEntity } from '../core/domain/configuration.entity';
import { ConfigurationRepositoryPort } from '../core/application/ports/configuration.repository.port'; import { ConfigurationRepositoryPort } from '../core/application/ports/configuration.repository.port';
import { PrismaService } from './prisma.service'; import { InjectRedis } from '@songkeys/nestjs-redis';
import { CONFIGURATION_MESSAGE_PUBLISHER } from '../configuration.di-tokens'; import { Redis } from 'ioredis';
import {
ConfigurationIdentifier,
ConfigurationValue,
} from '../core/domain/configuration.types';
import { ConfigurationEntity } from '../core/domain/configuration.entity';
import { ConfigurationMapper } from '../configuration.mapper'; import { ConfigurationMapper } from '../configuration.mapper';
import { NotFoundException } from '@mobicoop/ddd-library';
export type ConfigurationBaseModel = { export type ConfigurationReadModel = {
uuid: string;
domain: string;
key: string; key: string;
value: string; value: string;
}; };
export type ConfigurationWriteModel = ConfigurationReadModel;
export type ConfigurationReadModel = ConfigurationBaseModel & {
createdAt: Date;
updatedAt: Date;
};
export type ConfigurationWriteModel = ConfigurationBaseModel;
/**
* Repository is used for retrieving/saving domain entities
* */
@Injectable() @Injectable()
export class ConfigurationRepository export class ConfigurationRepository implements ConfigurationRepositoryPort {
extends PrismaRepositoryBase<
ConfigurationEntity,
ConfigurationReadModel,
ConfigurationWriteModel
>
implements ConfigurationRepositoryPort
{
constructor( constructor(
prisma: PrismaService, @InjectRedis() private readonly redis: Redis,
mapper: ConfigurationMapper, private readonly mapper: ConfigurationMapper,
eventEmitter: EventEmitter2, ) {}
@Inject(CONFIGURATION_MESSAGE_PUBLISHER)
protected readonly messagePublisher: MessagePublisherPort, get = async (
) { identifier: ConfigurationIdentifier,
super( ): Promise<ConfigurationEntity> => {
prisma.configuration, const key: string = `${identifier.domain}:${identifier.key}`;
prisma, const value: ConfigurationValue | null = await this.redis.get(key);
mapper, if (!value)
eventEmitter, throw new NotFoundException(
new LoggerBase({ `Configuration item not found for key ${key}`,
logger: new Logger(ConfigurationRepository.name), );
domain: SERVICE_NAME, return this.mapper.toDomain({
messagePublisher, key,
}), value,
); });
} };
set = async (
identifier: ConfigurationIdentifier,
value: ConfigurationValue,
): Promise<void> => {
await this.redis.set(`${identifier.domain}:${identifier.key}`, value);
};
} }

View File

@ -1,9 +0,0 @@
import { Injectable, OnModuleInit } from '@nestjs/common';
import { PrismaClient } from '@prisma/client';
@Injectable()
export class PrismaService extends PrismaClient implements OnModuleInit {
async onModuleInit() {
await this.$connect();
}
}

View File

@ -4,23 +4,18 @@ package configuration;
service ConfigurationService { service ConfigurationService {
rpc Get(ConfigurationByDomainKey) returns (Configuration); rpc Get(ConfigurationByDomainKey) returns (Configuration);
rpc Set(Configuration) returns (ConfigurationId); rpc Set(Configuration) returns (Empty);
rpc Delete(ConfigurationByDomainKey) returns (Empty);
rpc Propagate(Empty) returns (Empty);
}
message ConfigurationId {
string id = 1;
} }
message ConfigurationByDomainKey { message ConfigurationByDomainKey {
string domain = 1; string domain = 1;
string key = 2; string key = 2;
} }
message Configuration { message Configuration {
string id = 1; string domain = 1;
string domain = 2; string key = 2;
string key = 3; string value = 3;
string value = 4;
} }
message Empty {} message Empty {}

View File

@ -1,45 +0,0 @@
import {
DatabaseErrorException,
NotFoundException,
RpcExceptionCode,
RpcValidationPipe,
} from '@mobicoop/ddd-library';
import { Controller, UsePipes } from '@nestjs/common';
import { CommandBus } from '@nestjs/cqrs';
import { GrpcMethod, RpcException } from '@nestjs/microservices';
import { DeleteConfigurationRequestDto } from './dtos/delete-configuration.request.dto';
import { GRPC_SERVICE_NAME } from '@src/app.constants';
import { DeleteConfigurationCommand } from '@modules/configuration/core/application/commands/delete-configuration/delete-configuration.command';
@UsePipes(
new RpcValidationPipe({
whitelist: true,
forbidUnknownValues: false,
}),
)
@Controller()
export class DeleteConfigurationGrpcController {
constructor(private readonly commandBus: CommandBus) {}
@GrpcMethod(GRPC_SERVICE_NAME, 'Delete')
async delete(data: DeleteConfigurationRequestDto): Promise<void> {
try {
await this.commandBus.execute(new DeleteConfigurationCommand(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

@ -1,12 +0,0 @@
import { Domain } from '@modules/configuration/core/domain/configuration.types';
import { IsEnum, IsNotEmpty, IsString } from 'class-validator';
export class DeleteConfigurationRequestDto {
@IsEnum(Domain)
@IsNotEmpty()
domain: Domain;
@IsString()
@IsNotEmpty()
key: string;
}

View File

@ -1,10 +1,10 @@
import { Domain } from '@modules/configuration/core/domain/configuration.types'; import { ConfigurationDomain } from '@modules/configuration/core/domain/configuration.types';
import { IsEnum, IsNotEmpty, IsString } from 'class-validator'; import { IsEnum, IsNotEmpty, IsString } from 'class-validator';
export class GetConfigurationRequestDto { export class GetConfigurationRequestDto {
@IsEnum(Domain) @IsEnum(ConfigurationDomain)
@IsNotEmpty() @IsNotEmpty()
domain: Domain; domain: ConfigurationDomain;
@IsString() @IsString()
@IsNotEmpty() @IsNotEmpty()

View File

@ -1,10 +1,10 @@
import { Domain } from '@modules/configuration/core/domain/configuration.types'; import { ConfigurationDomain } from '@modules/configuration/core/domain/configuration.types';
import { IsEnum, IsNotEmpty, IsString } from 'class-validator'; import { IsEnum, IsNotEmpty, IsString } from 'class-validator';
export class SetConfigurationRequestDto { export class SetConfigurationRequestDto {
@IsEnum(Domain) @IsEnum(ConfigurationDomain)
@IsNotEmpty() @IsNotEmpty()
domain: Domain; domain: ConfigurationDomain;
@IsString() @IsString()
@IsNotEmpty() @IsNotEmpty()

View File

@ -1,33 +0,0 @@
import { Controller, UsePipes } from '@nestjs/common';
import { CommandBus } from '@nestjs/cqrs';
import { GrpcMethod, RpcException } from '@nestjs/microservices';
import { RpcExceptionCode } from '@mobicoop/ddd-library';
import { RpcValidationPipe } from '@mobicoop/ddd-library';
import { GRPC_SERVICE_NAME } from '@src/app.constants';
import { PropagateConfigurationsCommand } from '@modules/configuration/core/application/commands/propagate-configurations/propagate-configurations.command';
import { v4 } from 'uuid';
@UsePipes(
new RpcValidationPipe({
whitelist: false,
forbidUnknownValues: false,
}),
)
@Controller()
export class PropagateConfigurationsGrpcController {
constructor(private readonly commandBus: CommandBus) {}
@GrpcMethod(GRPC_SERVICE_NAME, 'Propagate')
async propagate(): Promise<void> {
try {
await this.commandBus.execute(
new PropagateConfigurationsCommand({ id: v4() }),
);
} catch (error: any) {
throw new RpcException({
code: RpcExceptionCode.UNKNOWN,
message: error.message,
});
}
}
}

View File

@ -1,8 +1,6 @@
import { Controller, UsePipes } from '@nestjs/common'; import { Controller, UsePipes } from '@nestjs/common';
import { CommandBus } from '@nestjs/cqrs'; import { CommandBus } from '@nestjs/cqrs';
import { GrpcMethod, RpcException } from '@nestjs/microservices'; import { GrpcMethod, RpcException } from '@nestjs/microservices';
import { AggregateID } from '@mobicoop/ddd-library';
import { IdResponse } from '@mobicoop/ddd-library';
import { RpcExceptionCode } from '@mobicoop/ddd-library'; import { RpcExceptionCode } from '@mobicoop/ddd-library';
import { RpcValidationPipe } from '@mobicoop/ddd-library'; import { RpcValidationPipe } from '@mobicoop/ddd-library';
import { GRPC_SERVICE_NAME } from '@src/app.constants'; import { GRPC_SERVICE_NAME } from '@src/app.constants';
@ -22,12 +20,11 @@ export class SetConfigurationGrpcController {
@GrpcMethod(GRPC_SERVICE_NAME, 'Set') @GrpcMethod(GRPC_SERVICE_NAME, 'Set')
async set( async set(
setConfigurationRequestDto: SetConfigurationRequestDto, setConfigurationRequestDto: SetConfigurationRequestDto,
): Promise<IdResponse> { ): Promise<void> {
try { try {
const aggregateID: AggregateID = await this.commandBus.execute( await this.commandBus.execute(
new SetConfigurationCommand(setConfigurationRequestDto), new SetConfigurationCommand(setConfigurationRequestDto),
); );
return new IdResponse(aggregateID);
} catch (error: any) { } catch (error: any) {
throw new RpcException({ throw new RpcException({
code: RpcExceptionCode.UNKNOWN, code: RpcExceptionCode.UNKNOWN,

View File

@ -1,174 +0,0 @@
import {
CONFIGURATION_MESSAGE_PUBLISHER,
CONFIGURATION_REPOSITORY,
} from '@modules/configuration/configuration.di-tokens';
import { ConfigurationMapper } from '@modules/configuration/configuration.mapper';
import { ConfigurationEntity } from '@modules/configuration/core/domain/configuration.entity';
import {
CreateConfigurationProps,
Domain,
} from '@modules/configuration/core/domain/configuration.types';
import { ConfigurationRepository } from '@modules/configuration/infrastructure/configuration.repository';
import { PrismaService } from '@modules/configuration/infrastructure/prisma.service';
import { ConfigModule } from '@nestjs/config';
import { EventEmitterModule } from '@nestjs/event-emitter';
import { Test } from '@nestjs/testing';
describe('Configuration Repository', () => {
let prismaService: PrismaService;
let configurationRepository: ConfigurationRepository;
const executeInsertCommand = async (table: string, object: any) => {
const command = `INSERT INTO "${table}" ("${Object.keys(object).join(
'","',
)}") VALUES ('${Object.values(object).join("','")}')`;
await prismaService.$executeRawUnsafe(command);
};
const getSeed = (index: number, uuid: string): string => {
return `${uuid.slice(0, -2)}${index.toString(16).padStart(2, '0')}`;
};
const baseUuid = {
uuid: 'be459a29-7a41-4c0b-b371-abe90bfb6f00',
};
const createConfigurations = async (nbToCreate = 10) => {
for (let i = 0; i < nbToCreate; i++) {
const configurationToCreate = {
uuid: getSeed(i, baseUuid.uuid),
domain: Domain.AD,
key: `key${i}`,
value: `value${i}`,
createdAt: '2023-07-24 13:07:05.000',
updatedAt: '2023-07-24 13:07:05.000',
};
configurationToCreate.uuid = getSeed(i, baseUuid.uuid);
await executeInsertCommand('configuration', configurationToCreate);
}
};
const mockMessagePublisher = {
publish: jest.fn().mockImplementation(),
};
const mockLogger = {
log: jest.fn(),
warn: jest.fn(),
error: jest.fn(),
};
beforeAll(async () => {
const module = await Test.createTestingModule({
imports: [
EventEmitterModule.forRoot(),
ConfigModule.forRoot({ isGlobal: true }),
],
providers: [
PrismaService,
ConfigurationMapper,
{
provide: CONFIGURATION_REPOSITORY,
useClass: ConfigurationRepository,
},
{
provide: CONFIGURATION_MESSAGE_PUBLISHER,
useValue: mockMessagePublisher,
},
],
})
// disable logging
.setLogger(mockLogger)
.compile();
prismaService = module.get<PrismaService>(PrismaService);
configurationRepository = module.get<ConfigurationRepository>(
CONFIGURATION_REPOSITORY,
);
});
afterAll(async () => {
await prismaService.$disconnect();
});
beforeEach(async () => {
await prismaService.configuration.deleteMany();
});
describe('findOne', () => {
it('should return a configuration', async () => {
await createConfigurations(1);
const result = await configurationRepository.findOne({
domain: Domain.AD,
key: 'key0',
});
expect(result.getProps().value).toBe('value0');
});
});
describe('findAll', () => {
it('should return all configurations', async () => {
await createConfigurations(10);
const configurations: ConfigurationEntity[] =
await configurationRepository.findAll({});
expect(configurations).toHaveLength(10);
});
});
describe('create', () => {
it('should create a configuration', async () => {
const beforeCount = await prismaService.configuration.count();
const createConfigurationProps: CreateConfigurationProps = {
domain: Domain.AD,
key: 'seatsProposed',
value: '3',
};
const configurationToCreate: ConfigurationEntity =
ConfigurationEntity.create(createConfigurationProps);
await configurationRepository.insert(configurationToCreate);
const afterCount = await prismaService.configuration.count();
expect(afterCount - beforeCount).toBe(1);
});
});
describe('update', () => {
it('should update a configuration', async () => {
await createConfigurations(1);
const configurationToUpdate: ConfigurationEntity =
await configurationRepository.findOne({
domain: Domain.AD,
key: 'key0',
});
configurationToUpdate.update({ value: 'newValue' });
await configurationRepository.update(
configurationToUpdate.id,
configurationToUpdate,
);
const result: ConfigurationEntity = await configurationRepository.findOne(
{
domain: Domain.AD,
key: 'key0',
},
);
expect(result.getProps().value).toBe('newValue');
});
});
describe('delete', () => {
it('should delete a configuration', async () => {
await createConfigurations(10);
const beforeCount = await prismaService.configuration.count();
const configurationToDelete: ConfigurationEntity =
await configurationRepository.findOne({
domain: Domain.AD,
key: 'key4',
});
await configurationRepository.delete(configurationToDelete);
const afterCount = await prismaService.configuration.count();
expect(afterCount - beforeCount).toBe(-1);
});
});
});

View File

@ -1,6 +1,6 @@
import { ConfigurationMapper } from '@modules/configuration/configuration.mapper'; import { ConfigurationMapper } from '@modules/configuration/configuration.mapper';
import { ConfigurationEntity } from '@modules/configuration/core/domain/configuration.entity'; import { ConfigurationEntity } from '@modules/configuration/core/domain/configuration.entity';
import { Domain } from '@modules/configuration/core/domain/configuration.types'; import { ConfigurationDomain } from '@modules/configuration/core/domain/configuration.types';
import { import {
ConfigurationReadModel, ConfigurationReadModel,
ConfigurationWriteModel, ConfigurationWriteModel,
@ -12,20 +12,18 @@ const now = new Date('2023-06-21 06:00:00');
const configurationEntity: ConfigurationEntity = new ConfigurationEntity({ const configurationEntity: ConfigurationEntity = new ConfigurationEntity({
id: 'c160cf8c-f057-4962-841f-3ad68346df44', id: 'c160cf8c-f057-4962-841f-3ad68346df44',
props: { props: {
domain: Domain.AD, identifier: {
key: 'seatsProposed', domain: ConfigurationDomain.CARPOOL,
key: 'seatsProposed',
},
value: '3', value: '3',
}, },
createdAt: now, createdAt: now,
updatedAt: now, updatedAt: now,
}); });
const configurationReadModel: ConfigurationReadModel = { const configurationReadModel: ConfigurationReadModel = {
uuid: 'c160cf8c-f057-4962-841f-3ad68346df44', key: 'AD:seatsProposed',
domain: 'AD',
key: 'seatsProposed',
value: '4', value: '4',
createdAt: now,
updatedAt: now,
}; };
describe('Configuration Mapper', () => { describe('Configuration Mapper', () => {

View File

@ -1,22 +1,17 @@
import { ConfigurationEntity } from '@modules/configuration/core/domain/configuration.entity'; import { ConfigurationEntity } from '@modules/configuration/core/domain/configuration.entity';
import { import {
CreateConfigurationProps, CreateConfigurationProps,
Domain, ConfigurationDomain,
UpdateConfigurationProps,
} from '@modules/configuration/core/domain/configuration.types'; } from '@modules/configuration/core/domain/configuration.types';
import { ConfigurationDeletedDomainEvent } from '@modules/configuration/core/domain/events/configuration-deleted.domain-event';
import { ConfigurationSetDomainEvent } from '@modules/configuration/core/domain/events/configuration-set.domain-event';
const createConfigurationProps: CreateConfigurationProps = { const createConfigurationProps: CreateConfigurationProps = {
domain: Domain.AD, identifier: {
key: 'seatsProposed', domain: ConfigurationDomain.CARPOOL,
key: 'seatsProposed',
},
value: '3', value: '3',
}; };
const updateConfigurationProps: UpdateConfigurationProps = {
value: '2',
};
describe('Configuration entity create', () => { describe('Configuration entity create', () => {
it('should create a new configuration entity', async () => { it('should create a new configuration entity', async () => {
const configurationEntity: ConfigurationEntity = ConfigurationEntity.create( const configurationEntity: ConfigurationEntity = ConfigurationEntity.create(
@ -24,38 +19,5 @@ describe('Configuration entity create', () => {
); );
expect(configurationEntity.id.length).toBe(36); expect(configurationEntity.id.length).toBe(36);
expect(configurationEntity.getProps().value).toBe('3'); expect(configurationEntity.getProps().value).toBe('3');
expect(configurationEntity.domainEvents.length).toBe(1);
expect(configurationEntity.domainEvents[0]).toBeInstanceOf(
ConfigurationSetDomainEvent,
);
});
});
describe('Configuration entity update', () => {
it('should update a configuration entity', async () => {
const configurationEntity: ConfigurationEntity = ConfigurationEntity.create(
createConfigurationProps,
);
configurationEntity.update(updateConfigurationProps);
expect(configurationEntity.getProps().value).toBe('2');
// 2 events because ConfigurationEntity.create sends a ConfigurationSetDomainEvent
expect(configurationEntity.domainEvents.length).toBe(2);
expect(configurationEntity.domainEvents[1]).toBeInstanceOf(
ConfigurationSetDomainEvent,
);
});
});
describe('Configuration entity delete', () => {
it('should delete a configuration entity', async () => {
const configurationEntity: ConfigurationEntity = ConfigurationEntity.create(
createConfigurationProps,
);
configurationEntity.delete();
// 2 events because ConfigurationEntity.create sends a ConfigurationSetDomainEvent
expect(configurationEntity.domainEvents.length).toBe(2);
expect(configurationEntity.domainEvents[1]).toBeInstanceOf(
ConfigurationDeletedDomainEvent,
);
}); });
}); });

View File

@ -1,56 +0,0 @@
import { CONFIGURATION_REPOSITORY } from '@modules/configuration/configuration.di-tokens';
import { DeleteConfigurationCommand } from '@modules/configuration/core/application/commands/delete-configuration/delete-configuration.command';
import { DeleteConfigurationService } from '@modules/configuration/core/application/commands/delete-configuration/delete-configuration.service';
import { Domain } from '@modules/configuration/core/domain/configuration.types';
import { DeleteConfigurationRequestDto } from '@modules/configuration/interface/grpc-controllers/dtos/delete-configuration.request.dto';
import { Test, TestingModule } from '@nestjs/testing';
const deleteConfigurationRequest: DeleteConfigurationRequestDto = {
domain: Domain.AD,
key: 'seatsProposed',
};
const mockConfigurationEntity = {
delete: jest.fn(),
};
const mockConfigurationRepository = {
findOne: jest.fn().mockImplementation(() => mockConfigurationEntity),
delete: jest.fn().mockImplementationOnce(() => true),
};
describe('Delete Configuration Service', () => {
let deleteConfigurationService: DeleteConfigurationService;
beforeAll(async () => {
const module: TestingModule = await Test.createTestingModule({
providers: [
{
provide: CONFIGURATION_REPOSITORY,
useValue: mockConfigurationRepository,
},
DeleteConfigurationService,
],
}).compile();
deleteConfigurationService = module.get<DeleteConfigurationService>(
DeleteConfigurationService,
);
});
it('should be defined', () => {
expect(deleteConfigurationService).toBeDefined();
});
describe('execution', () => {
const deleteConfigurationCommand = new DeleteConfigurationCommand(
deleteConfigurationRequest,
);
it('should delete a configuration item', async () => {
const result: boolean = await deleteConfigurationService.execute(
deleteConfigurationCommand,
);
expect(result).toBeTruthy();
});
});
});

View File

@ -1,6 +1,6 @@
import { Test, TestingModule } from '@nestjs/testing'; import { Test, TestingModule } from '@nestjs/testing';
import { ConfigurationEntity } from '@modules/configuration/core/domain/configuration.entity'; import { ConfigurationEntity } from '@modules/configuration/core/domain/configuration.entity';
import { Domain } from '@modules/configuration/core/domain/configuration.types'; import { ConfigurationDomain } from '@modules/configuration/core/domain/configuration.types';
import { GetConfigurationQueryHandler } from '@modules/configuration/core/application/queries/get-configuration/get-configuration.query-handler'; import { GetConfigurationQueryHandler } from '@modules/configuration/core/application/queries/get-configuration/get-configuration.query-handler';
import { CONFIGURATION_REPOSITORY } from '@modules/configuration/configuration.di-tokens'; import { CONFIGURATION_REPOSITORY } from '@modules/configuration/configuration.di-tokens';
import { GetConfigurationQuery } from '@modules/configuration/core/application/queries/get-configuration/get-configuration.query'; import { GetConfigurationQuery } from '@modules/configuration/core/application/queries/get-configuration/get-configuration.query';
@ -9,8 +9,10 @@ const now = new Date('2023-06-21 06:00:00');
const configuration: ConfigurationEntity = new ConfigurationEntity({ const configuration: ConfigurationEntity = new ConfigurationEntity({
id: 'c160cf8c-f057-4962-841f-3ad68346df44', id: 'c160cf8c-f057-4962-841f-3ad68346df44',
props: { props: {
domain: Domain.AD, identifier: {
key: 'seatsProposed', domain: ConfigurationDomain.CARPOOL,
key: 'seatsProposed',
},
value: '3', value: '3',
}, },
createdAt: now, createdAt: now,
@ -18,7 +20,7 @@ const configuration: ConfigurationEntity = new ConfigurationEntity({
}); });
const mockConfigurationRepository = { const mockConfigurationRepository = {
findOne: jest.fn().mockImplementation(() => configuration), get: jest.fn().mockImplementation(() => configuration),
}; };
describe('Get Configuration Query Handler', () => { describe('Get Configuration Query Handler', () => {
@ -47,7 +49,7 @@ describe('Get Configuration Query Handler', () => {
describe('execution', () => { describe('execution', () => {
it('should return a configuration item', async () => { it('should return a configuration item', async () => {
const getConfigurationQuery = new GetConfigurationQuery( const getConfigurationQuery = new GetConfigurationQuery(
Domain.AD, ConfigurationDomain.CARPOOL,
'seatsProposed', 'seatsProposed',
); );
const configuration: ConfigurationEntity = const configuration: ConfigurationEntity =

View File

@ -0,0 +1,84 @@
import { NotFoundException } from '@mobicoop/ddd-library';
import { CONFIGURATION_REPOSITORY } from '@modules/configuration/configuration.di-tokens';
import { PopulateService } from '@modules/configuration/core/application/services/populate.service';
import { ConfigurationEntity } from '@modules/configuration/core/domain/configuration.entity';
import { ConfigurationDomain } from '@modules/configuration/core/domain/configuration.types';
import { ConfigService } from '@nestjs/config';
import { Test, TestingModule } from '@nestjs/testing';
const mockConfigurationRepository = {
get: jest
.fn()
.mockImplementationOnce(
() =>
new ConfigurationEntity({
id: '001199d4-7187-4e83-a044-12159cba2e33',
props: {
identifier: {
domain: ConfigurationDomain.CARPOOL,
key: 'someKey',
},
value: 'someValue',
},
createdAt: new Date('2023-10-23'),
updatedAt: new Date('2023-10-23'),
}),
)
.mockImplementationOnce(() => {
throw new NotFoundException('Configuration not found');
}),
set: jest.fn(),
};
const mockConfigService = {
get: jest.fn().mockImplementation((domain: string) => {
switch (domain) {
case 'carpool':
return {
departureTimeMargin: 900,
role: 'passenger',
seatsProposed: 3,
seatsRequested: 1,
strictFrequency: false,
};
case 'pagination':
return {
perPage: 10,
};
}
}),
};
describe('Populate Service', () => {
let populateService: PopulateService;
beforeAll(async () => {
const module: TestingModule = await Test.createTestingModule({
providers: [
{
provide: CONFIGURATION_REPOSITORY,
useValue: mockConfigurationRepository,
},
{
provide: ConfigService,
useValue: mockConfigService,
},
PopulateService,
],
}).compile();
populateService = module.get<PopulateService>(PopulateService);
});
it('should be defined', () => {
expect(populateService).toBeDefined();
});
it('should populate database with default values', () => {
jest.spyOn(mockConfigurationRepository, 'get');
jest.spyOn(mockConfigurationRepository, 'set');
populateService.onApplicationBootstrap();
expect(mockConfigurationRepository.get).toHaveBeenCalled();
expect(mockConfigurationRepository.set).toHaveBeenCalled();
});
});

View File

@ -1,100 +0,0 @@
import { Test, TestingModule } from '@nestjs/testing';
import { Domain } from '@modules/configuration/core/domain/configuration.types';
import {
CONFIGURATION_MESSAGE_PUBLISHER,
CONFIGURATION_REPOSITORY,
} from '@modules/configuration/configuration.di-tokens';
import { ConfigurationEntity } from '@modules/configuration/core/domain/configuration.entity';
import { PropagateConfigurationsService } from '@modules/configuration/core/application/commands/propagate-configurations/propagate-configurations.service';
import { CONFIGURATION_PROPAGATED_ROUTING_KEY } from '@src/app.constants';
import { ConfigurationMapper } from '@modules/configuration/configuration.mapper';
const mockMessagePublisher = {
publish: jest.fn().mockImplementation(),
};
const configurationEntities = [
new ConfigurationEntity({
id: '047a6ecf-23d4-4d3e-877c-3225d560a8da',
props: {
domain: Domain.AD,
key: 'seatsProposed',
value: '3',
},
createdAt: new Date('2023-10-23T07:00:00Z'),
updatedAt: new Date('2023-10-23T07:00:00Z'),
}),
new ConfigurationEntity({
id: '047a6ecf-23d4-4d3e-877c-3225d560a8db',
props: {
domain: Domain.AD,
key: 'seatsRequested',
value: '1',
},
createdAt: new Date('2023-10-23T07:00:00Z'),
updatedAt: new Date('2023-10-23T07:00:00Z'),
}),
];
const mockConfigurationMapper = {
toResponse: jest
.fn()
.mockImplementationOnce(() => ({
domain: Domain.AD,
key: 'seatsProposed',
value: '3',
}))
.mockImplementationOnce(() => ({
domain: Domain.AD,
key: 'seatsRequested',
value: '1',
})),
};
const mockConfigurationRepository = {
findAll: jest.fn().mockImplementationOnce(() => configurationEntities),
};
describe('Propagate Configurations Service', () => {
let propagateConfigurationsService: PropagateConfigurationsService;
beforeAll(async () => {
const module: TestingModule = await Test.createTestingModule({
providers: [
{
provide: CONFIGURATION_REPOSITORY,
useValue: mockConfigurationRepository,
},
{
provide: CONFIGURATION_MESSAGE_PUBLISHER,
useValue: mockMessagePublisher,
},
{
provide: ConfigurationMapper,
useValue: mockConfigurationMapper,
},
PropagateConfigurationsService,
],
}).compile();
propagateConfigurationsService = module.get<PropagateConfigurationsService>(
PropagateConfigurationsService,
);
});
it('should be defined', () => {
expect(propagateConfigurationsService).toBeDefined();
});
describe('execution', () => {
it('should propagate configuration items', async () => {
jest.spyOn(mockMessagePublisher, 'publish');
await propagateConfigurationsService.execute();
expect(mockMessagePublisher.publish).toHaveBeenCalledTimes(1);
expect(mockMessagePublisher.publish).toHaveBeenCalledWith(
CONFIGURATION_PROPAGATED_ROUTING_KEY,
'[{"domain":"AD","key":"seatsProposed","value":"3"},{"domain":"AD","key":"seatsRequested","value":"1"}]',
);
});
});
});

View File

@ -1,56 +0,0 @@
import { CONFIGURATION_MESSAGE_PUBLISHER } from '@modules/configuration/configuration.di-tokens';
import { PublishMessageWhenConfigurationIsDeletedDomainEventHandler } from '@modules/configuration/core/application/event-handlers/publish-message-when-configuration-is-deleted.domain-event-handler';
import { Domain } from '@modules/configuration/core/domain/configuration.types';
import { ConfigurationDeletedDomainEvent } from '@modules/configuration/core/domain/events/configuration-deleted.domain-event';
import { Test, TestingModule } from '@nestjs/testing';
import { CONFIGURATION_DELETED_ROUTING_KEY } from '@src/app.constants';
const mockMessagePublisher = {
publish: jest.fn().mockImplementation(),
};
describe('Publish message when configuration is deleted domain event handler', () => {
let publishMessageWhenConfigurationIsDeletedDomainEventHandler: PublishMessageWhenConfigurationIsDeletedDomainEventHandler;
beforeAll(async () => {
const module: TestingModule = await Test.createTestingModule({
providers: [
{
provide: CONFIGURATION_MESSAGE_PUBLISHER,
useValue: mockMessagePublisher,
},
PublishMessageWhenConfigurationIsDeletedDomainEventHandler,
],
}).compile();
publishMessageWhenConfigurationIsDeletedDomainEventHandler =
module.get<PublishMessageWhenConfigurationIsDeletedDomainEventHandler>(
PublishMessageWhenConfigurationIsDeletedDomainEventHandler,
);
});
it('should publish a message', () => {
jest.spyOn(mockMessagePublisher, 'publish');
const configurationDeletedDomainEvent: ConfigurationDeletedDomainEvent = {
id: 'some-domain-event-id',
domain: Domain.AD,
key: 'seatsProposed',
aggregateId: 'some-aggregate-id',
metadata: {
timestamp: new Date('2023-06-28T05:00:00Z').getTime(),
correlationId: 'some-correlation-id',
},
};
publishMessageWhenConfigurationIsDeletedDomainEventHandler.handle(
configurationDeletedDomainEvent,
);
expect(
publishMessageWhenConfigurationIsDeletedDomainEventHandler,
).toBeDefined();
expect(mockMessagePublisher.publish).toHaveBeenCalledTimes(1);
expect(mockMessagePublisher.publish).toHaveBeenCalledWith(
CONFIGURATION_DELETED_ROUTING_KEY,
'{"id":"some-aggregate-id","metadata":{"correlationId":"some-correlation-id","timestamp":1687928400000},"domain":"AD","key":"seatsProposed"}',
);
});
});

View File

@ -1,57 +0,0 @@
import { CONFIGURATION_MESSAGE_PUBLISHER } from '@modules/configuration/configuration.di-tokens';
import { PublishMessageWhenConfigurationIsSetDomainEventHandler } from '@modules/configuration/core/application/event-handlers/publish-message-when-configuration-is-set.domain-event-handler';
import { Domain } from '@modules/configuration/core/domain/configuration.types';
import { ConfigurationSetDomainEvent } from '@modules/configuration/core/domain/events/configuration-set.domain-event';
import { Test, TestingModule } from '@nestjs/testing';
import { CONFIGURATION_SET_ROUTING_KEY } from '@src/app.constants';
const mockMessagePublisher = {
publish: jest.fn().mockImplementation(),
};
describe('Publish message when configuration is set domain event handler', () => {
let publishMessageWhenConfigurationIsSetDomainEventHandler: PublishMessageWhenConfigurationIsSetDomainEventHandler;
beforeAll(async () => {
const module: TestingModule = await Test.createTestingModule({
providers: [
{
provide: CONFIGURATION_MESSAGE_PUBLISHER,
useValue: mockMessagePublisher,
},
PublishMessageWhenConfigurationIsSetDomainEventHandler,
],
}).compile();
publishMessageWhenConfigurationIsSetDomainEventHandler =
module.get<PublishMessageWhenConfigurationIsSetDomainEventHandler>(
PublishMessageWhenConfigurationIsSetDomainEventHandler,
);
});
it('should publish a message', () => {
jest.spyOn(mockMessagePublisher, 'publish');
const configurationSetDomainEvent: ConfigurationSetDomainEvent = {
id: 'some-domain-event-id',
aggregateId: 'some-aggregate-id',
domain: Domain.AD,
key: 'seatsProposed',
value: '3',
metadata: {
timestamp: new Date('2023-06-28T05:00:00Z').getTime(),
correlationId: 'some-correlation-id',
},
};
publishMessageWhenConfigurationIsSetDomainEventHandler.handle(
configurationSetDomainEvent,
);
expect(
publishMessageWhenConfigurationIsSetDomainEventHandler,
).toBeDefined();
expect(mockMessagePublisher.publish).toHaveBeenCalledTimes(1);
expect(mockMessagePublisher.publish).toHaveBeenCalledWith(
CONFIGURATION_SET_ROUTING_KEY,
'{"id":"some-aggregate-id","metadata":{"correlationId":"some-correlation-id","timestamp":1687928400000},"domain":"AD","key":"seatsProposed","value":"3"}',
);
});
});

View File

@ -1,47 +1,19 @@
import { Test, TestingModule } from '@nestjs/testing'; import { Test, TestingModule } from '@nestjs/testing';
import { AggregateID, NotFoundException } from '@mobicoop/ddd-library';
import { SetConfigurationRequestDto } from '@modules/configuration/interface/grpc-controllers/dtos/set-configuration.request.dto'; import { SetConfigurationRequestDto } from '@modules/configuration/interface/grpc-controllers/dtos/set-configuration.request.dto';
import { Domain } from '@modules/configuration/core/domain/configuration.types'; import { ConfigurationDomain } from '@modules/configuration/core/domain/configuration.types';
import { SetConfigurationService } from '@modules/configuration/core/application/commands/set-configuration/set-configuration.service'; import { SetConfigurationService } from '@modules/configuration/core/application/commands/set-configuration/set-configuration.service';
import { CONFIGURATION_REPOSITORY } from '@modules/configuration/configuration.di-tokens'; import { CONFIGURATION_REPOSITORY } from '@modules/configuration/configuration.di-tokens';
import { SetConfigurationCommand } from '@modules/configuration/core/application/commands/set-configuration/set-configuration.command'; import { SetConfigurationCommand } from '@modules/configuration/core/application/commands/set-configuration/set-configuration.command';
import { ConfigurationEntity } from '@modules/configuration/core/domain/configuration.entity'; import { ConfigurationEntity } from '@modules/configuration/core/domain/configuration.entity';
const setConfigurationRequest: SetConfigurationRequestDto = { const setConfigurationRequest: SetConfigurationRequestDto = {
domain: Domain.AD, domain: ConfigurationDomain.CARPOOL,
key: 'seatsProposed', key: 'seatsProposed',
value: '3', value: '3',
}; };
const existingConfigurationEntity = new ConfigurationEntity({
id: '047a6ecf-23d4-4d3e-877c-3225d560a8da',
props: {
domain: Domain.AD,
key: 'seatsProposed',
value: '2',
},
createdAt: new Date('2023-10-23T07:00:00Z'),
updatedAt: new Date('2023-10-23T07:00:00Z'),
});
const mockConfigurationRepository = { const mockConfigurationRepository = {
findOne: jest set: jest
.fn()
.mockImplementationOnce(() => {
throw new NotFoundException();
})
.mockImplementationOnce(() => {
throw new NotFoundException();
})
.mockImplementationOnce(() => existingConfigurationEntity)
.mockImplementationOnce(() => existingConfigurationEntity),
insert: jest
.fn()
.mockImplementationOnce(() => ({}))
.mockImplementationOnce(() => {
throw new Error();
}),
update: jest
.fn() .fn()
.mockImplementationOnce(() => ({})) .mockImplementationOnce(() => ({}))
.mockImplementationOnce(() => { .mockImplementationOnce(() => {
@ -76,31 +48,13 @@ describe('Set Configuration Service', () => {
const setConfigurationCommand = new SetConfigurationCommand( const setConfigurationCommand = new SetConfigurationCommand(
setConfigurationRequest, setConfigurationRequest,
); );
it('should create a new configuration item', async () => { it('should set an existing configuration item', async () => {
jest.spyOn(mockConfigurationRepository, 'set');
ConfigurationEntity.create = jest.fn().mockReturnValue({ ConfigurationEntity.create = jest.fn().mockReturnValue({
id: '047a6ecf-23d4-4d3e-877c-3225d560a8da', id: '047a6ecf-23d4-4d3e-877c-3225d560a8da',
}); });
const result: AggregateID = await setConfigurationService.execute( await setConfigurationService.execute(setConfigurationCommand);
setConfigurationCommand, expect(mockConfigurationRepository.set).toHaveBeenCalledTimes(1);
);
expect(result).toBe('047a6ecf-23d4-4d3e-877c-3225d560a8da');
});
it('should throw an error if something bad happens on configuration item creation', async () => {
ConfigurationEntity.create = jest.fn().mockReturnValue({
id: '047a6ecf-23d4-4d3e-877c-3225d560a8da',
});
await expect(
setConfigurationService.execute(setConfigurationCommand),
).rejects.toBeInstanceOf(Error);
});
it('should update an existing configuration item', async () => {
ConfigurationEntity.create = jest.fn().mockReturnValue({
id: '047a6ecf-23d4-4d3e-877c-3225d560a8da',
});
const result: AggregateID = await setConfigurationService.execute(
setConfigurationCommand,
);
expect(result).toBe('047a6ecf-23d4-4d3e-877c-3225d560a8da');
}); });
it('should throw an error if something bad happens on configuration item update', async () => { it('should throw an error if something bad happens on configuration item update', async () => {
ConfigurationEntity.create = jest.fn().mockReturnValue({ ConfigurationEntity.create = jest.fn().mockReturnValue({

View File

@ -1,36 +1,93 @@
import { NotFoundException } from '@mobicoop/ddd-library';
import { ConfigurationMapper } from '@modules/configuration/configuration.mapper'; import { ConfigurationMapper } from '@modules/configuration/configuration.mapper';
import { ConfigurationEntity } from '@modules/configuration/core/domain/configuration.entity';
import { ConfigurationDomain } from '@modules/configuration/core/domain/configuration.types';
import { ConfigurationRepository } from '@modules/configuration/infrastructure/configuration.repository'; import { ConfigurationRepository } from '@modules/configuration/infrastructure/configuration.repository';
import { PrismaService } from '@modules/configuration/infrastructure/prisma.service';
import { EventEmitter2, EventEmitterModule } from '@nestjs/event-emitter';
import { Test, TestingModule } from '@nestjs/testing'; import { Test, TestingModule } from '@nestjs/testing';
import { getRedisToken } from '@songkeys/nestjs-redis';
const mockMessagePublisher = { const mockRedis = {
publish: jest.fn().mockImplementation(), get: jest
.fn()
.mockImplementationOnce(() => '1')
.mockImplementation(() => null),
set: jest.fn().mockImplementation(),
}; };
describe('Configuration repository', () => { const mockConfigurationMapper = {
let prismaService: PrismaService; toDomain: jest.fn().mockImplementation(
let configurationMapper: ConfigurationMapper; () =>
let eventEmitter: EventEmitter2; new ConfigurationEntity({
id: '001199d4-7187-4e83-a044-12159cba2e33',
props: {
identifier: {
domain: ConfigurationDomain.CARPOOL,
key: 'seatsProposed',
},
value: '1',
},
createdAt: new Date('2023-10-23'),
updatedAt: new Date('2023-10-23'),
}),
),
};
describe('Configuration Repository', () => {
let configurationRepository: ConfigurationRepository;
beforeAll(async () => { beforeAll(async () => {
const module: TestingModule = await Test.createTestingModule({ const module: TestingModule = await Test.createTestingModule({
imports: [EventEmitterModule.forRoot()], providers: [
providers: [PrismaService, ConfigurationMapper], {
provide: getRedisToken('default'),
useValue: mockRedis,
},
{
provide: ConfigurationMapper,
useValue: mockConfigurationMapper,
},
ConfigurationRepository,
],
}).compile(); }).compile();
prismaService = module.get<PrismaService>(PrismaService); configurationRepository = module.get<ConfigurationRepository>(
configurationMapper = module.get<ConfigurationMapper>(ConfigurationMapper); ConfigurationRepository,
eventEmitter = module.get<EventEmitter2>(EventEmitter2); );
}); });
it('should be defined', () => { it('should be defined', () => {
expect( expect(configurationRepository).toBeDefined();
new ConfigurationRepository( });
prismaService,
configurationMapper, describe('interact', () => {
eventEmitter, it('should get a value', async () => {
mockMessagePublisher, expect(
), (
).toBeDefined(); await configurationRepository.get({
domain: ConfigurationDomain.CARPOOL,
key: 'seatsProposed',
})
).getProps().value,
).toBe('1');
});
it('should throw if configuration is not found', async () => {
await expect(
configurationRepository.get({
domain: ConfigurationDomain.CARPOOL,
key: 'seatsProposed',
}),
).rejects.toBeInstanceOf(NotFoundException);
});
it('should set a value', async () => {
expect(
await configurationRepository.set(
{
domain: ConfigurationDomain.CARPOOL,
key: 'seatsProposed',
},
'3',
),
).toBeUndefined();
});
}); });
}); });

View File

@ -1,108 +0,0 @@
import {
DatabaseErrorException,
NotFoundException,
} from '@mobicoop/ddd-library';
import { RpcExceptionCode } from '@mobicoop/ddd-library';
import { Domain } from '@modules/configuration/core/domain/configuration.types';
import { DeleteConfigurationGrpcController } from '@modules/configuration/interface/grpc-controllers/delete-configuration.grpc.controller';
import { DeleteConfigurationRequestDto } from '@modules/configuration/interface/grpc-controllers/dtos/delete-configuration.request.dto';
import { CommandBus } from '@nestjs/cqrs';
import { RpcException } from '@nestjs/microservices';
import { Test, TestingModule } from '@nestjs/testing';
const deleteConfigurationRequest: DeleteConfigurationRequestDto = {
domain: Domain.AD,
key: 'seatsProposed',
};
const mockCommandBus = {
execute: jest
.fn()
.mockImplementationOnce(() => ({}))
.mockImplementationOnce(() => {
throw new NotFoundException();
})
.mockImplementationOnce(() => {
throw new DatabaseErrorException();
})
.mockImplementationOnce(() => {
throw new Error();
}),
};
describe('Delete Configuration Grpc Controller', () => {
let deleteConfigurationGrpcController: DeleteConfigurationGrpcController;
beforeAll(async () => {
const module: TestingModule = await Test.createTestingModule({
providers: [
{
provide: CommandBus,
useValue: mockCommandBus,
},
DeleteConfigurationGrpcController,
],
}).compile();
deleteConfigurationGrpcController =
module.get<DeleteConfigurationGrpcController>(
DeleteConfigurationGrpcController,
);
});
afterEach(async () => {
jest.clearAllMocks();
});
it('should be defined', () => {
expect(deleteConfigurationGrpcController).toBeDefined();
});
it('should delete a configuration item', async () => {
jest.spyOn(mockCommandBus, 'execute');
await deleteConfigurationGrpcController.delete(deleteConfigurationRequest);
expect(mockCommandBus.execute).toHaveBeenCalledTimes(1);
});
it('should throw a dedicated RpcException if configuration item does not exist', async () => {
jest.spyOn(mockCommandBus, 'execute');
expect.assertions(3);
try {
await deleteConfigurationGrpcController.delete(
deleteConfigurationRequest,
);
} catch (e: any) {
expect(e).toBeInstanceOf(RpcException);
expect(e.error.code).toBe(RpcExceptionCode.NOT_FOUND);
}
expect(mockCommandBus.execute).toHaveBeenCalledTimes(1);
});
it('should throw a dedicated RpcException if a database error occurs', async () => {
jest.spyOn(mockCommandBus, 'execute');
expect.assertions(3);
try {
await deleteConfigurationGrpcController.delete(
deleteConfigurationRequest,
);
} catch (e: any) {
expect(e).toBeInstanceOf(RpcException);
expect(e.error.code).toBe(RpcExceptionCode.INTERNAL);
}
expect(mockCommandBus.execute).toHaveBeenCalledTimes(1);
});
it('should throw a generic RpcException', async () => {
jest.spyOn(mockCommandBus, 'execute');
expect.assertions(3);
try {
await deleteConfigurationGrpcController.delete(
deleteConfigurationRequest,
);
} catch (e: any) {
expect(e).toBeInstanceOf(RpcException);
expect(e.error.code).toBe(RpcExceptionCode.UNKNOWN);
}
expect(mockCommandBus.execute).toHaveBeenCalledTimes(1);
});
});

View File

@ -1,7 +1,7 @@
import { NotFoundException } from '@mobicoop/ddd-library'; import { NotFoundException } from '@mobicoop/ddd-library';
import { RpcExceptionCode } from '@mobicoop/ddd-library'; import { RpcExceptionCode } from '@mobicoop/ddd-library';
import { ConfigurationMapper } from '@modules/configuration/configuration.mapper'; import { ConfigurationMapper } from '@modules/configuration/configuration.mapper';
import { Domain } from '@modules/configuration/core/domain/configuration.types'; import { ConfigurationDomain } from '@modules/configuration/core/domain/configuration.types';
import { GetConfigurationGrpcController } from '@modules/configuration/interface/grpc-controllers/get-configuration.grpc.controller'; import { GetConfigurationGrpcController } from '@modules/configuration/interface/grpc-controllers/get-configuration.grpc.controller';
import { QueryBus } from '@nestjs/cqrs'; import { QueryBus } from '@nestjs/cqrs';
import { RpcException } from '@nestjs/microservices'; import { RpcException } from '@nestjs/microservices';
@ -21,7 +21,7 @@ const mockQueryBus = {
const mockConfigurationMapper = { const mockConfigurationMapper = {
toResponse: jest.fn().mockImplementationOnce(() => ({ toResponse: jest.fn().mockImplementationOnce(() => ({
domain: Domain.AD, domain: ConfigurationDomain.CARPOOL,
key: 'seatsProposed', key: 'seatsProposed',
value: '3', value: '3',
})), })),
@ -62,7 +62,7 @@ describe('Get Configuration Grpc Controller', () => {
jest.spyOn(mockQueryBus, 'execute'); jest.spyOn(mockQueryBus, 'execute');
jest.spyOn(mockConfigurationMapper, 'toResponse'); jest.spyOn(mockConfigurationMapper, 'toResponse');
const response = await getConfigurationGrpcController.get({ const response = await getConfigurationGrpcController.get({
domain: Domain.AD, domain: ConfigurationDomain.CARPOOL,
key: 'seatsProposed', key: 'seatsProposed',
}); });
expect(response.value).toBe('3'); expect(response.value).toBe('3');
@ -76,7 +76,7 @@ describe('Get Configuration Grpc Controller', () => {
expect.assertions(4); expect.assertions(4);
try { try {
await getConfigurationGrpcController.get({ await getConfigurationGrpcController.get({
domain: Domain.AD, domain: ConfigurationDomain.CARPOOL,
key: 'price', key: 'price',
}); });
} catch (e: any) { } catch (e: any) {
@ -93,7 +93,7 @@ describe('Get Configuration Grpc Controller', () => {
expect.assertions(4); expect.assertions(4);
try { try {
await getConfigurationGrpcController.get({ await getConfigurationGrpcController.get({
domain: Domain.AD, domain: ConfigurationDomain.CARPOOL,
key: 'someValue', key: 'someValue',
}); });
} catch (e: any) { } catch (e: any) {

View File

@ -1,60 +0,0 @@
import { RpcExceptionCode } from '@mobicoop/ddd-library';
import { PropagateConfigurationsGrpcController } from '@modules/configuration/interface/grpc-controllers/propagate-configurations.grpc.controller';
import { CommandBus } from '@nestjs/cqrs';
import { RpcException } from '@nestjs/microservices';
import { Test, TestingModule } from '@nestjs/testing';
const mockCommandBus = {
execute: jest
.fn()
.mockImplementationOnce(() => {})
.mockImplementationOnce(() => {
throw new Error();
}),
};
describe('Propagate Configurations Grpc Controller', () => {
let propagateConfigurationsGrpcController: PropagateConfigurationsGrpcController;
beforeAll(async () => {
const module: TestingModule = await Test.createTestingModule({
providers: [
{
provide: CommandBus,
useValue: mockCommandBus,
},
PropagateConfigurationsGrpcController,
],
}).compile();
propagateConfigurationsGrpcController =
module.get<PropagateConfigurationsGrpcController>(
PropagateConfigurationsGrpcController,
);
});
afterEach(async () => {
jest.clearAllMocks();
});
it('should be defined', () => {
expect(propagateConfigurationsGrpcController).toBeDefined();
});
it('should propagate configuration items', async () => {
jest.spyOn(mockCommandBus, 'execute');
await propagateConfigurationsGrpcController.propagate();
expect(mockCommandBus.execute).toHaveBeenCalledTimes(1);
});
it('should throw a generic RpcException', async () => {
jest.spyOn(mockCommandBus, 'execute');
try {
await propagateConfigurationsGrpcController.propagate();
} catch (e: any) {
expect(e).toBeInstanceOf(RpcException);
expect(e.error.code).toBe(RpcExceptionCode.UNKNOWN);
}
expect(mockCommandBus.execute).toHaveBeenCalledTimes(1);
});
});

View File

@ -1,6 +1,5 @@
import { IdResponse } from '@mobicoop/ddd-library';
import { RpcExceptionCode } from '@mobicoop/ddd-library'; import { RpcExceptionCode } from '@mobicoop/ddd-library';
import { Domain } from '@modules/configuration/core/domain/configuration.types'; import { ConfigurationDomain } from '@modules/configuration/core/domain/configuration.types';
import { SetConfigurationRequestDto } from '@modules/configuration/interface/grpc-controllers/dtos/set-configuration.request.dto'; import { SetConfigurationRequestDto } from '@modules/configuration/interface/grpc-controllers/dtos/set-configuration.request.dto';
import { SetConfigurationGrpcController } from '@modules/configuration/interface/grpc-controllers/set-configuration.grpc.controller'; import { SetConfigurationGrpcController } from '@modules/configuration/interface/grpc-controllers/set-configuration.grpc.controller';
import { CommandBus } from '@nestjs/cqrs'; import { CommandBus } from '@nestjs/cqrs';
@ -8,7 +7,7 @@ import { RpcException } from '@nestjs/microservices';
import { Test, TestingModule } from '@nestjs/testing'; import { Test, TestingModule } from '@nestjs/testing';
const setConfigurationRequest: SetConfigurationRequestDto = { const setConfigurationRequest: SetConfigurationRequestDto = {
domain: Domain.AD, domain: ConfigurationDomain.CARPOOL,
key: 'seatsProposed', key: 'seatsProposed',
value: '3', value: '3',
}; };
@ -51,11 +50,7 @@ describe('Set Configuration Grpc Controller', () => {
it('should set a configuration item', async () => { it('should set a configuration item', async () => {
jest.spyOn(mockCommandBus, 'execute'); jest.spyOn(mockCommandBus, 'execute');
const result: IdResponse = await setConfigurationGrpcController.set( await setConfigurationGrpcController.set(setConfigurationRequest);
setConfigurationRequest,
);
expect(result).toBeInstanceOf(IdResponse);
expect(result.id).toBe('200d61a8-d878-4378-a609-c19ea71633d2');
expect(mockCommandBus.execute).toHaveBeenCalledTimes(1); expect(mockCommandBus.execute).toHaveBeenCalledTimes(1);
}); });

View File

@ -16,12 +16,10 @@ const imports = [
useFactory: async ( useFactory: async (
configService: ConfigService, configService: ConfigService,
): Promise<MessageBrokerModuleOptions> => ({ ): Promise<MessageBrokerModuleOptions> => ({
uri: configService.get<string>('MESSAGE_BROKER_URI') as string, uri: configService.get<string>('broker.uri') as string,
exchange: { exchange: {
name: configService.get<string>('MESSAGE_BROKER_EXCHANGE') as string, name: configService.get<string>('broker.exchange') as string,
durable: configService.get<boolean>( durable: configService.get<boolean>('broker.durability') as boolean,
'MESSAGE_BROKER_EXCHANGE_DURABILITY',
) as boolean,
}, },
name: SERVICE_NAME, name: SERVICE_NAME,
}), }),