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:
Collin Duncan 2022-10-23 11:55:47 +02:00
parent 5b1416d789
commit 3247ca5315
No known key found for this signature in database
18 changed files with 775 additions and 323 deletions

View file

@ -0,0 +1,7 @@
services:
database:
image: mysql:latest
restart: always
env_file: ./database/.env
ports:
- 3306:3306

View 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)
}
}

View 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
View 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)
}

View file

@ -1,15 +1,9 @@
import dayjs from 'dayjs'
import isSameOrBefore from 'dayjs/plugin/isSameOrBefore'
import { hashPassword } from './password'
dayjs.extend(isSameOrBefore)
import { DateRange, Opponent } from './reservation'
export interface ReservationRequest extends Record<string, unknown> {
username: string
password: string
dateRange: DateRange
opponent: Opponent
}
import { DateRange, Opponent, Reservation } from './reservation'
export enum ValidationErrorCode {
UNDEFINED_REQUEST_BODY,
@ -28,30 +22,19 @@ export class ValidationError extends Error {
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>
): ReservationRequest => {
const request = validateRequestBody(body)
): Promise<Reservation> => {
const request = await validateRequestBody(body)
validateRequestDateRange(request.dateRange)
validateRequestOpponent(request.opponent)
return request
}
const validateRequestBody = (
body?: string | Record<string, unknown>
): ReservationRequest => {
const validateRequestBody = async (
body?: Record<string, unknown>
): Promise<Reservation> => {
if (body === undefined) {
throw new ValidationError(
'Invalid request',
@ -59,22 +42,7 @@ const validateRequestBody = (
)
}
let jsonBody: ReservationRequest
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
const { username, password, dateRange, opponent }: Record<string, any> = body
if (
!username ||
@ -93,28 +61,14 @@ const validateRequestBody = (
)
}
return jsonBody
}
const transformRequestBody = (body: string): ReservationRequest => {
let json
try {
json = JSON.parse(body)
} catch (err) {
throw new ValidationError(
'Invalid request',
ValidationErrorCode.INVALID_JSON
const hashedPassword = await hashPassword(password)
const reservation = new Reservation(
{ username, password: hashedPassword },
convertDateRangeStringToObject(dateRange),
opponent
)
}
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,
}
return reservation
}
const convertDateRangeStringToObject = ({

View file

@ -1,7 +1,13 @@
import dayjs, { Dayjs } from 'dayjs'
import isSameOrBefore from 'dayjs/plugin/isSameOrBefore'
import { query } from './database'
dayjs.extend(isSameOrBefore)
export interface User {
username: string
password: string
}
export interface Opponent {
id: string
name: string
@ -15,15 +21,22 @@ export interface DateRange {
const RESERVATION_AVAILABLE_WITHIN_DAYS = 7
export class Reservation {
public readonly user: User
public readonly dateRange: DateRange
public readonly opponent: Opponent
public readonly possibleDates: Dayjs[]
public booked = false
constructor(dateRange: DateRange, opponent: Opponent) {
constructor(
user: User,
dateRange: DateRange,
opponent: Opponent,
possibleDates?: Dayjs[]
) {
this.user = user
this.dateRange = dateRange
this.opponent = opponent
this.possibleDates = this.createPossibleDates()
this.possibleDates = possibleDates || this.createPossibleDates()
}
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 {
user: {
username: this.user.username,
password: this.user.password ? '?' : null,
},
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 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
}

View file

@ -33,7 +33,7 @@ export class Runner {
BrowserLaunchArgumentOptions &
BrowserConnectOptions
): Promise<Reservation[]> {
Logger.debug('Launching browser');
Logger.debug('Launching browser')
this.browser = await puppeteer.launch(options)
this.page = await this.browser?.newPage()
await this.login()
@ -41,7 +41,7 @@ export class Runner {
}
private async login() {
Logger.debug('Logging in');
Logger.debug('Logging in')
await this.page?.goto('https://squashcity.baanreserveren.nl/')
await this.page
?.waitForSelector('input[name=username]')
@ -54,7 +54,7 @@ export class Runner {
private async makeReservations(): Promise<Reservation[]> {
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])
}
@ -69,31 +69,37 @@ export class Runner {
await this.confirmReservation()
reservation.booked = true
} catch (err) {
Logger.error('Error making reservation', reservation.format());
Logger.error('Error making reservation', reservation.format())
}
}
private getLastVisibleDay(): Dayjs {
const lastDayOfMonth = dayjs().add(1, 'month').set('date', 0);
let daysToAdd = 0;
const lastDayOfMonth = dayjs().add(1, 'month').set('date', 0)
let daysToAdd = 0
switch (lastDayOfMonth.day()) {
case 0: daysToAdd = 0; break;
default: daysToAdd = 7 - lastDayOfMonth.day(); break;
case 0:
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> {
Logger.debug(`Navigating to ${date.format()}`);
Logger.debug(`Navigating to ${date.format()}`)
if (this.getLastVisibleDay().isBefore(date)) {
Logger.debug('Date is on different page, increase month');
await this.page?.waitForSelector('td.month.next').then((d) => d?.click());
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#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())
await this.page?.waitForSelector(
@ -104,7 +110,7 @@ export class Runner {
}
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 i = 0
while (i < res.possibleDates.length && !freeCourt) {
@ -124,7 +130,7 @@ export class Runner {
}
private async selectOpponent(opponent: Opponent): Promise<void> {
Logger.debug('Selecting opponent', opponent);
Logger.debug('Selecting opponent', opponent)
const player2Search = await this.page?.waitForSelector(
'tr.res-make-player-2 > td > input'
)

View file

@ -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')
}

View file

@ -6,9 +6,9 @@ import { Reservation } from './common/reservation'
import { Runner } from './common/runner'
const run = async (request: ReservationRequest) => {
Logger.instantiate('local', v4(), LogLevel.DEBUG);
Logger.instantiate('local', v4(), LogLevel.DEBUG)
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])
await runner.run({ headless: false })
@ -51,5 +51,11 @@ run({
id: opponentId,
},
})
.then(() => console.log('Success'))
.catch((e) => console.error(e))
.then(() => {
console.log('Success')
process.exit(0)
})
.catch((e) => {
console.error(e)
process.exit(1)
})

View file

@ -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)
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 }))

View file

@ -3,61 +3,53 @@ import { v4 } from 'uuid'
import { Logger, LogLevel } from '../../common/logger'
import { Reservation } from '../../common/reservation'
import { ReservationRequest, validateJSONRequest } from '../../common/request'
import { scheduleDateToRequestReservation } from '../../common/schedule'
import { validateJSONRequest } from '../../common/request'
import { Worker } from '../types'
export interface ScheduledReservationRequest {
reservationRequest: ReservationRequest
export interface ScheduledReservation {
reservation: Reservation
scheduledFor?: Dayjs
}
export interface SchedulerResult {
scheduledReservationRequest?: ScheduledReservationRequest
scheduledReservation?: ScheduledReservation
}
export interface SchedulerInput extends Omit<ReservationRequest, 'dateRange'> {
dateRange: { start: string; end: string }
}
export type SchedulerInput = Record<string, unknown>
export const work: Worker<SchedulerInput, SchedulerResult> = async (
payload: SchedulerInput
): Promise<SchedulerResult> => {
Logger.instantiate('reservationScheduler', v4(), LogLevel.DEBUG)
Logger.instantiate('scheduler', v4(), LogLevel.DEBUG)
// TODO: obfuscate payload
Logger.debug('Handling reservation', { payload })
let reservationRequest: ReservationRequest
let reservation: Reservation
try {
reservationRequest = validateJSONRequest(payload)
reservation = await validateJSONRequest(payload)
} catch (err) {
Logger.error('Failed to validate request', { err })
throw err
}
Logger.debug('Successfully validated request', { reservationRequest })
Logger.debug('Successfully validated request', {
reservation: reservation.format(),
})
const res = new Reservation(
reservationRequest.dateRange,
reservationRequest.opponent
)
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')}`
if (!reservation.isAvailableForReservation()) {
Logger.debug(
'Reservation date is more than 7 days away; saving for later reservation'
)
return {
scheduledReservationRequest: {
reservationRequest,
scheduledFor: scheduledDay,
scheduledReservation: {
reservation,
scheduledFor: reservation.getAllowedReservationDate(),
},
}
}
Logger.info('Reservation request can be performed now')
return {
scheduledReservationRequest: { reservationRequest },
scheduledReservation: { reservation },
}
}

View file

@ -21,14 +21,29 @@ describe('Logger', () => {
expect(consoleLogSpy).toHaveBeenCalledTimes(2)
expect(consoleLogSpy).toHaveBeenNthCalledWith(
1, '<%s> [%s] %s: %s', 'tag', 'abc', 'DEBUG', 'first'
1,
'<%s> [%s] %s: %s',
'tag',
'abc',
'DEBUG',
'first'
)
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).toHaveBeenCalledWith(
'<%s> [%s] %s: %s - %O', 'tag', 'abc', 'ERROR', 'third', { "errorMessage": "test" }
'<%s> [%s] %s: %s - %O',
'tag',
'abc',
'ERROR',
'third',
{ errorMessage: 'test' }
)
})
@ -37,7 +52,7 @@ describe('Logger', () => {
jest.spyOn(console, 'log').mockImplementationOnce(consoleLogSpy)
Logger.instantiate('tag', 'abc', LogLevel.INFO)
Logger.debug('should\'t appear')
Logger.debug("should't appear")
expect(consoleLogSpy).not.toHaveBeenCalled()
})

View 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\$.+$/)
})
})
})

View file

@ -2,141 +2,13 @@ import dayjs from 'dayjs'
import {
validateJSONRequest,
validateStringRequest,
ValidationError,
ValidationErrorCode,
} from '../../src/common/request'
describe('request', () => {
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', () => {
test('should return ReservationRequest', () => {
const body = {
@ -144,15 +16,64 @@ describe('request', () => {
password: '123abc',
dateRange: {
start: testDate.clone().toISOString(),
end: testDate.add(15, 'minutes').toISOString()
end: testDate.add(15, 'minutes').toISOString(),
},
opponent: {
id: '123',
name: 'collin',
}
},
}
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)
})
})
})

View file

@ -1,4 +1,4 @@
import dayjs from 'dayjs'
import dayjs, { Dayjs } from 'dayjs'
import { DateRange, Reservation } from '../../src/common/reservation'
describe('Reservation', () => {
@ -9,7 +9,14 @@ describe('Reservation', () => {
start: startDate,
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)
@ -24,8 +31,38 @@ describe('Reservation', () => {
{ reservationDate: dayjs().add(7, 'days'), expected: true },
{ reservationDate: dayjs().add(1, 'days'), expected: true },
{ 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',
({ 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)
}
)
})

View file

@ -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)
})
})

View 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",
},
},
},
}
`;

View file

@ -1,8 +1,14 @@
import dayjs from 'dayjs'
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.useFakeTimers().setSystemTime(new Date('2022-01-01'))
describe('scheduler', () => {
test('should handle valid requests within reservation window', async () => {
@ -10,44 +16,51 @@ describe('scheduler', () => {
const end = start.add(15, 'minutes')
const payload: SchedulerInput = {
username: "collin",
password: "password",
dateRange: { start: start.toISOString(), end: end.toISOString() },
opponent: { id: "123", name: "collin" }
}
await expect(work(payload)).resolves
.toMatchObject<SchedulerResult>({
scheduledReservationRequest: {
reservationRequest: {
username: 'collin',
password: 'password',
dateRange: { start, end },
dateRange: { start: start.toISOString(), end: end.toISOString() },
opponent: { id: '123', name: 'collin' },
}
}})
await expect(work(payload)).resolves.toMatchSnapshot<SchedulerResult>({
scheduledReservation: {
// @ts-expect-error snapshot property matching
reservation: {
user: {
username: 'collin',
password: expect.any(String)
},
dateRange: { start, end },
opponent: { id: '123', name: 'collin' }
},
},
})
})
test('should handle valid requests outside of reservation window', async () => {
const start = dayjs().add(15, 'days')
const end = start.add(15, 'minutes')
const payload: SchedulerInput = {
username: "collin",
password: "password",
dateRange: { start: start.toISOString(), end: end.toISOString() },
opponent: { id: "123", name: "collin" }
}
await expect(work(payload)).resolves.toMatchObject<SchedulerResult>({
scheduledReservationRequest: {
reservationRequest: {
username: 'collin',
password: 'password',
dateRange: { start, end },
dateRange: { start: start.toISOString(), end: end.toISOString() },
opponent: { id: '123', name: 'collin' },
},
scheduledFor: start.subtract(7, 'days').hour(0).minute(0).second(0).millisecond(0)
}
await expect(work(payload)).resolves.toMatchSnapshot<SchedulerResult>({
scheduledReservation: {
reservation: new Reservation(
{ username: 'collin', password: expect.any(String) },
{ start, end },
{ id: '123', name: 'collin' }
),
scheduledFor: start
.subtract(7, 'days')
.hour(0)
.minute(0)
.second(0)
.millisecond(0),
},
})
})
@ -56,13 +69,16 @@ describe('scheduler', () => {
const end = start.add(15, 'minutes')
const payload: SchedulerInput = {
password: "password",
password: 'password',
dateRange: { start: start.toISOString(), end: end.toISOString() },
opponent: { id: "123", name: "collin" }
opponent: { id: '123', name: 'collin' },
}
await expect(work(payload))
.rejects
.toThrowError(new ValidationError('Invalid request', ValidationErrorCode.INVALID_REQUEST_BODY))
await expect(work(payload)).rejects.toThrowError(
new ValidationError(
'Invalid request',
ValidationErrorCode.INVALID_REQUEST_BODY
)
)
})
})