Compare commits

...

11 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
34 changed files with 715 additions and 264 deletions

View File

@ -232,6 +232,9 @@ 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 - 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 - 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 - **Delete** : Delete permanently an ad
```json ```json
@ -246,13 +249,16 @@ The app exposes the following [gRPC](https://grpc.io/) services :
The service listens to these RabbitMQ messages: 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) - **user.deleted** (to delete the associated ads)
### Emitted ### Emitted
As mentionned earlier, RabbitMQ messages are sent after these events : As mentionned earlier, RabbitMQ messages are sent after these events :
- **ad.created** (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) - **ad.deleted** (message: the id of the deleted ad)
## Tests / ESLint / Prettier ## Tests / ESLint / Prettier

12
package-lock.json generated
View File

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

View File

@ -1,6 +1,6 @@
{ {
"name": "@mobicoop/ad", "name": "@mobicoop/ad",
"version": "2.5.0", "version": "2.6.0",
"description": "Mobicoop V3 Ad", "description": "Mobicoop V3 Ad",
"author": "sbriat", "author": "sbriat",
"private": true, "private": true,
@ -34,7 +34,7 @@
"@grpc/grpc-js": "^1.9.14", "@grpc/grpc-js": "^1.9.14",
"@grpc/proto-loader": "^0.7.10", "@grpc/proto-loader": "^0.7.10",
"@songkeys/nestjs-redis": "^10.0.0", "@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/health-module": "^2.3.2",
"@mobicoop/message-broker-module": "^2.1.2", "@mobicoop/message-broker-module": "^2.1.2",
"@nestjs/common": "^10.3.0", "@nestjs/common": "^10.3.0",

View File

@ -7,7 +7,7 @@ export const GRPC_SERVICE_NAME = 'AdService';
// messaging output // messaging output
export const AD_CREATED_ROUTING_KEY = 'ad.created'; export const AD_CREATED_ROUTING_KEY = 'ad.created';
// messaging output export const AD_UPDATED_ROUTING_KEY = 'ad.updated';
export const AD_DELETED_ROUTING_KEY = 'ad.deleted'; export const AD_DELETED_ROUTING_KEY = 'ad.deleted';
// messaging input // messaging input

View File

@ -1,19 +1,21 @@
import { Mapper } from '@mobicoop/ddd-library'; import { Mapper } from '@mobicoop/ddd-library';
import { AdResponseDto } from './interface/dtos/ad.response.dto';
import { Inject, Injectable } from '@nestjs/common'; 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 { v4 } from 'uuid';
import { ScheduleItemProps } from './core/domain/value-objects/schedule-item.value-object';
import { OUTPUT_DATETIME_TRANSFORMER } from './ad.di-tokens'; import { OUTPUT_DATETIME_TRANSFORMER } from './ad.di-tokens';
import { DateTimeTransformerPort } from './core/application/ports/datetime-transformer.port'; 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: * Mapper constructs objects that are used in different layers:
@ -31,9 +33,8 @@ export class AdMapper
private readonly outputDatetimeTransformer: DateTimeTransformerPort, private readonly outputDatetimeTransformer: DateTimeTransformerPort,
) {} ) {}
toPersistence = (entity: AdEntity): AdWriteModel => { toPersistence = (entity: AdEntity, update?: boolean): AdWriteModel => {
const copy = entity.getProps(); const copy = entity.getProps();
const now = new Date();
const record: AdWriteModel = { const record: AdWriteModel = {
uuid: copy.id, uuid: copy.id,
userUuid: copy.userId, userUuid: copy.userId,
@ -43,50 +44,80 @@ export class AdMapper
frequency: copy.frequency, frequency: copy.frequency,
fromDate: new Date(copy.fromDate), fromDate: new Date(copy.fromDate),
toDate: new Date(copy.toDate), toDate: new Date(copy.toDate),
schedule: copy.schedule schedule: this.toScheduleItemWriteModel(copy.schedule, update),
? {
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,
seatsProposed: copy.seatsProposed as number, seatsProposed: copy.seatsProposed as number,
seatsRequested: copy.seatsRequested as number, seatsRequested: copy.seatsRequested as number,
strict: copy.strict as boolean, strict: copy.strict as boolean,
waypoints: copy.waypoints waypoints: this.toWaypointWriteModel(copy.waypoints, update),
? {
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,
comment: copy.comment, comment: copy.comment,
}; };
return record; 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 => { toDomain = (record: AdReadModel): AdEntity => {
const entity = new AdEntity({ const entity = new AdEntity({
id: record.uuid, id: record.uuid,
@ -141,6 +172,7 @@ export class AdMapper
response.userId = props.userId; response.userId = props.userId;
response.driver = props.driver as boolean; response.driver = props.driver as boolean;
response.passenger = props.passenger as boolean; response.passenger = props.passenger as boolean;
response.strict = props.strict;
response.status = props.status; response.status = props.status;
response.frequency = props.frequency; response.frequency = props.frequency;
response.fromDate = this.outputDatetimeTransformer.fromDate( response.fromDate = this.outputDatetimeTransformer.fromDate(
@ -163,7 +195,7 @@ export class AdMapper
response.schedule = props.schedule.map( response.schedule = props.schedule.map(
(scheduleItem: ScheduleItemProps) => ({ (scheduleItem: ScheduleItemProps) => ({
day: this.outputDatetimeTransformer.day( day: this.outputDatetimeTransformer.day(
scheduleItem.day as number, scheduleItem.day,
{ {
date: props.fromDate, date: props.fromDate,
time: scheduleItem.time, time: scheduleItem.time,
@ -179,7 +211,7 @@ export class AdMapper
}, },
props.frequency, props.frequency,
), ),
margin: scheduleItem.margin as number, margin: scheduleItem.margin,
}), }),
); );
response.seatsProposed = props.seatsProposed as number; response.seatsProposed = props.seatsProposed as number;

View File

@ -14,9 +14,11 @@ import { CreateAdService } from './core/application/commands/create-ad/create-ad
import { DeleteAdService } from './core/application/commands/delete-ad/delete-ad.service'; 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 { DeleteUserAdsService } from './core/application/commands/delete-user-ads/delete-user-ads.service';
import { InvalidateAdService } from './core/application/commands/invalidate-ad/invalidate-ad.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 { 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 { 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 { PublishMessageWhenAdIsCreatedDomainEventHandler } from './core/application/event-handlers/publish-message-when-ad-is-created.domain-event-handler';
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 { 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 { 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 { FindAdsByUserIdQueryHandler } from './core/application/queries/find-ads-by-user-id/find-ads-by-user-id.query-handler';
@ -31,12 +33,14 @@ import { DeleteAdGrpcController } from './interface/grpc-controllers/delete-ad.g
import { FindAdByIdGrpcController } from './interface/grpc-controllers/find-ad-by-id.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 { FindAdsByIdsGrpcController } from './interface/grpc-controllers/find-ads-by-ids.grpc.controller';
import { FindAdsByUserIdGrpcController } from './interface/grpc-controllers/find-ads-by-user-id.grpc.controller'; import { FindAdsByUserIdGrpcController } from './interface/grpc-controllers/find-ads-by-user-id.grpc.controller';
import { UpdateAdGrpcController } from './interface/grpc-controllers/update-ad.grpc.controller';
import { MatcherAdCreatedMessageHandler } from './interface/message-handlers/matcher-ad-created.message-handler'; import { MatcherAdCreatedMessageHandler } from './interface/message-handlers/matcher-ad-created.message-handler';
import { MatcherAdCreationFailedMessageHandler } from './interface/message-handlers/matcher-ad-creation-failed.message-handler'; import { MatcherAdCreationFailedMessageHandler } from './interface/message-handlers/matcher-ad-creation-failed.message-handler';
import { UserDeletedMessageHandler } from './interface/message-handlers/user-deleted.message-handler'; import { UserDeletedMessageHandler } from './interface/message-handlers/user-deleted.message-handler';
const grpcControllers = [ const grpcControllers = [
CreateAdGrpcController, CreateAdGrpcController,
UpdateAdGrpcController,
DeleteAdGrpcController, DeleteAdGrpcController,
FindAdByIdGrpcController, FindAdByIdGrpcController,
FindAdsByIdsGrpcController, FindAdsByIdsGrpcController,
@ -51,11 +55,13 @@ const messageHandlers = [
const eventHandlers: Provider[] = [ const eventHandlers: Provider[] = [
PublishMessageWhenAdIsCreatedDomainEventHandler, PublishMessageWhenAdIsCreatedDomainEventHandler,
PublishMessageWhenAdIsUpdatedDomainEventHandler,
PublishMessageWhenAdIsDeletedDomainEventHandler, PublishMessageWhenAdIsDeletedDomainEventHandler,
]; ];
const commandHandlers: Provider[] = [ const commandHandlers: Provider[] = [
CreateAdService, CreateAdService,
UpdateAdService,
DeleteAdService, DeleteAdService,
DeleteUserAdsService, DeleteUserAdsService,
ValidateAdService, ValidateAdService,

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 { 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 { export class CreateAdCommand extends Command {
readonly userId: string; readonly userId: string;
@ -10,7 +10,7 @@ export class CreateAdCommand extends Command {
readonly frequency: Frequency; readonly frequency: Frequency;
readonly fromDate: string; readonly fromDate: string;
readonly toDate: string; readonly toDate: string;
readonly schedule: ScheduleItem[]; readonly schedule: ScheduleItemProps[];
readonly seatsProposed?: number; readonly seatsProposed?: number;
readonly seatsRequested?: number; readonly seatsRequested?: number;
readonly strict: boolean; readonly strict: boolean;

View File

@ -1,17 +1,98 @@
import { CommandHandler, ICommandHandler } from '@nestjs/cqrs'; import { AggregateID, ConflictException } from '@mobicoop/ddd-library';
import { CreateAdCommand } from './create-ad.command';
import { Inject } from '@nestjs/common';
import { import {
AD_REPOSITORY, AD_REPOSITORY,
INPUT_DATETIME_TRANSFORMER, INPUT_DATETIME_TRANSFORMER,
} from '@modules/ad/ad.di-tokens'; } from '@modules/ad/ad.di-tokens';
import { AdEntity } from '@modules/ad/core/domain/ad.entity'; 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 { AdAlreadyExistsException } from '@modules/ad/core/domain/ad.errors';
import { AggregateID, ConflictException } from '@mobicoop/ddd-library'; import { ScheduleItemProps } from '@modules/ad/core/domain/value-objects/schedule-item.value-object';
import { ScheduleItem } from '../../types/schedule-item'; 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 { 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) @CommandHandler(CreateAdCommand)
export class CreateAdService implements ICommandHandler { export class CreateAdService implements ICommandHandler {
@ -23,80 +104,9 @@ export class CreateAdService implements ICommandHandler {
) {} ) {}
async execute(command: CreateAdCommand): Promise<AggregateID> { async execute(command: CreateAdCommand): Promise<AggregateID> {
const ad = AdEntity.create({ const ad = AdEntity.create(
userId: command.userId, createPropsFromCommand(command, this.datetimeTransformer),
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,
});
try { try {
await this.repository.insert(ad); await this.repository.insert(ad);

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,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'; import { Address } from './address';
//TODO Why not use the Waypoint value-object from the domain?
export type Waypoint = { export type Waypoint = {
position: number; position: number;
} & Address; } & Address;

View File

@ -30,9 +30,9 @@ export class AdEntity extends AggregateRoot<AdProps> {
fromDate: props.fromDate, fromDate: props.fromDate,
toDate: props.toDate, toDate: props.toDate,
schedule: props.schedule.map((day: ScheduleItemProps) => ({ schedule: props.schedule.map((day: ScheduleItemProps) => ({
day: day.day as number, day: day.day,
time: day.time, time: day.time,
margin: day.margin as number, margin: day.margin,
})), })),
seatsProposed: props.seatsProposed, seatsProposed: props.seatsProposed,
seatsRequested: props.seatsRequested, seatsRequested: props.seatsRequested,
@ -96,6 +96,24 @@ export class AdEntity extends AggregateRoot<AdProps> {
return this; 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 { delete(): void {
this.addEvent( this.addEvent(
new AdDeletedDomainEvent({ new AdDeletedDomainEvent({

View File

@ -1,23 +1,6 @@
import { ScheduleItemProps } from './value-objects/schedule-item.value-object'; import { ScheduleItemProps } from './value-objects/schedule-item.value-object';
import { WaypointProps } from './value-objects/waypoint.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 // Properties that are needed for an Ad creation
export interface CreateAdProps { export interface CreateAdProps {
userId: string; userId: string;
@ -34,6 +17,11 @@ export interface CreateAdProps {
comment?: string; comment?: string;
} }
// All properties that an Ad has
export interface AdProps extends CreateAdProps {
status: Status;
}
export enum Frequency { export enum Frequency {
PUNCTUAL = 'PUNCTUAL', PUNCTUAL = 'PUNCTUAL',
RECURRENT = 'RECURRENT', RECURRENT = 'RECURRENT',

View File

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

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 { export interface ScheduleItemProps {
day?: number; day: number;
time: string; time: string;
margin?: number; margin: number;
} }
export class ScheduleItem extends ValueObject<ScheduleItemProps> { export class ScheduleItem extends ValueObject<ScheduleItemProps> {
get day(): number | undefined { get day(): number {
return this.props.day; return this.props.day;
} }
@ -20,7 +20,7 @@ export class ScheduleItem extends ValueObject<ScheduleItemProps> {
return this.props.time; return this.props.time;
} }
get margin(): number | undefined { get margin(): number {
return this.props.margin; 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 { import {
LoggerBase, LoggerBase,
MessagePublisherPort, MessagePublisherPort,
PrismaRepositoryBase, PrismaRepositoryBase,
} from '@mobicoop/ddd-library'; } from '@mobicoop/ddd-library';
import { PrismaService } from './prisma.service'; import { Inject, Injectable, Logger } from '@nestjs/common';
import { AD_MESSAGE_PUBLISHER } from '../ad.di-tokens'; import { EventEmitter2 } from '@nestjs/event-emitter';
import { SERVICE_NAME } from '@src/app.constants'; 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 = { export type AdBaseModel = {
uuid: string; uuid: string;
@ -40,13 +40,21 @@ export type AdWriteModel = AdBaseModel & {
}; };
export type ScheduleWriteModel = { export type ScheduleWriteModel = {
deleteMany?: PastCreatedFilter;
create: ScheduleItemModel[]; create: ScheduleItemModel[];
}; };
export type WaypointWriteModel = { export type WaypointWriteModel = {
deleteMany?: PastCreatedFilter;
create: WaypointModel[]; 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 = { export type ScheduleItemModel = {
uuid: string; uuid: string;
day: number; day: number;

View File

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

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

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

@ -39,7 +39,9 @@ const punctualCreateAdProps = {
toDate: '2023-06-22', toDate: '2023-06-22',
schedule: [ schedule: [
{ {
day: 4,
time: '08:30', time: '08:30',
margin: 900,
}, },
], ],
frequency: Frequency.PUNCTUAL, frequency: Frequency.PUNCTUAL,

View File

@ -1,18 +1,17 @@
import { Test, TestingModule } from '@nestjs/testing'; import { AggregateID, ConflictException } from '@mobicoop/ddd-library';
import { import {
AD_REPOSITORY, AD_REPOSITORY,
INPUT_DATETIME_TRANSFORMER, INPUT_DATETIME_TRANSFORMER,
} from '@modules/ad/ad.di-tokens'; } 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 { 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 { 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 = { const originWaypoint: WaypointDto = {
position: 0, 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', () => { describe('create-ad.service', () => {
let createAdService: CreateAdService; let createAdService: CreateAdService;
@ -83,7 +75,7 @@ describe('create-ad.service', () => {
}, },
{ {
provide: INPUT_DATETIME_TRANSFORMER, provide: INPUT_DATETIME_TRANSFORMER,
useValue: mockInputDateTimeTransformer, useValue: mockInputDateTimeTransformer(),
}, },
CreateAdService, CreateAdService,
], ],

View File

@ -1,10 +1,10 @@
import { AD_REPOSITORY } from '@modules/ad/ad.di-tokens'; 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 { AdEntity } from '@modules/ad/core/domain/ad.entity';
import { CreateAdProps, Frequency } from '@modules/ad/core/domain/ad.types'; import { CreateAdProps, Frequency } from '@modules/ad/core/domain/ad.types';
import { WaypointProps } from '@modules/ad/core/domain/value-objects/waypoint.value-object'; import { WaypointProps } from '@modules/ad/core/domain/value-objects/waypoint.value-object';
import { Test, TestingModule } from '@nestjs/testing'; 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 = { const originWaypointProps: WaypointProps = {
position: 0, position: 0,
@ -44,7 +44,9 @@ const punctualCreateAdProps = {
toDate: '2023-06-22', toDate: '2023-06-22',
schedule: [ schedule: [
{ {
day: 4,
time: '08:30', time: '08:30',
margin: 900,
}, },
], ],
frequency: Frequency.PUNCTUAL, frequency: Frequency.PUNCTUAL,

View File

@ -1,10 +1,10 @@
import { AD_REPOSITORY } from '@modules/ad/ad.di-tokens'; 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 { AdEntity } from '@modules/ad/core/domain/ad.entity';
import { CreateAdProps, Frequency } from '@modules/ad/core/domain/ad.types'; import { CreateAdProps, Frequency } from '@modules/ad/core/domain/ad.types';
import { WaypointProps } from '@modules/ad/core/domain/value-objects/waypoint.value-object'; import { WaypointProps } from '@modules/ad/core/domain/value-objects/waypoint.value-object';
import { Test, TestingModule } from '@nestjs/testing'; 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 = { const originWaypointProps: WaypointProps = {
position: 0, position: 0,
@ -44,7 +44,9 @@ const punctualCreateAdProps = {
toDate: '2023-06-22', toDate: '2023-06-22',
schedule: [ schedule: [
{ {
day: 4,
time: '08:30', time: '08:30',
margin: 900,
}, },
], ],
frequency: Frequency.PUNCTUAL, 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 { 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 { AdEntity } from '@modules/ad/core/domain/ad.entity';
import { CreateAdProps, Frequency } from '@modules/ad/core/domain/ad.types'; import { CreateAdProps, Frequency } from '@modules/ad/core/domain/ad.types';
import { WaypointProps } from '@modules/ad/core/domain/value-objects/waypoint.value-object'; 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 { Test, TestingModule } from '@nestjs/testing';
import { InvalidateAdCommand } from '@modules/ad/core/application/commands/invalidate-ad/invalidate-ad.command';
const originWaypointProps: WaypointProps = { const originWaypointProps: WaypointProps = {
position: 0, position: 0,
@ -45,7 +45,9 @@ const punctualCreateAdProps = {
toDate: '2023-06-22', toDate: '2023-06-22',
schedule: [ schedule: [
{ {
day: 4,
time: '08:30', time: '08:30',
margin: 900,
}, },
], ],
frequency: Frequency.PUNCTUAL, 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 { 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 { AdEntity } from '@modules/ad/core/domain/ad.entity';
import { CreateAdProps, Frequency } from '@modules/ad/core/domain/ad.types'; 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 { WaypointProps } from '@modules/ad/core/domain/value-objects/waypoint.value-object';
import { Test, TestingModule } from '@nestjs/testing';
const originWaypointProps: WaypointProps = { const originWaypointProps: WaypointProps = {
position: 0, position: 0,
@ -45,7 +45,9 @@ const punctualCreateAdProps = {
toDate: '2023-06-22', toDate: '2023-06-22',
schedule: [ schedule: [
{ {
day: 4,
time: '08:30', time: '08:30',
margin: 900,
}, },
], ],
frequency: Frequency.PUNCTUAL, 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 { IdResponse, RpcExceptionCode } from '@mobicoop/ddd-library';
import { RpcExceptionCode } from '@mobicoop/ddd-library';
import { AdAlreadyExistsException } from '@modules/ad/core/domain/ad.errors'; 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 { 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 { CommandBus } from '@nestjs/cqrs';
import { RpcException } from '@nestjs/microservices'; import { RpcException } from '@nestjs/microservices';
import { Test, TestingModule } from '@nestjs/testing'; import { Test, TestingModule } from '@nestjs/testing';
import { punctualCreateAdRequest } from './ad.fixtures';
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],
};
const mockCommandBus = { const mockCommandBus = {
execute: jest execute: jest
@ -89,7 +48,7 @@ describe('Create Ad Grpc Controller', () => {
it('should create a new ad', async () => { it('should create a new ad', async () => {
jest.spyOn(mockCommandBus, 'execute'); jest.spyOn(mockCommandBus, 'execute');
const result: IdResponse = await createAdGrpcController.create( const result: IdResponse = await createAdGrpcController.create(
punctualCreateAdRequest, punctualCreateAdRequest(),
); );
expect(result).toBeInstanceOf(IdResponse); expect(result).toBeInstanceOf(IdResponse);
expect(result.id).toBe('200d61a8-d878-4378-a609-c19ea71633d2'); expect(result.id).toBe('200d61a8-d878-4378-a609-c19ea71633d2');
@ -100,7 +59,7 @@ describe('Create Ad Grpc Controller', () => {
jest.spyOn(mockCommandBus, 'execute'); jest.spyOn(mockCommandBus, 'execute');
expect.assertions(3); expect.assertions(3);
try { try {
await createAdGrpcController.create(punctualCreateAdRequest); await createAdGrpcController.create(punctualCreateAdRequest());
} catch (e: any) { } catch (e: any) {
expect(e).toBeInstanceOf(RpcException); expect(e).toBeInstanceOf(RpcException);
expect(e.error.code).toBe(RpcExceptionCode.ALREADY_EXISTS); expect(e.error.code).toBe(RpcExceptionCode.ALREADY_EXISTS);
@ -112,7 +71,7 @@ describe('Create Ad Grpc Controller', () => {
jest.spyOn(mockCommandBus, 'execute'); jest.spyOn(mockCommandBus, 'execute');
expect.assertions(3); expect.assertions(3);
try { try {
await createAdGrpcController.create(punctualCreateAdRequest); await createAdGrpcController.create(punctualCreateAdRequest());
} catch (e: any) { } catch (e: any) {
expect(e).toBeInstanceOf(RpcException); expect(e).toBeInstanceOf(RpcException);
expect(e.error.code).toBe(RpcExceptionCode.UNKNOWN); expect(e.error.code).toBe(RpcExceptionCode.UNKNOWN);

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