Adding some pretty stuff

Reorganized code and added some unit tests for reservations and requests
This commit is contained in:
Collin Duncan 2021-11-24 00:00:22 +01:00
parent eec0edb76b
commit 743cc08887
No known key found for this signature in database
16 changed files with 7977 additions and 262 deletions

View file

@ -7,5 +7,9 @@
"extends": [
"eslint:recommended",
"plugin:@typescript-eslint/recommended"
],
"ignorePatterns": [
"**/*.js",
"!src/**/*.ts"
]
}

View file

@ -1,3 +1,6 @@
{
"presets": ["@babel/preset-typescript"]
"presets": [
["@babel/preset-env", { "targets": { "node": "current" } }],
"@babel/preset-typescript"
]
}

29
jest.config.js Normal file
View file

@ -0,0 +1,29 @@
/*
* For a detailed explanation regarding each configuration property, visit:
* https://jestjs.io/docs/configuration
*/
module.exports = {
clearMocks: true,
collectCoverage: true,
coverageReporters: [
"text"
],
coveragePathIgnorePatterns: [
"/node_modules/"
],
coverageProvider: "v8",
moduleDirectories: [
"node_modules"
],
moduleFileExtensions: [
"js",
"ts"
],
roots: [
"<rootDir>/tests"
],
testMatch: [
"**/*.test.ts"
]
};

7683
package-lock.json generated

File diff suppressed because it is too large Load diff

View file

@ -7,6 +7,7 @@
"node": "14.x"
},
"scripts": {
"test": "jest",
"lint": "eslint src/ --ext ts",
"prettier": "prettier src -w",
"build": "tsc",
@ -16,18 +17,22 @@
"author": "Collin Duncan <cgduncan7@gmail.com>",
"license": "ISC",
"dependencies": {
"dayjs": "^1.10.7",
"puppeteer": "^11.0.0"
},
"devDependencies": {
"@babel/core": "^7.16.0",
"@babel/preset-env": "^7.16.0",
"@babel/preset-env": "^7.16.4",
"@babel/preset-typescript": "^7.16.0",
"@rollup/plugin-babel": "^5.3.0",
"@types/aws-lambda": "^8.10.85",
"@types/jest": "^27.0.2",
"@types/puppeteer": "^5.4.4",
"@typescript-eslint/eslint-plugin": "^5.4.0",
"@typescript-eslint/parser": "^5.4.0",
"babel-jest": "^27.3.1",
"eslint": "^8.2.0",
"jest": "^27.3.1",
"prettier": "^2.4.1",
"typescript": "^4.4.4"
}

116
src/common/request.ts Normal file
View file

@ -0,0 +1,116 @@
import dayjs, { Dayjs } from 'dayjs'
import isSameOrBefore from 'dayjs/plugin/isSameOrBefore'
dayjs.extend(isSameOrBefore)
import { DateRange, Opponent } from './reservation'
export interface ReservationRequest {
username: string
password: string
dateRanges: {
start: Dayjs
end: Dayjs
}[]
opponent: Opponent
}
export enum ValidationErrorCode {
UNDEFINED_REQUEST_BODY = 1,
INVALID_REQUEST_BODY = 2,
INVALID_DATE_RANGE = 3,
INVALID_START_OR_END_DATE = 4,
INVALID_OPPONENT = 5,
}
export class ValidationError extends Error {
public readonly code: ValidationErrorCode
constructor(message: string, code: ValidationErrorCode) {
super(message)
this.code = code
}
}
export const validateRequest = (
body: string
): ReservationRequest => {
const request = validateRequestBody(body)
validateRequestDateRanges(request.dateRanges)
validateRequestOpponent(request.opponent)
return request
}
const validateRequestBody = (body?: string): ReservationRequest => {
if (body === undefined) {
throw new ValidationError('Invalid request', ValidationErrorCode.UNDEFINED_REQUEST_BODY)
}
const jsonBody = transformRequestBody(body)
const { username, password, opponent, dateRanges } = jsonBody
if (
!username ||
username.length < 1 ||
!password ||
password.length < 1 ||
!dateRanges ||
dateRanges.length < 1 ||
(opponent && opponent.id && opponent.id.length < 1) ||
(opponent && opponent.name && opponent.name.length < 1)
) {
throw new ValidationError('Invalid request', ValidationErrorCode.INVALID_REQUEST_BODY)
}
return jsonBody
}
const transformRequestBody = (body: string): ReservationRequest => {
const json = JSON.parse(body)
const dateRanges: DateRange[] = json.dateRanges?.map(
({ start, end }: { start: string; end: string }): DateRange => {
return { start: dayjs(start), end: dayjs(end) }
}
)
return {
username: json.username,
password: json.password,
opponent: json.opponent,
dateRanges,
}
}
const validateRequestDateRanges = (dateRanges: DateRange[]): void => {
for (let i = 0; i < dateRanges.length; i++) {
// checking that both dates are valid
const { start, end } = dateRanges[i]
if (!start.isValid() || !end.isValid()) {
throw new ValidationError('Invalid request', ValidationErrorCode.INVALID_DATE_RANGE)
}
// checking that:
// 1. start occurs after now
// 2. start occurs before or same as end
// 3. start and end fall on same YYYY/MM/DD
if (
!start.isAfter(dayjs()) ||
!start.isSameOrBefore(end) ||
start.format('YYYY MM DD') !== end.format('YYYY MM DD')
) {
throw new ValidationError('Invalid request', ValidationErrorCode.INVALID_START_OR_END_DATE)
}
}
}
const validateRequestOpponent = (opponent?: Opponent): void => {
if (!opponent) return
const { id, name } = opponent
if (
typeof id !== 'string' ||
typeof name !== 'string' ||
id.length < 1 ||
name.length < 1
) {
throw new ValidationError('Invalid request', ValidationErrorCode.INVALID_OPPONENT)
}
}

40
src/common/reservation.ts Normal file
View file

@ -0,0 +1,40 @@
import dayjs, { Dayjs } from 'dayjs'
import isSameOrBefore from 'dayjs/plugin/isSameOrBefore'
dayjs.extend(isSameOrBefore)
export interface Opponent {
id: string
name: string
}
export interface DateRange {
start: dayjs.Dayjs
end: dayjs.Dayjs
}
export class Reservation {
public readonly dateRange: DateRange
public readonly opponent: Opponent
public readonly possibleDates: Dayjs[]
public booked = false
constructor(dateRange: DateRange, opponent: Opponent) {
this.dateRange = dateRange
this.opponent = opponent
this.possibleDates = this.createPossibleDates()
}
private createPossibleDates(): Dayjs[] {
const possibleDates: Dayjs[] = []
const { start, end } = this.dateRange
let possibleDate = dayjs(start)
while (possibleDate.isSameOrBefore(end)) {
possibleDates.push(possibleDate)
possibleDate = possibleDate.add(15, 'minute')
}
return possibleDates
}
}

View file

@ -1,3 +1,4 @@
import { Dayjs } from 'dayjs'
import puppeteer, {
Browser,
BrowserConnectOptions,
@ -6,7 +7,8 @@ import puppeteer, {
LaunchOptions,
Page,
} from 'puppeteer'
import { DateTime, Opponent, Reservation, timeToString } from './reservation'
import { Opponent, Reservation } from './reservation'
export class Runner {
private readonly username: string
@ -58,7 +60,7 @@ export class Runner {
private async makeReservation(reservation: Reservation): Promise<void> {
try {
await this.navigateToDay(reservation.dateTime)
await this.navigateToDay(reservation.dateRange.start)
await this.selectAvailableTime(reservation)
await this.selectOpponent(reservation.opponent)
await this.confirmReservation()
@ -68,21 +70,25 @@ export class Runner {
}
}
private async navigateToDay(dt: DateTime): Promise<void> {
private async navigateToDay(date: Dayjs): Promise<void> {
await this.page
?.waitForSelector(`td#cal_${dt.year}_${dt.month}_${dt.day}`)
?.waitForSelector(
`td#cal_${date.get('year')}_${date.get('month') + 1}_${date.get('day')}`
)
.then((d) => d?.click())
await this.page?.waitForSelector(
`td#cal_${dt.year}_${dt.month}_${dt.day}.selected`
`td#cal_${date.get('year')}_${date.get('month') + 1}_${date.get(
'day'
)}.selected`
)
}
private async selectAvailableTime(res: Reservation): Promise<void> {
let freeCourt: ElementHandle | null | undefined
let i = 0
while (i < res.possibleTimes.length && !freeCourt) {
const possibleTime = res.possibleTimes[i]
const timeString = timeToString(possibleTime)
while (i < res.possibleDates.length && !freeCourt) {
const possibleDate = res.possibleDates[i]
const timeString = possibleDate.format('HH:mm')
const selector =
`tr[data-time='${timeString}']` + `> td.free[rowspan='3'][type='free']`
freeCourt = await this.page?.$(selector)

View file

@ -0,0 +1,18 @@
import { SQSEvent, SQSHandler } from 'aws-lambda'
import { validateRequest } from '../common/request'
import { Reservation } from '../common/reservation'
import { Runner } from '../common/runner'
export const run: SQSHandler = async (event: SQSEvent): Promise<void> => {
const { request, error } = validateRequest(event.Records[0].body)
if (error || !request) {
throw new Error(error?.message)
}
const { username, password, dateRanges, opponent } = request
const reservations = dateRanges.map((dr) => new Reservation(dr, opponent))
const runner = new Runner(username, password, reservations)
await runner.run({ headless: false })
}

View file

@ -1,8 +1,8 @@
import { SQSEvent, SQSHandler } from 'aws-lambda'
import { validateRequestEvent } from './request'
import { validateRequestEvent } from '../common/request'
import { Reservation } from './reservation'
import { Runner } from './runner'
import { Reservation } from '../common/reservation'
import { Runner } from '../common/runner'
export const run: SQSHandler = async (event: SQSEvent): Promise<void> => {
const { request, error } = validateRequestEvent(event)

View file

@ -1,6 +1,6 @@
import { IncomingRequest } from './request'
import { Reservation } from './reservation'
import { Runner } from './runner'
import { IncomingRequest } from './common/request'
import { Reservation } from './common/reservation'
import { Runner } from './common/runner'
const run = async (request: IncomingRequest) => {
const { username, password, dateTimes, opponent } = request

View file

@ -1,114 +0,0 @@
import { SQSEvent } from 'aws-lambda'
import { DateTime, Opponent } from './reservation'
export interface IncomingRequest {
username: string
password: string
dateTimes: DateTime[]
opponent: Opponent
}
export interface ValidationError {
message: string
code: number
}
export const validateRequestEvent = (
event: SQSEvent
): { request?: IncomingRequest; error?: ValidationError } => {
try {
const request = validateRequestBody(event.Records[0].body)
validateRequestDateTimes(request.dateTimes)
validateRequestOpponent(request.opponent)
return { request }
} catch (err: unknown) {
return { error: { message: 'Invalid request', code: (err as ValidationError).code ?? 0 } }
}
}
const validateRequestBody = (body?: string): IncomingRequest => {
if (body === undefined) {
throw {
message: 'Invalid request',
code: 1,
}
}
let jsonBody: IncomingRequest
try {
jsonBody = JSON.parse(body)
} catch (err) {
throw {
message: 'Invalid request',
code: 2,
}
}
const { username, password, dateTimes } = jsonBody
if (
!username ||
username.length < 1 ||
!password ||
password.length < 1 ||
!dateTimes
) {
throw {
message: 'Invalid request',
code: 3,
}
}
return jsonBody
}
const validateRequestDateTimes = (dateTimes: DateTime[]): void => {
const now = new Date()
for (let i = 0; i < dateTimes.length; i++) {
const dateTime = dateTimes[i]
const { year, month, day, timeRange } = dateTime
const { start, end } = timeRange
if (
typeof year !== 'number' ||
typeof month !== 'number' ||
typeof day !== 'number' ||
typeof start.hour !== 'number' ||
typeof start.minute !== 'number' ||
typeof end.hour !== 'number' ||
typeof end.minute !== 'number'
) {
throw {
message: 'Invalid request',
code: 4,
}
}
const date = new Date()
date.setFullYear(year, month - 1, day)
date.setHours(start.hour, start.minute)
if (now.getTime() >= date.getTime()) {
throw {
message: 'Invalid request',
code: 5,
}
}
}
}
const validateRequestOpponent = (opponent?: Opponent): void => {
if (!opponent) return
const { id, name } = opponent
if (
typeof id !== 'string' ||
typeof name !== 'string' ||
id.length < 1 ||
name.length < 1
) {
throw {
message: 'Invalid request',
code: 6,
}
}
}

View file

@ -1,54 +0,0 @@
export interface Time {
hour: number
minute: number
}
export interface Opponent {
id: string
name: string
}
export interface DateTime {
year: number
month: number
day: number
timeRange: {
start: Time
end: Time
}
}
export const timeToString = ({ hour, minute }: Time): string =>
`${`${hour}`.padStart(2, '0')}:${`${minute}`.padStart(2, '0')}`
export class Reservation {
public readonly dateTime: DateTime
public readonly opponent: Opponent
public readonly possibleTimes: Time[]
public booked = false
constructor(dateTime: DateTime, opponent: Opponent) {
this.dateTime = dateTime
this.opponent = opponent
this.possibleTimes = this.createPossibleTimes()
}
private createPossibleTimes() {
const possibleTimes: Time[] = []
const { start, end } = this.dateTime.timeRange
let { hour, minute } = start
const { hour: endHour, minute: endMinute } = end
while (hour <= endHour && minute <= endMinute) {
possibleTimes.push({ hour, minute })
minute = (minute + 15) % 60
if (minute === 0) {
hour++
}
}
return possibleTimes
}
}

View file

@ -0,0 +1,112 @@
import dayjs from 'dayjs'
import {
validateRequest,
ValidationError,
ValidationErrorCode,
} from '../../src/common/request'
describe('request', () => {
describe('validateRequest', () => {
test('should return ReservationRequest', () => {
const body = JSON.stringify({
username: 'collin',
password: '123abc',
dateRanges: [
{ start: '2021-12-25T12:34:56Z', end: '2021-12-25T12:45:56Z' }
],
opponent: {
id: '123',
name: 'collin',
}
})
expect(() => validateRequest(body)).not.toThrow()
})
test('should fail for undefined body', () => {
expect(() => validateRequest(undefined)).toThrowError(new ValidationError('Invalid request', ValidationErrorCode.UNDEFINED_REQUEST_BODY))
})
test.each([
{ username: '', password: '1qaz2wsx', dateRanges: [{ start: '1', end: '1' }], opponent: { id: '123', name: 'abc' } },
{ password: '1qaz2wsx', dateRanges: [{ start: '1', end: '1' }], opponent: { id: '123', name: 'abc' } },
{ username: 'collin', password: '', dateRanges: [{ start: '1', end: '1' }], opponent: { id: '123', name: 'abc' } },
{ username: 'collin', dateRanges: [{ start: '1', end: '1' }], opponent: { id: '123', name: 'abc' } },
{ username: 'collin', password: '1qaz2wsx', dateRanges: [], opponent: { id: '123', name: 'abc' } },
{ username: 'collin', password: '1qaz2wsx', opponent: { id: '123', name: 'abc' } },
{ username: 'collin', password: '1qaz2wsx', dateRanges: [{ start: '1', end: '1' }], opponent: { id: '', name: 'abc' } },
{ username: 'collin', password: '1qaz2wsx', dateRanges: [{ start: '1', end: '1' }], opponent: { name: 'abc' } },
{ username: 'collin', password: '1qaz2wsx', dateRanges: [{ start: '1', end: '1' }], opponent: { id: '123', name: '' } },
{ username: 'collin', password: '1qaz2wsx', dateRanges: [{ start: '1', end: '1' }], opponent: { id: '123' } },
])('should fail for body missing required values', (body) => {
expect(() => validateRequest(JSON.stringify(body))).toThrowError(new ValidationError('Invalid request', ValidationErrorCode.INVALID_REQUEST_BODY))
})
test('should fail for invalid date range', () => {
const body = JSON.stringify({
username: 'collin',
password: '123abc',
dateRanges: [
{ start: 'monkey', end: '2021-12-25T12:45:56Z' }
],
opponent: {
id: '123',
name: 'collin',
}
})
expect(() => validateRequest(body)).toThrowError(new ValidationError('Invalid request', ValidationErrorCode.INVALID_DATE_RANGE))
})
test.each([
{ start: dayjs().subtract(1, 'hour').toString(), end: dayjs().add(1, 'hour').toString() },
{ start: dayjs().add(2, 'hour').toString(), end: dayjs().add(1, 'hour').toString() },
{ start: dayjs().toString(), end: dayjs().add(1, 'day').toString() }
])('should fail for improper start or end dates', (dateRange) => {
const body = JSON.stringify({
username: 'collin',
password: '123abc',
dateRanges: [
dateRange
],
opponent: {
id: '123',
name: 'collin',
}
})
expect(() => validateRequest(body)).toThrowError(new ValidationError('Invalid request', ValidationErrorCode.INVALID_START_OR_END_DATE))
})
test('should not fail if no opponent is provided', () => {
const body = JSON.stringify({
username: 'collin',
password: '123abc',
dateRanges: [
{ start: '2021-12-25T12:34:56Z', end: '2021-12-25T12:45:56Z' }
],
})
expect(() => validateRequest(body)).not.toThrow()
})
test.each([
{ id: 123, name: 'collin' },
{ id: '', name: 'collin' },
{ id: '123', name: true },
{ id: '123', name: '' },
])('should fail for invalid opponent id', (opponent) => {
const body = JSON.stringify({
username: 'collin',
password: '123abc',
dateRanges: [
{ start: '2021-12-25T12:34:56Z', end: '2021-12-25T12:45:56Z' }
],
opponent,
})
expect(() => validateRequest(body)).toThrowError(new ValidationError('Invalid request', ValidationErrorCode.INVALID_OPPONENT))
})
})
})

View file

@ -0,0 +1,22 @@
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)
const dateRange: DateRange = {
start: startDate,
end: endDate,
}
const res = new Reservation(dateRange, { id: 'collin', name: 'collin' })
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"))
})
})

View file

@ -8,6 +8,7 @@
"strict": true,
"esModuleInterop": true,
"allowSyntheticDefaultImports": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"outDir": "dist/"