Compare commits

..

19 Commits

Author SHA1 Message Date
Romain Thouvenin 12c237b980 Update the README doc 2024-05-16 17:13:34 +02:00
Romain Thouvenin f6f9696620 Add a zero-value to the Frequency GRPC enum (required by protobuf specs) 2024-05-16 17:13:34 +02:00
Romain Thouvenin 5aa4d9e568 Unit tests for the update-ad service 2024-05-16 17:13:34 +02:00
Romain Thouvenin f6c3204708 Emit the AdUpdated domain event from the service instead of the repository
This is to avoid storing the event in the entity, which prevents serializing it into JSON
(because it has a circular dependency to AdEntity)
2024-05-16 17:13:34 +02:00
Romain Thouvenin 659c1baea8 Implement the GRPC controller to update ads 2024-05-16 17:13:34 +02:00
Romain Thouvenin 7a84bff260 Implement update ad command 2024-05-16 17:13:34 +02:00
Romain Thouvenin 3d4ff00066 publish integration event when an ad is updated 2024-05-16 17:13:34 +02:00
Romain Thouvenin 3ff5277d5f Add update method to Ad entity 2024-05-16 17:13:34 +02:00
Romain Thouvenin 62e5fd56d9 Upgrade ddd-library 2024-05-16 17:13:34 +02:00
Romain Thouvenin c7d4792893 Consistent and DRY declarations of ScheduleItem types 2024-05-07 10:43:49 +02:00
Romain Thouvenin 5e449ad69a Prepare release 2.6 2024-05-07 10:42:54 +02:00
Romain Thouvenin 51ca6cf9c4 Update documentation about the delete command 2024-04-29 08:34:40 +02:00
Romain Thouvenin be2af64f60 Update the documentation about integration events 2024-04-29 08:29:50 +02:00
Romain Thouvenin 9fb7ef2eac Listen to user.deleted events to delete the corresponding user ads 2024-04-26 12:31:16 +02:00
Romain Thouvenin 492bb3ca44 Expose the debugger in the dev container 2024-04-26 12:29:06 +02:00
Romain Thouvenin e8903099d7 Implement the delete GRPC command 2024-04-26 10:58:44 +02:00
Romain Thouvenin b17fc32a12 Fix tests path in build config 2024-04-26 10:57:45 +02:00
Romain Thouvenin 8c7512b6c3 Use common test and build jobs 2024-04-03 08:51:29 +02:00
Romain Thouvenin 15236904e3 Prepare release 2.5 2024-04-03 08:48:51 +02:00
52 changed files with 1198 additions and 393 deletions

View File

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

View File

@ -232,11 +232,34 @@ The app exposes the following [gRPC](https://grpc.io/) services :
- waypoints: an array of addresses that represent the waypoints of the journey (only first and last waypoints are used for passenger ads). Note that positions are **required** and **must** be consecutives
- comment: optional freetext comment / description about the ad
- **Update** : Replace the content of an ad
Accepts the same data as the `Create` function + an ad id, and replace the given ad with the given data.
- **Delete** : Delete permanently an ad
```json
{
"id": "80126a61-d128-4f96-afdb-92e33c75a3e1"
}
```
## Messages
### Handled
The service listens to these RabbitMQ messages:
- **matcher-ad.created** (to update the status of pending ads)
- **matcher-ad.creation-failed** (to update the status of pending ads)
- **user.deleted** (to delete the associated ads)
### Emitted
As mentionned earlier, RabbitMQ messages are sent after these events :
- **Create** (message : the created ad informations)
- **ad.created** (message: the created ad information)
- **ad.updated** (message: the updated ad information)
- **ad.deleted** (message: the id of the deleted ad)
## Tests / ESLint / Prettier

View File

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

12
package-lock.json generated
View File

@ -1,17 +1,17 @@
{
"name": "@mobicoop/ad",
"version": "2.4.5",
"version": "2.6.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@mobicoop/ad",
"version": "2.4.5",
"version": "2.6.0",
"license": "AGPL",
"dependencies": {
"@grpc/grpc-js": "^1.9.14",
"@grpc/proto-loader": "^0.7.10",
"@mobicoop/ddd-library": "^2.4.3",
"@mobicoop/ddd-library": "^2.5.0",
"@mobicoop/health-module": "^2.3.2",
"@mobicoop/message-broker-module": "^2.1.2",
"@nestjs/common": "^10.3.0",
@ -1852,9 +1852,9 @@
}
},
"node_modules/@mobicoop/ddd-library": {
"version": "2.4.3",
"resolved": "https://registry.npmjs.org/@mobicoop/ddd-library/-/ddd-library-2.4.3.tgz",
"integrity": "sha512-HxNtAfov8ne7XsFTSIDI811r3L1VDV9YUikgX7HPjrB8u2gQh6FQFnIz3Fjb/zWOGxrDEIy8HEM0AYmXkf8ULA==",
"version": "2.5.0",
"resolved": "https://registry.npmjs.org/@mobicoop/ddd-library/-/ddd-library-2.5.0.tgz",
"integrity": "sha512-dTx7KTILs53HCqNx0BDVTzIZxfPW3pi0fZ4UMw/vDNm3oTqGA+jg7YBfNxn8yadM+j2dDIN5Kum43CmKGH8yYA==",
"dependencies": {
"@nestjs/event-emitter": "^2.0.3",
"@nestjs/microservices": "^10.3.0",

View File

@ -1,6 +1,6 @@
{
"name": "@mobicoop/ad",
"version": "2.4.5",
"version": "2.6.0",
"description": "Mobicoop V3 Ad",
"author": "sbriat",
"private": true,
@ -11,7 +11,7 @@
"format": "prettier --write \"src/**/*.ts\" \"test/**/*.ts\"",
"start": "nest start",
"start:dev": "nest start --watch",
"start:debug": "nest start --debug --watch",
"start:debug": "nest start --debug 0.0.0.0:9229 --watch",
"start:prod": "node dist/main",
"lint": "eslint \"{src,apps,libs,test}/**/*.ts\" --fix",
"lint:check": "eslint \"{src,apps,libs,test}/**/*.ts\" --fix-dry-run --ignore-path .gitignore",
@ -34,7 +34,7 @@
"@grpc/grpc-js": "^1.9.14",
"@grpc/proto-loader": "^0.7.10",
"@songkeys/nestjs-redis": "^10.0.0",
"@mobicoop/ddd-library": "^2.4.3",
"@mobicoop/ddd-library": "^2.5.0",
"@mobicoop/health-module": "^2.3.2",
"@mobicoop/message-broker-module": "^2.1.2",
"@nestjs/common": "^10.3.0",

View File

@ -7,6 +7,8 @@ export const GRPC_SERVICE_NAME = 'AdService';
// messaging output
export const AD_CREATED_ROUTING_KEY = 'ad.created';
export const AD_UPDATED_ROUTING_KEY = 'ad.updated';
export const AD_DELETED_ROUTING_KEY = 'ad.deleted';
// messaging input
export const MATCHER_AD_CREATED_MESSAGE_HANDLER = 'matcherAdCreated';
@ -18,6 +20,10 @@ export const MATCHER_AD_CREATION_FAILED_ROUTING_KEY =
'matcher-ad.creation-failed';
export const MATCHER_AD_CREATION_FAILED_QUEUE = 'ad.matcher-ad.creation-failed';
export const USER_DELETED_MESSAGE_HANDLER = 'userDeleted';
export const USER_DELETED_ROUTING_KEY = 'user.deleted';
export const USER_DELETED_QUEUE = 'ad.user.deleted';
// configuration
export const SERVICE_CONFIGURATION_SET_QUEUE = 'ad-configuration-set';
export const SERVICE_CONFIGURATION_DELETE_QUEUE = 'ad-configuration-delete';

View File

@ -1,19 +1,21 @@
import { Mapper } from '@mobicoop/ddd-library';
import { AdResponseDto } from './interface/dtos/ad.response.dto';
import { Inject, Injectable } from '@nestjs/common';
import { AdEntity } from './core/domain/ad.entity';
import {
AdWriteModel,
AdReadModel,
WaypointModel,
ScheduleItemModel,
} from './infrastructure/ad.repository';
import { Frequency, Status } from './core/domain/ad.types';
import { WaypointProps } from './core/domain/value-objects/waypoint.value-object';
import { v4 } from 'uuid';
import { ScheduleItemProps } from './core/domain/value-objects/schedule-item.value-object';
import { OUTPUT_DATETIME_TRANSFORMER } from './ad.di-tokens';
import { DateTimeTransformerPort } from './core/application/ports/datetime-transformer.port';
import { AdEntity } from './core/domain/ad.entity';
import { Frequency, Status } from './core/domain/ad.types';
import { ScheduleItemProps } from './core/domain/value-objects/schedule-item.value-object';
import { WaypointProps } from './core/domain/value-objects/waypoint.value-object';
import {
AdReadModel,
AdWriteModel,
ScheduleItemModel,
ScheduleWriteModel,
WaypointModel,
WaypointWriteModel,
} from './infrastructure/ad.repository';
import { AdResponseDto } from './interface/dtos/ad.response.dto';
/**
* Mapper constructs objects that are used in different layers:
@ -31,9 +33,8 @@ export class AdMapper
private readonly outputDatetimeTransformer: DateTimeTransformerPort,
) {}
toPersistence = (entity: AdEntity): AdWriteModel => {
toPersistence = (entity: AdEntity, update?: boolean): AdWriteModel => {
const copy = entity.getProps();
const now = new Date();
const record: AdWriteModel = {
uuid: copy.id,
userUuid: copy.userId,
@ -43,50 +44,80 @@ export class AdMapper
frequency: copy.frequency,
fromDate: new Date(copy.fromDate),
toDate: new Date(copy.toDate),
schedule: copy.schedule
? {
create: copy.schedule.map((scheduleItem: ScheduleItemProps) => ({
uuid: v4(),
day: scheduleItem.day as number,
time: new Date(
1970,
0,
1,
parseInt(scheduleItem.time.split(':')[0]),
parseInt(scheduleItem.time.split(':')[1]),
),
margin: scheduleItem.margin as number,
createdAt: now,
updatedAt: now,
})),
}
: undefined,
schedule: this.toScheduleItemWriteModel(copy.schedule, update),
seatsProposed: copy.seatsProposed as number,
seatsRequested: copy.seatsRequested as number,
strict: copy.strict as boolean,
waypoints: copy.waypoints
? {
create: copy.waypoints.map((waypoint: WaypointProps) => ({
uuid: v4(),
position: waypoint.position,
name: waypoint.address.name,
houseNumber: waypoint.address.houseNumber,
street: waypoint.address.street,
locality: waypoint.address.locality,
postalCode: waypoint.address.postalCode,
country: waypoint.address.country,
lon: waypoint.address.coordinates.lon,
lat: waypoint.address.coordinates.lat,
createdAt: now,
updatedAt: now,
})),
}
: undefined,
waypoints: this.toWaypointWriteModel(copy.waypoints, update),
comment: copy.comment,
};
return record;
};
toScheduleItemWriteModel = (
schedule: ScheduleItemProps[],
update?: boolean,
): ScheduleWriteModel | undefined => {
if (!schedule) {
return undefined;
}
const now = new Date();
const record: ScheduleWriteModel = {
create: schedule.map((scheduleItem: ScheduleItemProps) => ({
uuid: v4(),
day: scheduleItem.day,
time: new Date(
1970,
0,
1,
parseInt(scheduleItem.time.split(':')[0]),
parseInt(scheduleItem.time.split(':')[1]),
),
margin: scheduleItem.margin,
createdAt: now,
updatedAt: now,
})),
};
if (update) {
record.deleteMany = {
createdAt: { lt: now },
};
}
return record;
};
toWaypointWriteModel = (
waypoints: WaypointProps[],
update?: boolean,
): WaypointWriteModel | undefined => {
if (!waypoints) {
return undefined;
}
const now = new Date();
const record: WaypointWriteModel = {
create: waypoints.map((waypoint: WaypointProps) => ({
uuid: v4(),
position: waypoint.position,
name: waypoint.address.name,
houseNumber: waypoint.address.houseNumber,
street: waypoint.address.street,
locality: waypoint.address.locality,
postalCode: waypoint.address.postalCode,
country: waypoint.address.country,
lon: waypoint.address.coordinates.lon,
lat: waypoint.address.coordinates.lat,
createdAt: now,
updatedAt: now,
})),
};
if (update) {
record.deleteMany = {
createdAt: { lt: now },
};
}
return record;
};
toDomain = (record: AdReadModel): AdEntity => {
const entity = new AdEntity({
id: record.uuid,
@ -141,6 +172,7 @@ export class AdMapper
response.userId = props.userId;
response.driver = props.driver as boolean;
response.passenger = props.passenger as boolean;
response.strict = props.strict;
response.status = props.status;
response.frequency = props.frequency;
response.fromDate = this.outputDatetimeTransformer.fromDate(
@ -163,7 +195,7 @@ export class AdMapper
response.schedule = props.schedule.map(
(scheduleItem: ScheduleItemProps) => ({
day: this.outputDatetimeTransformer.day(
scheduleItem.day as number,
scheduleItem.day,
{
date: props.fromDate,
time: scheduleItem.time,
@ -179,7 +211,7 @@ export class AdMapper
},
props.frequency,
),
margin: scheduleItem.margin as number,
margin: scheduleItem.margin,
}),
);
response.seatsProposed = props.seatsProposed as number;

View File

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

View File

@ -1,7 +1,7 @@
import { ScheduleItem } from '../../types/schedule-item';
import { Waypoint } from '../../types/waypoint';
import { Frequency } from '@modules/ad/core/domain/ad.types';
import { Command, CommandProps } from '@mobicoop/ddd-library';
import { Frequency } from '@modules/ad/core/domain/ad.types';
import { ScheduleItemProps } from '@modules/ad/core/domain/value-objects/schedule-item.value-object';
import { Waypoint } from '../../types/waypoint';
export class CreateAdCommand extends Command {
readonly userId: string;
@ -10,7 +10,7 @@ export class CreateAdCommand extends Command {
readonly frequency: Frequency;
readonly fromDate: string;
readonly toDate: string;
readonly schedule: ScheduleItem[];
readonly schedule: ScheduleItemProps[];
readonly seatsProposed?: number;
readonly seatsRequested?: number;
readonly strict: boolean;

View File

@ -1,17 +1,98 @@
import { CommandHandler, ICommandHandler } from '@nestjs/cqrs';
import { CreateAdCommand } from './create-ad.command';
import { Inject } from '@nestjs/common';
import { AggregateID, ConflictException } from '@mobicoop/ddd-library';
import {
AD_REPOSITORY,
INPUT_DATETIME_TRANSFORMER,
} from '@modules/ad/ad.di-tokens';
import { AdEntity } from '@modules/ad/core/domain/ad.entity';
import { Waypoint } from '../../types/waypoint';
import { AdRepositoryPort } from '../../ports/ad.repository.port';
import { AdAlreadyExistsException } from '@modules/ad/core/domain/ad.errors';
import { AggregateID, ConflictException } from '@mobicoop/ddd-library';
import { ScheduleItem } from '../../types/schedule-item';
import { ScheduleItemProps } from '@modules/ad/core/domain/value-objects/schedule-item.value-object';
import { Inject } from '@nestjs/common';
import { CommandHandler, ICommandHandler } from '@nestjs/cqrs';
import { AdRepositoryPort } from '../../ports/ad.repository.port';
import { DateTimeTransformerPort } from '../../ports/datetime-transformer.port';
import { Waypoint } from '../../types/waypoint';
import { CreateAdCommand } from './create-ad.command';
export function createPropsFromCommand(
command: CreateAdCommand,
datetimeTransformer: DateTimeTransformerPort,
) {
return {
userId: command.userId,
driver: command.driver,
passenger: command.passenger,
frequency: command.frequency,
//TODO Shouldn't that kind of logic be in the domain layer?
fromDate: datetimeTransformer.fromDate(
{
date: command.fromDate,
time: command.schedule[0].time,
coordinates: {
lon: command.waypoints[0].lon,
lat: command.waypoints[0].lat,
},
},
command.frequency,
),
toDate: datetimeTransformer.toDate(
command.toDate,
{
date: command.fromDate,
time: command.schedule[0].time,
coordinates: {
lon: command.waypoints[0].lon,
lat: command.waypoints[0].lat,
},
},
command.frequency,
),
schedule: command.schedule.map((scheduleItem: ScheduleItemProps) => ({
day: datetimeTransformer.day(
scheduleItem.day,
{
date: command.fromDate,
time: scheduleItem.time,
coordinates: {
lon: command.waypoints[0].lon,
lat: command.waypoints[0].lat,
},
},
command.frequency,
),
time: datetimeTransformer.time(
{
date: command.fromDate,
time: scheduleItem.time,
coordinates: {
lon: command.waypoints[0].lon,
lat: command.waypoints[0].lat,
},
},
command.frequency,
),
margin: scheduleItem.margin,
})),
seatsProposed: command.seatsProposed ?? 0,
seatsRequested: command.seatsRequested ?? 0,
strict: command.strict,
waypoints: command.waypoints.map((waypoint: Waypoint) => ({
position: waypoint.position,
address: {
name: waypoint.name,
houseNumber: waypoint.houseNumber,
street: waypoint.street,
postalCode: waypoint.postalCode,
locality: waypoint.locality,
country: waypoint.country,
coordinates: {
lon: waypoint.lon,
lat: waypoint.lat,
},
},
})),
comment: command.comment,
};
}
@CommandHandler(CreateAdCommand)
export class CreateAdService implements ICommandHandler {
@ -23,80 +104,9 @@ export class CreateAdService implements ICommandHandler {
) {}
async execute(command: CreateAdCommand): Promise<AggregateID> {
const ad = AdEntity.create({
userId: command.userId,
driver: command.driver,
passenger: command.passenger,
frequency: command.frequency,
fromDate: this.datetimeTransformer.fromDate(
{
date: command.fromDate,
time: command.schedule[0].time,
coordinates: {
lon: command.waypoints[0].lon,
lat: command.waypoints[0].lat,
},
},
command.frequency,
),
toDate: this.datetimeTransformer.toDate(
command.toDate,
{
date: command.fromDate,
time: command.schedule[0].time,
coordinates: {
lon: command.waypoints[0].lon,
lat: command.waypoints[0].lat,
},
},
command.frequency,
),
schedule: command.schedule.map((scheduleItem: ScheduleItem) => ({
day: this.datetimeTransformer.day(
scheduleItem.day,
{
date: command.fromDate,
time: scheduleItem.time,
coordinates: {
lon: command.waypoints[0].lon,
lat: command.waypoints[0].lat,
},
},
command.frequency,
),
time: this.datetimeTransformer.time(
{
date: command.fromDate,
time: scheduleItem.time,
coordinates: {
lon: command.waypoints[0].lon,
lat: command.waypoints[0].lat,
},
},
command.frequency,
),
margin: scheduleItem.margin,
})),
seatsProposed: command.seatsProposed ?? 0,
seatsRequested: command.seatsRequested ?? 0,
strict: command.strict,
waypoints: command.waypoints.map((waypoint: Waypoint) => ({
position: waypoint.position,
address: {
name: waypoint.name,
houseNumber: waypoint.houseNumber,
street: waypoint.street,
postalCode: waypoint.postalCode,
locality: waypoint.locality,
country: waypoint.country,
coordinates: {
lon: waypoint.lon,
lat: waypoint.lat,
},
},
})),
comment: command.comment,
});
const ad = AdEntity.create(
createPropsFromCommand(command, this.datetimeTransformer),
);
try {
await this.repository.insert(ad);

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1,12 +1,13 @@
import { AggregateRoot, AggregateID } from '@mobicoop/ddd-library';
import { AggregateID, AggregateRoot } from '@mobicoop/ddd-library';
import { v4 } from 'uuid';
import { AdCreatedDomainEvent } from './events/ad-created.domain-event';
import { AdProps, CreateAdProps, Status } from './ad.types';
import { ScheduleItemProps } from './value-objects/schedule-item.value-object';
import { WaypointProps } from './value-objects/waypoint.value-object';
import { AdValidatedDomainEvent } from './events/ad-validated.domain-event';
import { AdCreatedDomainEvent } from './events/ad-created.domain-event';
import { AdDeletedDomainEvent } from './events/ad-delete.domain-event';
import { AdInvalidatedDomainEvent } from './events/ad-invalidated.domain-event';
import { AdSuspendedDomainEvent } from './events/ad-suspended.domain-event';
import { AdValidatedDomainEvent } from './events/ad-validated.domain-event';
import { ScheduleItemProps } from './value-objects/schedule-item.value-object';
import { WaypointProps } from './value-objects/waypoint.value-object';
export class AdEntity extends AggregateRoot<AdProps> {
protected readonly _id: AggregateID;
@ -29,9 +30,9 @@ export class AdEntity extends AggregateRoot<AdProps> {
fromDate: props.fromDate,
toDate: props.toDate,
schedule: props.schedule.map((day: ScheduleItemProps) => ({
day: day.day as number,
day: day.day,
time: day.time,
margin: day.margin as number,
margin: day.margin,
})),
seatsProposed: props.seatsProposed,
seatsRequested: props.seatsRequested,
@ -95,6 +96,32 @@ export class AdEntity extends AggregateRoot<AdProps> {
return this;
};
update = (newProps: CreateAdProps): AdEntity => {
this.props.driver = newProps.driver;
this.props.passenger = newProps.passenger;
this.props.frequency = newProps.frequency;
this.props.fromDate = newProps.fromDate;
this.props.toDate = newProps.toDate;
this.props.seatsProposed = newProps.seatsProposed;
this.props.seatsRequested = newProps.seatsRequested;
this.props.strict = newProps.strict;
this.props.comment = newProps.comment;
this.props.schedule = newProps.schedule.map((item) => ({ ...item }));
this.props.waypoints = newProps.waypoints.map((wp) => ({ ...wp }));
//The ad goes back to pending status until it is validated again
this.props.status = Status.PENDING;
this.validate();
return this;
};
delete(): void {
this.addEvent(
new AdDeletedDomainEvent({
aggregateId: this.id,
}),
);
}
validate(): void {
// entity business rules validation to protect it's invariant before saving entity to a database
}

View File

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

View File

@ -1,4 +1,5 @@
import { DomainEvent, DomainEventProps } from '@mobicoop/ddd-library';
import { ScheduleItemProps } from '../value-objects/schedule-item.value-object';
export class AdCreatedDomainEvent extends DomainEvent {
readonly userId: string;
@ -7,7 +8,7 @@ export class AdCreatedDomainEvent extends DomainEvent {
readonly frequency: string;
readonly fromDate: string;
readonly toDate: string;
readonly schedule: ScheduleItem[];
readonly schedule: ScheduleItemProps[];
readonly seatsProposed: number;
readonly seatsRequested: number;
readonly strict: boolean;
@ -31,12 +32,6 @@ export class AdCreatedDomainEvent extends DomainEvent {
}
}
export class ScheduleItem {
day: number;
time: string;
margin: number;
}
export class Waypoint {
position: number;
name?: string;

View File

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

View File

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

View File

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

View File

@ -1,16 +1,16 @@
import { Inject, Injectable, Logger } from '@nestjs/common';
import { EventEmitter2 } from '@nestjs/event-emitter';
import { AdEntity } from '../core/domain/ad.entity';
import { AdMapper } from '../ad.mapper';
import { AdRepositoryPort } from '../core/application/ports/ad.repository.port';
import {
LoggerBase,
MessagePublisherPort,
PrismaRepositoryBase,
} from '@mobicoop/ddd-library';
import { PrismaService } from './prisma.service';
import { AD_MESSAGE_PUBLISHER } from '../ad.di-tokens';
import { Inject, Injectable, Logger } from '@nestjs/common';
import { EventEmitter2 } from '@nestjs/event-emitter';
import { SERVICE_NAME } from '@src/app.constants';
import { AD_MESSAGE_PUBLISHER } from '../ad.di-tokens';
import { AdMapper } from '../ad.mapper';
import { AdRepositoryPort } from '../core/application/ports/ad.repository.port';
import { AdEntity } from '../core/domain/ad.entity';
import { PrismaService } from './prisma.service';
export type AdBaseModel = {
uuid: string;
@ -40,13 +40,21 @@ export type AdWriteModel = AdBaseModel & {
};
export type ScheduleWriteModel = {
deleteMany?: PastCreatedFilter;
create: ScheduleItemModel[];
};
export type WaypointWriteModel = {
deleteMany?: PastCreatedFilter;
create: WaypointModel[];
};
// used to delete records created in the past,
// because the order of `create` and `deleteMany` is not guaranteed
export type PastCreatedFilter = {
createdAt: { lt: Date };
};
export type ScheduleItemModel = {
uuid: string;
day: number;

View File

@ -7,7 +7,7 @@ service AdService {
rpc FindAllByIds(AdsById) returns (Ads);
rpc FindAllByUserId(UserById) returns (Ads);
rpc Create(Ad) returns (AdById);
rpc Update(Ad) returns (Ad);
rpc Update(Ad) returns (Empty);
rpc Delete(AdById) returns (Empty);
}
@ -58,6 +58,7 @@ message Waypoint {
}
enum Frequency {
UNSPECIFIED = 0;
PUNCTUAL = 1;
RECURRENT = 2;
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1,6 +1,11 @@
import { Module, Provider } from '@nestjs/common';
import { MESSAGE_PUBLISHER } from './messager.di-tokens';
import {
MessageBrokerModule,
MessageBrokerModuleOptions,
MessageBrokerPublisher,
} from '@mobicoop/message-broker-module';
import { ConfigModule, ConfigService } from '@nestjs/config';
import {
MATCHER_AD_CREATED_MESSAGE_HANDLER,
@ -10,12 +15,10 @@ import {
MATCHER_AD_CREATION_FAILED_QUEUE,
MATCHER_AD_CREATION_FAILED_ROUTING_KEY,
SERVICE_NAME,
USER_DELETED_MESSAGE_HANDLER,
USER_DELETED_QUEUE,
USER_DELETED_ROUTING_KEY,
} from '@src/app.constants';
import {
MessageBrokerModule,
MessageBrokerModuleOptions,
MessageBrokerPublisher,
} from '@mobicoop/message-broker-module';
const imports = [
MessageBrokerModule.forRootAsync({
@ -41,6 +44,10 @@ const imports = [
routingKey: MATCHER_AD_CREATION_FAILED_ROUTING_KEY,
queue: MATCHER_AD_CREATION_FAILED_QUEUE,
},
[USER_DELETED_MESSAGE_HANDLER]: {
routingKey: USER_DELETED_ROUTING_KEY,
queue: USER_DELETED_QUEUE,
},
},
}),
}),

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

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1,62 +1,11 @@
import { AD_REPOSITORY } from '@modules/ad/ad.di-tokens';
import { AdEntity } from '@modules/ad/core/domain/ad.entity';
import { CreateAdProps, Frequency } from '@modules/ad/core/domain/ad.types';
import { FindAdByIdQuery } from '@modules/ad/core/application/queries/find-ad-by-id/find-ad-by-id.query';
import { FindAdByIdQueryHandler } from '@modules/ad/core/application/queries/find-ad-by-id/find-ad-by-id.query-handler';
import { WaypointProps } from '@modules/ad/core/domain/value-objects/waypoint.value-object';
import { AdEntity } from '@modules/ad/core/domain/ad.entity';
import { Test, TestingModule } from '@nestjs/testing';
import { punctualPassengerCreateAdProps } from './ad.fixtures';
const originWaypointProps: WaypointProps = {
position: 0,
address: {
houseNumber: '5',
street: 'Avenue Foch',
locality: 'Nancy',
postalCode: '54000',
country: 'France',
coordinates: {
lat: 48.689445,
lon: 6.17651,
},
},
};
const destinationWaypointProps: WaypointProps = {
position: 1,
address: {
locality: 'Paris',
postalCode: '75000',
country: 'France',
coordinates: {
lat: 48.8566,
lon: 2.3522,
},
},
};
const baseCreateAdProps = {
userId: 'e8fe64b1-4c33-49e1-9f69-4db48b21df36',
seatsProposed: 3,
seatsRequested: 1,
strict: false,
waypoints: [originWaypointProps, destinationWaypointProps],
};
const punctualCreateAdProps = {
fromDate: '2023-06-22',
toDate: '2023-06-22',
schedule: [
{
time: '08:30',
},
],
frequency: Frequency.PUNCTUAL,
};
const punctualPassengerCreateAdProps: CreateAdProps = {
...baseCreateAdProps,
...punctualCreateAdProps,
driver: false,
passenger: true,
};
const ad: AdEntity = AdEntity.create(punctualPassengerCreateAdProps);
const ad: AdEntity = AdEntity.create(punctualPassengerCreateAdProps());
const mockAdRepository = {
findOneById: jest.fn().mockImplementation(() => ad),

View File

@ -1,10 +1,10 @@
import { AD_REPOSITORY } from '@modules/ad/ad.di-tokens';
import { FindAdsByIdsQuery } from '@modules/ad/core/application/queries/find-ads-by-ids/find-ads-by-ids.query';
import { FindAdsByIdsQueryHandler } from '@modules/ad/core/application/queries/find-ads-by-ids/find-ads-by-ids.query-handler';
import { AdEntity } from '@modules/ad/core/domain/ad.entity';
import { CreateAdProps, Frequency } from '@modules/ad/core/domain/ad.types';
import { WaypointProps } from '@modules/ad/core/domain/value-objects/waypoint.value-object';
import { Test, TestingModule } from '@nestjs/testing';
import { FindAdsByIdsQueryHandler } from '@modules/ad/core/application/queries/find-ads-by-ids/find-ads-by-ids.query-handler';
import { FindAdsByIdsQuery } from '@modules/ad/core/application/queries/find-ads-by-ids/find-ads-by-ids.query';
const originWaypointProps: WaypointProps = {
position: 0,
@ -44,7 +44,9 @@ const punctualCreateAdProps = {
toDate: '2023-06-22',
schedule: [
{
day: 4,
time: '08:30',
margin: 900,
},
],
frequency: Frequency.PUNCTUAL,

View File

@ -1,10 +1,10 @@
import { AD_REPOSITORY } from '@modules/ad/ad.di-tokens';
import { FindAdsByUserIdQuery } from '@modules/ad/core/application/queries/find-ads-by-user-id/find-ads-by-user-id.query';
import { FindAdsByUserIdQueryHandler } from '@modules/ad/core/application/queries/find-ads-by-user-id/find-ads-by-user-id.query-handler';
import { AdEntity } from '@modules/ad/core/domain/ad.entity';
import { CreateAdProps, Frequency } from '@modules/ad/core/domain/ad.types';
import { WaypointProps } from '@modules/ad/core/domain/value-objects/waypoint.value-object';
import { Test, TestingModule } from '@nestjs/testing';
import { FindAdsByUserIdQueryHandler } from '@modules/ad/core/application/queries/find-ads-by-user-id/find-ads-by-user-id.query-handler';
import { FindAdsByUserIdQuery } from '@modules/ad/core/application/queries/find-ads-by-user-id/find-ads-by-user-id.query';
const originWaypointProps: WaypointProps = {
position: 0,
@ -44,7 +44,9 @@ const punctualCreateAdProps = {
toDate: '2023-06-22',
schedule: [
{
day: 4,
time: '08:30',
margin: 900,
},
],
frequency: Frequency.PUNCTUAL,

View File

@ -1,11 +1,11 @@
import { Test, TestingModule } from '@nestjs/testing';
import { AD_REPOSITORY } from '@modules/ad/ad.di-tokens';
import { AggregateID } from '@mobicoop/ddd-library';
import { AD_REPOSITORY } from '@modules/ad/ad.di-tokens';
import { InvalidateAdCommand } from '@modules/ad/core/application/commands/invalidate-ad/invalidate-ad.command';
import { InvalidateAdService } from '@modules/ad/core/application/commands/invalidate-ad/invalidate-ad.service';
import { AdEntity } from '@modules/ad/core/domain/ad.entity';
import { CreateAdProps, Frequency } from '@modules/ad/core/domain/ad.types';
import { WaypointProps } from '@modules/ad/core/domain/value-objects/waypoint.value-object';
import { InvalidateAdService } from '@modules/ad/core/application/commands/invalidate-ad/invalidate-ad.service';
import { InvalidateAdCommand } from '@modules/ad/core/application/commands/invalidate-ad/invalidate-ad.command';
import { Test, TestingModule } from '@nestjs/testing';
const originWaypointProps: WaypointProps = {
position: 0,
@ -45,7 +45,9 @@ const punctualCreateAdProps = {
toDate: '2023-06-22',
schedule: [
{
day: 4,
time: '08:30',
margin: 900,
},
],
frequency: Frequency.PUNCTUAL,

View File

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

View File

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

View File

@ -1,11 +1,11 @@
import { Test, TestingModule } from '@nestjs/testing';
import { AD_REPOSITORY } from '@modules/ad/ad.di-tokens';
import { AggregateID } from '@mobicoop/ddd-library';
import { AD_REPOSITORY } from '@modules/ad/ad.di-tokens';
import { ValidateAdCommand } from '@modules/ad/core/application/commands/validate-ad/validate-ad.command';
import { ValidateAdService } from '@modules/ad/core/application/commands/validate-ad/validate-ad.service';
import { AdEntity } from '@modules/ad/core/domain/ad.entity';
import { CreateAdProps, Frequency } from '@modules/ad/core/domain/ad.types';
import { ValidateAdService } from '@modules/ad/core/application/commands/validate-ad/validate-ad.service';
import { ValidateAdCommand } from '@modules/ad/core/application/commands/validate-ad/validate-ad.command';
import { WaypointProps } from '@modules/ad/core/domain/value-objects/waypoint.value-object';
import { Test, TestingModule } from '@nestjs/testing';
const originWaypointProps: WaypointProps = {
position: 0,
@ -45,7 +45,9 @@ const punctualCreateAdProps = {
toDate: '2023-06-22',
schedule: [
{
day: 4,
time: '08:30',
margin: 900,
},
],
frequency: Frequency.PUNCTUAL,

View File

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

View File

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

View File

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

View File

@ -0,0 +1,76 @@
import { NotFoundException, RpcExceptionCode } from '@mobicoop/ddd-library';
import { UpdateAdGrpcController } from '@modules/ad/interface/grpc-controllers/update-ad.grpc.controller';
import { CommandBus } from '@nestjs/cqrs';
import { RpcException } from '@nestjs/microservices';
import { Test, TestingModule } from '@nestjs/testing';
import { punctualCreateAdRequest } from './ad.fixtures';
const validAdId = '200d61a8-d878-4378-a609-c19ea71633d2';
const mockCommandBus = {
execute: jest.fn().mockImplementation(async (command) => {
if (command.adId === '') throw 'Ad id is empty';
if (command.adId != validAdId) throw new NotFoundException();
}),
};
describe('Update Ad GRPC Controller', () => {
let updateAdGrpcController: UpdateAdGrpcController;
beforeAll(async () => {
const module: TestingModule = await Test.createTestingModule({
providers: [
{
provide: CommandBus,
useValue: mockCommandBus,
},
UpdateAdGrpcController,
],
}).compile();
updateAdGrpcController = module.get<UpdateAdGrpcController>(
UpdateAdGrpcController,
);
});
afterEach(async () => {
jest.clearAllMocks();
});
it('should be defined', () => {
expect(updateAdGrpcController).toBeDefined();
});
it('should execute the update ad command', async () => {
await updateAdGrpcController.update({
id: validAdId,
...punctualCreateAdRequest(),
});
expect(mockCommandBus.execute).toHaveBeenCalledTimes(1);
});
it('should throw a dedicated RpcException if ad is not found', async () => {
expect.assertions(3);
try {
await updateAdGrpcController.update({
id: 'ac85f5f4-41cd-4c5d-9aee-0a1acb176fb8',
...punctualCreateAdRequest(),
});
} catch (e: any) {
expect(e).toBeInstanceOf(RpcException);
expect(e.error.code).toBe(RpcExceptionCode.NOT_FOUND);
}
expect(mockCommandBus.execute).toHaveBeenCalledTimes(1);
});
it('should rethrow any other exceptions', async () => {
expect.assertions(2);
try {
await updateAdGrpcController.update({
id: '',
...punctualCreateAdRequest(),
});
} catch (e: any) {
expect(e).toBe('Ad id is empty');
}
expect(mockCommandBus.execute).toHaveBeenCalledTimes(1);
});
});

View File

@ -0,0 +1,43 @@
import { UserDeletedMessageHandler } from '@modules/ad/interface/message-handlers/user-deleted.message-handler';
import { CommandBus } from '@nestjs/cqrs';
import { Test, TestingModule } from '@nestjs/testing';
const mockCommandBus = {
execute: jest.fn(),
};
describe('Matcher Ad Created Message Handler', () => {
let userDeletedMessageHandler: UserDeletedMessageHandler;
beforeAll(async () => {
const module: TestingModule = await Test.createTestingModule({
providers: [
{
provide: CommandBus,
useValue: mockCommandBus,
},
UserDeletedMessageHandler,
],
}).compile();
userDeletedMessageHandler = module.get<UserDeletedMessageHandler>(
UserDeletedMessageHandler,
);
});
afterEach(async () => {
jest.clearAllMocks();
});
it('should be defined', () => {
expect(userDeletedMessageHandler).toBeDefined();
});
it('should call the command bus', async () => {
const userId = '4eb6a6af-ecfd-41c3-9118-473a507014d4';
const userDeletedMessage = `{"id":"${userId}"}`;
await userDeletedMessageHandler.userDeleted(userDeletedMessage);
expect(mockCommandBus.execute).toHaveBeenCalledTimes(1);
expect(mockCommandBus.execute.mock.lastCall[0].id).toBe(userId);
});
});

View File

@ -1,4 +1,4 @@
{
"extends": "./tsconfig.json",
"exclude": ["node_modules", "test", "dist", "**/*spec.ts"]
"exclude": ["node_modules", "tests", "dist", "**/*spec.ts"]
}