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:
parent
a765df3530
commit
69249a11dc
14 changed files with 202 additions and 56 deletions
48
package-lock.json
generated
48
package-lock.json
generated
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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
24
src/common/reserver.ts
Normal 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)
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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 },
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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
42
src/server/cron.ts
Normal 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 })
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
@ -2,4 +2,4 @@ describe('failure', () => {
|
|||
test('should fail', () => {
|
||||
expect(true).toBeFalsy()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -78,7 +78,7 @@ describe('Logger', () => {
|
|||
'abc',
|
||||
'INFO',
|
||||
'first',
|
||||
{ password: '***'},
|
||||
{ password: '***' }
|
||||
)
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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`,
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue