Skip to content

Commit

Permalink
36 retrieve cars planning (#39)
Browse files Browse the repository at this point in the history
* feat: add retrieve cars planning
  • Loading branch information
haroldcohen authored Oct 19, 2023
1 parent 2e4e039 commit ca3fdd4
Show file tree
Hide file tree
Showing 23 changed files with 902 additions and 13 deletions.
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@
"uuid": "^9.0.1"
},
"devDependencies": {
"@faker-js/faker": "^8.1.0",
"@jorgebodega/typeorm-factory": "^1.4.0",
"@thetribe/eslint-config-typescript": "^0.5.1",
"@types/jest": "^29.5.5",
Expand Down
17 changes: 17 additions & 0 deletions src/core/domain/car/interfaces/repositories/read.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
import Car from '../../model';
import CarsPlanningDTO from "src/core/domain/car/outputBoundaries/outputBoundary";
import DecodedCursor from "src/core/domain/common/types/cursor";

/**
* Interface for CarReadRepository.
Expand All @@ -20,4 +22,19 @@ export default interface CarReadRepositoryInterface {
pickupDateTime,
dropOffDateTime
}: { modelId: string, pickupDateTime: Date, dropOffDateTime: Date }): Promise<Car>;

/**
* Gets a cars planning (meaning the planning for a selection of cars) based on date range and a cursor.
*
* @param startDate
* @param endDate
* @param cursor
* @param limit
*/
getCarsPlanning({
startDate,
endDate,
cursor,
limit,
}: { startDate: Date, endDate: Date, cursor: DecodedCursor, limit: number }): Promise<CarsPlanningDTO>;
}
25 changes: 25 additions & 0 deletions src/core/domain/car/outputBoundaries/outputBoundary.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
export type CarRentalPlanningDTO = {
id: string;
pickupDateTime: Date;
dropOffDateTime: Date;
}

export type CarPlanningDTO = {
rentals: CarRentalPlanningDTO[];
};

type Cursor = {
nextPage: null | string;
}

type CarPlanningGroupType = {
[key: string]: CarPlanningDTO;
}

type CarsPlanningDTO = {
cars: CarPlanningGroupType;
cursor: Cursor;
};

export default CarsPlanningDTO;

6 changes: 6 additions & 0 deletions src/core/domain/common/types/cursor.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
type DecodedCursor = {
order: string;
address: string;
}

export default DecodedCursor;
19 changes: 19 additions & 0 deletions src/core/useCases/car/query/retrieveCarsPlanning/authorizer.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
import UserIsNotAuthorizedToRetrieveCarsPlanningError
from "src/core/useCases/car/query/retrieveCarsPlanning/exceptions/notAuthorized";
import PermissionsGatewayInterface from "src/core/useCases/common/interfaces/gateways/permissions";

export default class RetrieveCarsPlanningAuthorizer {
private readonly permissionsGateway: PermissionsGatewayInterface;

constructor({permissionsGateway}: { permissionsGateway: PermissionsGatewayInterface }) {
this.permissionsGateway = permissionsGateway;
}

async authorize({actorId}: { actorId: string }): Promise<void> {
const permissions = await this.permissionsGateway.getUserPermissions({userId: actorId});

if (permissions.length === 0) {
throw new UserIsNotAuthorizedToRetrieveCarsPlanningError();
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
export default class UserIsNotAuthorizedToRetrieveCarsPlanningError extends Error {

}
32 changes: 32 additions & 0 deletions src/core/useCases/car/query/retrieveCarsPlanning/handler.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
import CarsPlanningDTO from "src/core/domain/car/outputBoundaries/outputBoundary";
import CarReadRepositoryInterface from "src/core/domain/car/interfaces/repositories/read";
import RetrieveCarsPlanningQuery from "src/core/useCases/car/query/retrieveCarsPlanning/types/query";
import RetrieveCarsPlanningAuthorizer from "src/core/useCases/car/query/retrieveCarsPlanning/authorizer";
import {decodeCursor} from "src/core/useCases/common/cursors/encodeDecode";

export default class RetrieveCarsPlanning {
private readonly carReadRepository: CarReadRepositoryInterface;

private readonly authorizer: RetrieveCarsPlanningAuthorizer;

constructor({
carReadRepository,
authorizer
}: { carReadRepository: CarReadRepositoryInterface, authorizer: RetrieveCarsPlanningAuthorizer }) {
this.carReadRepository = carReadRepository;
this.authorizer = authorizer;
}

async execute(query: RetrieveCarsPlanningQuery): Promise<CarsPlanningDTO> {
await this.authorizer.authorize({
actorId: query.actor.id,
});

return this.carReadRepository.getCarsPlanning({
startDate: query.startDate,
endDate: query.endDate,
limit: query.limit,
cursor: decodeCursor(query.cursor),
});
}
}
13 changes: 13 additions & 0 deletions src/core/useCases/car/query/retrieveCarsPlanning/types/query.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
type Actor = {
id: string;
}

type RetrieveCarsPlanningQuery = {
startDate: Date;
endDate: Date;
limit: number;
cursor: string;
actor: Actor;
};

export default RetrieveCarsPlanningQuery;
36 changes: 36 additions & 0 deletions src/core/useCases/common/cursors/encodeDecode.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
import DecodedCursor from "src/core/domain/common/types/cursor";

/**
* Encodes a cursor address to a base64 format.
*
* @param cursor The cursor address to encode
* @param order The order of the cursor (gte for next, lte for prev)
*/
export const encodeCursor = (cursor: string, order: string): string => {
if (cursor === ''){
return '';
}
return btoa(`${order === 'gte' ? 'next' : 'prev'}___${cursor}`);
}

/**
* Decodes a cursor address from a base64 format.
*
* @param cursor The cursor address to decode
*/
export const decodeCursor = (cursor: string): DecodedCursor => {
if (cursor === '') {
return {
order: 'gte',
address: ''
}
}
// eslint-disable-next-line prefer-regex-literals
const cursorMatch = atob(cursor).match(new RegExp('^(?<order>(next|prev))___(?<address>[\\w\'-]+)$')) as RegExpMatchArray;
const groups = cursorMatch.groups as any;

return {
order: groups.order === 'next' ? 'gte' : 'lte',
address: groups.address as string,
}
}
5 changes: 5 additions & 0 deletions src/core/useCases/common/interfaces/gateways/permissions.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
import UserPermissionsProfile from "src/core/useCases/common/permissions/types/userPermissionsProfile";

export default interface PermissionsGatewayInterface {
getUserPermissions({userId}: { userId: string }): Promise<UserPermissionsProfile[]>;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
type UserPermissionsProfile = {
}

export default UserPermissionsProfile;
1 change: 1 addition & 0 deletions src/driven/repositories/inMemory/car/car.entity.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
type InMemoryCar = {
id: string;
licensePlate: string;
modelId: string;
}

Expand Down
81 changes: 77 additions & 4 deletions src/driven/repositories/inMemory/car/read.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,9 @@ import InMemoryCarRental from '../carRental/carRental.entity';
import CarModel from 'src/core/domain/carModel/model';
import InMemoryCarModel from '../carModel/carModel.entity';
import UnavailableCarError from 'src/core/domain/car/errors/unavailable';
import CarsPlanningDTO from "src/core/domain/car/outputBoundaries/outputBoundary";
import DecodedCursor from "src/core/domain/common/types/cursor";
import {encodeCursor} from "src/core/useCases/common/cursors/encodeDecode";

@injectable()
export default class InMemoryCarReadRepository implements CarReadRepositoryInterface {
Expand All @@ -24,7 +27,10 @@ export default class InMemoryCarReadRepository implements CarReadRepositoryInter
* @param inMemoryCar The in memory car to use for mapping.
* @param inMemoryCarModel The in memory car model to use for mapping.
*/
static toCar({inMemoryCar, inMemoryCarModel}:{inMemoryCar: InMemoryCar, inMemoryCarModel: InMemoryCarModel}): Car {
static toCar({
inMemoryCar,
inMemoryCarModel
}: { inMemoryCar: InMemoryCar, inMemoryCarModel: InMemoryCarModel }): Car {
return new Car({
id: inMemoryCar.id,
model: new CarModel({
Expand All @@ -34,7 +40,11 @@ export default class InMemoryCarReadRepository implements CarReadRepositoryInter
})
}

async getOneAvailableCar({modelId, pickupDateTime, dropOffDateTime}: { modelId: string, pickupDateTime: Date, dropOffDateTime: Date }): Promise<Car> {
async getOneAvailableCar({
modelId,
pickupDateTime,
dropOffDateTime
}: { modelId: string, pickupDateTime: Date, dropOffDateTime: Date }): Promise<Car> {
// The code below needs be refactored using composition
// See ticket https://github.com/thetribeio/megahertz/issues/14
const retrievedCars: InMemoryCar[] = _.filter(
Expand All @@ -55,13 +65,76 @@ export default class InMemoryCarReadRepository implements CarReadRepositoryInter
throw new UnavailableCarError();
}
const retrievedCarModel = _.find(
this.unitOfWork.carModels,
inMemoryCarModel => inMemoryCarModel.id === modelId
this.unitOfWork.carModels,
inMemoryCarModel => inMemoryCarModel.id === modelId
) as InMemoryCarModel;

return InMemoryCarReadRepository.toCar({
inMemoryCar: retrievedCar,
inMemoryCarModel: retrievedCarModel,
})
}

findNextCursor(cars: InMemoryCar[]): string {
const nextCar = _.find(
this.unitOfWork.cars,
inMemoryCar => inMemoryCar.licensePlate > cars[cars.length - 1].licensePlate
);
if (nextCar === undefined) {
return "";
}

return cars[cars.length - 1].licensePlate;
}

async getCarsPlanning({
startDate,
endDate,
cursor,
limit,
}: { startDate: Date, endDate: Date, cursor: DecodedCursor, limit: number }): Promise<CarsPlanningDTO> {
const retrievedCars: InMemoryCar[] = [];
const sorted = _.orderBy(this.unitOfWork.cars, ['licensePlate'], ['asc']);
const filtered = _.filter(
sorted,
inMemoryCar => cursor.order === 'gte' ? inMemoryCar.licensePlate >= cursor.address : inMemoryCar.licensePlate >= cursor.address
);
_.forEach(filtered, (car) => {
if (retrievedCars.length === limit) {
return false;
}
retrievedCars.push(car);

return car;
});
const planning = {
cars: {},
cursor: {
nextPage: encodeCursor(this.findNextCursor(retrievedCars), cursor.order),
},
} as CarsPlanningDTO;
for (const retrievedCar of retrievedCars) {
const retrievedCarRentals: InMemoryCarRental[] = _.filter(
this.unitOfWork.carRentals,
inMemoryCarRental =>
inMemoryCarRental.carId === retrievedCar.id
&& (
(inMemoryCarRental.pickupDateTime >= startDate && inMemoryCarRental.pickupDateTime <= endDate)
|| (inMemoryCarRental.dropOffDateTime > startDate && inMemoryCarRental.pickupDateTime <= startDate)
)
);
planning.cars[retrievedCar.id] = {
rentals: []
}
for (const retrievedCarRental of retrievedCarRentals) {
planning.cars[retrievedCar.id].rentals.push({
id: retrievedCarRental.id,
pickupDateTime: retrievedCarRental.pickupDateTime,
dropOffDateTime: retrievedCarRental.dropOffDateTime,
})
}
}

return planning;
}
}
15 changes: 15 additions & 0 deletions tests/common/stubs/gateways/permissions/allow.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
import PermissionsGatewayInterface from "src/core/useCases/common/interfaces/gateways/permissions";
import UserPermissionsProfile from "src/core/useCases/common/permissions/types/userPermissionsProfile";

export default class PermissionsStubGateway implements PermissionsGatewayInterface {
private readonly permissionsToProvide: UserPermissionsProfile[];

constructor(permissionsToProvide: UserPermissionsProfile[]) {
this.permissionsToProvide = permissionsToProvide;
}

async getUserPermissions(): Promise<UserPermissionsProfile[]> {
return this.permissionsToProvide;
}

}
9 changes: 9 additions & 0 deletions tests/common/stubs/gateways/permissions/deny.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
import PermissionsGatewayInterface from "src/core/useCases/common/interfaces/gateways/permissions";
import UserPermissionsProfile from "src/core/useCases/common/permissions/types/userPermissionsProfile";

export default class PermissionsDenyStubGateway implements PermissionsGatewayInterface {
// @ts-ignore
async getUserPermissions(): Promise<UserPermissionsProfile[]> {
return [];
}
}
30 changes: 30 additions & 0 deletions tests/unit/car/query/retrieveCarsPlanning/beforeEach.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
import {container} from "tsyringe";
import CarsPlanningDTO from "src/core/domain/car/outputBoundaries/outputBoundary";
import DateParser from "tests/utils/dateParser";
import {CarTestCaseEntry} from "tests/unit/utils/testCase.types";

export const buildExpectedCarsPlanning = (cars: CarTestCaseEntry[], nextCursor: string, prevCursor: null | string): CarsPlanningDTO => {
const dateParser: DateParser = container.resolve("DateParser");
const expectedPlanning: CarsPlanningDTO = {
cars: {},
cursor: {
nextPage: nextCursor,
}
}
for (const carTestCaseEntry of cars) {
expectedPlanning.cars[carTestCaseEntry.id] = {
rentals: []
}
for (const rentalEntry of carTestCaseEntry.rentals) {
expectedPlanning.cars[carTestCaseEntry.id].rentals.push(
{
id: rentalEntry.id,
pickupDateTime: dateParser.parse(rentalEntry.pickupDateTime),
dropOffDateTime: dateParser.parse(rentalEntry.dropOffDateTime),
}
)
}
}

return expectedPlanning;
}
Loading

0 comments on commit ca3fdd4

Please sign in to comment.