diff --git a/package-lock.json b/package-lock.json index 7c55290..a51eaaf 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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", diff --git a/package.json b/package.json index 17e470a..737ce75 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/src/common/reservation.ts b/src/common/reservation.ts index a120049..041c93d 100644 --- a/src/common/reservation.ts +++ b/src/common/reservation.ts @@ -156,12 +156,12 @@ export class Reservation { ) } - public static async fetch(id: number): Promise { + public static async fetchById(id: number): Promise { const response = await query( ` SELECT * FROM reservations - WHERE id = ? + WHERE id = ?; `, [id] ) @@ -184,6 +184,35 @@ export class Reservation { return null } + + public static async fetchFirst(): Promise { + const response = await query( + ` + 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 { diff --git a/src/common/reserver.ts b/src/common/reserver.ts new file mode 100644 index 0000000..b350b03 --- /dev/null +++ b/src/common/reserver.ts @@ -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) +} diff --git a/src/common/runner.ts b/src/common/runner.ts index 6a1a661..7c03254 100644 --- a/src/common/runner.ts +++ b/src/common/runner.ts @@ -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 { - 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 { + 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 { - 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 { + private async makeReservation(reservation: Reservation): Promise { 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 } } diff --git a/src/common/scheduler.ts b/src/common/scheduler.ts index 5989b4e..4c0413a 100644 --- a/src/common/scheduler.ts +++ b/src/common/scheduler.ts @@ -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 -export const work = async ( +export const schedule = async ( payload: SchedulerInput ): Promise => { 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 }, } diff --git a/src/index.ts b/src/index.ts index 2bfdcdb..0902071 100644 --- a/src/index.ts +++ b/src/index.ts @@ -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() diff --git a/src/local.ts b/src/local.ts index f126ec8..7f05f49 100644 --- a/src/local.ts +++ b/src/local.ts @@ -9,8 +9,8 @@ const run = async (request: Record) => { 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 diff --git a/src/server/cron.ts b/src/server/cron.ts new file mode 100644 index 0000000..11dd594 --- /dev/null +++ b/src/server/cron.ts @@ -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 }) + } +} diff --git a/src/server/index.ts b/src/server/http.ts similarity index 96% rename from src/server/index.ts rename to src/server/http.ts index ae33e8e..5093dc5 100644 --- a/src/server/index.ts +++ b/src/server/http.ts @@ -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 diff --git a/tests/integration/index.test.ts b/tests/integration/index.test.ts index 1d9d203..14d16ef 100644 --- a/tests/integration/index.test.ts +++ b/tests/integration/index.test.ts @@ -2,4 +2,4 @@ describe('failure', () => { test('should fail', () => { expect(true).toBeFalsy() }) -}) \ No newline at end of file +}) diff --git a/tests/unit/common/logger.test.ts b/tests/unit/common/logger.test.ts index bf53c11..cde3223 100644 --- a/tests/unit/common/logger.test.ts +++ b/tests/unit/common/logger.test.ts @@ -78,7 +78,7 @@ describe('Logger', () => { 'abc', 'INFO', 'first', - { password: '***'}, + { password: '***' } ) }) }) diff --git a/tests/unit/common/scheduler.test.ts b/tests/unit/common/scheduler.test.ts index e9c9842..8874d26 100644 --- a/tests/unit/common/scheduler.test.ts +++ b/tests/unit/common/scheduler.test.ts @@ -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 diff --git a/tests/unit/server/index.test.ts b/tests/unit/server/index.test.ts index 608baaf..5a9d381 100644 --- a/tests/unit/server/index.test.ts +++ b/tests/unit/server/index.test.ts @@ -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`,