Lots of changes to project structure
First integration of requester and scheduler workers Added password hashing functions Adding initial database integration
This commit is contained in:
parent
5b1416d789
commit
3247ca5315
18 changed files with 775 additions and 323 deletions
7
docker/docker-compose.yml
Normal file
7
docker/docker-compose.yml
Normal file
|
|
@ -0,0 +1,7 @@
|
||||||
|
services:
|
||||||
|
database:
|
||||||
|
image: mysql:latest
|
||||||
|
restart: always
|
||||||
|
env_file: ./database/.env
|
||||||
|
ports:
|
||||||
|
- 3306:3306
|
||||||
75
src/common/database/index.ts
Normal file
75
src/common/database/index.ts
Normal file
|
|
@ -0,0 +1,75 @@
|
||||||
|
import mysql, { Connection, ConnectionConfig, FieldInfo } from 'mysql'
|
||||||
|
import { readFile } from 'fs/promises'
|
||||||
|
import { resolve } from 'path'
|
||||||
|
import { TABLE_reservations } from './sql'
|
||||||
|
|
||||||
|
const createConnectionConfig = async (): Promise<ConnectionConfig> => {
|
||||||
|
const user = await readFile(resolve('.', './secrets/dbUser'))
|
||||||
|
const password = await readFile(resolve('.', './secrets/dbPassword'))
|
||||||
|
return {
|
||||||
|
user: user.toString(),
|
||||||
|
password: password.toString(),
|
||||||
|
database: 'autobaan',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let connection: Connection
|
||||||
|
|
||||||
|
export const getConnection = async (): Promise<Connection> => {
|
||||||
|
if (!connection) {
|
||||||
|
const config = await createConnectionConfig()
|
||||||
|
connection = mysql.createConnection(config)
|
||||||
|
}
|
||||||
|
return connection
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ResultSet<T> = T[]
|
||||||
|
|
||||||
|
export const connect = async () => {
|
||||||
|
return new Promise<void>((res, rej) =>
|
||||||
|
getConnection().then((cn) => {
|
||||||
|
cn.connect((err) => {
|
||||||
|
if (err) {
|
||||||
|
rej(err)
|
||||||
|
}
|
||||||
|
res()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export const disconnect = async () => {
|
||||||
|
return new Promise<void>((res, rej) =>
|
||||||
|
getConnection().then((cn) => {
|
||||||
|
cn.end((err) => {
|
||||||
|
if (err) {
|
||||||
|
rej(err)
|
||||||
|
}
|
||||||
|
res()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export const query = async <T = unknown, V = unknown>(
|
||||||
|
sql: string,
|
||||||
|
values?: V
|
||||||
|
): Promise<{ results: ResultSet<T>; fields?: FieldInfo[] }> => {
|
||||||
|
return new Promise((res, rej) => {
|
||||||
|
connection.query({ sql, values }, (err, results, fields) => {
|
||||||
|
if (err) {
|
||||||
|
rej(err)
|
||||||
|
}
|
||||||
|
res({ results, fields })
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export const init = async () => {
|
||||||
|
try {
|
||||||
|
await connect()
|
||||||
|
await query(TABLE_reservations)
|
||||||
|
} catch (err: any) {
|
||||||
|
console.error(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
11
src/common/database/sql.ts
Normal file
11
src/common/database/sql.ts
Normal file
|
|
@ -0,0 +1,11 @@
|
||||||
|
export const TABLE_reservations = `
|
||||||
|
CREATE TABLE reservations (
|
||||||
|
id INT unsigned NOT NULL PRIMARY KEY AUTO_INCREMENT,
|
||||||
|
username VARCHAR(64) NOT NULL UNIQUE,
|
||||||
|
password VARCHAR(255) NOT NULL
|
||||||
|
date_range_start DATETIME NOT NULL,
|
||||||
|
date_range_end DATETIME NOT NULL,
|
||||||
|
opponent_id VARCHAR(32) NOT NULL,
|
||||||
|
opponent_name VARCHAR(255) NOT NULL
|
||||||
|
);
|
||||||
|
`
|
||||||
52
src/common/password.ts
Normal file
52
src/common/password.ts
Normal file
|
|
@ -0,0 +1,52 @@
|
||||||
|
import argon2 from 'argon2'
|
||||||
|
import crypto from 'crypto'
|
||||||
|
import { Logger } from './logger'
|
||||||
|
|
||||||
|
const SALT_LENGTH = Number.parseInt(process.env.SALT_LENGTH || '32', 10)
|
||||||
|
|
||||||
|
const randomFillPromise = (buffer: Buffer) => {
|
||||||
|
return new Promise<Buffer>((res, rej) => {
|
||||||
|
crypto.randomFill(buffer, (err, buff) => {
|
||||||
|
if (err) {
|
||||||
|
rej(err)
|
||||||
|
}
|
||||||
|
res(buff)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export const generateSalt = async () => {
|
||||||
|
const saltBuffer = Buffer.alloc(SALT_LENGTH)
|
||||||
|
return randomFillPromise(saltBuffer)
|
||||||
|
}
|
||||||
|
|
||||||
|
export const generateHash = async (password: string, saltBuffer: Buffer) => {
|
||||||
|
const hashOptions: argon2.Options & { raw: false } = {
|
||||||
|
hashLength: 32,
|
||||||
|
parallelism: 1,
|
||||||
|
memoryCost: 1 << 14,
|
||||||
|
timeCost: 2,
|
||||||
|
type: argon2.argon2id,
|
||||||
|
salt: saltBuffer,
|
||||||
|
saltLength: saltBuffer.length,
|
||||||
|
raw: false,
|
||||||
|
}
|
||||||
|
|
||||||
|
const hash = await argon2.hash(password, hashOptions)
|
||||||
|
return hash
|
||||||
|
}
|
||||||
|
|
||||||
|
export const hashPassword = async (password: string) => {
|
||||||
|
try {
|
||||||
|
const saltBuffer = await generateSalt()
|
||||||
|
const hash = await generateHash(password, saltBuffer)
|
||||||
|
return hash
|
||||||
|
} catch (err: any) {
|
||||||
|
Logger.error('Error hashing and salting password', { message: err.message })
|
||||||
|
throw err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const verifyPassword = async (hash: string, password: string) => {
|
||||||
|
return await argon2.verify(hash, password)
|
||||||
|
}
|
||||||
|
|
@ -1,15 +1,9 @@
|
||||||
import dayjs from 'dayjs'
|
import dayjs from 'dayjs'
|
||||||
import isSameOrBefore from 'dayjs/plugin/isSameOrBefore'
|
import isSameOrBefore from 'dayjs/plugin/isSameOrBefore'
|
||||||
|
import { hashPassword } from './password'
|
||||||
dayjs.extend(isSameOrBefore)
|
dayjs.extend(isSameOrBefore)
|
||||||
|
|
||||||
import { DateRange, Opponent } from './reservation'
|
import { DateRange, Opponent, Reservation } from './reservation'
|
||||||
|
|
||||||
export interface ReservationRequest extends Record<string, unknown> {
|
|
||||||
username: string
|
|
||||||
password: string
|
|
||||||
dateRange: DateRange
|
|
||||||
opponent: Opponent
|
|
||||||
}
|
|
||||||
|
|
||||||
export enum ValidationErrorCode {
|
export enum ValidationErrorCode {
|
||||||
UNDEFINED_REQUEST_BODY,
|
UNDEFINED_REQUEST_BODY,
|
||||||
|
|
@ -28,30 +22,19 @@ export class ValidationError extends Error {
|
||||||
this.code = code
|
this.code = code
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
/**
|
|
||||||
* Validates an incoming request body and converts to ReservationRequest
|
|
||||||
* @param body String of request body
|
|
||||||
* @returns ReservationRequest
|
|
||||||
*/
|
|
||||||
export const validateStringRequest = (body: string): ReservationRequest => {
|
|
||||||
const request = validateRequestBody(body)
|
|
||||||
validateRequestDateRange(request.dateRange)
|
|
||||||
validateRequestOpponent(request.opponent)
|
|
||||||
return request
|
|
||||||
}
|
|
||||||
|
|
||||||
export const validateJSONRequest = (
|
export const validateJSONRequest = async (
|
||||||
body: Record<string, unknown>
|
body: Record<string, unknown>
|
||||||
): ReservationRequest => {
|
): Promise<Reservation> => {
|
||||||
const request = validateRequestBody(body)
|
const request = await validateRequestBody(body)
|
||||||
validateRequestDateRange(request.dateRange)
|
validateRequestDateRange(request.dateRange)
|
||||||
validateRequestOpponent(request.opponent)
|
validateRequestOpponent(request.opponent)
|
||||||
return request
|
return request
|
||||||
}
|
}
|
||||||
|
|
||||||
const validateRequestBody = (
|
const validateRequestBody = async (
|
||||||
body?: string | Record<string, unknown>
|
body?: Record<string, unknown>
|
||||||
): ReservationRequest => {
|
): Promise<Reservation> => {
|
||||||
if (body === undefined) {
|
if (body === undefined) {
|
||||||
throw new ValidationError(
|
throw new ValidationError(
|
||||||
'Invalid request',
|
'Invalid request',
|
||||||
|
|
@ -59,22 +42,7 @@ const validateRequestBody = (
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
let jsonBody: ReservationRequest
|
const { username, password, dateRange, opponent }: Record<string, any> = body
|
||||||
if (typeof body === 'string') {
|
|
||||||
jsonBody = transformRequestBody(body)
|
|
||||||
} else {
|
|
||||||
const { username, password, dateRange, opponent } = body
|
|
||||||
jsonBody = {
|
|
||||||
username: username as string,
|
|
||||||
password: password as string,
|
|
||||||
dateRange: convertDateRangeStringToObject(
|
|
||||||
dateRange as { start: string; end: string }
|
|
||||||
),
|
|
||||||
opponent: opponent as Opponent,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const { username, password, opponent, dateRange } = jsonBody
|
|
||||||
|
|
||||||
if (
|
if (
|
||||||
!username ||
|
!username ||
|
||||||
|
|
@ -93,28 +61,14 @@ const validateRequestBody = (
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
return jsonBody
|
const hashedPassword = await hashPassword(password)
|
||||||
}
|
const reservation = new Reservation(
|
||||||
|
{ username, password: hashedPassword },
|
||||||
|
convertDateRangeStringToObject(dateRange),
|
||||||
|
opponent
|
||||||
|
)
|
||||||
|
|
||||||
const transformRequestBody = (body: string): ReservationRequest => {
|
return reservation
|
||||||
let json
|
|
||||||
try {
|
|
||||||
json = JSON.parse(body)
|
|
||||||
} catch (err) {
|
|
||||||
throw new ValidationError(
|
|
||||||
'Invalid request',
|
|
||||||
ValidationErrorCode.INVALID_JSON
|
|
||||||
)
|
|
||||||
}
|
|
||||||
const start = json.dateRange?.start ?? 'invalid'
|
|
||||||
const end = json.dateRange?.end ?? 'invalid'
|
|
||||||
const dateRange: DateRange = convertDateRangeStringToObject({ start, end })
|
|
||||||
return {
|
|
||||||
username: json.username,
|
|
||||||
password: json.password,
|
|
||||||
dateRange,
|
|
||||||
opponent: json.opponent,
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const convertDateRangeStringToObject = ({
|
const convertDateRangeStringToObject = ({
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,13 @@
|
||||||
import dayjs, { Dayjs } from 'dayjs'
|
import dayjs, { Dayjs } from 'dayjs'
|
||||||
import isSameOrBefore from 'dayjs/plugin/isSameOrBefore'
|
import isSameOrBefore from 'dayjs/plugin/isSameOrBefore'
|
||||||
|
import { query } from './database'
|
||||||
dayjs.extend(isSameOrBefore)
|
dayjs.extend(isSameOrBefore)
|
||||||
|
|
||||||
|
export interface User {
|
||||||
|
username: string
|
||||||
|
password: string
|
||||||
|
}
|
||||||
|
|
||||||
export interface Opponent {
|
export interface Opponent {
|
||||||
id: string
|
id: string
|
||||||
name: string
|
name: string
|
||||||
|
|
@ -15,15 +21,22 @@ export interface DateRange {
|
||||||
const RESERVATION_AVAILABLE_WITHIN_DAYS = 7
|
const RESERVATION_AVAILABLE_WITHIN_DAYS = 7
|
||||||
|
|
||||||
export class Reservation {
|
export class Reservation {
|
||||||
|
public readonly user: User
|
||||||
public readonly dateRange: DateRange
|
public readonly dateRange: DateRange
|
||||||
public readonly opponent: Opponent
|
public readonly opponent: Opponent
|
||||||
public readonly possibleDates: Dayjs[]
|
public readonly possibleDates: Dayjs[]
|
||||||
public booked = false
|
public booked = false
|
||||||
|
|
||||||
constructor(dateRange: DateRange, opponent: Opponent) {
|
constructor(
|
||||||
|
user: User,
|
||||||
|
dateRange: DateRange,
|
||||||
|
opponent: Opponent,
|
||||||
|
possibleDates?: Dayjs[]
|
||||||
|
) {
|
||||||
|
this.user = user
|
||||||
this.dateRange = dateRange
|
this.dateRange = dateRange
|
||||||
this.opponent = opponent
|
this.opponent = opponent
|
||||||
this.possibleDates = this.createPossibleDates()
|
this.possibleDates = possibleDates || this.createPossibleDates()
|
||||||
}
|
}
|
||||||
|
|
||||||
private createPossibleDates(): Dayjs[] {
|
private createPossibleDates(): Dayjs[] {
|
||||||
|
|
@ -51,15 +64,146 @@ export class Reservation {
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
public format(): unknown {
|
public getAllowedReservationDate(): Dayjs {
|
||||||
|
return this.dateRange.start
|
||||||
|
.hour(0)
|
||||||
|
.minute(0)
|
||||||
|
.second(0)
|
||||||
|
.millisecond(0)
|
||||||
|
.subtract(RESERVATION_AVAILABLE_WITHIN_DAYS, 'days')
|
||||||
|
}
|
||||||
|
|
||||||
|
public toString() {
|
||||||
|
return JSON.stringify(this.format())
|
||||||
|
}
|
||||||
|
|
||||||
|
public format() {
|
||||||
return {
|
return {
|
||||||
|
user: {
|
||||||
|
username: this.user.username,
|
||||||
|
password: this.user.password ? '?' : null,
|
||||||
|
},
|
||||||
opponent: this.opponent,
|
opponent: this.opponent,
|
||||||
booked: this.booked,
|
booked: this.booked,
|
||||||
possibleDates: this.possibleDates.map((date) => date.format()),
|
possibleDates: this.possibleDates.map((date) => date.format()),
|
||||||
dateRange: {
|
dateRange: {
|
||||||
start: this.dateRange.start.format(),
|
start: this.dateRange.start.format(),
|
||||||
end: this.dateRange.end.format(),
|
end: this.dateRange.end.format(),
|
||||||
}
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public serializeToJson(): SerializedReservation {
|
||||||
|
return {
|
||||||
|
user: this.user,
|
||||||
|
opponent: this.opponent,
|
||||||
|
booked: this.booked,
|
||||||
|
possibleDates: this.possibleDates.map((date) => date.format()),
|
||||||
|
dateRange: {
|
||||||
|
start: this.dateRange.start.format(),
|
||||||
|
end: this.dateRange.end.format(),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public static deserializeFromJson(
|
||||||
|
serializedData: SerializedReservation
|
||||||
|
): Reservation {
|
||||||
|
const start = dayjs(serializedData.dateRange.start)
|
||||||
|
const end = dayjs(serializedData.dateRange.end)
|
||||||
|
return new Reservation(
|
||||||
|
serializedData.user,
|
||||||
|
{ start, end },
|
||||||
|
serializedData.opponent,
|
||||||
|
Reservation.deserializePossibleDates(serializedData.possibleDates)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
public static deserializePossibleDates(dates: string[]): Dayjs[] {
|
||||||
|
return dates.map((date) => dayjs(date))
|
||||||
|
}
|
||||||
|
|
||||||
|
public static async save(res: Reservation) {
|
||||||
|
await query(
|
||||||
|
`
|
||||||
|
INSERT INTO reservations
|
||||||
|
(
|
||||||
|
username,
|
||||||
|
password,
|
||||||
|
date_range_start,
|
||||||
|
date_range_end,
|
||||||
|
opponent_id,
|
||||||
|
opponent_name
|
||||||
|
)
|
||||||
|
VALUES
|
||||||
|
(
|
||||||
|
?,
|
||||||
|
?,
|
||||||
|
?,
|
||||||
|
?,
|
||||||
|
?,
|
||||||
|
?
|
||||||
|
)
|
||||||
|
`,
|
||||||
|
[
|
||||||
|
res.user.username,
|
||||||
|
res.user.password,
|
||||||
|
res.dateRange.start,
|
||||||
|
res.dateRange.end,
|
||||||
|
res.opponent.id,
|
||||||
|
res.opponent.name,
|
||||||
|
]
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
public static async fetch(id: number): Promise<Reservation | null> {
|
||||||
|
const response = await query<SqlReservation>(
|
||||||
|
`
|
||||||
|
SELECT *
|
||||||
|
FROM reservations
|
||||||
|
WHERE id = ?
|
||||||
|
`,
|
||||||
|
[id]
|
||||||
|
)
|
||||||
|
|
||||||
|
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 {
|
||||||
|
start: string
|
||||||
|
end: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SerializedReservation {
|
||||||
|
user: User
|
||||||
|
opponent: Opponent
|
||||||
|
booked: boolean
|
||||||
|
possibleDates: string[]
|
||||||
|
dateRange: SerializedDateRange
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SqlReservation {
|
||||||
|
username: string
|
||||||
|
password: string
|
||||||
|
date_range_start: string
|
||||||
|
date_range_end: string
|
||||||
|
opponent_id: string
|
||||||
|
opponent_name: string
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -33,7 +33,7 @@ export class Runner {
|
||||||
BrowserLaunchArgumentOptions &
|
BrowserLaunchArgumentOptions &
|
||||||
BrowserConnectOptions
|
BrowserConnectOptions
|
||||||
): Promise<Reservation[]> {
|
): Promise<Reservation[]> {
|
||||||
Logger.debug('Launching browser');
|
Logger.debug('Launching browser')
|
||||||
this.browser = await puppeteer.launch(options)
|
this.browser = await puppeteer.launch(options)
|
||||||
this.page = await this.browser?.newPage()
|
this.page = await this.browser?.newPage()
|
||||||
await this.login()
|
await this.login()
|
||||||
|
|
@ -41,7 +41,7 @@ export class Runner {
|
||||||
}
|
}
|
||||||
|
|
||||||
private async login() {
|
private async login() {
|
||||||
Logger.debug('Logging in');
|
Logger.debug('Logging in')
|
||||||
await this.page?.goto('https://squashcity.baanreserveren.nl/')
|
await this.page?.goto('https://squashcity.baanreserveren.nl/')
|
||||||
await this.page
|
await this.page
|
||||||
?.waitForSelector('input[name=username]')
|
?.waitForSelector('input[name=username]')
|
||||||
|
|
@ -54,7 +54,7 @@ export class Runner {
|
||||||
|
|
||||||
private async makeReservations(): Promise<Reservation[]> {
|
private async makeReservations(): Promise<Reservation[]> {
|
||||||
for (let i = 0; i < this.reservations.length; i++) {
|
for (let i = 0; i < this.reservations.length; i++) {
|
||||||
Logger.debug('Making reservation', this.reservations[i].format());
|
Logger.debug('Making reservation', this.reservations[i].format())
|
||||||
await this.makeReservation(this.reservations[i])
|
await this.makeReservation(this.reservations[i])
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -69,31 +69,37 @@ export class Runner {
|
||||||
await this.confirmReservation()
|
await this.confirmReservation()
|
||||||
reservation.booked = true
|
reservation.booked = true
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
Logger.error('Error making reservation', reservation.format());
|
Logger.error('Error making reservation', reservation.format())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private getLastVisibleDay(): Dayjs {
|
private getLastVisibleDay(): Dayjs {
|
||||||
const lastDayOfMonth = dayjs().add(1, 'month').set('date', 0);
|
const lastDayOfMonth = dayjs().add(1, 'month').set('date', 0)
|
||||||
let daysToAdd = 0;
|
let daysToAdd = 0
|
||||||
switch (lastDayOfMonth.day()) {
|
switch (lastDayOfMonth.day()) {
|
||||||
case 0: daysToAdd = 0; break;
|
case 0:
|
||||||
default: daysToAdd = 7 - lastDayOfMonth.day(); break;
|
daysToAdd = 0
|
||||||
|
break
|
||||||
|
default:
|
||||||
|
daysToAdd = 7 - lastDayOfMonth.day()
|
||||||
|
break
|
||||||
}
|
}
|
||||||
return lastDayOfMonth.add(daysToAdd, 'day');
|
return lastDayOfMonth.add(daysToAdd, 'day')
|
||||||
}
|
}
|
||||||
|
|
||||||
private async navigateToDay(date: Dayjs): Promise<void> {
|
private async navigateToDay(date: Dayjs): Promise<void> {
|
||||||
Logger.debug(`Navigating to ${date.format()}`);
|
Logger.debug(`Navigating to ${date.format()}`)
|
||||||
|
|
||||||
if (this.getLastVisibleDay().isBefore(date)) {
|
if (this.getLastVisibleDay().isBefore(date)) {
|
||||||
Logger.debug('Date is on different page, increase month');
|
Logger.debug('Date is on different page, increase month')
|
||||||
await this.page?.waitForSelector('td.month.next').then((d) => d?.click());
|
await this.page?.waitForSelector('td.month.next').then((d) => d?.click())
|
||||||
}
|
}
|
||||||
|
|
||||||
await this.page
|
await this.page
|
||||||
?.waitForSelector(
|
?.waitForSelector(
|
||||||
`td#cal_${date.get('year')}_${date.get('month') + 1}_${date.get('date')}`
|
`td#cal_${date.get('year')}_${date.get('month') + 1}_${date.get(
|
||||||
|
'date'
|
||||||
|
)}`
|
||||||
)
|
)
|
||||||
.then((d) => d?.click())
|
.then((d) => d?.click())
|
||||||
await this.page?.waitForSelector(
|
await this.page?.waitForSelector(
|
||||||
|
|
@ -104,7 +110,7 @@ export class Runner {
|
||||||
}
|
}
|
||||||
|
|
||||||
private async selectAvailableTime(res: Reservation): Promise<void> {
|
private async selectAvailableTime(res: Reservation): Promise<void> {
|
||||||
Logger.debug('Selecting available time', res.format());
|
Logger.debug('Selecting available time', res.format())
|
||||||
let freeCourt: ElementHandle | null | undefined
|
let freeCourt: ElementHandle | null | undefined
|
||||||
let i = 0
|
let i = 0
|
||||||
while (i < res.possibleDates.length && !freeCourt) {
|
while (i < res.possibleDates.length && !freeCourt) {
|
||||||
|
|
@ -124,7 +130,7 @@ export class Runner {
|
||||||
}
|
}
|
||||||
|
|
||||||
private async selectOpponent(opponent: Opponent): Promise<void> {
|
private async selectOpponent(opponent: Opponent): Promise<void> {
|
||||||
Logger.debug('Selecting opponent', opponent);
|
Logger.debug('Selecting opponent', opponent)
|
||||||
const player2Search = await this.page?.waitForSelector(
|
const player2Search = await this.page?.waitForSelector(
|
||||||
'tr.res-make-player-2 > td > input'
|
'tr.res-make-player-2 > td > input'
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -1,17 +0,0 @@
|
||||||
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')
|
|
||||||
}
|
|
||||||
14
src/local.ts
14
src/local.ts
|
|
@ -6,9 +6,9 @@ import { Reservation } from './common/reservation'
|
||||||
import { Runner } from './common/runner'
|
import { Runner } from './common/runner'
|
||||||
|
|
||||||
const run = async (request: ReservationRequest) => {
|
const run = async (request: ReservationRequest) => {
|
||||||
Logger.instantiate('local', v4(), LogLevel.DEBUG);
|
Logger.instantiate('local', v4(), LogLevel.DEBUG)
|
||||||
const { username, password, dateRange, opponent } = request
|
const { username, password, dateRange, opponent } = request
|
||||||
const reservation = new Reservation(dateRange, opponent)
|
const reservation = new Reservation({ username, password }, dateRange, opponent)
|
||||||
|
|
||||||
const runner = new Runner(username, password, [reservation])
|
const runner = new Runner(username, password, [reservation])
|
||||||
await runner.run({ headless: false })
|
await runner.run({ headless: false })
|
||||||
|
|
@ -51,5 +51,11 @@ run({
|
||||||
id: opponentId,
|
id: opponentId,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
.then(() => console.log('Success'))
|
.then(() => {
|
||||||
.catch((e) => console.error(e))
|
console.log('Success')
|
||||||
|
process.exit(0)
|
||||||
|
})
|
||||||
|
.catch((e) => {
|
||||||
|
console.error(e)
|
||||||
|
process.exit(1)
|
||||||
|
})
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,86 @@
|
||||||
import { Worker } from '../types'
|
import http from 'http'
|
||||||
|
import { Readable } from 'stream'
|
||||||
|
import { v4 } from 'uuid'
|
||||||
|
import { Logger, LogLevel } from '../../common/logger'
|
||||||
|
import { work as schedule } from '../../workers/scheduler'
|
||||||
|
|
||||||
export const work: Worker<undefined, void> = async (): Promise<void> => {
|
Logger.instantiate('requester', 'start-up', LogLevel.INFO)
|
||||||
return
|
|
||||||
|
process.on('unhandledRejection', (reason) => {
|
||||||
|
Logger.error('unhandled rejection', { reason })
|
||||||
|
})
|
||||||
|
|
||||||
|
process.on('uncaughtException', (error, origin) => {
|
||||||
|
Logger.error('uncaught exception', { error, origin })
|
||||||
|
})
|
||||||
|
|
||||||
|
const parseJson = async <T extends Record<string, unknown>>(
|
||||||
|
length: number,
|
||||||
|
encoding: BufferEncoding,
|
||||||
|
readable: Readable
|
||||||
|
) => {
|
||||||
|
return new Promise<T>((res, rej) => {
|
||||||
|
let jsonBuffer: Buffer
|
||||||
|
try {
|
||||||
|
jsonBuffer = Buffer.alloc(length, encoding)
|
||||||
|
readable.setEncoding(encoding)
|
||||||
|
} catch (error: any) {
|
||||||
|
rej(error)
|
||||||
|
}
|
||||||
|
|
||||||
|
readable.on('data', (chunk) => {
|
||||||
|
try {
|
||||||
|
jsonBuffer.write(chunk, encoding)
|
||||||
|
} catch (error: any) {
|
||||||
|
rej(error)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
readable.on('end', () => {
|
||||||
|
try {
|
||||||
|
const jsonObject = JSON.parse(jsonBuffer.toString())
|
||||||
|
res(jsonObject)
|
||||||
|
} catch (error: any) {
|
||||||
|
rej(error)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Handles POST requests to /reservations
|
||||||
|
const server = http.createServer(async (req, res) => {
|
||||||
|
const { url, method } = req
|
||||||
|
|
||||||
|
Logger.instantiate('requester', v4(), LogLevel.DEBUG)
|
||||||
|
Logger.debug('Incoming request')
|
||||||
|
|
||||||
|
if (
|
||||||
|
!url ||
|
||||||
|
!method ||
|
||||||
|
!/^\/reservations$/.test(url) ||
|
||||||
|
method.toLowerCase() !== 'post'
|
||||||
|
) {
|
||||||
|
Logger.info('Bad request')
|
||||||
|
res.writeHead(400, 'Bad request')
|
||||||
|
res.end()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const length = Number.parseInt(req.headers['content-length'] || '0')
|
||||||
|
const encoding = req.readableEncoding || 'utf8'
|
||||||
|
const json = await parseJson(length, encoding, req)
|
||||||
|
await schedule(json)
|
||||||
|
} catch (error: any) {
|
||||||
|
Logger.error('Failed to parse body', { error })
|
||||||
|
res.writeHead(400, 'Bad request')
|
||||||
|
res.end()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
res.end()
|
||||||
|
})
|
||||||
|
|
||||||
|
const port = process.env.SERVER_PORT || 3000
|
||||||
|
|
||||||
|
server.listen(port, () => Logger.info('server ready and listening', { port }))
|
||||||
|
|
|
||||||
|
|
@ -3,61 +3,53 @@ import { v4 } from 'uuid'
|
||||||
|
|
||||||
import { Logger, LogLevel } from '../../common/logger'
|
import { Logger, LogLevel } from '../../common/logger'
|
||||||
import { Reservation } from '../../common/reservation'
|
import { Reservation } from '../../common/reservation'
|
||||||
import { ReservationRequest, validateJSONRequest } from '../../common/request'
|
import { validateJSONRequest } from '../../common/request'
|
||||||
import { scheduleDateToRequestReservation } from '../../common/schedule'
|
|
||||||
import { Worker } from '../types'
|
import { Worker } from '../types'
|
||||||
|
|
||||||
export interface ScheduledReservationRequest {
|
export interface ScheduledReservation {
|
||||||
reservationRequest: ReservationRequest
|
reservation: Reservation
|
||||||
scheduledFor?: Dayjs
|
scheduledFor?: Dayjs
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface SchedulerResult {
|
export interface SchedulerResult {
|
||||||
scheduledReservationRequest?: ScheduledReservationRequest
|
scheduledReservation?: ScheduledReservation
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface SchedulerInput extends Omit<ReservationRequest, 'dateRange'> {
|
export type SchedulerInput = Record<string, unknown>
|
||||||
dateRange: { start: string; end: string }
|
|
||||||
}
|
|
||||||
|
|
||||||
export const work: Worker<SchedulerInput, SchedulerResult> = async (
|
export const work: Worker<SchedulerInput, SchedulerResult> = async (
|
||||||
payload: SchedulerInput
|
payload: SchedulerInput
|
||||||
): Promise<SchedulerResult> => {
|
): Promise<SchedulerResult> => {
|
||||||
Logger.instantiate('reservationScheduler', v4(), LogLevel.DEBUG)
|
Logger.instantiate('scheduler', v4(), LogLevel.DEBUG)
|
||||||
|
|
||||||
|
// TODO: obfuscate payload
|
||||||
Logger.debug('Handling reservation', { payload })
|
Logger.debug('Handling reservation', { payload })
|
||||||
let reservationRequest: ReservationRequest
|
let reservation: Reservation
|
||||||
try {
|
try {
|
||||||
reservationRequest = validateJSONRequest(payload)
|
reservation = await validateJSONRequest(payload)
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
Logger.error('Failed to validate request', { err })
|
Logger.error('Failed to validate request', { err })
|
||||||
throw err
|
throw err
|
||||||
}
|
}
|
||||||
|
|
||||||
Logger.debug('Successfully validated request', { reservationRequest })
|
Logger.debug('Successfully validated request', {
|
||||||
|
reservation: reservation.format(),
|
||||||
|
})
|
||||||
|
|
||||||
const res = new Reservation(
|
if (!reservation.isAvailableForReservation()) {
|
||||||
reservationRequest.dateRange,
|
Logger.debug(
|
||||||
reservationRequest.opponent
|
'Reservation date is more than 7 days away; saving for later reservation'
|
||||||
)
|
|
||||||
|
|
||||||
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 {
|
return {
|
||||||
scheduledReservationRequest: {
|
scheduledReservation: {
|
||||||
reservationRequest,
|
reservation,
|
||||||
scheduledFor: scheduledDay,
|
scheduledFor: reservation.getAllowedReservationDate(),
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Logger.info('Reservation request can be performed now')
|
Logger.info('Reservation request can be performed now')
|
||||||
return {
|
return {
|
||||||
scheduledReservationRequest: { reservationRequest },
|
scheduledReservation: { reservation },
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -21,24 +21,39 @@ describe('Logger', () => {
|
||||||
|
|
||||||
expect(consoleLogSpy).toHaveBeenCalledTimes(2)
|
expect(consoleLogSpy).toHaveBeenCalledTimes(2)
|
||||||
expect(consoleLogSpy).toHaveBeenNthCalledWith(
|
expect(consoleLogSpy).toHaveBeenNthCalledWith(
|
||||||
1, '<%s> [%s] %s: %s', 'tag', 'abc', 'DEBUG', 'first'
|
1,
|
||||||
|
'<%s> [%s] %s: %s',
|
||||||
|
'tag',
|
||||||
|
'abc',
|
||||||
|
'DEBUG',
|
||||||
|
'first'
|
||||||
)
|
)
|
||||||
expect(consoleLogSpy).toHaveBeenNthCalledWith(
|
expect(consoleLogSpy).toHaveBeenNthCalledWith(
|
||||||
2, '<%s> [%s] %s: %s', 'tag', 'abc', 'INFO', 'second'
|
2,
|
||||||
|
'<%s> [%s] %s: %s',
|
||||||
|
'tag',
|
||||||
|
'abc',
|
||||||
|
'INFO',
|
||||||
|
'second'
|
||||||
)
|
)
|
||||||
expect(consoleErrorSpy).toHaveBeenCalledTimes(1)
|
expect(consoleErrorSpy).toHaveBeenCalledTimes(1)
|
||||||
expect(consoleErrorSpy).toHaveBeenCalledWith(
|
expect(consoleErrorSpy).toHaveBeenCalledWith(
|
||||||
'<%s> [%s] %s: %s - %O', 'tag', 'abc', 'ERROR', 'third', { "errorMessage": "test" }
|
'<%s> [%s] %s: %s - %O',
|
||||||
|
'tag',
|
||||||
|
'abc',
|
||||||
|
'ERROR',
|
||||||
|
'third',
|
||||||
|
{ errorMessage: 'test' }
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
test('should log only when level is >= LogLevel of LoggerInstance', () => {
|
test('should log only when level is >= LogLevel of LoggerInstance', () => {
|
||||||
const consoleLogSpy = jest.fn()
|
const consoleLogSpy = jest.fn()
|
||||||
jest.spyOn(console, 'log').mockImplementationOnce(consoleLogSpy)
|
jest.spyOn(console, 'log').mockImplementationOnce(consoleLogSpy)
|
||||||
|
|
||||||
Logger.instantiate('tag', 'abc', LogLevel.INFO)
|
Logger.instantiate('tag', 'abc', LogLevel.INFO)
|
||||||
Logger.debug('should\'t appear')
|
Logger.debug("should't appear")
|
||||||
|
|
||||||
expect(consoleLogSpy).not.toHaveBeenCalled()
|
expect(consoleLogSpy).not.toHaveBeenCalled()
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
|
||||||
27
tests/common/password.test.ts
Normal file
27
tests/common/password.test.ts
Normal file
|
|
@ -0,0 +1,27 @@
|
||||||
|
import * as password from '../../src/common/password'
|
||||||
|
|
||||||
|
describe('password', () => {
|
||||||
|
describe('generateSalt', () => {
|
||||||
|
test('should generate salt of 32 bytes', async () => {
|
||||||
|
const saltBuffer = await password.generateSalt()
|
||||||
|
expect(saltBuffer.length).toEqual(32)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('generateHash', () => {
|
||||||
|
test('should generate a hash of 64 bytes', async () => {
|
||||||
|
const saltBuffer = Buffer.alloc(32, 1)
|
||||||
|
const hash = await password.generateHash('abc123', saltBuffer)
|
||||||
|
expect(hash).toEqual(
|
||||||
|
'$argon2id$v=19$m=16384,t=2,p=1$AQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQE$jF3SXC/JRI9d1jr48kQkvWaSVlf3XGNSRUCNnNp5IaI'
|
||||||
|
)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('hashPassword', () => {
|
||||||
|
test('it should create salt and hash password', async () => {
|
||||||
|
const hash = await password.hashPassword('abc123')
|
||||||
|
expect(hash).toMatch(/^\$argon2id\$v=19\$m=16384,t=2,p=1\$.+$/)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
@ -2,141 +2,13 @@ import dayjs from 'dayjs'
|
||||||
|
|
||||||
import {
|
import {
|
||||||
validateJSONRequest,
|
validateJSONRequest,
|
||||||
validateStringRequest,
|
|
||||||
ValidationError,
|
ValidationError,
|
||||||
ValidationErrorCode,
|
ValidationErrorCode,
|
||||||
} from '../../src/common/request'
|
} from '../../src/common/request'
|
||||||
|
|
||||||
describe('request', () => {
|
describe('request', () => {
|
||||||
|
|
||||||
const testDate = dayjs().add(1, 'day')
|
const testDate = dayjs().add(1, 'day')
|
||||||
|
|
||||||
describe('validateStringRequest', () => {
|
|
||||||
test('should return ReservationRequest', () => {
|
|
||||||
const body = JSON.stringify({
|
|
||||||
username: 'collin',
|
|
||||||
password: '123abc',
|
|
||||||
dateRange: {
|
|
||||||
start: testDate.clone().toISOString(),
|
|
||||||
end: testDate.add(15, 'minutes').toISOString(),
|
|
||||||
},
|
|
||||||
opponent: {
|
|
||||||
id: '123',
|
|
||||||
name: 'collin',
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
expect(() => validateStringRequest(body)).not.toThrow()
|
|
||||||
})
|
|
||||||
|
|
||||||
test('should fail for undefined body', () => {
|
|
||||||
expect(() => validateStringRequest(undefined)).toThrowError(new ValidationError('Invalid request', ValidationErrorCode.UNDEFINED_REQUEST_BODY))
|
|
||||||
})
|
|
||||||
|
|
||||||
test('should fail for invalid json', () => {
|
|
||||||
const body = `A{
|
|
||||||
username: 'collin',
|
|
||||||
password: '123abc',
|
|
||||||
dateRange: {
|
|
||||||
start: '2021-12-25T12:34:56Z',
|
|
||||||
end: '2021-12-25T12:45:56Z'
|
|
||||||
},
|
|
||||||
opponent: {
|
|
||||||
id: '123',
|
|
||||||
name: 'collin',
|
|
||||||
}
|
|
||||||
}`
|
|
||||||
|
|
||||||
expect(() => validateStringRequest(body)).toThrowError(new ValidationError('Invalid request', ValidationErrorCode.INVALID_JSON))
|
|
||||||
})
|
|
||||||
|
|
||||||
test.each([
|
|
||||||
{ username: '', password: '1qaz2wsx', dateRange: { start: '1', end: '1' }, opponent: { id: '123', name: 'abc' } },
|
|
||||||
{ password: '1qaz2wsx', dateRange: { start: '1', end: '1' }, opponent: { id: '123', name: 'abc' } },
|
|
||||||
{ username: 'collin', password: '', dateRange: { start: '1', end: '1' }, opponent: { id: '123', name: 'abc' } },
|
|
||||||
{ username: 'collin', dateRange: { start: '1', end: '1' }, opponent: { id: '123', name: 'abc' } },
|
|
||||||
{ username: 'collin', password: '1qaz2wsx', dateRange: {}, opponent: { id: '123', name: 'abc' } },
|
|
||||||
{ username: 'collin', password: '1qaz2wsx', dateRange: { start: '1' }, opponent: { id: '123', name: 'abc' } },
|
|
||||||
{ username: 'collin', password: '1qaz2wsx', dateRange: { end: '1' }, opponent: { id: '123', name: 'abc' } },
|
|
||||||
{ username: 'collin', password: '1qaz2wsx', opponent: { id: '123', name: 'abc' } },
|
|
||||||
{ username: 'collin', password: '1qaz2wsx', dateRange: { start: '1', end: '1' }, opponent: { id: '', name: 'abc' } },
|
|
||||||
{ username: 'collin', password: '1qaz2wsx', dateRange: { start: '1', end: '1' }, opponent: { name: 'abc' } },
|
|
||||||
{ username: 'collin', password: '1qaz2wsx', dateRange: { start: '1', end: '1' }, opponent: { id: '123', name: '' } },
|
|
||||||
{ username: 'collin', password: '1qaz2wsx', dateRange: { start: '1', end: '1' }, opponent: { id: '123' } },
|
|
||||||
])('should fail for body missing required values', (body) => {
|
|
||||||
expect(() => validateStringRequest(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',
|
|
||||||
dateRange: {
|
|
||||||
start: 'monkey',
|
|
||||||
end: testDate.add(15, 'minutes').toISOString(),
|
|
||||||
},
|
|
||||||
opponent: {
|
|
||||||
id: '123',
|
|
||||||
name: 'collin',
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
expect(() => validateStringRequest(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',
|
|
||||||
dateRange: [
|
|
||||||
dateRange
|
|
||||||
],
|
|
||||||
opponent: {
|
|
||||||
id: '123',
|
|
||||||
name: 'collin',
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
expect(() => validateStringRequest(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',
|
|
||||||
dateRange: {
|
|
||||||
start: testDate.clone().toISOString(),
|
|
||||||
end: testDate.add(15, 'minutes').toISOString()
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
expect(() => validateStringRequest(body)).not.toThrow()
|
|
||||||
})
|
|
||||||
|
|
||||||
test.each([
|
|
||||||
{ id: '-50', name: 'collin' },
|
|
||||||
{ id: 'abc', name: 'collin' },
|
|
||||||
{ id: '-1', name: '*!@#' },
|
|
||||||
{ id: '123', name: '!@#' },
|
|
||||||
])('should fail for invalid opponent $id, $name', (opponent) => {
|
|
||||||
const body = JSON.stringify({
|
|
||||||
username: 'collin',
|
|
||||||
password: '123abc',
|
|
||||||
dateRange: {
|
|
||||||
start: testDate.clone().toISOString(),
|
|
||||||
end: testDate.add(15, 'minutes').toISOString(),
|
|
||||||
},
|
|
||||||
opponent,
|
|
||||||
})
|
|
||||||
|
|
||||||
expect(() => validateStringRequest(body)).toThrowError(new ValidationError('Invalid request', ValidationErrorCode.INVALID_OPPONENT))
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
describe('validateJSONRequest', () => {
|
describe('validateJSONRequest', () => {
|
||||||
test('should return ReservationRequest', () => {
|
test('should return ReservationRequest', () => {
|
||||||
const body = {
|
const body = {
|
||||||
|
|
@ -144,15 +16,64 @@ describe('request', () => {
|
||||||
password: '123abc',
|
password: '123abc',
|
||||||
dateRange: {
|
dateRange: {
|
||||||
start: testDate.clone().toISOString(),
|
start: testDate.clone().toISOString(),
|
||||||
end: testDate.add(15, 'minutes').toISOString()
|
end: testDate.add(15, 'minutes').toISOString(),
|
||||||
},
|
},
|
||||||
opponent: {
|
opponent: {
|
||||||
id: '123',
|
id: '123',
|
||||||
name: 'collin',
|
name: 'collin',
|
||||||
}
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
expect(() => validateJSONRequest(body)).not.toThrow()
|
expect(() => validateJSONRequest(body)).not.toThrow()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test('should throw error for undefined body', async () => {
|
||||||
|
// @ts-expect-error undefined body
|
||||||
|
expect(() => validateJSONRequest(undefined)).rejects.toThrowError(
|
||||||
|
ValidationError
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('should throw error for invalid body', () => {
|
||||||
|
expect(() =>
|
||||||
|
validateJSONRequest({ username: '', password: '' })
|
||||||
|
).rejects.toThrowError(ValidationError)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('should throw error for invalid date range', () => {
|
||||||
|
expect(() =>
|
||||||
|
validateJSONRequest({
|
||||||
|
username: 'test',
|
||||||
|
password: 'test',
|
||||||
|
dateRange: { start: 'a', end: 'a' },
|
||||||
|
opponent: { id: 1, name: 'test' },
|
||||||
|
})
|
||||||
|
).rejects.toThrowError(ValidationError)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('should throw error for incorrect date range', () => {
|
||||||
|
expect(() =>
|
||||||
|
validateJSONRequest({
|
||||||
|
username: 'test',
|
||||||
|
password: 'test',
|
||||||
|
dateRange: { start: '2022-01-01', end: '2021-01-01' },
|
||||||
|
opponent: { id: 1, name: 'test' },
|
||||||
|
})
|
||||||
|
).rejects.toThrowError(ValidationError)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('should throw error for incorrect date range', () => {
|
||||||
|
expect(() =>
|
||||||
|
validateJSONRequest({
|
||||||
|
username: 'test',
|
||||||
|
password: 'test',
|
||||||
|
dateRange: {
|
||||||
|
start: testDate.toString(),
|
||||||
|
end: testDate.add(15, 'minute').toString(),
|
||||||
|
},
|
||||||
|
opponent: { id: 1, name: 'test' },
|
||||||
|
})
|
||||||
|
).rejects.toThrowError(ValidationError)
|
||||||
|
})
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import dayjs from 'dayjs'
|
import dayjs, { Dayjs } from 'dayjs'
|
||||||
import { DateRange, Reservation } from '../../src/common/reservation'
|
import { DateRange, Reservation } from '../../src/common/reservation'
|
||||||
|
|
||||||
describe('Reservation', () => {
|
describe('Reservation', () => {
|
||||||
|
|
@ -9,8 +9,15 @@ describe('Reservation', () => {
|
||||||
start: startDate,
|
start: startDate,
|
||||||
end: endDate,
|
end: endDate,
|
||||||
}
|
}
|
||||||
const res = new Reservation(dateRange, { id: 'collin', name: 'collin' })
|
const res = new Reservation(
|
||||||
|
{ username: 'collin', password: 'password' },
|
||||||
|
dateRange,
|
||||||
|
{
|
||||||
|
id: 'collin',
|
||||||
|
name: 'collin',
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
expect(res.possibleDates).toHaveLength(5)
|
expect(res.possibleDates).toHaveLength(5)
|
||||||
|
|
||||||
expect(res.possibleDates[0]).toEqual(startDate)
|
expect(res.possibleDates[0]).toEqual(startDate)
|
||||||
|
|
@ -24,8 +31,38 @@ describe('Reservation', () => {
|
||||||
{ reservationDate: dayjs().add(7, 'days'), expected: true },
|
{ reservationDate: dayjs().add(7, 'days'), expected: true },
|
||||||
{ reservationDate: dayjs().add(1, 'days'), expected: true },
|
{ reservationDate: dayjs().add(1, 'days'), expected: true },
|
||||||
{ reservationDate: dayjs().add(8, 'days'), expected: false },
|
{ 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' })
|
'will properly mark reservation availability according to date',
|
||||||
expect(res.isAvailableForReservation()).toBe(expected)
|
({ reservationDate, expected }) => {
|
||||||
})
|
const res = new Reservation(
|
||||||
})
|
{ username: 'collin', password: 'collin' },
|
||||||
|
{ start: reservationDate, end: reservationDate },
|
||||||
|
{ id: 'collin', name: 'collin' }
|
||||||
|
)
|
||||||
|
expect(res.isAvailableForReservation()).toBe(expected)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
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 }) => {
|
||||||
|
const res = new Reservation(
|
||||||
|
{ username: 'collin', password: 'collin' },
|
||||||
|
{ start: date, end: date },
|
||||||
|
{ id: 'collin', name: 'collin' }
|
||||||
|
)
|
||||||
|
expect(res.getAllowedReservationDate()).toStrictEqual(expected)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
|
||||||
|
|
@ -1,13 +0,0 @@
|
||||||
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)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
138
tests/workers/__snapshots__/scheduler.test.ts.snap
Normal file
138
tests/workers/__snapshots__/scheduler.test.ts.snap
Normal file
|
|
@ -0,0 +1,138 @@
|
||||||
|
// Jest Snapshot v1, https://goo.gl/fbAQLP
|
||||||
|
|
||||||
|
exports[`scheduler should handle valid requests outside of reservation window 1`] = `
|
||||||
|
Object {
|
||||||
|
"scheduledReservation": Object {
|
||||||
|
"reservation": Object {
|
||||||
|
"booked": false,
|
||||||
|
"dateRange": Object {
|
||||||
|
"end": Object {
|
||||||
|
"$D": 16,
|
||||||
|
"$H": 1,
|
||||||
|
"$L": "en",
|
||||||
|
"$M": 0,
|
||||||
|
"$W": 0,
|
||||||
|
"$d": Object {},
|
||||||
|
"$m": 15,
|
||||||
|
"$ms": 0,
|
||||||
|
"$s": 0,
|
||||||
|
"$x": Object {},
|
||||||
|
"$y": 2022,
|
||||||
|
},
|
||||||
|
"start": Object {
|
||||||
|
"$D": 16,
|
||||||
|
"$H": 1,
|
||||||
|
"$L": "en",
|
||||||
|
"$M": 0,
|
||||||
|
"$W": 0,
|
||||||
|
"$d": Object {},
|
||||||
|
"$m": 0,
|
||||||
|
"$ms": 0,
|
||||||
|
"$s": 0,
|
||||||
|
"$x": Object {},
|
||||||
|
"$y": 2022,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"opponent": Object {
|
||||||
|
"id": "123",
|
||||||
|
"name": "collin",
|
||||||
|
},
|
||||||
|
"possibleDates": Array [
|
||||||
|
Object {
|
||||||
|
"$D": 16,
|
||||||
|
"$H": 1,
|
||||||
|
"$L": "en",
|
||||||
|
"$M": 0,
|
||||||
|
"$W": 0,
|
||||||
|
"$d": Object {},
|
||||||
|
"$m": 0,
|
||||||
|
"$ms": 0,
|
||||||
|
"$s": 0,
|
||||||
|
"$x": Object {},
|
||||||
|
"$y": 2022,
|
||||||
|
},
|
||||||
|
Object {
|
||||||
|
"$D": 16,
|
||||||
|
"$H": 1,
|
||||||
|
"$L": "en",
|
||||||
|
"$M": 0,
|
||||||
|
"$W": 0,
|
||||||
|
"$d": Object {},
|
||||||
|
"$m": 15,
|
||||||
|
"$ms": 0,
|
||||||
|
"$s": 0,
|
||||||
|
"$x": Object {},
|
||||||
|
"$y": 2022,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
"user": Object {
|
||||||
|
"password": Any<String>,
|
||||||
|
"username": "collin",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"scheduledFor": Object {
|
||||||
|
"$D": 9,
|
||||||
|
"$H": 0,
|
||||||
|
"$L": "en",
|
||||||
|
"$M": 0,
|
||||||
|
"$W": 0,
|
||||||
|
"$d": Object {},
|
||||||
|
"$m": 0,
|
||||||
|
"$ms": 0,
|
||||||
|
"$s": 0,
|
||||||
|
"$x": Object {},
|
||||||
|
"$y": 2022,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
exports[`scheduler should handle valid requests within reservation window 1`] = `
|
||||||
|
Object {
|
||||||
|
"scheduledReservation": Object {
|
||||||
|
"reservation": Object {
|
||||||
|
"booked": false,
|
||||||
|
"dateRange": Object {
|
||||||
|
"end": Object {
|
||||||
|
"$D": 1,
|
||||||
|
"$H": 1,
|
||||||
|
"$L": "en",
|
||||||
|
"$M": 0,
|
||||||
|
"$W": 6,
|
||||||
|
"$d": Object {},
|
||||||
|
"$m": 30,
|
||||||
|
"$ms": 0,
|
||||||
|
"$s": 0,
|
||||||
|
"$x": Object {},
|
||||||
|
"$y": 2022,
|
||||||
|
},
|
||||||
|
"start": Object {
|
||||||
|
"$D": 1,
|
||||||
|
"$H": 1,
|
||||||
|
"$L": "en",
|
||||||
|
"$M": 0,
|
||||||
|
"$W": 6,
|
||||||
|
"$d": Object {},
|
||||||
|
"$m": 15,
|
||||||
|
"$ms": 0,
|
||||||
|
"$s": 0,
|
||||||
|
"$x": Object {},
|
||||||
|
"$y": 2022,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"opponent": Object {
|
||||||
|
"id": "123",
|
||||||
|
"name": "collin",
|
||||||
|
},
|
||||||
|
"possibleDates": Array [
|
||||||
|
"2022-01-01T00:15:00.000Z",
|
||||||
|
"2022-01-01T00:30:00.000Z",
|
||||||
|
],
|
||||||
|
"user": Object {
|
||||||
|
"password": Any<String>,
|
||||||
|
"username": "collin",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
@ -1,8 +1,14 @@
|
||||||
import dayjs from 'dayjs'
|
import dayjs from 'dayjs'
|
||||||
import { ValidationError, ValidationErrorCode } from '../../src/common/request'
|
import { ValidationError, ValidationErrorCode } from '../../src/common/request'
|
||||||
import { work, SchedulerInput, SchedulerResult } from '../../src/workers/scheduler'
|
import { Reservation } from '../../src/common/reservation'
|
||||||
|
import {
|
||||||
|
work,
|
||||||
|
SchedulerInput,
|
||||||
|
SchedulerResult,
|
||||||
|
} from '../../src/workers/scheduler'
|
||||||
|
|
||||||
jest.mock('../../src/common/logger')
|
jest.mock('../../src/common/logger')
|
||||||
|
jest.useFakeTimers().setSystemTime(new Date('2022-01-01'))
|
||||||
|
|
||||||
describe('scheduler', () => {
|
describe('scheduler', () => {
|
||||||
test('should handle valid requests within reservation window', async () => {
|
test('should handle valid requests within reservation window', async () => {
|
||||||
|
|
@ -10,44 +16,51 @@ describe('scheduler', () => {
|
||||||
const end = start.add(15, 'minutes')
|
const end = start.add(15, 'minutes')
|
||||||
|
|
||||||
const payload: SchedulerInput = {
|
const payload: SchedulerInput = {
|
||||||
username: "collin",
|
username: 'collin',
|
||||||
password: "password",
|
password: 'password',
|
||||||
dateRange: { start: start.toISOString(), end: end.toISOString() },
|
dateRange: { start: start.toISOString(), end: end.toISOString() },
|
||||||
opponent: { id: "123", name: "collin" }
|
opponent: { id: '123', name: 'collin' },
|
||||||
}
|
}
|
||||||
|
|
||||||
await expect(work(payload)).resolves
|
await expect(work(payload)).resolves.toMatchSnapshot<SchedulerResult>({
|
||||||
.toMatchObject<SchedulerResult>({
|
scheduledReservation: {
|
||||||
scheduledReservationRequest: {
|
// @ts-expect-error snapshot property matching
|
||||||
reservationRequest: {
|
reservation: {
|
||||||
|
user: {
|
||||||
username: 'collin',
|
username: 'collin',
|
||||||
password: 'password',
|
password: expect.any(String)
|
||||||
dateRange: { start, end },
|
},
|
||||||
opponent: { id: '123', name: 'collin' },
|
dateRange: { start, end },
|
||||||
}
|
opponent: { id: '123', name: 'collin' }
|
||||||
}})
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
test('should handle valid requests outside of reservation window', async () => {
|
test('should handle valid requests outside of reservation window', async () => {
|
||||||
const start = dayjs().add(15, 'days')
|
const start = dayjs().add(15, 'days')
|
||||||
const end = start.add(15, 'minutes')
|
const end = start.add(15, 'minutes')
|
||||||
const payload: SchedulerInput = {
|
const payload: SchedulerInput = {
|
||||||
username: "collin",
|
username: 'collin',
|
||||||
password: "password",
|
password: 'password',
|
||||||
dateRange: { start: start.toISOString(), end: end.toISOString() },
|
dateRange: { start: start.toISOString(), end: end.toISOString() },
|
||||||
opponent: { id: "123", name: "collin" }
|
opponent: { id: '123', name: 'collin' },
|
||||||
}
|
}
|
||||||
|
|
||||||
await expect(work(payload)).resolves.toMatchObject<SchedulerResult>({
|
await expect(work(payload)).resolves.toMatchSnapshot<SchedulerResult>({
|
||||||
scheduledReservationRequest: {
|
scheduledReservation: {
|
||||||
reservationRequest: {
|
reservation: new Reservation(
|
||||||
username: 'collin',
|
{ username: 'collin', password: expect.any(String) },
|
||||||
password: 'password',
|
{ start, end },
|
||||||
dateRange: { start, end },
|
{ id: '123', name: 'collin' }
|
||||||
opponent: { id: '123', name: 'collin' },
|
),
|
||||||
},
|
scheduledFor: start
|
||||||
scheduledFor: start.subtract(7, 'days').hour(0).minute(0).second(0).millisecond(0)
|
.subtract(7, 'days')
|
||||||
}
|
.hour(0)
|
||||||
|
.minute(0)
|
||||||
|
.second(0)
|
||||||
|
.millisecond(0),
|
||||||
|
},
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
@ -56,13 +69,16 @@ describe('scheduler', () => {
|
||||||
const end = start.add(15, 'minutes')
|
const end = start.add(15, 'minutes')
|
||||||
|
|
||||||
const payload: SchedulerInput = {
|
const payload: SchedulerInput = {
|
||||||
password: "password",
|
password: 'password',
|
||||||
dateRange: { start: start.toISOString(), end: end.toISOString() },
|
dateRange: { start: start.toISOString(), end: end.toISOString() },
|
||||||
opponent: { id: "123", name: "collin" }
|
opponent: { id: '123', name: 'collin' },
|
||||||
}
|
}
|
||||||
|
|
||||||
await expect(work(payload))
|
await expect(work(payload)).rejects.toThrowError(
|
||||||
.rejects
|
new ValidationError(
|
||||||
.toThrowError(new ValidationError('Invalid request', ValidationErrorCode.INVALID_REQUEST_BODY))
|
'Invalid request',
|
||||||
|
ValidationErrorCode.INVALID_REQUEST_BODY
|
||||||
|
)
|
||||||
|
)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue