diff --git a/.gitignore b/.gitignore index d052431..b4ec188 100644 --- a/.gitignore +++ b/.gitignore @@ -7,4 +7,7 @@ dist/ # terraform .terraform/ -deploy/ \ No newline at end of file +deploy/ + +# vscode +.vscode/settings.json diff --git a/src/common/logger.ts b/src/common/logger.ts new file mode 100644 index 0000000..9ba3730 --- /dev/null +++ b/src/common/logger.ts @@ -0,0 +1,60 @@ +export enum LogLevel { + DEBUG, + INFO, + ERROR, +} + +export class Logger { + private readonly correlationId: string + private readonly level: LogLevel + + constructor(correlationId: string, level = LogLevel.ERROR) { + this.correlationId = correlationId + this.level = level + } + + private log(logLevel: LogLevel, message: string, details?: unknown): void { + if (logLevel < this.level) { + return + } + + let levelString + switch (logLevel) { + case LogLevel.ERROR: + levelString = 'ERROR' + break + case LogLevel.INFO: + levelString = 'INFO' + break + case LogLevel.DEBUG: + default: + levelString = 'DEBUG' + break + } + + let fmtString = '[%s] %s: %s' + const params: Array = [this.correlationId, levelString, message] + if (details) { + params.push(details) + fmtString += ' - %O' + } + + if (logLevel === LogLevel.ERROR) { + console.error(fmtString, ...params) + } else { + console.log(fmtString, ...params) + } + } + + public debug(message: string, details?: unknown): void { + this.log(LogLevel.DEBUG, message, details) + } + + public info(message: string, details?: unknown): void { + this.log(LogLevel.INFO, message, details) + } + + public error(message: string, details?: unknown): void { + this.log(LogLevel.ERROR, message, details) + } +} diff --git a/src/common/request.ts b/src/common/request.ts index d317645..eb57d49 100644 --- a/src/common/request.ts +++ b/src/common/request.ts @@ -1,4 +1,4 @@ -import dayjs, { Dayjs } from 'dayjs' +import dayjs from 'dayjs' import isSameOrBefore from 'dayjs/plugin/isSameOrBefore' dayjs.extend(isSameOrBefore) @@ -7,10 +7,7 @@ import { DateRange, Opponent } from './reservation' export interface ReservationRequest { username: string password: string - dateRange: { - start: Dayjs - end: Dayjs - } + dateRange: DateRange opponent: Opponent } @@ -37,9 +34,7 @@ export class ValidationError extends Error { * @param body String of request body * @returns ReservationRequest */ -export const validateRequest = ( - body: string -): ReservationRequest => { +export const validateRequest = (body: string): ReservationRequest => { const request = validateRequestBody(body) validateRequestDateRange(request.dateRange) validateRequestOpponent(request.opponent) @@ -48,7 +43,10 @@ export const validateRequest = ( const validateRequestBody = (body?: string): ReservationRequest => { if (body === undefined) { - throw new ValidationError('Invalid request', ValidationErrorCode.UNDEFINED_REQUEST_BODY) + throw new ValidationError( + 'Invalid request', + ValidationErrorCode.UNDEFINED_REQUEST_BODY + ) } const jsonBody = transformRequestBody(body) @@ -65,7 +63,10 @@ const validateRequestBody = (body?: string): ReservationRequest => { (opponent && opponent.id && opponent.id.length < 1) || (opponent && opponent.name && opponent.name.length < 1) ) { - throw new ValidationError('Invalid request', ValidationErrorCode.INVALID_REQUEST_BODY) + throw new ValidationError( + 'Invalid request', + ValidationErrorCode.INVALID_REQUEST_BODY + ) } return jsonBody @@ -76,7 +77,10 @@ const transformRequestBody = (body: string): ReservationRequest => { try { json = JSON.parse(body) } catch (err) { - throw new ValidationError('Invalid request', ValidationErrorCode.INVALID_JSON) + throw new ValidationError( + 'Invalid request', + ValidationErrorCode.INVALID_JSON + ) } const startTime = json.dateRange?.start ?? 'invalid' const endTime = json.dateRange?.end ?? 'invalid' @@ -84,8 +88,8 @@ const transformRequestBody = (body: string): ReservationRequest => { return { username: json.username, password: json.password, - opponent: json.opponent, dateRange, + opponent: json.opponent, } } @@ -93,7 +97,10 @@ const validateRequestDateRange = (dateRange: DateRange): void => { // checking that both dates are valid const { start, end } = dateRange if (!start.isValid() || !end.isValid()) { - throw new ValidationError('Invalid request', ValidationErrorCode.INVALID_DATE_RANGE) + throw new ValidationError( + 'Invalid request', + ValidationErrorCode.INVALID_DATE_RANGE + ) } // checking that: @@ -105,7 +112,10 @@ const validateRequestDateRange = (dateRange: DateRange): void => { !start.isSameOrBefore(end) || start.format('YYYY MM DD') !== end.format('YYYY MM DD') ) { - throw new ValidationError('Invalid request', ValidationErrorCode.INVALID_START_OR_END_DATE) + throw new ValidationError( + 'Invalid request', + ValidationErrorCode.INVALID_START_OR_END_DATE + ) } } @@ -119,6 +129,9 @@ const validateRequestOpponent = (opponent?: Opponent): void => { id.length < 1 || name.length < 1 ) { - throw new ValidationError('Invalid request', ValidationErrorCode.INVALID_OPPONENT) + throw new ValidationError( + 'Invalid request', + ValidationErrorCode.INVALID_OPPONENT + ) } } diff --git a/src/common/reservation.ts b/src/common/reservation.ts index b164b4c..f3ac840 100644 --- a/src/common/reservation.ts +++ b/src/common/reservation.ts @@ -12,6 +12,8 @@ export interface DateRange { end: dayjs.Dayjs } +const RESERVATION_AVAILABLE_WITHIN_DAYS = 7 + export class Reservation { public readonly dateRange: DateRange public readonly opponent: Opponent @@ -29,7 +31,7 @@ export class Reservation { const { start, end } = this.dateRange - let possibleDate = dayjs(start) + let possibleDate = dayjs(start).second(0).millisecond(0) while (possibleDate.isSameOrBefore(end)) { possibleDates.push(possibleDate) possibleDate = possibleDate.add(15, 'minute') @@ -37,4 +39,15 @@ export class Reservation { return possibleDates } + + /** + * Method to check if a reservation is available for reservation in the system + * @returns is reservation date within 7 days + */ + public isAvailableForReservation(): boolean { + return ( + Math.ceil(this.dateRange.start.diff(dayjs(), 'days', true)) <= + RESERVATION_AVAILABLE_WITHIN_DAYS + ) + } } diff --git a/src/common/schedule.ts b/src/common/schedule.ts new file mode 100644 index 0000000..c10c3a6 --- /dev/null +++ b/src/common/schedule.ts @@ -0,0 +1,17 @@ +import { Dayjs } from 'dayjs' + +/** + * + * @param requestedDate + * @returns + */ +export const scheduleDateToRequestReservation = ( + requestedDate: Dayjs +): Dayjs => { + return requestedDate + .hour(0) + .minute(0) + .second(0) + .millisecond(0) + .subtract(7, 'days') +} diff --git a/src/lambdas/reservationHandler.ts b/src/lambdas/reservationHandler.ts index bcf46d4..386c590 100644 --- a/src/lambdas/reservationHandler.ts +++ b/src/lambdas/reservationHandler.ts @@ -1,7 +1,5 @@ import { Handler } from 'aws-lambda' -import { Reservation } from '../common/reservation' -import { Runner } from '../common/runner' - -export const run: Handler = async (payload: string): Promise => { +export const run: Handler = async (): Promise => { + return } diff --git a/src/lambdas/reservationScheduler.ts b/src/lambdas/reservationScheduler.ts index 7cd3a68..96637d2 100644 --- a/src/lambdas/reservationScheduler.ts +++ b/src/lambdas/reservationScheduler.ts @@ -1,22 +1,64 @@ -import { Handler } from 'aws-lambda' -import dayjs from 'dayjs' +import { Context, Handler } from 'aws-lambda' +import { Dayjs } from 'dayjs' -import { InputEvent } from '../stepFunctions/event' +import { Logger, LogLevel } from '../common/logger' import { Reservation } from '../common/reservation' -import { validateRequest } from '../common/request' -import { Runner } from '../common/runner' +import { + validateRequest, + ReservationRequest, +} from '../common/request' +import { scheduleDateToRequestReservation } from '../common/schedule' -export const run: Handler = async (input: InputEvent): Promise => { - console.log(`Handling event: ${input}`) - const { username, password, dateRange, opponent } = validateRequest(JSON.stringify(input.reservationRequest)) - console.log('Successfully validated request') - - console.log('Creating reservation') - const reservation = new Reservation(dateRange, opponent) - console.log('Created reservation') - - console.log('Runner starting') - const runner = new Runner(username, password, [reservation]) - await runner.run() - console.log('Runner finished') +export interface ScheduledReservationRequest { + reservationRequest: ReservationRequest + scheduledFor?: Dayjs } + +export interface ReservationSchedulerResult { + scheduledReservationRequest?: ScheduledReservationRequest +} + +const handler: Handler = async ( + payload: string, + context: Context, +): Promise => { + const logger = new Logger(context.awsRequestId, LogLevel.DEBUG) + logger.debug('Handling event', { payload }) + let reservationRequest: ReservationRequest + try { + reservationRequest = validateRequest(payload) + } catch (err) { + logger.error('Failed to validate request', { err }) + throw err + } + + logger.debug('Successfully validated request', { reservationRequest }) + + const res = new Reservation( + reservationRequest.dateRange, + reservationRequest.opponent + ) + + if (!res.isAvailableForReservation()) { + logger.debug('Reservation date is more than 7 days away') + const scheduledDay = scheduleDateToRequestReservation( + reservationRequest.dateRange.start + ) + logger.info( + `Scheduling reservation request for ${scheduledDay.format('YYYY-MM-DD')}` + ) + return { + scheduledReservationRequest: { + reservationRequest, + scheduledFor: scheduledDay, + }, + } + } + + logger.info('Reservation request can be performed now') + return { + scheduledReservationRequest: { reservationRequest }, + } +} + +export default handler diff --git a/src/stepFunctions/event.ts b/src/stepFunctions/event.ts deleted file mode 100644 index 518367e..0000000 --- a/src/stepFunctions/event.ts +++ /dev/null @@ -1,9 +0,0 @@ -import { ReservationRequest } from "../common/request"; - -export interface RawReservationRequest extends Omit { - dateRanges: { start: string, end: string }[] -} - -export interface InputEvent { - reservationRequest: RawReservationRequest -} \ No newline at end of file diff --git a/tests/common/reservation.test.ts b/tests/common/reservation.test.ts index 01b462c..0db62cc 100644 --- a/tests/common/reservation.test.ts +++ b/tests/common/reservation.test.ts @@ -1,10 +1,10 @@ import dayjs from 'dayjs' import { DateRange, Reservation } from '../../src/common/reservation' -describe("Reservation", () => { - it("will create correct possible dates", () => { - const startDate = dayjs().set("hour", 12).set("minute", 0) - const endDate = dayjs().set("hour", 13).set("minute", 0) +describe('Reservation', () => { + test('will create correct possible dates', () => { + const startDate = dayjs().hour(12).minute(0).second(0).millisecond(0) + const endDate = startDate.add(1, 'hour') const dateRange: DateRange = { start: startDate, end: endDate, @@ -14,9 +14,18 @@ describe("Reservation", () => { expect(res.possibleDates).toHaveLength(5) expect(res.possibleDates[0]).toEqual(startDate) - expect(res.possibleDates[1]).toEqual(startDate.add(15, "minute")) - expect(res.possibleDates[2]).toEqual(startDate.add(30, "minute")) - expect(res.possibleDates[3]).toEqual(startDate.add(45, "minute")) - expect(res.possibleDates[4]).toEqual(startDate.add(60, "minute")) + expect(res.possibleDates[1]).toEqual(startDate.add(15, 'minute')) + expect(res.possibleDates[2]).toEqual(startDate.add(30, 'minute')) + expect(res.possibleDates[3]).toEqual(startDate.add(45, 'minute')) + expect(res.possibleDates[4]).toEqual(startDate.add(60, 'minute')) + }) + + test.each([ + { reservationDate: dayjs().add(7, 'days'), expected: true }, + { reservationDate: dayjs().add(1, 'days'), expected: true }, + { reservationDate: dayjs().add(8, 'days'), expected: false }, + ])('will properly mark reservation availability according to date', ({ reservationDate, expected }) => { + const res = new Reservation({ start: reservationDate, end: reservationDate }, { id: 'collin', name: 'collin' }) + expect(res.isAvailableForReservation()).toBe(expected) }) }) \ No newline at end of file diff --git a/tests/common/schedule.test.ts b/tests/common/schedule.test.ts new file mode 100644 index 0000000..2eff1ef --- /dev/null +++ b/tests/common/schedule.test.ts @@ -0,0 +1,13 @@ +import dayjs, { Dayjs } from 'dayjs' +import { scheduleDateToRequestReservation } from '../../src/common/schedule' + +describe('scheduleDateToRequestReservation', () => { + const zeroTime = (date: Dayjs): Dayjs => date.hour(0).minute(0).second(0).millisecond(0) + + test.each([ + { date: dayjs().add(8, 'days'), expected: zeroTime(dayjs().add(1, 'days')) }, + { date: dayjs().add(31, 'days'), expected: zeroTime(dayjs().add(24, 'days')) }, + ])('should return value indicating if reservation is possible now', ({ date, expected }) => { + expect(scheduleDateToRequestReservation(date)).toStrictEqual(expected) + }) +}) \ No newline at end of file diff --git a/tests/lambdas/reservationScheduler.test.ts b/tests/lambdas/reservationScheduler.test.ts new file mode 100644 index 0000000..7bac066 --- /dev/null +++ b/tests/lambdas/reservationScheduler.test.ts @@ -0,0 +1,71 @@ +import dayjs from 'dayjs' +import { ValidationError, ValidationErrorCode } from '../../src/common/request' +import handler, { ReservationSchedulerResult } from '../../src/lambdas/reservationScheduler' + +jest.mock('../../src/common/logger') + +describe('reservationScheduler', () => { + test('should handle valid requests within reservation window', async () => { + const start = dayjs().add(15, 'minutes') + const end = start.add(15, 'minutes') + + const payload = '{' + + '"username": "collin",' + + '"password": "password",' + + `"dateRange": { "start": "${start.toISOString()}", "end": "${end.toISOString()}" },` + + '"opponent": { "id": "123", "name": "collin" }' + + '}' + + // @ts-expect-error - Stubbing AWS context + await expect(handler(payload, { awsRequestId: '1234' }, undefined)).resolves + .toMatchObject({ + scheduledReservationRequest: { + reservationRequest: { + username: 'collin', + password: 'password', + dateRange: { start, end }, + opponent: { id: '123', name: 'collin' }, + } + }}) + }) + + test('should handle valid requests outside of reservation window', async () => { + const start = dayjs().add(15, 'days') + const end = start.add(15, 'minutes') + const payload = '{' + + '"username": "collin",' + + '"password": "password",' + + `"dateRange": { "start": "${start.toISOString()}", "end": "${end.toISOString()}" },` + + '"opponent": { "id": "123", "name": "collin" }' + + '}' + + // @ts-expect-error - Stubbing AWS context + await expect(handler(payload, { awsRequestId: '1234' }, undefined)).resolves.toMatchObject({ + scheduledReservationRequest: { + reservationRequest: { + username: 'collin', + password: 'password', + dateRange: { start, end }, + opponent: { id: '123', name: 'collin' }, + }, + scheduledFor: start.subtract(7, 'days').hour(0).minute(0).second(0).millisecond(0) + } + }) + }) + + test('should throw error for invalid requests', async () => { + const start = dayjs().add(15, 'days') + const end = start.add(15, 'minutes') + const payload = '{invalidJson' + + '"username": "collin",' + + '"password": "password",' + + `"dateRange": { "start": "${start.format()}", "end": "${end.format()}" },` + + '"opponent": { "id": "123", "name": "collin" }' + + '}' + + // @ts-expect-error - Stubbing AWS context + await expect(handler(payload, { awsRequestId: '1234' }, undefined)) + .rejects + .toThrowError(new ValidationError('Invalid request', ValidationErrorCode.INVALID_JSON)) + }) +}) \ No newline at end of file