18 Commits

Author SHA1 Message Date
Fanch
bfc731bd96 test(ad pause): fix test after adding update event emission 2024-05-23 07:19:39 +02:00
Fanch
d213408c83 feat(pause ad): emit update event after pause ad 2024-05-23 07:08:52 +02:00
Fanch
2ce64cd1c4 fix(ad pause): fix code after merge of update ad 2024-05-21 08:33:55 +00:00
Fanch
be2a2badcf test(pause ad): adapt current test after adding pause flag in ad 2024-05-21 08:33:55 +00:00
Fanch
54d1dab461 feat(pause ad): add pause in db with prisma 2024-05-21 08:33:55 +00:00
Fanch
3b8ab49396 feat(pause ad): add pause as rpc route in proto 2024-05-21 08:33:55 +00:00
Fanch
3bef47c27e feat(pause ad): add first basic ad pause service 2024-05-21 08:33:55 +00:00
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
48 changed files with 953 additions and 266 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
- 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
@@ -246,13 +249,16 @@ The app exposes the following [gRPC](https://grpc.io/) services :
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 :
- **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)
## Tests / ESLint / Prettier

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.5.0",
"version": "2.6.0",
"description": "Mobicoop V3 Ad",
"author": "sbriat",
"private": true,
@@ -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

@@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "ad" ADD COLUMN "pause" BOOLEAN NOT NULL DEFAULT false;

View File

@@ -27,6 +27,7 @@ model Ad {
createdAt DateTime @default(now())
updatedAt DateTime @default(now()) @updatedAt
waypoints Waypoint[]
pause Boolean @default(false)
comment String?
@@map("ad")

View File

@@ -7,8 +7,10 @@ export const GRPC_SERVICE_NAME = 'AdService';
// messaging output
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';
// messaging output
export const AD_PAUSED_ROUTING_KEY = 'ad.paused';
// messaging input
export const MATCHER_AD_CREATED_MESSAGE_HANDLER = 'matcherAdCreated';

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,81 @@ 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),
pause: copy.pause,
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,
@@ -129,6 +161,7 @@ export class AdMapper
},
},
})),
pause: record.pause,
comment: record.comment,
},
});
@@ -141,6 +174,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 +197,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 +213,7 @@ export class AdMapper
},
props.frequency,
),
margin: scheduleItem.margin as number,
margin: scheduleItem.margin,
}),
);
response.seatsProposed = props.seatsProposed as number;
@@ -195,6 +229,7 @@ export class AdMapper
lon: waypoint.address.coordinates.lon,
lat: waypoint.address.coordinates.lat,
}));
response.pause = props.pause;
response.comment = props.comment;
return response;
};

View File

@@ -12,11 +12,14 @@ import {
import { AdMapper } from './ad.mapper';
import { CreateAdService } from './core/application/commands/create-ad/create-ad.service';
import { DeleteAdService } from './core/application/commands/delete-ad/delete-ad.service';
import { PauseAdService } from './core/application/commands/pause-ad/pause-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 { 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';
@@ -28,16 +31,20 @@ 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 { PauseAdGrpcController } from './interface/grpc-controllers/pause-ad.grpc.controller';
import { FindAdByIdGrpcController } from './interface/grpc-controllers/find-ad-by-id.grpc.controller';
import { FindAdsByIdsGrpcController } from './interface/grpc-controllers/find-ads-by-ids.grpc.controller';
import { FindAdsByUserIdGrpcController } from './interface/grpc-controllers/find-ads-by-user-id.grpc.controller';
import { UpdateAdGrpcController } from './interface/grpc-controllers/update-ad.grpc.controller';
import { MatcherAdCreatedMessageHandler } from './interface/message-handlers/matcher-ad-created.message-handler';
import { MatcherAdCreationFailedMessageHandler } from './interface/message-handlers/matcher-ad-creation-failed.message-handler';
import { UserDeletedMessageHandler } from './interface/message-handlers/user-deleted.message-handler';
const grpcControllers = [
CreateAdGrpcController,
UpdateAdGrpcController,
DeleteAdGrpcController,
PauseAdGrpcController,
FindAdByIdGrpcController,
FindAdsByIdsGrpcController,
FindAdsByUserIdGrpcController,
@@ -51,12 +58,15 @@ const messageHandlers = [
const eventHandlers: Provider[] = [
PublishMessageWhenAdIsCreatedDomainEventHandler,
PublishMessageWhenAdIsUpdatedDomainEventHandler,
PublishMessageWhenAdIsDeletedDomainEventHandler,
];
const commandHandlers: Provider[] = [
CreateAdService,
UpdateAdService,
DeleteAdService,
PauseAdService,
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,11 +10,12 @@ export class CreateAdCommand extends Command {
readonly frequency: Frequency;
readonly fromDate: string;
readonly toDate: string;
readonly schedule: ScheduleItem[];
readonly schedule: ScheduleItemProps[];
readonly seatsProposed?: number;
readonly seatsRequested?: number;
readonly strict: boolean;
readonly waypoints: Waypoint[];
readonly pause: boolean;
readonly comment?: string;
constructor(props: CommandProps<CreateAdCommand>) {
@@ -30,6 +31,7 @@ export class CreateAdCommand extends Command {
this.seatsRequested = props.seatsRequested;
this.strict = props.strict;
this.waypoints = props.waypoints;
this.pause = props.pause;
this.comment = props.comment;
}
}

View File

@@ -1,17 +1,99 @@
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,
pause: command.pause,
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 +105,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 PauseAdCommand extends Command {
constructor(props: CommandProps<PauseAdCommand>) {
super(props);
}
}

View File

@@ -0,0 +1,30 @@
import { AD_REPOSITORY } from '@modules/ad/ad.di-tokens';
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 { PauseAdCommand } from './pause-ad.command';
import { AdUpdatedDomainEvent } from '@modules/ad/core/domain/events/ad.domain-event';
@CommandHandler(PauseAdCommand)
export class PauseAdService implements ICommandHandler {
constructor(
@Inject(AD_REPOSITORY)
private readonly adRepository: AdRepositoryPort,
private readonly eventEmitter: EventEmitter2,
) {}
async execute(command: PauseAdCommand): Promise<void> {
const ad = await this.adRepository.findOneById(command.id, {
// TODO: waypoints and schedule needed for event, should be optional if no modif on them ...
waypoints: true,
schedule: true,
});
ad.pause();
await this.adRepository.update(ad.id, ad);
this.eventEmitter.emitAsync(
AdUpdatedDomainEvent.name,
new AdUpdatedDomainEvent(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

@@ -2,3 +2,8 @@ import { RepositoryPort } from '@mobicoop/ddd-library';
import { AdEntity } from '../../domain/ad.entity';
export type AdRepositoryPort = RepositoryPort<AdEntity>;
/*
& {
pause(entity: AdEntity, identifier?: string): Promise<boolean>;
};
*/

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

@@ -30,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,
@@ -48,6 +48,7 @@ export class AdEntity extends AggregateRoot<AdProps> {
lon: waypoint.address.coordinates.lon,
lat: waypoint.address.coordinates.lat,
})),
pause: props.pause,
comment: props.comment,
}),
);
@@ -96,6 +97,28 @@ 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;
};
validate(): void {
// entity business rules validation to protect it's invariant before saving entity to a database
}
delete(): void {
this.addEvent(
new AdDeletedDomainEvent({
@@ -104,7 +127,8 @@ export class AdEntity extends AggregateRoot<AdProps> {
);
}
validate(): void {
// entity business rules validation to protect it's invariant before saving entity to a database
pause(): AdEntity {
this.props.pause = !this.props.pause;
return this;
}
}

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;
@@ -31,9 +14,15 @@ export interface CreateAdProps {
seatsRequested: number;
strict: boolean;
waypoints: WaypointProps[];
pause: boolean;
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,11 +8,12 @@ export class AdCreatedDomainEvent extends DomainEvent {
readonly frequency: string;
readonly fromDate: string;
readonly toDate: string;
readonly schedule: ScheduleItem[];
readonly schedule: ScheduleItemProps[];
readonly seatsProposed: number;
readonly seatsRequested: number;
readonly strict: boolean;
readonly waypoints: Waypoint[];
readonly pause?: boolean;
readonly comment?: string;
constructor(props: DomainEventProps<AdCreatedDomainEvent>) {
@@ -27,16 +29,11 @@ export class AdCreatedDomainEvent extends DomainEvent {
this.seatsRequested = props.seatsRequested;
this.strict = props.strict;
this.waypoints = props.waypoints;
this.pause = props.pause;
this.comment = props.comment;
}
}
export class ScheduleItem {
day: number;
time: string;
margin: number;
}
export class Waypoint {
position: number;
name?: string;

View File

@@ -0,0 +1,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;
@@ -24,6 +24,7 @@ export type AdBaseModel = {
seatsProposed: number;
seatsRequested: number;
strict: boolean;
pause: boolean;
comment?: string;
};
@@ -40,13 +41,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

@@ -28,5 +28,6 @@ export class AdResponseDto extends ResponseBase {
lon: number;
lat: number;
}[];
pause: boolean;
comment?: string;
}

View File

@@ -7,8 +7,9 @@ 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);
rpc Pause(AdById) returns (Empty);
}
message AdById {
@@ -37,6 +38,7 @@ message Ad {
bool strict = 11;
repeated Waypoint waypoints = 12;
optional string comment = 13;
optional bool pause = 14;
}
message ScheduleItem {
@@ -58,6 +60,7 @@ message Waypoint {
}
enum Frequency {
UNSPECIFIED = 0;
PUNCTUAL = 1;
RECURRENT = 2;
}

View File

@@ -81,6 +81,10 @@ export class CreateAdRequestDto {
@ValidateNested({ each: true })
waypoints: WaypointDto[];
@IsBoolean()
@IsOptional()
pause: boolean;
@Length(0, 2000)
@IsString()
@IsOptional()

View File

@@ -0,0 +1,7 @@
import { IsNotEmpty, IsString } from 'class-validator';
export class PauseAdRequestDto {
@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,45 @@
import {
DatabaseErrorException,
NotFoundException,
RpcExceptionCode,
RpcValidationPipe,
} from '@mobicoop/ddd-library';
import { PauseAdCommand } from '@modules/ad/core/application/commands/pause-ad/pause-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 { PauseAdRequestDto } from './dtos/pause-ad.request.dto';
@UsePipes(
new RpcValidationPipe({
whitelist: false,
forbidUnknownValues: false,
}),
)
@Controller()
export class PauseAdGrpcController {
constructor(private readonly commandBus: CommandBus) {}
@GrpcMethod(GRPC_SERVICE_NAME, 'Pause')
async pause(data: PauseAdRequestDto): Promise<void> {
try {
await this.commandBus.execute(new PauseAdCommand(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,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

@@ -225,6 +225,7 @@ describe('Ad Repository', () => {
},
},
],
pause: false,
};
const adToCreate: AdEntity = AdEntity.create(createAdProps);
@@ -301,6 +302,7 @@ describe('Ad Repository', () => {
},
},
],
pause: false,
};
const adToCreate: AdEntity = AdEntity.create(createAdProps);

View File

@@ -59,6 +59,7 @@ const adEntity: AdEntity = new AdEntity({
strict: false,
seatsProposed: 3,
seatsRequested: 1,
pause: false,
},
createdAt: now,
updatedAt: now,
@@ -112,6 +113,7 @@ const adReadModel: AdReadModel = {
seatsProposed: 3,
seatsRequested: 1,
comment: '',
pause: false,
createdAt: now,
updatedAt: now,
};

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

@@ -90,36 +90,42 @@ const punctualPassengerCreateAdProps: CreateAdProps = {
...punctualCreateAdProps,
driver: false,
passenger: true,
pause: false,
};
const recurrentPassengerCreateAdProps: CreateAdProps = {
...baseCreateAdProps,
...recurrentCreateAdProps,
driver: false,
passenger: true,
pause: false,
};
const punctualDriverCreateAdProps: CreateAdProps = {
...baseCreateAdProps,
...punctualCreateAdProps,
driver: true,
passenger: false,
pause: false,
};
const recurrentDriverCreateAdProps: CreateAdProps = {
...baseCreateAdProps,
...recurrentCreateAdProps,
driver: true,
passenger: false,
pause: false,
};
const punctualDriverPassengerCreateAdProps: CreateAdProps = {
...baseCreateAdProps,
...punctualCreateAdProps,
driver: true,
passenger: true,
pause: false,
};
const recurrentDriverPassengerCreateAdProps: CreateAdProps = {
...baseCreateAdProps,
...recurrentCreateAdProps,
driver: true,
passenger: true,
pause: false,
};
describe('Ad entity create', () => {

View File

@@ -39,7 +39,9 @@ const punctualCreateAdProps = {
toDate: '2023-06-22',
schedule: [
{
day: 4,
time: '08:30',
margin: 900,
},
],
frequency: Frequency.PUNCTUAL,
@@ -51,5 +53,6 @@ export function punctualPassengerCreateAdProps(): CreateAdProps {
...punctualCreateAdProps,
driver: false,
passenger: true,
pause: false,
};
}

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,
@@ -50,6 +49,7 @@ const punctualCreateAdRequest: CreateAdRequestDto = {
strict: false,
frequency: Frequency.PUNCTUAL,
waypoints: [originWaypoint, destinationWaypoint],
pause: false,
};
const mockAdRepository = {
@@ -64,13 +64,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 +76,7 @@ describe('create-ad.service', () => {
},
{
provide: INPUT_DATETIME_TRANSFORMER,
useValue: mockInputDateTimeTransformer,
useValue: mockInputDateTimeTransformer(),
},
CreateAdService,
],

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,
@@ -54,6 +56,7 @@ const punctualPassengerCreateAdProps: CreateAdProps = {
...punctualCreateAdProps,
driver: false,
passenger: true,
pause: false,
};
const ads: AdEntity[] = [

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,
@@ -54,6 +56,7 @@ const punctualPassengerCreateAdProps: CreateAdProps = {
...punctualCreateAdProps,
driver: false,
passenger: true,
pause: false,
};
const ads: AdEntity[] = [

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,
@@ -55,6 +57,7 @@ const punctualPassengerCreateAdProps: CreateAdProps = {
...punctualCreateAdProps,
driver: false,
passenger: true,
pause: false,
};
const ad: AdEntity = AdEntity.create(punctualPassengerCreateAdProps);

View File

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

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,
@@ -55,6 +57,7 @@ const punctualPassengerCreateAdProps: CreateAdProps = {
...punctualCreateAdProps,
driver: false,
passenger: true,
pause: false,
};
const ad: AdEntity = AdEntity.create(punctualPassengerCreateAdProps);

View File

@@ -0,0 +1,44 @@
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,
pause: 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 { PauseAdGrpcController } from '@modules/ad/interface/grpc-controllers/pause-ad.grpc.controller';
import { CommandBus } from '@nestjs/cqrs';
import { Test, TestingModule } from '@nestjs/testing';
const mockCommandBus = {
execute: jest.fn(),
};
describe('Pause Ad Grpc Controller', () => {
let pauseAdGrpcController: PauseAdGrpcController;
beforeAll(async () => {
const module: TestingModule = await Test.createTestingModule({
providers: [
{
provide: CommandBus,
useValue: mockCommandBus,
},
PauseAdGrpcController,
],
}).compile();
pauseAdGrpcController = module.get<PauseAdGrpcController>(
PauseAdGrpcController,
);
});
afterEach(async () => {
jest.clearAllMocks();
});
it('should be defined', () => {
expect(pauseAdGrpcController).toBeDefined();
});
it('should execute the pause ad command', async () => {
await pauseAdGrpcController.pause({
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);
});
});