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 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 = ({
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
|
||||
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)
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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> => {
|
||||
return
|
||||
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 }))
|
||||
|
|
|
|||
|
|
@ -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 },
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
})
|
||||
|
|
|
|||
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 {
|
||||
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)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
@ -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)
|
||||
}
|
||||
)
|
||||
})
|
||||
|
|
@ -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 { 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
|
||||
)
|
||||
)
|
||||
})
|
||||
})
|
||||
Loading…
Add table
Reference in a new issue