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 2dae1fe..f3ac840 100644 --- a/src/common/reservation.ts +++ b/src/common/reservation.ts @@ -31,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') @@ -45,6 +45,9 @@ export class Reservation { * @returns is reservation date within 7 days */ public isAvailableForReservation(): boolean { - return Math.ceil(this.dateRange.start.diff(dayjs(), 'days', true)) <= RESERVATION_AVAILABLE_WITHIN_DAYS + 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 index cde6c43..c10c3a6 100644 --- a/src/common/schedule.ts +++ b/src/common/schedule.ts @@ -1,10 +1,17 @@ import { Dayjs } from 'dayjs' /** - * - * @param requestedDate - * @returns + * + * @param requestedDate + * @returns */ -export const scheduleDateToRequestReservation = (requestedDate: Dayjs): Dayjs => { - return requestedDate.hour(0).minute(0).second(0).millisecond(0).subtract(7, 'days') -} \ No newline at end of file +export const scheduleDateToRequestReservation = ( + requestedDate: Dayjs +): Dayjs => { + return requestedDate + .hour(0) + .minute(0) + .second(0) + .millisecond(0) + .subtract(7, 'days') +} diff --git a/src/lambdas/reservationScheduler.ts b/src/lambdas/reservationScheduler.ts index b75416d..96637d2 100644 --- a/src/lambdas/reservationScheduler.ts +++ b/src/lambdas/reservationScheduler.ts @@ -1,24 +1,64 @@ -import { Handler } from 'aws-lambda' +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, ReservationRequest } from '../common/request' +import { + validateRequest, + ReservationRequest, +} from '../common/request' import { scheduleDateToRequestReservation } from '../common/schedule' export interface ScheduledReservationRequest { reservationRequest: ReservationRequest - availableAt: Dayjs + scheduledFor?: Dayjs } -export const run: Handler = async (input: InputEvent): Promise => { - console.log(`Handling event: ${input}`) - const reservationRequest = validateRequest(JSON.stringify(input.reservationRequest)) - console.log('Successfully validated request') +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 + ) - const res = new Reservation(reservationRequest.dateRange, reservationRequest.opponent) if (!res.isAvailableForReservation()) { - console.log('Reservation date is more than 7 days away; scheduling for later') - scheduleDateToRequestReservation(reservationRequest.dateRange.start) + 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 f88fafb..0db62cc 100644 --- a/tests/common/reservation.test.ts +++ b/tests/common/reservation.test.ts @@ -3,8 +3,8 @@ import { DateRange, Reservation } from '../../src/common/reservation' describe('Reservation', () => { test('will create correct possible dates', () => { - const startDate = dayjs().set('hour', 12).set('minute', 0) - const endDate = dayjs().set('hour', 13).set('minute', 0) + const startDate = dayjs().hour(12).minute(0).second(0).millisecond(0) + const endDate = startDate.add(1, 'hour') const dateRange: DateRange = { start: startDate, end: endDate, 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