Merge branch 'main' into terraform-dev

This commit is contained in:
cgduncan7 2021-11-28 13:24:53 +01:00
commit 367b3d53fd
No known key found for this signature in database
GPG key ID: EC1CAB0285287510
11 changed files with 286 additions and 56 deletions

5
.gitignore vendored
View file

@ -7,4 +7,7 @@ dist/
# terraform # terraform
.terraform/ .terraform/
deploy/ deploy/
# vscode
.vscode/settings.json

60
src/common/logger.ts Normal file
View file

@ -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<unknown> = [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)
}
}

View file

@ -1,4 +1,4 @@
import dayjs, { Dayjs } from 'dayjs' import dayjs from 'dayjs'
import isSameOrBefore from 'dayjs/plugin/isSameOrBefore' import isSameOrBefore from 'dayjs/plugin/isSameOrBefore'
dayjs.extend(isSameOrBefore) dayjs.extend(isSameOrBefore)
@ -7,10 +7,7 @@ import { DateRange, Opponent } from './reservation'
export interface ReservationRequest { export interface ReservationRequest {
username: string username: string
password: string password: string
dateRange: { dateRange: DateRange
start: Dayjs
end: Dayjs
}
opponent: Opponent opponent: Opponent
} }
@ -37,9 +34,7 @@ export class ValidationError extends Error {
* @param body String of request body * @param body String of request body
* @returns ReservationRequest * @returns ReservationRequest
*/ */
export const validateRequest = ( export const validateRequest = (body: string): ReservationRequest => {
body: string
): ReservationRequest => {
const request = validateRequestBody(body) const request = validateRequestBody(body)
validateRequestDateRange(request.dateRange) validateRequestDateRange(request.dateRange)
validateRequestOpponent(request.opponent) validateRequestOpponent(request.opponent)
@ -48,7 +43,10 @@ export const validateRequest = (
const validateRequestBody = (body?: string): ReservationRequest => { const validateRequestBody = (body?: string): ReservationRequest => {
if (body === undefined) { 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) const jsonBody = transformRequestBody(body)
@ -65,7 +63,10 @@ const validateRequestBody = (body?: string): ReservationRequest => {
(opponent && opponent.id && opponent.id.length < 1) || (opponent && opponent.id && opponent.id.length < 1) ||
(opponent && opponent.name && opponent.name.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 return jsonBody
@ -76,7 +77,10 @@ const transformRequestBody = (body: string): ReservationRequest => {
try { try {
json = JSON.parse(body) json = JSON.parse(body)
} catch (err) { } 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 startTime = json.dateRange?.start ?? 'invalid'
const endTime = json.dateRange?.end ?? 'invalid' const endTime = json.dateRange?.end ?? 'invalid'
@ -84,8 +88,8 @@ const transformRequestBody = (body: string): ReservationRequest => {
return { return {
username: json.username, username: json.username,
password: json.password, password: json.password,
opponent: json.opponent,
dateRange, dateRange,
opponent: json.opponent,
} }
} }
@ -93,7 +97,10 @@ const validateRequestDateRange = (dateRange: DateRange): void => {
// checking that both dates are valid // checking that both dates are valid
const { start, end } = dateRange const { start, end } = dateRange
if (!start.isValid() || !end.isValid()) { 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: // checking that:
@ -105,7 +112,10 @@ const validateRequestDateRange = (dateRange: DateRange): void => {
!start.isSameOrBefore(end) || !start.isSameOrBefore(end) ||
start.format('YYYY MM DD') !== end.format('YYYY MM DD') 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 || id.length < 1 ||
name.length < 1 name.length < 1
) { ) {
throw new ValidationError('Invalid request', ValidationErrorCode.INVALID_OPPONENT) throw new ValidationError(
'Invalid request',
ValidationErrorCode.INVALID_OPPONENT
)
} }
} }

View file

@ -12,6 +12,8 @@ export interface DateRange {
end: dayjs.Dayjs end: dayjs.Dayjs
} }
const RESERVATION_AVAILABLE_WITHIN_DAYS = 7
export class Reservation { export class Reservation {
public readonly dateRange: DateRange public readonly dateRange: DateRange
public readonly opponent: Opponent public readonly opponent: Opponent
@ -29,7 +31,7 @@ export class Reservation {
const { start, end } = this.dateRange const { start, end } = this.dateRange
let possibleDate = dayjs(start) let possibleDate = dayjs(start).second(0).millisecond(0)
while (possibleDate.isSameOrBefore(end)) { while (possibleDate.isSameOrBefore(end)) {
possibleDates.push(possibleDate) possibleDates.push(possibleDate)
possibleDate = possibleDate.add(15, 'minute') possibleDate = possibleDate.add(15, 'minute')
@ -37,4 +39,15 @@ export class Reservation {
return possibleDates 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
)
}
} }

17
src/common/schedule.ts Normal file
View file

@ -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')
}

View file

@ -1,7 +1,5 @@
import { Handler } from 'aws-lambda' import { Handler } from 'aws-lambda'
import { Reservation } from '../common/reservation' export const run: Handler = async (): Promise<void> => {
import { Runner } from '../common/runner' return
export const run: Handler = async (payload: string): Promise<void> => {
} }

View file

@ -1,22 +1,64 @@
import { Handler } from 'aws-lambda' import { Context, Handler } from 'aws-lambda'
import dayjs from 'dayjs' import { Dayjs } from 'dayjs'
import { InputEvent } from '../stepFunctions/event' import { Logger, LogLevel } from '../common/logger'
import { Reservation } from '../common/reservation' import { Reservation } from '../common/reservation'
import { validateRequest } from '../common/request' import {
import { Runner } from '../common/runner' validateRequest,
ReservationRequest,
} from '../common/request'
import { scheduleDateToRequestReservation } from '../common/schedule'
export const run: Handler<InputEvent, void> = async (input: InputEvent): Promise<void> => { export interface ScheduledReservationRequest {
console.log(`Handling event: ${input}`) reservationRequest: ReservationRequest
const { username, password, dateRange, opponent } = validateRequest(JSON.stringify(input.reservationRequest)) scheduledFor?: Dayjs
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 ReservationSchedulerResult {
scheduledReservationRequest?: ScheduledReservationRequest
}
const handler: Handler<string, ReservationSchedulerResult> = async (
payload: string,
context: Context,
): Promise<ReservationSchedulerResult> => {
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

View file

@ -1,9 +0,0 @@
import { ReservationRequest } from "../common/request";
export interface RawReservationRequest extends Omit<ReservationRequest, 'dateRanges'> {
dateRanges: { start: string, end: string }[]
}
export interface InputEvent {
reservationRequest: RawReservationRequest
}

View file

@ -1,10 +1,10 @@
import dayjs from 'dayjs' import dayjs from 'dayjs'
import { DateRange, Reservation } from '../../src/common/reservation' import { DateRange, Reservation } from '../../src/common/reservation'
describe("Reservation", () => { describe('Reservation', () => {
it("will create correct possible dates", () => { test('will create correct possible dates', () => {
const startDate = dayjs().set("hour", 12).set("minute", 0) const startDate = dayjs().hour(12).minute(0).second(0).millisecond(0)
const endDate = dayjs().set("hour", 13).set("minute", 0) const endDate = startDate.add(1, 'hour')
const dateRange: DateRange = { const dateRange: DateRange = {
start: startDate, start: startDate,
end: endDate, end: endDate,
@ -14,9 +14,18 @@ describe("Reservation", () => {
expect(res.possibleDates).toHaveLength(5) expect(res.possibleDates).toHaveLength(5)
expect(res.possibleDates[0]).toEqual(startDate) expect(res.possibleDates[0]).toEqual(startDate)
expect(res.possibleDates[1]).toEqual(startDate.add(15, "minute")) expect(res.possibleDates[1]).toEqual(startDate.add(15, 'minute'))
expect(res.possibleDates[2]).toEqual(startDate.add(30, "minute")) expect(res.possibleDates[2]).toEqual(startDate.add(30, 'minute'))
expect(res.possibleDates[3]).toEqual(startDate.add(45, "minute")) expect(res.possibleDates[3]).toEqual(startDate.add(45, 'minute'))
expect(res.possibleDates[4]).toEqual(startDate.add(60, "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)
}) })
}) })

View file

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

View file

@ -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<ReservationSchedulerResult>({
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<ReservationSchedulerResult>({
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))
})
})