Adding cron job for running reservations; changing how the runner worked to only instantiate one runner and reuse it; lots of tests!

This commit is contained in:
Collin Duncan 2022-11-29 22:51:28 +01:00
parent a765df3530
commit 69249a11dc
No known key found for this signature in database
14 changed files with 202 additions and 56 deletions

48
package-lock.json generated
View file

@ -14,6 +14,7 @@
"axios": "^1.2.0",
"dayjs": "^1.11.6",
"mysql": "^2.18.1",
"node-cron": "^3.0.2",
"puppeteer": "^19.1.0",
"uuid": "^9.0.0"
},
@ -24,6 +25,7 @@
"@types/jest": "^29.2.3",
"@types/mysql": "^2.15.21",
"@types/node": "^18.11.9",
"@types/node-cron": "^3.0.6",
"@types/puppeteer": "^5.4.7",
"@types/uuid": "^8.3.4",
"@typescript-eslint/eslint-plugin": "^5.42.0",
@ -3339,6 +3341,12 @@
"integrity": "sha512-CRpX21/kGdzjOpFsZSkcrXMGIBWMGNIHXXBVFSH+ggkftxg+XYP20TESbh+zFvFj3EQOl5byk0HTRn1IL6hbqg==",
"devOptional": true
},
"node_modules/@types/node-cron": {
"version": "3.0.6",
"resolved": "https://registry.npmjs.org/@types/node-cron/-/node-cron-3.0.6.tgz",
"integrity": "sha512-Qu9dpjkgj2JmzRmDMVzpt2dFKuJ7wma0mxEvbbgomwkhAdHKT2LpSLYHawzd9OeeP4HsyhmcV9o/xLgJyPNcgw==",
"dev": true
},
"node_modules/@types/parse-json": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/@types/parse-json/-/parse-json-4.0.0.tgz",
@ -9517,6 +9525,25 @@
"resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-5.0.0.tgz",
"integrity": "sha512-CvkDw2OEnme7ybCykJpVcKH+uAOLV2qLqiyla128dN9TkEWfrYmxG6C2boDe5KcNQqZF3orkqzGgOMvZ/JNekA=="
},
"node_modules/node-cron": {
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/node-cron/-/node-cron-3.0.2.tgz",
"integrity": "sha512-iP8l0yGlNpE0e6q1o185yOApANRe47UPbLf4YxfbiNHt/RU5eBcGB/e0oudruheSf+LQeDMezqC5BVAb5wwRcQ==",
"dependencies": {
"uuid": "8.3.2"
},
"engines": {
"node": ">=6.0.0"
}
},
"node_modules/node-cron/node_modules/uuid": {
"version": "8.3.2",
"resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz",
"integrity": "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==",
"bin": {
"uuid": "dist/bin/uuid"
}
},
"node_modules/node-fetch": {
"version": "2.6.7",
"resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.6.7.tgz",
@ -13540,6 +13567,12 @@
"integrity": "sha512-CRpX21/kGdzjOpFsZSkcrXMGIBWMGNIHXXBVFSH+ggkftxg+XYP20TESbh+zFvFj3EQOl5byk0HTRn1IL6hbqg==",
"devOptional": true
},
"@types/node-cron": {
"version": "3.0.6",
"resolved": "https://registry.npmjs.org/@types/node-cron/-/node-cron-3.0.6.tgz",
"integrity": "sha512-Qu9dpjkgj2JmzRmDMVzpt2dFKuJ7wma0mxEvbbgomwkhAdHKT2LpSLYHawzd9OeeP4HsyhmcV9o/xLgJyPNcgw==",
"dev": true
},
"@types/parse-json": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/@types/parse-json/-/parse-json-4.0.0.tgz",
@ -18223,6 +18256,21 @@
"resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-5.0.0.tgz",
"integrity": "sha512-CvkDw2OEnme7ybCykJpVcKH+uAOLV2qLqiyla128dN9TkEWfrYmxG6C2boDe5KcNQqZF3orkqzGgOMvZ/JNekA=="
},
"node-cron": {
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/node-cron/-/node-cron-3.0.2.tgz",
"integrity": "sha512-iP8l0yGlNpE0e6q1o185yOApANRe47UPbLf4YxfbiNHt/RU5eBcGB/e0oudruheSf+LQeDMezqC5BVAb5wwRcQ==",
"requires": {
"uuid": "8.3.2"
},
"dependencies": {
"uuid": {
"version": "8.3.2",
"resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz",
"integrity": "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg=="
}
}
},
"node-fetch": {
"version": "2.6.7",
"resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.6.7.tgz",

View file

@ -11,6 +11,7 @@
"preinstall": "export CXX=g++-12",
"clean": "rm -r ./dist || true",
"build": "rollup -c src/server/rollup.config.js",
"test": "jest",
"test:unit": "jest tests/unit/*",
"test:unit:clean": "npm run test:clear-cache && npm run test:unit",
"test:integration": "jest tests/integration/*",
@ -29,6 +30,7 @@
"axios": "^1.2.0",
"dayjs": "^1.11.6",
"mysql": "^2.18.1",
"node-cron": "^3.0.2",
"puppeteer": "^19.1.0",
"uuid": "^9.0.0"
},
@ -39,6 +41,7 @@
"@types/jest": "^29.2.3",
"@types/mysql": "^2.15.21",
"@types/node": "^18.11.9",
"@types/node-cron": "^3.0.6",
"@types/puppeteer": "^5.4.7",
"@types/uuid": "^8.3.4",
"@typescript-eslint/eslint-plugin": "^5.42.0",

View file

@ -156,12 +156,12 @@ export class Reservation {
)
}
public static async fetch(id: number): Promise<Reservation | null> {
public static async fetchById(id: number): Promise<Reservation | null> {
const response = await query<SqlReservation>(
`
SELECT *
FROM reservations
WHERE id = ?
WHERE id = ?;
`,
[id]
)
@ -184,6 +184,35 @@ export class Reservation {
return null
}
public static async fetchFirst(): Promise<Reservation | null> {
const response = await query<SqlReservation>(
`
SELECT *
FROM reservations
ORDER BY date_range_start DESC
LIMIT 1;
`
)
if (response.results.length === 1) {
const sqlReservation = response.results[0]
const res = new Reservation(
{
username: sqlReservation.username,
password: sqlReservation.password,
},
{
start: dayjs(sqlReservation.date_range_start),
end: dayjs(sqlReservation.date_range_end),
},
{ id: sqlReservation.opponent_id, name: sqlReservation.opponent_name }
)
return res
}
return null
}
}
export interface SerializedDateRange {

24
src/common/reserver.ts Normal file
View file

@ -0,0 +1,24 @@
import { Reservation } from './reservation'
import { Runner } from './runner'
let runner: Runner | undefined
const getRunner = () => {
if (!runner) {
runner = new Runner({ headless: true })
}
return runner
}
export const reserve = async (reservation?: Reservation) => {
let reservationToPerform = reservation
if (!reservationToPerform) {
reservationToPerform = (await Reservation.fetchFirst()) || undefined
}
if (!reservationToPerform) {
return
}
const runner = getRunner()
await runner.run(reservationToPerform)
}

View file

@ -11,65 +11,51 @@ import { Logger } from './logger'
import { Opponent, Reservation } from './reservation'
export class Runner {
private readonly username: string
private readonly password: string
private readonly reservations: Reservation[]
private browser: Browser | undefined
private page: Page | undefined
private options?: LaunchOptions &
BrowserLaunchArgumentOptions &
BrowserConnectOptions
public constructor(
username: string,
password: string,
reservations: Reservation[]
) {
this.username = username
this.password = password
this.reservations = reservations
}
public async run(
constructor(
options?: LaunchOptions &
BrowserLaunchArgumentOptions &
BrowserConnectOptions
): Promise<Reservation[]> {
Logger.debug('Launching browser')
this.browser = await puppeteer.launch(options)
this.page = await this.browser?.newPage()
await this.login()
return await this.makeReservations()
) {
this.options = options
}
private async login() {
public async run(reservation: Reservation): Promise<boolean> {
Logger.debug('Launching browser')
if (!this.browser) {
this.browser = await puppeteer.launch(this.options)
}
this.page = await this.browser?.newPage()
await this.login(reservation.user.username, reservation.user.password)
return this.makeReservation(reservation)
}
private async login(username: string, password: string) {
Logger.debug('Logging in')
await this.page?.goto('https://squashcity.baanreserveren.nl/')
await this.page
?.waitForSelector('input[name=username]')
.then((i) => i?.type(this.username))
await this.page
?.$('input[name=password]')
.then((i) => i?.type(this.password))
.then((i) => i?.type(username))
await this.page?.$('input[name=password]').then((i) => i?.type(password))
await this.page?.$('button').then((b) => b?.click())
}
private async makeReservations(): Promise<Reservation[]> {
for (let i = 0; i < this.reservations.length; i++) {
Logger.debug('Making reservation', this.reservations[i].format())
await this.makeReservation(this.reservations[i])
}
return this.reservations
}
private async makeReservation(reservation: Reservation): Promise<void> {
private async makeReservation(reservation: Reservation): Promise<boolean> {
try {
await this.navigateToDay(reservation.dateRange.start)
await this.selectAvailableTime(reservation)
await this.selectOpponent(reservation.opponent)
await this.confirmReservation()
reservation.booked = true
return true
} catch (err) {
Logger.error('Error making reservation', reservation.format())
return false
}
}

View file

@ -4,6 +4,7 @@ import { v4 } from 'uuid'
import { Logger, LogLevel } from './logger'
import { Reservation } from './reservation'
import { validateJSONRequest } from './request'
import { reserve } from './reserver'
export interface ScheduledReservation {
reservation: Reservation
@ -16,7 +17,7 @@ export interface SchedulerResult {
export type SchedulerInput = Record<string, unknown>
export const work = async (
export const schedule = async (
payload: SchedulerInput
): Promise<SchedulerResult> => {
Logger.instantiate('scheduler', v4(), LogLevel.DEBUG)
@ -38,6 +39,9 @@ export const work = async (
Logger.debug(
'Reservation date is more than 7 days away; saving for later reservation'
)
await Reservation.save(reservation)
return {
scheduledReservation: {
reservation,
@ -47,6 +51,7 @@ export const work = async (
}
Logger.info('Reservation request can be performed now')
await reserve(reservation)
return {
scheduledReservation: { reservation },
}

View file

@ -1,6 +1,7 @@
import { connect, disconnect } from './common/database'
import { Logger } from './common/logger'
import server from './server'
import server from './server/http'
import { startTasks, stopTasks } from './server/cron'
process.on('unhandledRejection', (reason) => {
Logger.error('unhandled rejection', { reason })
@ -12,10 +13,13 @@ process.on('uncaughtException', (error, origin) => {
process.on('beforeExit', async () => {
await disconnect()
stopTasks()
})
const port = process.env.SERVER_PORT || 3000
startTasks()
server.listen(port, async () => {
Logger.info('server ready and listening', { port })
await connect()

View file

@ -9,8 +9,8 @@ const run = async (request: Record<string, any>) => {
const { user, dateRange, opponent } = request
const reservation = new Reservation(user, dateRange, opponent)
const runner = new Runner(username, password, [reservation])
await runner.run({ headless: false })
const runner = new Runner({ headless: false })
await runner.run(reservation)
}
// get supplied args

42
src/server/cron.ts Normal file
View file

@ -0,0 +1,42 @@
import { schedule, ScheduledTask, ScheduleOptions } from 'node-cron'
import { Logger } from '../common/logger'
import { reserve } from '../common/reserver'
const tasks: ScheduledTask[] = []
const getTaskConfig = (name: string): ScheduleOptions => ({
name,
recoverMissedExecutions: false,
timezone: 'Europe/Amsterdam',
})
export const startTasks = () => {
try {
const task = schedule(
'0 * * * * *',
async (timestamp) => {
Logger.info('Running cron job', { timestamp })
try {
await reserve()
Logger.info('Completed running cron job')
} catch (error: any) {
Logger.error('Error running cron job', { error: error.message })
}
},
getTaskConfig('reserver cron')
)
Logger.debug('Started cron task')
tasks.push(task)
} catch (error: any) {
Logger.error('Failed to start tasks', { error: error.message })
}
}
export const stopTasks = () => {
try {
tasks.map((task) => task.stop())
Logger.debug('Stopped cron tasks')
} catch (error: any) {
Logger.error('Failed to stop tasks', { error: error.message })
}
}

View file

@ -1,7 +1,7 @@
import http from 'http'
import { v4 } from 'uuid'
import { Logger, LogLevel } from '../common/logger'
import { work as schedule } from '../common/scheduler'
import { schedule } from '../common/scheduler'
import { parseJson } from './utils'
// Handles POST requests to /reservations

View file

@ -78,7 +78,7 @@ describe('Logger', () => {
'abc',
'INFO',
'first',
{ password: '***'},
{ password: '***' }
)
})
})

View file

@ -1,13 +1,18 @@
import dayjs from 'dayjs'
import { ValidationError, ValidationErrorCode } from '../../../src/common/request'
import {
ValidationError,
ValidationErrorCode,
} from '../../../src/common/request'
import { Reservation } from '../../../src/common/reservation'
import { work, SchedulerInput } from '../../../src/common/scheduler'
import { schedule, SchedulerInput } from '../../../src/common/scheduler'
jest.mock('../../../src/common/logger')
jest.mock('../../../src/common/reserver')
jest.useFakeTimers().setSystemTime(new Date('2022-01-01'))
describe('scheduler', () => {
test('should handle valid requests within reservation window', async () => {
jest.spyOn(Reservation, 'save').mockResolvedValueOnce()
const start = dayjs().add(15, 'minutes')
const end = start.add(15, 'minutes')
@ -18,7 +23,7 @@ describe('scheduler', () => {
opponent: { id: '123', name: 'collin' },
}
expect(await work(payload)).toMatchSnapshot({
expect(await schedule(payload)).toMatchSnapshot({
scheduledReservation: {
reservation: {
user: {
@ -42,7 +47,7 @@ describe('scheduler', () => {
opponent: { id: '123', name: 'collin' },
}
await expect(await work(payload)).toMatchSnapshot({
await expect(await schedule(payload)).toMatchSnapshot({
scheduledReservation: {
reservation: new Reservation(
{ username: 'collin', password: expect.any(String) },
@ -69,7 +74,7 @@ describe('scheduler', () => {
opponent: { id: '123', name: 'collin' },
}
await expect(work(payload)).rejects.toThrowError(
await expect(schedule(payload)).rejects.toThrowError(
new ValidationError(
'Invalid request',
ValidationErrorCode.INVALID_REQUEST_BODY

View file

@ -1,5 +1,5 @@
import axios from 'axios'
import server from '../../../src/server/index'
import server from '../../../src/server/http'
import * as scheduler from '../../../src/common/scheduler'
import * as utils from '../../../src/server/utils'
@ -26,7 +26,7 @@ describe('server', () => {
test('should accept POST to /reservations', async () => {
jest
.spyOn(scheduler, 'work')
.spyOn(scheduler, 'schedule')
.mockImplementationOnce(() => Promise.resolve({}))
const response = await axios.post(
`${baseUrl}/reservations`,
@ -38,7 +38,7 @@ describe('server', () => {
test('should reject non-POST request', async () => {
jest
.spyOn(scheduler, 'work')
.spyOn(scheduler, 'schedule')
.mockImplementationOnce(() => Promise.resolve({}))
await expect(() => axios.get(`${baseUrl}/reservations`)).rejects.toThrow(
axios.AxiosError
@ -47,7 +47,7 @@ describe('server', () => {
test('should reject request to other route', async () => {
jest
.spyOn(scheduler, 'work')
.spyOn(scheduler, 'schedule')
.mockImplementationOnce(() => Promise.resolve({}))
await expect(() => axios.post(`${baseUrl}/something-else`)).rejects.toThrow(
axios.AxiosError
@ -56,7 +56,7 @@ describe('server', () => {
test('should reject request without content-type of json', async () => {
jest
.spyOn(scheduler, 'work')
.spyOn(scheduler, 'schedule')
.mockImplementationOnce(() => Promise.resolve({}))
await expect(() =>
axios.post(`${baseUrl}/reservations`, 'test,123', {
@ -79,7 +79,7 @@ describe('server', () => {
})
test('should reject request if schedule cannot be performed', async () => {
jest.spyOn(scheduler, 'work').mockImplementationOnce(Promise.reject)
jest.spyOn(scheduler, 'schedule').mockImplementationOnce(Promise.reject)
await expect(() =>
axios.post(
`${baseUrl}/reservations`,