Big ole refactor to using nestjs for some practice
This commit is contained in:
parent
eb9991895a
commit
1def4de40c
61 changed files with 6858 additions and 13766 deletions
25
.eslintrc.js
Normal file
25
.eslintrc.js
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
module.exports = {
|
||||
parser: '@typescript-eslint/parser',
|
||||
parserOptions: {
|
||||
project: 'tsconfig.json',
|
||||
tsconfigRootDir: __dirname,
|
||||
sourceType: 'module',
|
||||
},
|
||||
plugins: ['@typescript-eslint/eslint-plugin'],
|
||||
extends: [
|
||||
'plugin:@typescript-eslint/recommended',
|
||||
'plugin:prettier/recommended',
|
||||
],
|
||||
root: true,
|
||||
env: {
|
||||
node: true,
|
||||
jest: true,
|
||||
},
|
||||
ignorePatterns: ['.eslintrc.js'],
|
||||
rules: {
|
||||
'@typescript-eslint/interface-name-prefix': 'off',
|
||||
'@typescript-eslint/explicit-function-return-type': 'off',
|
||||
'@typescript-eslint/explicit-module-boundary-types': 'off',
|
||||
'@typescript-eslint/no-explicit-any': 'off',
|
||||
},
|
||||
};
|
||||
|
|
@ -1,15 +0,0 @@
|
|||
{
|
||||
"root": true,
|
||||
"parser": "@typescript-eslint/parser",
|
||||
"plugins": [
|
||||
"@typescript-eslint"
|
||||
],
|
||||
"extends": [
|
||||
"eslint:recommended",
|
||||
"plugin:@typescript-eslint/recommended"
|
||||
],
|
||||
"ignorePatterns": [
|
||||
"**/*.js",
|
||||
"!src/**/*.ts"
|
||||
]
|
||||
}
|
||||
|
|
@ -1,8 +1,7 @@
|
|||
{
|
||||
"$schema":"http://json.schemastore.org/prettierrc",
|
||||
"trailingComma": "es5",
|
||||
"singleQuote": true,
|
||||
"trailingComma": "all",
|
||||
"tabWidth": 2,
|
||||
"useTabs": false,
|
||||
"semi": false,
|
||||
"singleQuote": true
|
||||
"useTabs": true,
|
||||
"semi": false
|
||||
}
|
||||
31
README.md
31
README.md
|
|
@ -8,8 +8,9 @@ Automatic court reservation!
|
|||
|
||||
- Node.js (18.x)
|
||||
- npm (8.x)
|
||||
- gcc (g++-12)
|
||||
- nvm (optional)
|
||||
- nvm
|
||||
- Docker
|
||||
- redis
|
||||
|
||||
#### Using nvm
|
||||
|
||||
|
|
@ -21,31 +22,7 @@ Automatic court reservation!
|
|||
|
||||
```bash
|
||||
npm install
|
||||
npm run local <username> <password> <year> <month> <day> <startTime> <endTime> <opponentName> <opponentId>
|
||||
```
|
||||
|
||||
## Architecture
|
||||
|
||||
```ascii
|
||||
|======|
|
||||
| User |
|
||||
|======|
|
||||
|
|
||||
[requests reservation]
|
||||
|
|
||||
|
|
||||
V
|
||||
|===========| /---\ |==========|
|
||||
| Scheduler | ---[checks possibility]--->/ ok? \--[y/ forward request]--> | Reserver |
|
||||
|===========| \ / |==========|
|
||||
\---/ |
|
||||
| |
|
||||
[n/ save request] [find possible, saved reservations]
|
||||
| |
|
||||
V |
|
||||
|==========| |
|
||||
| Database |<---------------------------|
|
||||
|==========|
|
||||
npm start:dev
|
||||
```
|
||||
|
||||
## Deployment
|
||||
|
|
|
|||
13
data-source.ts
Normal file
13
data-source.ts
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
import 'reflect-metadata'
|
||||
import { Reservation } from './src/reservations/entity'
|
||||
import { DataSource } from 'typeorm'
|
||||
|
||||
|
||||
export const AppDataSource = new DataSource({
|
||||
type: 'sqlite',
|
||||
database: 'autobaan',
|
||||
logging: true,
|
||||
entities: [Reservation],
|
||||
migrations: [],
|
||||
subscribers: [],
|
||||
})
|
||||
|
|
@ -4,8 +4,15 @@ services:
|
|||
context: ..
|
||||
dockerfile: ./docker/server/Dockerfile
|
||||
restart: always
|
||||
env_file: ./server/.env
|
||||
environment:
|
||||
- PORT=3000
|
||||
- REDIS_HOST=redis
|
||||
- REDIS_PORT=6379
|
||||
ports:
|
||||
- 3000:3000
|
||||
volumes:
|
||||
- ./autobaan_db:/app/db
|
||||
- ../db:/app/db
|
||||
redis:
|
||||
image: redis
|
||||
ports:
|
||||
- 6379
|
||||
|
|
@ -44,4 +44,4 @@ FROM base as app
|
|||
|
||||
COPY --chown=node:node --from=builder /app/dist ./dist
|
||||
|
||||
ENTRYPOINT node dist/server/index.js
|
||||
ENTRYPOINT ["node", "./dist/main.js"]
|
||||
|
|
@ -1,31 +0,0 @@
|
|||
import type { JestConfigWithTsJest } from 'ts-jest'
|
||||
|
||||
const jestConfig: JestConfigWithTsJest = {
|
||||
preset: 'ts-jest',
|
||||
testEnvironment: 'node',
|
||||
verbose: true,
|
||||
clearMocks: true,
|
||||
collectCoverage: true,
|
||||
coverageReporters: [
|
||||
"text"
|
||||
],
|
||||
coveragePathIgnorePatterns: [
|
||||
"/node_modules/"
|
||||
],
|
||||
coverageProvider: "v8",
|
||||
moduleDirectories: [
|
||||
"node_modules"
|
||||
],
|
||||
moduleFileExtensions: [
|
||||
"js",
|
||||
"ts"
|
||||
],
|
||||
roots: [
|
||||
"<rootDir>/tests"
|
||||
],
|
||||
testMatch: [
|
||||
"**/*.test.ts"
|
||||
]
|
||||
}
|
||||
|
||||
export default jestConfig
|
||||
8
nest-cli.json
Normal file
8
nest-cli.json
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
{
|
||||
"$schema": "https://json.schemastore.org/nest-cli",
|
||||
"collection": "@nestjs/schematics",
|
||||
"sourceRoot": "src",
|
||||
"compilerOptions": {
|
||||
"deleteOutDir": true
|
||||
}
|
||||
}
|
||||
17165
package-lock.json
generated
17165
package-lock.json
generated
File diff suppressed because it is too large
Load diff
109
package.json
109
package.json
|
|
@ -1,53 +1,82 @@
|
|||
{
|
||||
"name": "autobaan",
|
||||
"version": "1.0.0",
|
||||
"version": "0.0.1",
|
||||
"description": "",
|
||||
"author": "",
|
||||
"private": true,
|
||||
"engines": {
|
||||
"node": "18"
|
||||
},
|
||||
"license": "UNLICENSED",
|
||||
"scripts": {
|
||||
"preinstall": "export CXX=g++-12",
|
||||
"clean": "rm -r ./dist || true",
|
||||
"prebuild": "npm run clean",
|
||||
"build": "tsc",
|
||||
"build": "nest build",
|
||||
"format": "prettier --write \"src/**/*.ts\" \"test/**/*.ts\"",
|
||||
"start": "nest start",
|
||||
"start:dev": "nest start --watch",
|
||||
"start:debug": "nest start --debug --watch",
|
||||
"start:prod": "node dist/main",
|
||||
"lint": "eslint \"{src,apps,libs,test}/**/*.ts\" --fix",
|
||||
"test": "jest",
|
||||
"test:unit": "jest tests/unit/*",
|
||||
"test:unit:clean": "npm run test:clear-cache && npm run test:unit",
|
||||
"test:integration": "jest tests/integration/*",
|
||||
"test:integration:clean": "npm run test:clear-cache && npm run test:integration",
|
||||
"test:clear-cache": "jest --clearCache",
|
||||
"lint": "eslint src/ --ext ts",
|
||||
"prettier": "prettier src tests -w",
|
||||
"local": "npx ts-node src/local/index.ts",
|
||||
"docker:build": "docker build . -f docker/server/Dockerfile",
|
||||
"docker:start": "docker compose -f docker/docker-compose.yml up",
|
||||
"docker:stop": "docker compose -f docker/docker-compose.yml down"
|
||||
"test:watch": "jest --watch",
|
||||
"test:cov": "jest --coverage",
|
||||
"test:debug": "node --inspect-brk -r tsconfig-paths/register -r ts-node/register node_modules/.bin/jest --runInBand",
|
||||
"test:e2e": "jest --config ./test/jest-e2e.json"
|
||||
},
|
||||
"author": "Collin Duncan",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"argon2": "^0.30.3",
|
||||
"axios": "^1.2.0",
|
||||
"dayjs": "^1.11.6",
|
||||
"node-cron": "^3.0.2",
|
||||
"puppeteer": "^19.5.2",
|
||||
"sqlite3": "^5.1.4",
|
||||
"uuid": "^9.0.0"
|
||||
"@nestjs/bull": "^0.6.3",
|
||||
"@nestjs/common": "^9.0.0",
|
||||
"@nestjs/config": "^2.3.2",
|
||||
"@nestjs/core": "^9.0.0",
|
||||
"@nestjs/platform-express": "^9.0.0",
|
||||
"@nestjs/schedule": "^2.2.2",
|
||||
"@nestjs/typeorm": "^9.0.1",
|
||||
"@types/cron": "^2.0.1",
|
||||
"bull": "^4.10.4",
|
||||
"class-transformer": "^0.5.1",
|
||||
"class-validator": "^0.14.0",
|
||||
"dayjs": "^1.11.7",
|
||||
"puppeteer": "^20.4.0",
|
||||
"reflect-metadata": "^0.1.13",
|
||||
"rxjs": "^7.2.0",
|
||||
"sqlite3": "^5.1.6",
|
||||
"typeorm": "^0.3.16"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/jest": "^29.2.3",
|
||||
"@types/node": "^18.11.9",
|
||||
"@types/node-cron": "^3.0.6",
|
||||
"@nestjs/cli": "^9.0.0",
|
||||
"@nestjs/schematics": "^9.0.0",
|
||||
"@nestjs/testing": "^9.0.0",
|
||||
"@types/express": "^4.17.13",
|
||||
"@types/jest": "29.5.1",
|
||||
"@types/node": "18.16.12",
|
||||
"@types/puppeteer": "^7.0.4",
|
||||
"@types/uuid": "^8.3.4",
|
||||
"@typescript-eslint/eslint-plugin": "^5.42.0",
|
||||
"@typescript-eslint/parser": "^5.42.0",
|
||||
"babel-jest": "^29.2.1",
|
||||
"eslint": "^8.27.0",
|
||||
"jest": "^29.2.1",
|
||||
"prettier": "^2.7.1",
|
||||
"ts-jest": "^29.0.3",
|
||||
"typescript": "^4.9.4"
|
||||
"@types/supertest": "^2.0.11",
|
||||
"@typescript-eslint/eslint-plugin": "^5.0.0",
|
||||
"@typescript-eslint/parser": "^5.0.0",
|
||||
"eslint": "^8.0.1",
|
||||
"eslint-config-prettier": "^8.3.0",
|
||||
"eslint-plugin-prettier": "^4.0.0",
|
||||
"jest": "29.5.0",
|
||||
"prettier": "^2.3.2",
|
||||
"source-map-support": "^0.5.20",
|
||||
"supertest": "^6.1.3",
|
||||
"ts-jest": "29.1.0",
|
||||
"ts-loader": "^9.2.3",
|
||||
"ts-node": "^10.0.0",
|
||||
"tsconfig-paths": "4.2.0",
|
||||
"typescript": "^5.0.0"
|
||||
},
|
||||
"jest": {
|
||||
"moduleFileExtensions": [
|
||||
"js",
|
||||
"json",
|
||||
"ts"
|
||||
],
|
||||
"rootDir": "src",
|
||||
"testRegex": ".*\\.spec\\.ts$",
|
||||
"transform": {
|
||||
"^.+\\.(t|j)s$": "ts-jest"
|
||||
},
|
||||
"collectCoverageFrom": [
|
||||
"**/*.(t|j)s"
|
||||
],
|
||||
"coverageDirectory": "../coverage",
|
||||
"testEnvironment": "node"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
54
package.json.old
Normal file
54
package.json.old
Normal file
|
|
@ -0,0 +1,54 @@
|
|||
{
|
||||
"name": "autobaan",
|
||||
"version": "1.0.0",
|
||||
"description": "",
|
||||
"private": true,
|
||||
"engines": {
|
||||
"node": "18"
|
||||
},
|
||||
"scripts": {
|
||||
"preinstall": "export CXX=g++-12",
|
||||
"clean": "rm -r ./dist || true",
|
||||
"prebuild": "npm run clean",
|
||||
"build": "tsc",
|
||||
"test": "jest",
|
||||
"test:unit": "jest tests/unit/*",
|
||||
"test:unit:clean": "npm run test:clear-cache && npm run test:unit",
|
||||
"test:integration": "jest tests/integration/*",
|
||||
"test:integration:clean": "npm run test:clear-cache && npm run test:integration",
|
||||
"test:clear-cache": "jest --clearCache",
|
||||
"lint": "eslint src/ --ext ts",
|
||||
"prettier": "prettier src tests -w",
|
||||
"local": "npx ts-node src/local/index.ts",
|
||||
"docker:build": "docker build . -f docker/server/Dockerfile",
|
||||
"docker:start": "docker compose -f docker/docker-compose.yml up",
|
||||
"docker:stop": "docker compose -f docker/docker-compose.yml down"
|
||||
},
|
||||
"author": "Collin Duncan",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"@nestjs/cli": "^9.5.0",
|
||||
"argon2": "^0.30.3",
|
||||
"axios": "^1.2.0",
|
||||
"dayjs": "^1.11.6",
|
||||
"node-cron": "^3.0.2",
|
||||
"puppeteer": "^19.5.2",
|
||||
"sqlite3": "^5.1.4",
|
||||
"uuid": "^9.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/jest": "^29.2.3",
|
||||
"@types/node": "^18.11.9",
|
||||
"@types/node-cron": "^3.0.6",
|
||||
"@types/puppeteer": "^7.0.4",
|
||||
"@types/uuid": "^8.3.4",
|
||||
"@typescript-eslint/eslint-plugin": "^5.42.0",
|
||||
"@typescript-eslint/parser": "^5.42.0",
|
||||
"babel-jest": "^29.2.1",
|
||||
"eslint": "^8.27.0",
|
||||
"jest": "^29.2.1",
|
||||
"prettier": "^2.7.1",
|
||||
"ts-jest": "^29.0.3",
|
||||
"typescript": "^4.9.4"
|
||||
}
|
||||
}
|
||||
41
src/app.module.ts
Normal file
41
src/app.module.ts
Normal file
|
|
@ -0,0 +1,41 @@
|
|||
import { BullModule } from '@nestjs/bull'
|
||||
import { MiddlewareConsumer, Module, NestModule } from '@nestjs/common'
|
||||
import { ScheduleModule } from '@nestjs/schedule'
|
||||
import { TypeOrmModule } from '@nestjs/typeorm'
|
||||
import { resolve } from 'path'
|
||||
import { ReservationsModule } from './reservations/module'
|
||||
import { RunnerModule } from './runner/module'
|
||||
import { ConfigModule } from '@nestjs/config'
|
||||
import { LoggerMiddleware } from './logger/middleware'
|
||||
import { LoggerModule } from './logger/module'
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
TypeOrmModule.forRoot({
|
||||
type: 'sqlite',
|
||||
database: resolve('./db/autobaan_db'),
|
||||
autoLoadEntities: true,
|
||||
logging: true,
|
||||
synchronize: true,
|
||||
}),
|
||||
BullModule.forRoot({
|
||||
redis: {
|
||||
host: process.env.REDIS_HOST,
|
||||
port: Number.parseInt(process.env.REDIS_PORT || '6379'),
|
||||
},
|
||||
defaultJobOptions: {
|
||||
removeOnComplete: true,
|
||||
}
|
||||
}),
|
||||
ScheduleModule.forRoot(),
|
||||
ConfigModule.forRoot({ isGlobal: true }),
|
||||
ReservationsModule,
|
||||
RunnerModule,
|
||||
LoggerModule,
|
||||
],
|
||||
})
|
||||
export class AppModule implements NestModule {
|
||||
configure(consumer: MiddlewareConsumer) {
|
||||
consumer.apply(LoggerMiddleware).forRoutes('*')
|
||||
}
|
||||
}
|
||||
14
src/common/customResponse.ts
Normal file
14
src/common/customResponse.ts
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
import { Injectable, NestInterceptor, ExecutionContext, CallHandler } from '@nestjs/common'
|
||||
import { Observable } from 'rxjs'
|
||||
import { map } from 'rxjs/operators'
|
||||
|
||||
export interface CustomResponse<T = unknown> {
|
||||
data: T
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class CustomResponseTransformInterceptor<T> implements NestInterceptor<T, CustomResponse<T>> {
|
||||
intercept(_context: ExecutionContext, next: CallHandler): Observable<CustomResponse<T>> {
|
||||
return next.handle().pipe(map(data => ({ data })))
|
||||
}
|
||||
}
|
||||
|
|
@ -1,46 +0,0 @@
|
|||
import { resolve } from 'path'
|
||||
import sqlite from 'sqlite3'
|
||||
import { asyncLocalStorage } from '../logger'
|
||||
import { CREATE_TABLE_reservations } from './sql'
|
||||
|
||||
const getDatabase = () => new sqlite.Database(resolve('./db/autobaan_db'))
|
||||
|
||||
export const run = async (sql: string, params?: unknown) => {
|
||||
const db = getDatabase()
|
||||
await new Promise<void>((res, rej) => {
|
||||
asyncLocalStorage
|
||||
.getStore()
|
||||
?.debug(`<database> run ~> ${sql.replace(/\s*\n\s*/g, ' ')} (${params})`)
|
||||
db.run(sql, params, (err) => {
|
||||
if (err) rej(err)
|
||||
res()
|
||||
})
|
||||
})
|
||||
db.close()
|
||||
}
|
||||
|
||||
export const all = async <T>(sql: string, params?: unknown) => {
|
||||
const db = getDatabase()
|
||||
const rows = await new Promise<T[]>((res, rej) => {
|
||||
asyncLocalStorage
|
||||
.getStore()
|
||||
?.debug(`<database> all ~> ${sql.replace(/\s*\n\s*/g, ' ')} (${params})`)
|
||||
db.all(sql, params, (err, rows) => {
|
||||
if (err) rej(err)
|
||||
res(rows)
|
||||
})
|
||||
})
|
||||
db.close()
|
||||
return rows
|
||||
}
|
||||
|
||||
export const init = async () => {
|
||||
try {
|
||||
await run(CREATE_TABLE_reservations)
|
||||
} catch (err: unknown) {
|
||||
console.error(err)
|
||||
}
|
||||
}
|
||||
|
||||
export class DatabaseError extends Error {}
|
||||
export class DatabaseEnvironmentError extends DatabaseError {}
|
||||
|
|
@ -1,11 +0,0 @@
|
|||
export const CREATE_TABLE_reservations = `
|
||||
CREATE TABLE IF NOT EXISTS reservations (
|
||||
id VARCHAR(36) NOT NULL PRIMARY KEY,
|
||||
username VARCHAR(64) NOT NULL,
|
||||
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
|
||||
);
|
||||
`
|
||||
|
|
@ -1,7 +1,7 @@
|
|||
import dayjs from 'dayjs'
|
||||
import isSameOrBefore from 'dayjs/plugin/isSameOrBefore'
|
||||
import utc from 'dayjs/plugin/utc'
|
||||
import timezone from 'dayjs/plugin/timezone'
|
||||
import * as dayjs from 'dayjs'
|
||||
import * as isSameOrBefore from 'dayjs/plugin/isSameOrBefore'
|
||||
import * as utc from 'dayjs/plugin/utc'
|
||||
import * as timezone from 'dayjs/plugin/timezone'
|
||||
import 'dayjs/locale/nl'
|
||||
|
||||
dayjs.extend(isSameOrBefore)
|
||||
|
|
@ -11,6 +11,24 @@ dayjs.locale('nl')
|
|||
|
||||
dayjs.tz.setDefault('Europe/Amsterdam')
|
||||
|
||||
export interface DateRange {
|
||||
start: dayjs.Dayjs
|
||||
end: dayjs.Dayjs
|
||||
}
|
||||
|
||||
export interface SerializedDateRange {
|
||||
start: string
|
||||
end: string
|
||||
}
|
||||
|
||||
export const convertDateRangeStringToObject = ({
|
||||
start,
|
||||
end,
|
||||
}: {
|
||||
start: string
|
||||
end: string
|
||||
}): DateRange => ({ start: dayjs(start), end: dayjs(end) })
|
||||
|
||||
const dayjsTz = (
|
||||
date?: string | number | Date | dayjs.Dayjs | null | undefined
|
||||
) => {
|
||||
|
|
|
|||
|
|
@ -1,93 +0,0 @@
|
|||
import { AsyncLocalStorage } from 'async_hooks'
|
||||
import dayjs from './dayjs'
|
||||
|
||||
export enum LogLevel {
|
||||
DEBUG,
|
||||
INFO,
|
||||
ERROR,
|
||||
}
|
||||
|
||||
export const asyncLocalStorage = new AsyncLocalStorage<Logger>()
|
||||
|
||||
export class Logger {
|
||||
private readonly tag: string
|
||||
private readonly correlationId: string
|
||||
private readonly level: LogLevel
|
||||
|
||||
public constructor(
|
||||
tag: string,
|
||||
correlationId: string,
|
||||
level = LogLevel.ERROR
|
||||
) {
|
||||
this.tag = tag
|
||||
this.correlationId = correlationId
|
||||
this.level = level
|
||||
}
|
||||
|
||||
private log(logLevel: LogLevel, message: string, details?: unknown): void {
|
||||
if (logLevel < this.level) {
|
||||
return
|
||||
}
|
||||
|
||||
let levelString
|
||||
switch (logLevel) {
|
||||
case LogLevel.ERROR:
|
||||
levelString = 'ERROR'
|
||||
break
|
||||
case LogLevel.INFO:
|
||||
levelString = 'INFO'
|
||||
break
|
||||
case LogLevel.DEBUG:
|
||||
default:
|
||||
levelString = 'DEBUG'
|
||||
break
|
||||
}
|
||||
|
||||
let fmtString = '(%s) <%s> [%s] %s: %s'
|
||||
const params: Array<unknown> = [
|
||||
dayjs().format(),
|
||||
this.tag,
|
||||
this.correlationId,
|
||||
levelString,
|
||||
message,
|
||||
]
|
||||
if (details) {
|
||||
if (typeof details === 'object') {
|
||||
const toObfuscate = ['password']
|
||||
toObfuscate.forEach((key) => {
|
||||
if ((details as Record<string, unknown>)[key]) {
|
||||
// Prettier and eslint are fighting
|
||||
// eslint-disable-next-line @typescript-eslint/no-extra-semi
|
||||
;(details as Record<string, unknown>)[key] = '***'
|
||||
}
|
||||
})
|
||||
}
|
||||
params.push(details)
|
||||
fmtString += ' - %O'
|
||||
}
|
||||
|
||||
if (logLevel === LogLevel.ERROR) {
|
||||
console.error(fmtString, ...params)
|
||||
} else {
|
||||
console.log(fmtString, ...params)
|
||||
}
|
||||
}
|
||||
|
||||
public debug(message: string, details?: unknown): void {
|
||||
this.log(LogLevel.DEBUG, message, details)
|
||||
}
|
||||
|
||||
public info(message: string, details?: unknown): void {
|
||||
this.log(LogLevel.INFO, message, details)
|
||||
}
|
||||
|
||||
public error(message: string, details?: unknown): void {
|
||||
this.log(LogLevel.ERROR, message, details)
|
||||
}
|
||||
}
|
||||
|
||||
export class LoggableError extends Error {
|
||||
toString() {
|
||||
return `${this.name} - ${this.message}\n${this.stack}`
|
||||
}
|
||||
}
|
||||
|
|
@ -1,54 +1,55 @@
|
|||
import argon2 from 'argon2'
|
||||
import crypto from 'crypto'
|
||||
import { asyncLocalStorage } from './logger'
|
||||
// TODO: refactor to nestjs if needed
|
||||
// import argon2 from 'argon2'
|
||||
// import crypto from 'crypto'
|
||||
// import { asyncLocalStorage } from './logger'
|
||||
|
||||
const SALT_LENGTH = Number.parseInt(process.env.SALT_LENGTH || '32', 10)
|
||||
// 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)
|
||||
})
|
||||
})
|
||||
}
|
||||
// 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 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,
|
||||
}
|
||||
// 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
|
||||
}
|
||||
// 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) {
|
||||
asyncLocalStorage
|
||||
.getStore()
|
||||
?.error('Error hashing and salting password', { message: err.message })
|
||||
throw err
|
||||
}
|
||||
}
|
||||
// export const hashPassword = async (password: string) => {
|
||||
// try {
|
||||
// const saltBuffer = await generateSalt()
|
||||
// const hash = await generateHash(password, saltBuffer)
|
||||
// return hash
|
||||
// } catch (err: any) {
|
||||
// asyncLocalStorage
|
||||
// .getStore()
|
||||
// ?.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)
|
||||
}
|
||||
// export const verifyPassword = async (hash: string, password: string) => {
|
||||
// return await argon2.verify(hash, password)
|
||||
// }
|
||||
|
|
|
|||
|
|
@ -1,115 +0,0 @@
|
|||
import dayjs from './dayjs'
|
||||
import { DateRange, Opponent, Reservation } from './reservation'
|
||||
|
||||
export enum ValidationErrorCode {
|
||||
UNDEFINED_REQUEST_BODY,
|
||||
INVALID_JSON,
|
||||
INVALID_REQUEST_BODY,
|
||||
INVALID_DATE_RANGE,
|
||||
INVALID_START_OR_END_DATE,
|
||||
INVALID_OPPONENT,
|
||||
}
|
||||
|
||||
export class ValidationError extends Error {
|
||||
public readonly code: ValidationErrorCode
|
||||
|
||||
constructor(message: string, code: ValidationErrorCode) {
|
||||
super(message)
|
||||
this.code = code
|
||||
}
|
||||
}
|
||||
|
||||
export const validateJSONRequest = async (
|
||||
body: Record<string, unknown>
|
||||
): Promise<Reservation> => {
|
||||
const request = await validateRequestBody(body)
|
||||
validateRequestDateRange(request.dateRange)
|
||||
validateRequestOpponent(request.opponent)
|
||||
return request
|
||||
}
|
||||
|
||||
const validateRequestBody = async (
|
||||
body?: Record<string, unknown>
|
||||
): Promise<Reservation> => {
|
||||
if (body === undefined) {
|
||||
throw new ValidationError(
|
||||
'Invalid request',
|
||||
ValidationErrorCode.UNDEFINED_REQUEST_BODY
|
||||
)
|
||||
}
|
||||
|
||||
const { username, password, dateRange, opponent }: Record<string, any> = body
|
||||
|
||||
if (
|
||||
!username ||
|
||||
username.length < 1 ||
|
||||
!password ||
|
||||
password.length < 1 ||
|
||||
!dateRange ||
|
||||
!dateRange.start ||
|
||||
!dateRange.end ||
|
||||
(opponent && opponent.id && opponent.id.length < 1) ||
|
||||
(opponent && opponent.name && opponent.name.length < 1)
|
||||
) {
|
||||
throw new ValidationError(
|
||||
'Invalid request',
|
||||
ValidationErrorCode.INVALID_REQUEST_BODY
|
||||
)
|
||||
}
|
||||
|
||||
const reservation = new Reservation(
|
||||
{ username, password },
|
||||
convertDateRangeStringToObject(dateRange),
|
||||
opponent
|
||||
)
|
||||
|
||||
return reservation
|
||||
}
|
||||
|
||||
const convertDateRangeStringToObject = ({
|
||||
start,
|
||||
end,
|
||||
}: {
|
||||
start: string
|
||||
end: string
|
||||
}): DateRange => ({ start: dayjs(start), end: dayjs(end) })
|
||||
|
||||
const validateRequestDateRange = (dateRange: DateRange): void => {
|
||||
// checking that both dates are valid
|
||||
const { start, end } = dateRange
|
||||
if (!start.isValid() || !end.isValid()) {
|
||||
throw new ValidationError(
|
||||
'Invalid request',
|
||||
ValidationErrorCode.INVALID_DATE_RANGE
|
||||
)
|
||||
}
|
||||
|
||||
// checking that:
|
||||
// 1. start occurs after now
|
||||
// 2. start occurs before or same as end
|
||||
// 3. start and end fall on same YYYY/MM/DD
|
||||
if (
|
||||
!start.isAfter(dayjs()) ||
|
||||
!start.isSameOrBefore(end) ||
|
||||
start.format('YYYY MM DD') !== end.format('YYYY MM DD')
|
||||
) {
|
||||
throw new ValidationError(
|
||||
'Invalid request',
|
||||
ValidationErrorCode.INVALID_START_OR_END_DATE
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
const validateRequestOpponent = (opponent?: Opponent): void => {
|
||||
if (!opponent) return
|
||||
|
||||
const idRegex = /^-1$|^[^-]\d+$/
|
||||
const nameRegex = /^[A-Za-z0-9 -.'()]+$/
|
||||
const { id, name } = opponent
|
||||
if (!idRegex.test(id) || !nameRegex.test(name)) {
|
||||
throw new ValidationError(
|
||||
'Invalid request',
|
||||
ValidationErrorCode.INVALID_OPPONENT
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -1,352 +0,0 @@
|
|||
import { Dayjs } from 'dayjs'
|
||||
import { v4 } from 'uuid'
|
||||
import dayjs from './dayjs'
|
||||
import { all, run } from './database'
|
||||
|
||||
const RESERVATION_AVAILABLE_WITHIN_DAYS = 7
|
||||
|
||||
export interface User {
|
||||
username: string
|
||||
password: string
|
||||
}
|
||||
|
||||
export interface Opponent {
|
||||
id: string
|
||||
name: string
|
||||
}
|
||||
|
||||
export interface DateRange {
|
||||
start: Dayjs
|
||||
end: Dayjs
|
||||
}
|
||||
|
||||
export class Reservation {
|
||||
public readonly id: string
|
||||
public readonly user: User
|
||||
public readonly dateRange: DateRange
|
||||
public readonly opponent: Opponent
|
||||
public readonly possibleDates: Dayjs[]
|
||||
public booked = false
|
||||
|
||||
constructor(
|
||||
user: User,
|
||||
dateRange: DateRange,
|
||||
opponent: Opponent,
|
||||
possibleDates?: Dayjs[],
|
||||
id = v4()
|
||||
) {
|
||||
this.id = id
|
||||
this.user = user
|
||||
this.dateRange = dateRange
|
||||
this.opponent = opponent
|
||||
this.possibleDates = possibleDates || this.createPossibleDates()
|
||||
}
|
||||
|
||||
private createPossibleDates(): Dayjs[] {
|
||||
const possibleDates: Dayjs[] = []
|
||||
|
||||
const { start, end } = this.dateRange
|
||||
|
||||
let possibleDate = dayjs(start).second(0).millisecond(0)
|
||||
while (possibleDate.isSameOrBefore(end)) {
|
||||
possibleDates.push(possibleDate)
|
||||
possibleDate = possibleDate.add(15, 'minute')
|
||||
}
|
||||
|
||||
return possibleDates
|
||||
}
|
||||
|
||||
/**
|
||||
* Method to check if a reservation is available for reservation in the system
|
||||
* @returns is reservation date within 7 days
|
||||
*/
|
||||
public isAvailableForReservation(): boolean {
|
||||
return (
|
||||
Math.floor(this.dateRange.start.diff(dayjs(), 'days', true)) <=
|
||||
RESERVATION_AVAILABLE_WITHIN_DAYS
|
||||
)
|
||||
}
|
||||
|
||||
public getAllowedReservationDate(): Dayjs {
|
||||
return this.dateRange.start
|
||||
.hour(0)
|
||||
.minute(0)
|
||||
.second(0)
|
||||
.millisecond(0)
|
||||
.subtract(RESERVATION_AVAILABLE_WITHIN_DAYS, 'days')
|
||||
}
|
||||
|
||||
public toString(obfuscate = false) {
|
||||
return JSON.stringify(this.format(obfuscate))
|
||||
}
|
||||
|
||||
public format(obfuscate = false) {
|
||||
return {
|
||||
id: this.id,
|
||||
user: {
|
||||
username: this.user.username,
|
||||
password: obfuscate ? '???' : this.user.password,
|
||||
},
|
||||
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 {
|
||||
id: this.id,
|
||||
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 async save() {
|
||||
await run(
|
||||
`
|
||||
INSERT INTO reservations
|
||||
(
|
||||
id,
|
||||
username,
|
||||
password,
|
||||
date_range_start,
|
||||
date_range_end,
|
||||
opponent_id,
|
||||
opponent_name
|
||||
)
|
||||
VALUES
|
||||
(
|
||||
?,
|
||||
?,
|
||||
?,
|
||||
?,
|
||||
?,
|
||||
?,
|
||||
?
|
||||
)
|
||||
`,
|
||||
[
|
||||
this.id,
|
||||
this.user.username,
|
||||
this.user.password,
|
||||
this.dateRange.start.utc().format('YYYY-MM-DD HH:mm:ss'),
|
||||
this.dateRange.end.utc().format('YYYY-MM-DD HH:mm:ss'),
|
||||
this.opponent.id,
|
||||
this.opponent.name,
|
||||
]
|
||||
)
|
||||
}
|
||||
|
||||
public async delete() {
|
||||
await run(
|
||||
`
|
||||
DELETE FROM reservations
|
||||
WHERE id = ?
|
||||
`,
|
||||
[this.id]
|
||||
)
|
||||
}
|
||||
|
||||
public static async deleteById(id: string) {
|
||||
await run(
|
||||
`
|
||||
DELETE FROM reservations
|
||||
WHERE id = ?
|
||||
`,
|
||||
[id]
|
||||
)
|
||||
}
|
||||
|
||||
public static async fetchById(id: string): Promise<Reservation | null> {
|
||||
const response = await all<SqlReservation>(
|
||||
`
|
||||
SELECT *
|
||||
FROM reservations
|
||||
WHERE id = ?;
|
||||
`,
|
||||
[id]
|
||||
)
|
||||
|
||||
if (response.length === 1) {
|
||||
const sqlReservation = response[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 },
|
||||
undefined,
|
||||
sqlReservation.id
|
||||
)
|
||||
return res
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
public static async fetchFirst(): Promise<Reservation | null> {
|
||||
const response = await all<SqlReservation>(
|
||||
`
|
||||
SELECT *
|
||||
FROM reservations
|
||||
ORDER BY date_range_start ASC
|
||||
LIMIT 1;
|
||||
`
|
||||
)
|
||||
|
||||
if (response.length === 1) {
|
||||
const sqlReservation = response[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 },
|
||||
undefined,
|
||||
sqlReservation.id
|
||||
)
|
||||
return res
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
public static async fetchByDate(
|
||||
date: Dayjs,
|
||||
limit = 20
|
||||
): Promise<Reservation[]> {
|
||||
const response = await all<SqlReservation>(
|
||||
`
|
||||
SELECT *
|
||||
FROM reservations
|
||||
WHERE DATE(date_range_start, '-7 day') = ?
|
||||
ORDER BY date_range_start ASC
|
||||
LIMIT ?;
|
||||
`,
|
||||
[date.format('YYYY-MM-DD'), limit]
|
||||
)
|
||||
|
||||
if (response.length > 0) {
|
||||
return response.map(
|
||||
(sqlReservation) =>
|
||||
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,
|
||||
},
|
||||
undefined,
|
||||
sqlReservation.id
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
return []
|
||||
}
|
||||
|
||||
public static async fetchByPage(
|
||||
pageNumber: number,
|
||||
pageSize = 50
|
||||
): Promise<Reservation[]> {
|
||||
const response = await all<SqlReservation>(
|
||||
`
|
||||
SELECT *
|
||||
FROM reservations
|
||||
ORDER BY date_range_start ASC
|
||||
LIMIT ?
|
||||
OFFSET ?;
|
||||
`,
|
||||
[pageSize, pageSize * pageNumber]
|
||||
)
|
||||
|
||||
if (response.length > 0) {
|
||||
return response.map(
|
||||
(sqlReservation) =>
|
||||
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,
|
||||
},
|
||||
undefined,
|
||||
sqlReservation.id
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
export interface SerializedDateRange {
|
||||
start: string
|
||||
end: string
|
||||
}
|
||||
|
||||
export interface SerializedReservation {
|
||||
id: string
|
||||
user: User
|
||||
opponent: Opponent
|
||||
booked: boolean
|
||||
possibleDates: string[]
|
||||
dateRange: SerializedDateRange
|
||||
}
|
||||
|
||||
export interface SqlReservation {
|
||||
id: string
|
||||
username: string
|
||||
password: string
|
||||
date_range_start: string
|
||||
date_range_end: string
|
||||
opponent_id: string
|
||||
opponent_name: string
|
||||
}
|
||||
|
|
@ -1,48 +0,0 @@
|
|||
import dayjs from './dayjs'
|
||||
import { asyncLocalStorage as l, LoggableError } from './logger'
|
||||
import { Reservation } from './reservation'
|
||||
import { Runner } from './runner'
|
||||
|
||||
let runner: Runner | undefined
|
||||
const getRunner = () => {
|
||||
if (!runner) {
|
||||
runner = new Runner({
|
||||
headless: true,
|
||||
})
|
||||
}
|
||||
return runner
|
||||
}
|
||||
|
||||
export const reserve = async (
|
||||
reservations?: Reservation[]
|
||||
): Promise<boolean> => {
|
||||
let reservationsToPerform = reservations
|
||||
if (!reservationsToPerform) {
|
||||
l.getStore()?.debug('No reservation provided, fetching first in database')
|
||||
reservationsToPerform =
|
||||
(await Reservation.fetchByDate(dayjs())) || undefined
|
||||
}
|
||||
|
||||
if (!reservationsToPerform || reservationsToPerform.length === 0) {
|
||||
l.getStore()?.info('No reservation to perform')
|
||||
return true
|
||||
}
|
||||
|
||||
for (const reservationToPerform of reservationsToPerform) {
|
||||
l.getStore()?.debug('Trying to perform reservation', {
|
||||
reservationToPerform: reservationToPerform.toString(true),
|
||||
})
|
||||
const runner = getRunner()
|
||||
try {
|
||||
await runner.run(reservationToPerform)
|
||||
await reservationToPerform.delete()
|
||||
} catch (error) {
|
||||
l.getStore()?.error('Failed to perform reservation', {
|
||||
error: (error as LoggableError).toString(),
|
||||
})
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
|
@ -1,57 +0,0 @@
|
|||
import { Dayjs } from 'dayjs'
|
||||
|
||||
import { asyncLocalStorage } from './logger'
|
||||
import { Reservation } from './reservation'
|
||||
import { validateJSONRequest } from './request'
|
||||
import { reserve } from './reserver'
|
||||
|
||||
export interface ScheduledReservation {
|
||||
reservation: Reservation
|
||||
scheduledFor?: Dayjs
|
||||
}
|
||||
|
||||
export interface SchedulerResult {
|
||||
scheduledReservation?: ScheduledReservation
|
||||
}
|
||||
|
||||
export type SchedulerInput = Record<string, unknown>
|
||||
|
||||
export const schedule = async (
|
||||
payload: SchedulerInput
|
||||
): Promise<SchedulerResult> => {
|
||||
const logger = asyncLocalStorage.getStore()
|
||||
|
||||
logger?.debug('Handling reservation', { payload })
|
||||
let reservation: Reservation
|
||||
try {
|
||||
reservation = await validateJSONRequest(payload)
|
||||
} catch (err) {
|
||||
logger?.error('Failed to validate request', { err })
|
||||
throw err
|
||||
}
|
||||
|
||||
logger?.debug('Successfully validated request', {
|
||||
reservation: reservation.toString(true),
|
||||
})
|
||||
|
||||
if (!reservation.isAvailableForReservation()) {
|
||||
logger?.debug(
|
||||
'Reservation date is more than 7 days away; saving for later reservation'
|
||||
)
|
||||
|
||||
await reservation.save()
|
||||
|
||||
return {
|
||||
scheduledReservation: {
|
||||
reservation,
|
||||
scheduledFor: reservation.getAllowedReservationDate(),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
logger?.info('Reservation request can be performed now')
|
||||
await reserve([reservation])
|
||||
return {
|
||||
scheduledReservation: { reservation },
|
||||
}
|
||||
}
|
||||
|
|
@ -1,59 +0,0 @@
|
|||
import dayjs from '../common/dayjs'
|
||||
import { Reservation } from '../common/reservation'
|
||||
import { Runner } from '../common/runner'
|
||||
|
||||
const run = async (request: Record<string, any>) => {
|
||||
const { user, dateRange, opponent } = request
|
||||
const reservation = new Reservation(user, dateRange, opponent)
|
||||
|
||||
const runner = new Runner({ headless: false })
|
||||
await runner.run(reservation)
|
||||
}
|
||||
|
||||
// get supplied args
|
||||
const args = process.argv.filter((_, index) => index >= 2)
|
||||
if (args.length !== 9) {
|
||||
console.error(
|
||||
'Usage: npm run local <username> <password> <year> <month> <day> <startTime> <endTime> <opponentName> <opponentId>'
|
||||
)
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
const [
|
||||
username,
|
||||
password,
|
||||
year,
|
||||
month,
|
||||
day,
|
||||
startTime,
|
||||
endTime,
|
||||
opponentName,
|
||||
opponentId,
|
||||
] = args
|
||||
const [startHour, startMinute] = startTime
|
||||
.split(':')
|
||||
.map((t) => Number.parseInt(t))
|
||||
const [endHour, endMinute] = endTime.split(':').map((t) => Number.parseInt(t))
|
||||
|
||||
run({
|
||||
user: {
|
||||
username: username,
|
||||
password: password,
|
||||
},
|
||||
dateRange: {
|
||||
start: dayjs(`${year}-${month}-${day}T${startHour}:${startMinute}`),
|
||||
end: dayjs(`${year}-${month}-${day}T${endHour}:${endMinute}`),
|
||||
},
|
||||
opponent: {
|
||||
name: opponentName,
|
||||
id: opponentId,
|
||||
},
|
||||
})
|
||||
.then(() => {
|
||||
console.log('Success')
|
||||
process.exit(0)
|
||||
})
|
||||
.catch((e) => {
|
||||
console.error(e)
|
||||
process.exit(1)
|
||||
})
|
||||
16
src/logger/middleware.ts
Normal file
16
src/logger/middleware.ts
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
import { Inject, Injectable, NestMiddleware } from '@nestjs/common'
|
||||
import { Request, Response, NextFunction } from 'express'
|
||||
import { LoggerService } from './service'
|
||||
|
||||
@Injectable()
|
||||
export class LoggerMiddleware implements NestMiddleware {
|
||||
constructor(
|
||||
@Inject(LoggerService)
|
||||
private readonly logger: LoggerService,
|
||||
) {}
|
||||
|
||||
use(req: Request, _res: Response, next: NextFunction) {
|
||||
this.logger.log(`${req.method} ${req.originalUrl}`)
|
||||
next()
|
||||
}
|
||||
}
|
||||
9
src/logger/module.ts
Normal file
9
src/logger/module.ts
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
import { Module } from '@nestjs/common'
|
||||
import { LoggerService } from './service'
|
||||
import { LoggerMiddleware } from './middleware'
|
||||
|
||||
@Module({
|
||||
providers: [LoggerService, LoggerMiddleware],
|
||||
exports: [LoggerService, LoggerMiddleware],
|
||||
})
|
||||
export class LoggerModule {}
|
||||
14
src/logger/service.ts
Normal file
14
src/logger/service.ts
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
import { ConsoleLogger, Injectable, Scope } from '@nestjs/common'
|
||||
|
||||
@Injectable({ scope: Scope.REQUEST })
|
||||
export class LoggerService extends ConsoleLogger {
|
||||
log(message: any, ...optionalParams: any[]) {
|
||||
super.log(message, ...optionalParams)
|
||||
}
|
||||
error(message: any, ...optionalParams: any[]) {
|
||||
super.error(message, ...optionalParams)
|
||||
}
|
||||
warn(message: any, ...optionalParams: any[]) {
|
||||
super.warn(message, ...optionalParams)
|
||||
}
|
||||
}
|
||||
15
src/main.ts
Normal file
15
src/main.ts
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
import { NestFactory } from '@nestjs/core'
|
||||
import { AppModule } from './app.module'
|
||||
import { ConfigService } from '@nestjs/config'
|
||||
import { CustomResponseTransformInterceptor } from './common/customResponse'
|
||||
|
||||
async function bootstrap() {
|
||||
const app = await NestFactory.create(AppModule, { abortOnError: false })
|
||||
const config = app.get(ConfigService)
|
||||
const port = config.get('PORT')
|
||||
app.enableShutdownHooks()
|
||||
app.useGlobalInterceptors(new CustomResponseTransformInterceptor())
|
||||
await app.listen(port)
|
||||
}
|
||||
|
||||
bootstrap()
|
||||
5
src/reservations/config.ts
Normal file
5
src/reservations/config.ts
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
export const RESERVATIONS_QUEUE_NAME = 'reservations'
|
||||
|
||||
export default () => ({
|
||||
queueName: RESERVATIONS_QUEUE_NAME
|
||||
})
|
||||
73
src/reservations/controller.ts
Normal file
73
src/reservations/controller.ts
Normal file
|
|
@ -0,0 +1,73 @@
|
|||
import {
|
||||
Controller,
|
||||
Get,
|
||||
Post,
|
||||
Body,
|
||||
Param,
|
||||
Delete,
|
||||
Inject,
|
||||
UsePipes,
|
||||
ValidationPipe,
|
||||
UseInterceptors,
|
||||
ClassSerializerInterceptor,
|
||||
Query,
|
||||
Response,
|
||||
} from '@nestjs/common'
|
||||
import { InjectQueue } from '@nestjs/bull'
|
||||
import { Dayjs } from 'dayjs'
|
||||
import { Queue } from 'bull'
|
||||
import { RESERVATIONS_QUEUE_NAME } from './config'
|
||||
import { Reservation } from './entity'
|
||||
import { ReservationsService } from './service'
|
||||
import { LoggerService } from '../logger/service'
|
||||
|
||||
@Controller('reservations')
|
||||
@UseInterceptors(ClassSerializerInterceptor)
|
||||
export class ReservationsController {
|
||||
constructor(
|
||||
@Inject(ReservationsService)
|
||||
private reservationsService: ReservationsService,
|
||||
|
||||
@InjectQueue(RESERVATIONS_QUEUE_NAME)
|
||||
private reservationsQueue: Queue<Reservation>,
|
||||
|
||||
@Inject(LoggerService)
|
||||
private loggerService: LoggerService,
|
||||
) {}
|
||||
|
||||
@Get()
|
||||
getReservations(@Query('date') date?: Dayjs) {
|
||||
if (date) {
|
||||
return this.reservationsService.getByDate(date)
|
||||
}
|
||||
return this.reservationsService.getAll()
|
||||
}
|
||||
|
||||
@Get(':id')
|
||||
getReservationById(@Param('id') id: string) {
|
||||
return this.reservationsService.getById(id)
|
||||
}
|
||||
|
||||
@Post()
|
||||
@UsePipes(
|
||||
new ValidationPipe({
|
||||
transform: true,
|
||||
transformOptions: { groups: ['password'] },
|
||||
groups: ['password'],
|
||||
}),
|
||||
)
|
||||
async createReservation(@Body() reservation: Reservation) {
|
||||
if (!reservation.isAvailableForReservation()) {
|
||||
await this.reservationsService.create(reservation)
|
||||
return 'Reservation saved'
|
||||
}
|
||||
await this.reservationsQueue.add(reservation)
|
||||
return 'Reservation queued'
|
||||
}
|
||||
|
||||
@Delete(':id')
|
||||
async deleteReservationById(@Param('id') id: string) {
|
||||
await this.reservationsService.deleteById(id)
|
||||
return 'Reservation deleted'
|
||||
}
|
||||
}
|
||||
31
src/reservations/cron.ts
Normal file
31
src/reservations/cron.ts
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
import { InjectQueue } from '@nestjs/bull'
|
||||
import { Inject, Injectable } from '@nestjs/common'
|
||||
import { Cron, CronExpression } from '@nestjs/schedule'
|
||||
import { Queue } from 'bull'
|
||||
import { RESERVATIONS_QUEUE_NAME } from './config'
|
||||
import { ReservationsService } from './service'
|
||||
import { LoggerService } from '../logger/service'
|
||||
|
||||
@Injectable()
|
||||
export class ReservationsCronService {
|
||||
constructor(
|
||||
@Inject(ReservationsService)
|
||||
private readonly reservationService: ReservationsService,
|
||||
|
||||
@InjectQueue(RESERVATIONS_QUEUE_NAME)
|
||||
private readonly reservationsQueue: Queue,
|
||||
|
||||
@Inject(LoggerService)
|
||||
private readonly logger: LoggerService,
|
||||
) {}
|
||||
|
||||
@Cron(CronExpression.EVERY_DAY_AT_7AM, {
|
||||
name: 'handleDailyReservations',
|
||||
timeZone: 'Europe/Amsterdam',
|
||||
})
|
||||
async handleDailyReservations() {
|
||||
const reservationsToPerform = await this.reservationService.getByDate()
|
||||
this.logger.log(`Found ${reservationsToPerform.length} reservations to perform`)
|
||||
await this.reservationsQueue.addBulk(reservationsToPerform.map((r) => ({ data: r })))
|
||||
}
|
||||
}
|
||||
71
src/reservations/entity.ts
Normal file
71
src/reservations/entity.ts
Normal file
|
|
@ -0,0 +1,71 @@
|
|||
import { Exclude, Transform, Type } from 'class-transformer'
|
||||
import { Dayjs } from 'dayjs'
|
||||
import { Entity, Column, PrimaryGeneratedColumn } from 'typeorm'
|
||||
import dayjs from '../common/dayjs'
|
||||
|
||||
@Entity({ name: 'reservations' })
|
||||
export class Reservation {
|
||||
@PrimaryGeneratedColumn('uuid')
|
||||
id: string
|
||||
|
||||
@Column('varchar', { length: 64, nullable: false })
|
||||
username: string
|
||||
|
||||
@Transform(({ value, options: { groups = [] } }) =>
|
||||
groups.includes('password') ? value : '***',
|
||||
)
|
||||
@Column('varchar', { length: 255, nullable: false })
|
||||
password: string
|
||||
|
||||
@Column('datetime', { nullable: false })
|
||||
@Type(() => Dayjs)
|
||||
@Transform(({ value }) => dayjs(value).format())
|
||||
dateRangeStart: Dayjs
|
||||
|
||||
@Column('datetime', { nullable: false })
|
||||
@Type(() => Dayjs)
|
||||
@Transform(({ value }) => dayjs(value).format())
|
||||
dateRangeEnd: Dayjs
|
||||
|
||||
@Column('varchar', { length: 32, nullable: false })
|
||||
opponentId: string
|
||||
|
||||
@Column('varchar', { length: 255, nullable: false })
|
||||
opponentName: string
|
||||
|
||||
constructor(partial: Partial<Reservation>) {
|
||||
Object.assign(this, partial)
|
||||
}
|
||||
|
||||
@Exclude()
|
||||
public createPossibleDates(): Dayjs[] {
|
||||
const possibleDates: Dayjs[] = []
|
||||
|
||||
let possibleDate = dayjs(this.dateRangeStart).second(0).millisecond(0)
|
||||
while (possibleDate.isSameOrBefore(this.dateRangeEnd)) {
|
||||
possibleDates.push(possibleDate)
|
||||
possibleDate = possibleDate.add(15, 'minute')
|
||||
}
|
||||
|
||||
return possibleDates
|
||||
}
|
||||
|
||||
/**
|
||||
* Method to check if a reservation is available for reservation in the system
|
||||
* @returns is reservation date within 7 days
|
||||
*/
|
||||
@Exclude()
|
||||
public isAvailableForReservation(): boolean {
|
||||
return dayjs().diff(this.dateRangeStart, 'day') <= 7
|
||||
}
|
||||
|
||||
@Exclude()
|
||||
public getAllowedReservationDate(): Dayjs {
|
||||
return this.dateRangeStart
|
||||
.hour(0)
|
||||
.minute(0)
|
||||
.second(0)
|
||||
.millisecond(0)
|
||||
.subtract(7, 'days')
|
||||
}
|
||||
}
|
||||
25
src/reservations/module.ts
Normal file
25
src/reservations/module.ts
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
import { BullModule } from '@nestjs/bull'
|
||||
import { Module } from '@nestjs/common'
|
||||
import { ConfigModule } from '@nestjs/config'
|
||||
import { TypeOrmModule } from '@nestjs/typeorm'
|
||||
import { Reservation } from './entity'
|
||||
import { ReservationsController } from './controller'
|
||||
import config, { RESERVATIONS_QUEUE_NAME } from './config'
|
||||
import { ReservationsService } from './service'
|
||||
import { ReservationsWorker } from './worker'
|
||||
import { LoggerModule } from '../logger/module'
|
||||
import { RunnerModule } from '../runner/module'
|
||||
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
LoggerModule,
|
||||
TypeOrmModule.forFeature([Reservation]),
|
||||
BullModule.registerQueue({ name: RESERVATIONS_QUEUE_NAME }),
|
||||
RunnerModule,
|
||||
ConfigModule.forFeature(config),
|
||||
],
|
||||
controllers: [ReservationsController],
|
||||
providers: [ReservationsService, ReservationsWorker],
|
||||
})
|
||||
export class ReservationsModule {}
|
||||
35
src/reservations/service.ts
Normal file
35
src/reservations/service.ts
Normal file
|
|
@ -0,0 +1,35 @@
|
|||
import { Injectable } from '@nestjs/common'
|
||||
import { InjectRepository } from '@nestjs/typeorm'
|
||||
import { Repository } from 'typeorm'
|
||||
import { Reservation } from './entity'
|
||||
import dayjs from '../common/dayjs'
|
||||
|
||||
@Injectable()
|
||||
export class ReservationsService {
|
||||
constructor(
|
||||
@InjectRepository(Reservation)
|
||||
private reservationsRepository: Repository<Reservation>,
|
||||
) {}
|
||||
|
||||
getAll() {
|
||||
return this.reservationsRepository.find()
|
||||
}
|
||||
|
||||
getById(id: string) {
|
||||
return this.reservationsRepository.findOneBy({ id })
|
||||
}
|
||||
|
||||
getByDate(date = dayjs()) {
|
||||
return this.reservationsRepository.createQueryBuilder()
|
||||
.where(`DATE(dateRangeStart, '-7 day') = DATE(:date)`, { date })
|
||||
.getMany()
|
||||
}
|
||||
|
||||
create(reservation: Reservation) {
|
||||
return this.reservationsRepository.save(reservation)
|
||||
}
|
||||
|
||||
async deleteById(id: string) {
|
||||
await this.reservationsRepository.delete({ id })
|
||||
}
|
||||
}
|
||||
30
src/reservations/worker.ts
Normal file
30
src/reservations/worker.ts
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
import { Inject } from '@nestjs/common'
|
||||
import { Process, Processor } from '@nestjs/bull'
|
||||
import { Job } from 'bull'
|
||||
import { instanceToPlain, plainToInstance } from 'class-transformer'
|
||||
import { RESERVATIONS_QUEUE_NAME } from './config'
|
||||
import { Reservation } from './entity'
|
||||
import { BaanReserverenService } from '../runner/baanreserveren/service'
|
||||
import { LoggerService } from '../logger/service'
|
||||
|
||||
@Processor(RESERVATIONS_QUEUE_NAME)
|
||||
export class ReservationsWorker {
|
||||
constructor(
|
||||
@Inject(BaanReserverenService)
|
||||
private readonly brService: BaanReserverenService,
|
||||
|
||||
@Inject(LoggerService)
|
||||
private readonly logger: LoggerService,
|
||||
) {}
|
||||
|
||||
@Process()
|
||||
async handleReservationJob(job: Job<Reservation>) {
|
||||
const reservation = plainToInstance(Reservation, job.data, { groups: ['password'] })
|
||||
this.logger.log('Handling reservation', { reservation: instanceToPlain(reservation) })
|
||||
await this.performReservation(reservation)
|
||||
}
|
||||
|
||||
async performReservation(reservation: Reservation) {
|
||||
await this.brService.performReservation(reservation)
|
||||
}
|
||||
}
|
||||
|
|
@ -1,15 +1,17 @@
|
|||
import { Inject, Injectable } from '@nestjs/common'
|
||||
import { Dayjs } from 'dayjs'
|
||||
import dayjs from './dayjs'
|
||||
import puppeteer, {
|
||||
Browser,
|
||||
BrowserConnectOptions,
|
||||
BrowserLaunchArgumentOptions,
|
||||
ElementHandle,
|
||||
LaunchOptions,
|
||||
Page,
|
||||
} from 'puppeteer'
|
||||
import { asyncLocalStorage as l, LoggableError } from './logger'
|
||||
import { Opponent, Reservation } from './reservation'
|
||||
import { ElementHandle, Page } from 'puppeteer'
|
||||
import { RunnerService } from '../service'
|
||||
import { EmptyPage } from '../pages/empty'
|
||||
import dayjs from '../../common/dayjs'
|
||||
import { Reservation } from '../../reservations/entity'
|
||||
|
||||
const baanReserverenRoot = 'https://squashcity.baanreserveren.nl'
|
||||
|
||||
export enum BaanReserverenUrls {
|
||||
Reservations = '/reservations',
|
||||
Logout = '/auth/logout',
|
||||
}
|
||||
|
||||
enum SessionAction {
|
||||
NoAction,
|
||||
|
|
@ -17,67 +19,25 @@ enum SessionAction {
|
|||
Login,
|
||||
}
|
||||
|
||||
interface RunnerSession {
|
||||
interface BaanReserverenSession {
|
||||
username: string
|
||||
loggedInAt: Dayjs
|
||||
startedAt: Dayjs
|
||||
}
|
||||
|
||||
export class Runner {
|
||||
private browser?: Browser
|
||||
private page?: Page
|
||||
private options?: LaunchOptions &
|
||||
BrowserLaunchArgumentOptions &
|
||||
BrowserConnectOptions
|
||||
private session?: RunnerSession
|
||||
@Injectable()
|
||||
export class BaanReserverenService {
|
||||
private session: BaanReserverenSession | null = null
|
||||
|
||||
constructor(
|
||||
options?: LaunchOptions &
|
||||
BrowserLaunchArgumentOptions &
|
||||
BrowserConnectOptions
|
||||
) {
|
||||
const defaultArgs = ['--disable-setuid-sandbox', '--no-sandbox']
|
||||
this.options = { args: defaultArgs, ...options }
|
||||
}
|
||||
@Inject(RunnerService)
|
||||
private readonly runnerService: RunnerService,
|
||||
|
||||
public async test() {
|
||||
l.getStore()?.debug('Runner test')
|
||||
try {
|
||||
if (!this.browser) {
|
||||
this.browser = await puppeteer.launch(this.options)
|
||||
}
|
||||
} catch (error: unknown) {
|
||||
l.getStore()?.error('Browser error', { error: (error as Error).message })
|
||||
throw new PuppeteerBrowserLaunchError(error as Error)
|
||||
}
|
||||
@Inject(EmptyPage)
|
||||
private readonly page: Page,
|
||||
) {}
|
||||
|
||||
try {
|
||||
this.page = await this.browser?.newPage()
|
||||
} catch (error) {
|
||||
l.getStore()?.error('Page error', { error: (error as Error).message })
|
||||
throw new PuppeteerNewPageError(error as Error)
|
||||
}
|
||||
}
|
||||
|
||||
public async run(reservation: Reservation) {
|
||||
try {
|
||||
if (!this.browser) {
|
||||
l.getStore()?.debug('Launching browser')
|
||||
this.browser = await puppeteer.launch(this.options)
|
||||
}
|
||||
} catch (error) {
|
||||
throw new PuppeteerBrowserLaunchError(error as Error)
|
||||
}
|
||||
await this.startSession(reservation)
|
||||
await this.makeReservation(reservation)
|
||||
}
|
||||
|
||||
private async checkSession(username: string): Promise<SessionAction> {
|
||||
if (
|
||||
this.page
|
||||
?.url()
|
||||
.startsWith('https://squashcity.baanreserveren.nl/reservations')
|
||||
) {
|
||||
l.getStore()?.info('Already logged in', { username })
|
||||
private checkSession(username: string) {
|
||||
if (this.page.url().endsWith(BaanReserverenUrls.Reservations)) {
|
||||
return this.session?.username !== username
|
||||
? SessionAction.Logout
|
||||
: SessionAction.NoAction
|
||||
|
|
@ -85,83 +45,69 @@ export class Runner {
|
|||
return SessionAction.Login
|
||||
}
|
||||
|
||||
private async startSession(reservation: Reservation) {
|
||||
if (!this.page) {
|
||||
try {
|
||||
this.page = await this.browser?.newPage()
|
||||
} catch (error) {
|
||||
throw new PuppeteerNewPageError(error as Error)
|
||||
}
|
||||
private startSession(username: string) {
|
||||
if (this.session && this.session.username !== username) {
|
||||
throw new Error('Session already started')
|
||||
}
|
||||
|
||||
this.page
|
||||
?.goto('https://squashcity.baanreserveren.nl/reservations')
|
||||
.catch((e: Error) => {
|
||||
throw new RunnerNewSessionError(e)
|
||||
})
|
||||
if (this.session?.username === username) {
|
||||
return
|
||||
}
|
||||
|
||||
const sessionAction = await this.checkSession(reservation.user.username)
|
||||
switch (sessionAction) {
|
||||
case SessionAction.Login: {
|
||||
await this.login(reservation.user.username, reservation.user.password)
|
||||
break
|
||||
}
|
||||
case SessionAction.Logout: {
|
||||
this.session = {
|
||||
username,
|
||||
startedAt: dayjs(),
|
||||
}
|
||||
}
|
||||
|
||||
private endSession() {
|
||||
this.session = null
|
||||
}
|
||||
|
||||
private async login(username: string, password: string) {
|
||||
await this.page
|
||||
.waitForSelector('input[name=username]')
|
||||
.then((i) => i?.type(username))
|
||||
.catch((e: Error) => {
|
||||
// throw new RunnerLoginUsernameInputError(e)
|
||||
})
|
||||
await this.page
|
||||
.$('input[name=password]')
|
||||
.then((i) => i?.type(password))
|
||||
.catch((e: Error) => {
|
||||
// throw new RunnerLoginPasswordInputError(e)
|
||||
})
|
||||
await this.page
|
||||
.$('button')
|
||||
.then((b) => b?.click())
|
||||
.catch((e: Error) => {
|
||||
// throw new RunnerLoginSubmitError(e)
|
||||
})
|
||||
this.startSession(username)
|
||||
}
|
||||
|
||||
private async logout() {
|
||||
await this.page.goto(`${baanReserverenRoot}${BaanReserverenUrls.Logout}`)
|
||||
this.endSession()
|
||||
}
|
||||
|
||||
private async init(reservation: Reservation) {
|
||||
await this.page.goto(baanReserverenRoot)
|
||||
const action = await this.checkSession(reservation.username)
|
||||
switch (action) {
|
||||
case SessionAction.Logout:
|
||||
await this.logout()
|
||||
await this.login(reservation.user.username, reservation.user.password)
|
||||
await this.login(reservation.username, reservation.password)
|
||||
break
|
||||
case SessionAction.Login:
|
||||
await this.login(reservation.username, reservation.password)
|
||||
break
|
||||
}
|
||||
case SessionAction.NoAction:
|
||||
default:
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
private async login(username: string, password: string) {
|
||||
l.getStore()?.debug('Logging in', { username })
|
||||
await this.page
|
||||
?.waitForSelector('input[name=username]')
|
||||
.then((i) => i?.type(username))
|
||||
.catch((e: Error) => {
|
||||
throw new RunnerLoginUsernameInputError(e)
|
||||
})
|
||||
await this.page
|
||||
?.$('input[name=password]')
|
||||
.then((i) => i?.type(password))
|
||||
.catch((e: Error) => {
|
||||
throw new RunnerLoginPasswordInputError(e)
|
||||
})
|
||||
await this.page
|
||||
?.$('button')
|
||||
.then((b) => b?.click())
|
||||
.catch((e: Error) => {
|
||||
throw new RunnerLoginSubmitError(e)
|
||||
})
|
||||
|
||||
this.session = {
|
||||
loggedInAt: dayjs(),
|
||||
username,
|
||||
}
|
||||
}
|
||||
|
||||
private async logout() {
|
||||
l.getStore()?.debug('Logging out', { username: this.session?.username })
|
||||
await this.page
|
||||
?.goto('https://squashcity.baanreserveren.nl/auth/logout')
|
||||
.catch((e: Error) => {
|
||||
throw new RunnerLogoutError(e)
|
||||
})
|
||||
this.session = undefined
|
||||
}
|
||||
|
||||
private async makeReservation(reservation: Reservation) {
|
||||
await this.navigateToDay(reservation.dateRange.start)
|
||||
await this.selectAvailableTime(reservation)
|
||||
await this.selectOpponent(reservation.opponent)
|
||||
await this.confirmReservation()
|
||||
reservation.booked = true
|
||||
}
|
||||
|
||||
private getLastVisibleDay(): Dayjs {
|
||||
const lastDayOfMonth = dayjs().add(1, 'month').set('date', 0)
|
||||
let daysToAdd = 0
|
||||
|
|
@ -177,10 +123,7 @@ export class Runner {
|
|||
}
|
||||
|
||||
private async navigateToDay(date: Dayjs): Promise<void> {
|
||||
l.getStore()?.debug(`Navigating to ${date.format()}`)
|
||||
|
||||
if (this.getLastVisibleDay().isBefore(date)) {
|
||||
l.getStore()?.debug('Date is on different page, increase month')
|
||||
await this.page
|
||||
?.waitForSelector('td.month.next')
|
||||
.then((d) => d?.click())
|
||||
|
|
@ -210,14 +153,12 @@ export class Runner {
|
|||
})
|
||||
}
|
||||
|
||||
private async selectAvailableTime(res: Reservation): Promise<void> {
|
||||
l.getStore()?.debug('Selecting available time', {
|
||||
reservation: res.toString(true),
|
||||
})
|
||||
private async selectAvailableTime(reservation: Reservation): Promise<void> {
|
||||
let freeCourt: ElementHandle | null | undefined
|
||||
let i = 0
|
||||
while (i < res.possibleDates.length && !freeCourt) {
|
||||
const possibleDate = res.possibleDates[i]
|
||||
const possibleDates = reservation.createPossibleDates()
|
||||
while (i < possibleDates.length && !freeCourt) {
|
||||
const possibleDate = possibleDates[i]
|
||||
const timeString = possibleDate.format('HH:mm')
|
||||
const selector =
|
||||
`tr[data-time='${timeString}']` + `> td.free[rowspan='3'][type='free']`
|
||||
|
|
@ -234,14 +175,13 @@ export class Runner {
|
|||
})
|
||||
}
|
||||
|
||||
private async selectOpponent(opponent: Opponent): Promise<void> {
|
||||
l.getStore()?.debug('Selecting opponent', { opponent })
|
||||
private async selectOpponent(id: string, name: string): Promise<void> {
|
||||
const player2Search = await this.page
|
||||
?.waitForSelector('tr.res-make-player-2 > td > input')
|
||||
.catch((e: Error) => {
|
||||
throw new RunnerOpponentSearchError(e)
|
||||
})
|
||||
await player2Search?.type(opponent.name).catch((e: Error) => {
|
||||
await player2Search?.type(name).catch((e: Error) => {
|
||||
throw new RunnerOpponentSearchInputError(e)
|
||||
})
|
||||
await this.page?.waitForNetworkIdle().catch((e: Error) => {
|
||||
|
|
@ -249,7 +189,7 @@ export class Runner {
|
|||
})
|
||||
await this.page
|
||||
?.$('select.br-user-select[name="players[2]"]')
|
||||
.then((d) => d?.select(opponent.id))
|
||||
.then((d) => d?.select(id))
|
||||
.catch((e: Error) => {
|
||||
throw new RunnerOpponentSearchSelectionError(e)
|
||||
})
|
||||
|
|
@ -269,9 +209,17 @@ export class Runner {
|
|||
throw new RunnerReservationConfirmSubmitError(e)
|
||||
})
|
||||
}
|
||||
|
||||
public async performReservation(reservation: Reservation) {
|
||||
await this.init(reservation)
|
||||
await this.navigateToDay(reservation.dateRangeStart)
|
||||
await this.selectAvailableTime(reservation)
|
||||
await this.selectOpponent(reservation.opponentId, reservation.opponentName)
|
||||
// await this.confirmReservation()
|
||||
}
|
||||
}
|
||||
|
||||
export class RunnerError extends LoggableError {
|
||||
export class RunnerError extends Error {
|
||||
constructor(error: Error) {
|
||||
super(error.message)
|
||||
this.stack = error.stack
|
||||
|
|
@ -295,7 +243,7 @@ export class RunnerNavigationDayError extends RunnerError {}
|
|||
export class RunnerNavigationSelectionError extends RunnerError {}
|
||||
|
||||
export class RunnerCourtSelectionError extends RunnerError {}
|
||||
export class NoCourtAvailableError extends LoggableError {}
|
||||
export class NoCourtAvailableError extends Error {}
|
||||
|
||||
export class RunnerOpponentSearchError extends RunnerError {}
|
||||
export class RunnerOpponentSearchInputError extends RunnerError {}
|
||||
324
src/runner/controller.ts.old
Normal file
324
src/runner/controller.ts.old
Normal file
|
|
@ -0,0 +1,324 @@
|
|||
// import { Dayjs } from 'dayjs'
|
||||
// import { Injectable } from '@nestjs/common'
|
||||
// import dayjs from '../common/dayjs'
|
||||
// import puppeteer, {
|
||||
// Browser,
|
||||
// BrowserConnectOptions,
|
||||
// BrowserLaunchArgumentOptions,
|
||||
// ElementHandle,
|
||||
// LaunchOptions,
|
||||
// Page,
|
||||
// } from 'puppeteer'
|
||||
// import { asyncLocalStorage as l, LoggableError } from '../common/logger'
|
||||
// import { Reservation } from '../reservations/model'
|
||||
// import { Opponent } from '../common/opponent'
|
||||
|
||||
// enum SessionAction {
|
||||
// NoAction,
|
||||
// Logout,
|
||||
// Login,
|
||||
// }
|
||||
|
||||
// interface RunnerSession {
|
||||
// username: string
|
||||
// loggedInAt: Dayjs
|
||||
// }
|
||||
|
||||
// export class RunnerInstance {
|
||||
// private static runner: Runner
|
||||
|
||||
// static getRunner(): Runner {
|
||||
// if (!RunnerInstance.runner) {
|
||||
// RunnerInstance.runner = new Runner({
|
||||
// headless: true,
|
||||
// })
|
||||
// }
|
||||
// return RunnerInstance.runner
|
||||
// }
|
||||
// }
|
||||
|
||||
// @Injectable()
|
||||
// export class Runner {
|
||||
// private browser?: Browser
|
||||
// private page?: Page
|
||||
// private options?: LaunchOptions &
|
||||
// BrowserLaunchArgumentOptions &
|
||||
// BrowserConnectOptions
|
||||
// private session?: RunnerSession
|
||||
|
||||
// constructor(
|
||||
// options?: LaunchOptions &
|
||||
// BrowserLaunchArgumentOptions &
|
||||
// BrowserConnectOptions
|
||||
// ) {
|
||||
// const defaultArgs = ['--disable-setuid-sandbox', '--no-sandbox']
|
||||
// this.options = { args: defaultArgs, ...options }
|
||||
// }
|
||||
|
||||
// public async test() {
|
||||
// l.getStore()?.debug('Runner test')
|
||||
// try {
|
||||
// if (!this.browser) {
|
||||
// this.browser = await puppeteer.launch(this.options)
|
||||
// }
|
||||
// } catch (error: unknown) {
|
||||
// l.getStore()?.error('Browser error', { error: (error as Error).message })
|
||||
// throw new PuppeteerBrowserLaunchError(error as Error)
|
||||
// }
|
||||
|
||||
// try {
|
||||
// this.page = await this.browser?.newPage()
|
||||
// } catch (error) {
|
||||
// l.getStore()?.error('Page error', { error: (error as Error).message })
|
||||
// throw new PuppeteerNewPageError(error as Error)
|
||||
// }
|
||||
// }
|
||||
|
||||
// public async run(reservations: Reservation[]) {
|
||||
// try {
|
||||
// if (!this.browser) {
|
||||
// l.getStore()?.debug('Launching browser')
|
||||
// this.browser = await puppeteer.launch(this.options)
|
||||
// }
|
||||
// } catch (error) {
|
||||
// throw new PuppeteerBrowserLaunchError(error as Error)
|
||||
// }
|
||||
// for (const reservation of reservations) {
|
||||
// await this.startSession(reservation)
|
||||
// await this.makeReservation(reservation)
|
||||
// }
|
||||
// }
|
||||
|
||||
// private async checkSession(username: string): Promise<SessionAction> {
|
||||
// if (
|
||||
// this.page
|
||||
// ?.url()
|
||||
// .startsWith('https://squashcity.baanreserveren.nl/reservations')
|
||||
// ) {
|
||||
// l.getStore()?.info('Already logged in', { username })
|
||||
// return this.session?.username !== username
|
||||
// ? SessionAction.Logout
|
||||
// : SessionAction.NoAction
|
||||
// }
|
||||
// return SessionAction.Login
|
||||
// }
|
||||
|
||||
// private async startSession(reservation: Reservation) {
|
||||
// if (!this.page) {
|
||||
// try {
|
||||
// this.page = await this.browser?.newPage()
|
||||
// } catch (error) {
|
||||
// throw new PuppeteerNewPageError(error as Error)
|
||||
// }
|
||||
// }
|
||||
|
||||
// this.page
|
||||
// ?.goto('https://squashcity.baanreserveren.nl/reservations')
|
||||
// .catch((e: Error) => {
|
||||
// throw new RunnerNewSessionError(e)
|
||||
// })
|
||||
|
||||
// const sessionAction = await this.checkSession(reservation.user.username)
|
||||
// switch (sessionAction) {
|
||||
// case SessionAction.Login: {
|
||||
// await this.login(reservation.user.username, reservation.user.password)
|
||||
// break
|
||||
// }
|
||||
// case SessionAction.Logout: {
|
||||
// await this.logout()
|
||||
// await this.login(reservation.user.username, reservation.user.password)
|
||||
// break
|
||||
// }
|
||||
// case SessionAction.NoAction:
|
||||
// default:
|
||||
// break
|
||||
// }
|
||||
// }
|
||||
|
||||
// private async login(username: string, password: string) {
|
||||
// l.getStore()?.debug('Logging in', { username })
|
||||
// await this.page
|
||||
// ?.waitForSelector('input[name=username]')
|
||||
// .then((i) => i?.type(username))
|
||||
// .catch((e: Error) => {
|
||||
// throw new RunnerLoginUsernameInputError(e)
|
||||
// })
|
||||
// await this.page
|
||||
// ?.$('input[name=password]')
|
||||
// .then((i) => i?.type(password))
|
||||
// .catch((e: Error) => {
|
||||
// throw new RunnerLoginPasswordInputError(e)
|
||||
// })
|
||||
// await this.page
|
||||
// ?.$('button')
|
||||
// .then((b) => b?.click())
|
||||
// .catch((e: Error) => {
|
||||
// throw new RunnerLoginSubmitError(e)
|
||||
// })
|
||||
|
||||
// this.session = {
|
||||
// loggedInAt: dayjs(),
|
||||
// username,
|
||||
// }
|
||||
// }
|
||||
|
||||
// private async logout() {
|
||||
// l.getStore()?.debug('Logging out', { username: this.session?.username })
|
||||
// await this.page
|
||||
// ?.goto('https://squashcity.baanreserveren.nl/auth/logout')
|
||||
// .catch((e: Error) => {
|
||||
// throw new RunnerLogoutError(e)
|
||||
// })
|
||||
// this.session = undefined
|
||||
// }
|
||||
|
||||
// private async makeReservation(reservation: Reservation) {
|
||||
// await this.navigateToDay(reservation.dateRange.start)
|
||||
// await this.selectAvailableTime(reservation)
|
||||
// await this.selectOpponent(reservation.opponent)
|
||||
// await this.confirmReservation()
|
||||
// reservation.booked = true
|
||||
// }
|
||||
|
||||
// private getLastVisibleDay(): Dayjs {
|
||||
// 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
|
||||
// }
|
||||
// return lastDayOfMonth.add(daysToAdd, 'day')
|
||||
// }
|
||||
|
||||
// private async navigateToDay(date: Dayjs): Promise<void> {
|
||||
// l.getStore()?.debug(`Navigating to ${date.format()}`)
|
||||
|
||||
// if (this.getLastVisibleDay().isBefore(date)) {
|
||||
// l.getStore()?.debug('Date is on different page, increase month')
|
||||
// await this.page
|
||||
// ?.waitForSelector('td.month.next')
|
||||
// .then((d) => d?.click())
|
||||
// .catch((e: Error) => {
|
||||
// throw new RunnerNavigationMonthError(e)
|
||||
// })
|
||||
// }
|
||||
|
||||
// await this.page
|
||||
// ?.waitForSelector(
|
||||
// `td#cal_${date.get('year')}_${date.get('month') + 1}_${date.get(
|
||||
// 'date'
|
||||
// )}`
|
||||
// )
|
||||
// .then((d) => d?.click())
|
||||
// .catch((e: Error) => {
|
||||
// throw new RunnerNavigationDayError(e)
|
||||
// })
|
||||
// await this.page
|
||||
// ?.waitForSelector(
|
||||
// `td#cal_${date.get('year')}_${date.get('month') + 1}_${date.get(
|
||||
// 'date'
|
||||
// )}.selected`
|
||||
// )
|
||||
// .catch((e: Error) => {
|
||||
// throw new RunnerNavigationSelectionError(e)
|
||||
// })
|
||||
// }
|
||||
|
||||
// private async selectAvailableTime(res: Reservation): Promise<void> {
|
||||
// l.getStore()?.debug('Selecting available time', {
|
||||
// reservation: res.toString(true),
|
||||
// })
|
||||
// let freeCourt: ElementHandle | null | undefined
|
||||
// let i = 0
|
||||
// while (i < res.possibleDates.length && !freeCourt) {
|
||||
// const possibleDate = res.possibleDates[i]
|
||||
// const timeString = possibleDate.format('HH:mm')
|
||||
// const selector =
|
||||
// `tr[data-time='${timeString}']` + `> td.free[rowspan='3'][type='free']`
|
||||
// freeCourt = await this.page?.$(selector)
|
||||
// i++
|
||||
// }
|
||||
|
||||
// if (!freeCourt) {
|
||||
// throw new NoCourtAvailableError()
|
||||
// }
|
||||
|
||||
// await freeCourt.click().catch((e: Error) => {
|
||||
// throw new RunnerCourtSelectionError(e)
|
||||
// })
|
||||
// }
|
||||
|
||||
// private async selectOpponent(opponent: Opponent): Promise<void> {
|
||||
// l.getStore()?.debug('Selecting opponent', { opponent })
|
||||
// const player2Search = await this.page
|
||||
// ?.waitForSelector('tr.res-make-player-2 > td > input')
|
||||
// .catch((e: Error) => {
|
||||
// throw new RunnerOpponentSearchError(e)
|
||||
// })
|
||||
// await player2Search?.type(opponent.name).catch((e: Error) => {
|
||||
// throw new RunnerOpponentSearchInputError(e)
|
||||
// })
|
||||
// await this.page?.waitForNetworkIdle().catch((e: Error) => {
|
||||
// throw new RunnerOpponentSearchNetworkError(e)
|
||||
// })
|
||||
// await this.page
|
||||
// ?.$('select.br-user-select[name="players[2]"]')
|
||||
// .then((d) => d?.select(opponent.id))
|
||||
// .catch((e: Error) => {
|
||||
// throw new RunnerOpponentSearchSelectionError(e)
|
||||
// })
|
||||
// }
|
||||
|
||||
// private async confirmReservation(): Promise<void> {
|
||||
// await this.page
|
||||
// ?.$('input#__make_submit')
|
||||
// .then((b) => b?.click())
|
||||
// .catch((e: Error) => {
|
||||
// throw new RunnerReservationConfirmButtonError(e)
|
||||
// })
|
||||
// await this.page
|
||||
// ?.waitForSelector('input#__make_submit2')
|
||||
// .then((b) => b?.click())
|
||||
// .catch((e: Error) => {
|
||||
// throw new RunnerReservationConfirmSubmitError(e)
|
||||
// })
|
||||
// }
|
||||
// }
|
||||
|
||||
// export class RunnerError extends LoggableError {
|
||||
// constructor(error: Error) {
|
||||
// super(error.message)
|
||||
// this.stack = error.stack
|
||||
// }
|
||||
// }
|
||||
// export class PuppeteerError extends RunnerError {}
|
||||
// export class PuppeteerBrowserLaunchError extends PuppeteerError {}
|
||||
// export class PuppeteerNewPageError extends PuppeteerError {}
|
||||
|
||||
// export class RunnerNewSessionError extends RunnerError {}
|
||||
|
||||
// export class RunnerLogoutError extends RunnerError {}
|
||||
|
||||
// export class RunnerLoginNavigationError extends RunnerError {}
|
||||
// export class RunnerLoginUsernameInputError extends RunnerError {}
|
||||
// export class RunnerLoginPasswordInputError extends RunnerError {}
|
||||
// export class RunnerLoginSubmitError extends RunnerError {}
|
||||
|
||||
// export class RunnerNavigationMonthError extends RunnerError {}
|
||||
// export class RunnerNavigationDayError extends RunnerError {}
|
||||
// export class RunnerNavigationSelectionError extends RunnerError {}
|
||||
|
||||
// export class RunnerCourtSelectionError extends RunnerError {}
|
||||
// export class NoCourtAvailableError extends LoggableError {}
|
||||
|
||||
// export class RunnerOpponentSearchError extends RunnerError {}
|
||||
// export class RunnerOpponentSearchInputError extends RunnerError {}
|
||||
// export class RunnerOpponentSearchNetworkError extends RunnerError {}
|
||||
// export class RunnerOpponentSearchSelectionError extends RunnerError {}
|
||||
|
||||
// export class RunnerReservationConfirmButtonError extends RunnerError {}
|
||||
// export class RunnerReservationConfirmSubmitError extends RunnerError {}
|
||||
14
src/runner/module.ts
Normal file
14
src/runner/module.ts
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
import { Module } from '@nestjs/common'
|
||||
import { BullModule } from '@nestjs/bull'
|
||||
import { EmptyPageFactory } from './pages/empty'
|
||||
import { RunnerService } from './service'
|
||||
import { BaanReserverenService } from './baanreserveren/service'
|
||||
import { LoggerModule } from '../logger/module'
|
||||
|
||||
@Module({
|
||||
providers: [RunnerService, BaanReserverenService, EmptyPageFactory],
|
||||
imports: [LoggerModule, BullModule.registerQueue({ name: 'reservations' })],
|
||||
exports: [EmptyPageFactory, BaanReserverenService],
|
||||
})
|
||||
|
||||
export class RunnerModule {}
|
||||
17
src/runner/pages/empty.ts
Normal file
17
src/runner/pages/empty.ts
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
import { FactoryProvider, Scope } from '@nestjs/common'
|
||||
import { RunnerService } from '../service'
|
||||
import { Page } from 'puppeteer'
|
||||
|
||||
export const EmptyPage = Symbol.for('EmptyPage')
|
||||
|
||||
export const EmptyPageFactory: FactoryProvider<Page> = {
|
||||
provide: EmptyPage,
|
||||
useFactory: async (runnerService: RunnerService) => {
|
||||
const browser = await runnerService.getBrowser()
|
||||
const page = await browser.newPage()
|
||||
|
||||
return page
|
||||
},
|
||||
inject: [RunnerService],
|
||||
scope: Scope.TRANSIENT,
|
||||
}
|
||||
35
src/runner/router.ts
Normal file
35
src/runner/router.ts
Normal file
|
|
@ -0,0 +1,35 @@
|
|||
// import { IncomingMessage, ServerResponse } from 'http'
|
||||
// import { Router } from '../server/http/routers/index'
|
||||
// import { Runner } from './controller'
|
||||
|
||||
// export class RunnerRouter extends Router {
|
||||
// public async handleRequest(
|
||||
// req: IncomingMessage,
|
||||
// res: ServerResponse<IncomingMessage>
|
||||
// ) {
|
||||
// const { url = '', method } = req
|
||||
// const [route] = url.split('?')
|
||||
// switch (true) {
|
||||
// case /^\/runner\/test$/.test(route) && method === 'GET': {
|
||||
// await this.GET_runner_test(req, res)
|
||||
// break
|
||||
// }
|
||||
// default: {
|
||||
// this.handle404(req, res)
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
|
||||
// private async GET_runner_test(
|
||||
// _req: IncomingMessage,
|
||||
// res: ServerResponse<IncomingMessage>
|
||||
// ) {
|
||||
// try {
|
||||
// const runner = new Runner({ headless: true })
|
||||
// await runner.test()
|
||||
// res.writeHead(200, 'OK')
|
||||
// } catch (e) {
|
||||
// res.writeHead(500, 'Internal server error')
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
115
src/runner/service.ts
Normal file
115
src/runner/service.ts
Normal file
|
|
@ -0,0 +1,115 @@
|
|||
import { Inject, Injectable, BeforeApplicationShutdown, OnModuleInit, Scope } from '@nestjs/common'
|
||||
import puppeteer, { Browser, BrowserConnectOptions, BrowserLaunchArgumentOptions, LaunchOptions, Page } from 'puppeteer'
|
||||
import { LoggerService } from '../logger/service'
|
||||
|
||||
enum SessionAction {
|
||||
NoAction,
|
||||
Logout,
|
||||
Login,
|
||||
}
|
||||
|
||||
interface RunnerSession {
|
||||
username: string
|
||||
loggedInAt: Date
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class RunnerService implements OnModuleInit, BeforeApplicationShutdown {
|
||||
private browser?: Browser
|
||||
private options: LaunchOptions &
|
||||
BrowserLaunchArgumentOptions &
|
||||
BrowserConnectOptions = {
|
||||
args: ['--disable-setuid-sandbox', '--no-sandbox'],
|
||||
headless: 'new'
|
||||
}
|
||||
private session: RunnerSession | null = null
|
||||
|
||||
private async init() {
|
||||
try {
|
||||
if (!this.browser) {
|
||||
this.browser = await puppeteer.launch(this.options)
|
||||
}
|
||||
} catch (error) {
|
||||
throw new PuppeteerBrowserLaunchError(error)
|
||||
}
|
||||
}
|
||||
|
||||
public async onModuleInit() {
|
||||
await this.init()
|
||||
}
|
||||
|
||||
public async beforeApplicationShutdown() {
|
||||
try {
|
||||
if (this.browser && this.browser.isConnected()) {
|
||||
await this.browser.close()
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('error shutting down browser', error)
|
||||
}
|
||||
}
|
||||
|
||||
public async getBrowser(): Promise<Browser> {
|
||||
await this.init()
|
||||
if (!this.browser) {
|
||||
throw new Error('Browser not initialized')
|
||||
}
|
||||
return this.browser
|
||||
}
|
||||
|
||||
public async getSession(): Promise<RunnerSession | null> {
|
||||
return this.session
|
||||
}
|
||||
|
||||
public startSession(username: string) {
|
||||
if (this.session && this.session.username !== username) {
|
||||
throw new RunnerNewSessionError(new Error('Session already started'))
|
||||
}
|
||||
|
||||
if (this.session?.username === username) {
|
||||
return
|
||||
}
|
||||
|
||||
this.session = {
|
||||
username,
|
||||
loggedInAt: new Date(),
|
||||
}
|
||||
}
|
||||
|
||||
public endSession() {
|
||||
this.session = null
|
||||
}
|
||||
}
|
||||
|
||||
export class RunnerError extends Error {
|
||||
constructor(error: Error) {
|
||||
super(error.message)
|
||||
this.stack = error.stack
|
||||
}
|
||||
}
|
||||
export class PuppeteerError extends RunnerError {}
|
||||
export class PuppeteerBrowserLaunchError extends PuppeteerError {}
|
||||
export class PuppeteerNewPageError extends PuppeteerError {}
|
||||
|
||||
export class RunnerNewSessionError extends RunnerError {}
|
||||
|
||||
export class RunnerLogoutError extends RunnerError {}
|
||||
|
||||
export class RunnerLoginNavigationError extends RunnerError {}
|
||||
export class RunnerLoginUsernameInputError extends RunnerError {}
|
||||
export class RunnerLoginPasswordInputError extends RunnerError {}
|
||||
export class RunnerLoginSubmitError extends RunnerError {}
|
||||
|
||||
export class RunnerNavigationMonthError extends RunnerError {}
|
||||
export class RunnerNavigationDayError extends RunnerError {}
|
||||
export class RunnerNavigationSelectionError extends RunnerError {}
|
||||
|
||||
export class RunnerCourtSelectionError extends RunnerError {}
|
||||
export class NoCourtAvailableError extends Error {}
|
||||
|
||||
export class RunnerOpponentSearchError extends RunnerError {}
|
||||
export class RunnerOpponentSearchInputError extends RunnerError {}
|
||||
export class RunnerOpponentSearchNetworkError extends RunnerError {}
|
||||
export class RunnerOpponentSearchSelectionError extends RunnerError {}
|
||||
|
||||
export class RunnerReservationConfirmButtonError extends RunnerError {}
|
||||
export class RunnerReservationConfirmSubmitError extends RunnerError {}
|
||||
|
|
@ -1,75 +0,0 @@
|
|||
import {
|
||||
schedule,
|
||||
ScheduledTask,
|
||||
ScheduleOptions,
|
||||
getTasks as getCronTasks,
|
||||
} from 'node-cron'
|
||||
import { v4 } from 'uuid'
|
||||
import { asyncLocalStorage, Logger, LogLevel } from '../common/logger'
|
||||
import { reserve } from '../common/reserver'
|
||||
|
||||
const tasks: ScheduledTask[] = []
|
||||
|
||||
const getTaskConfig = (name: string): ScheduleOptions => ({
|
||||
name,
|
||||
recoverMissedExecutions: false,
|
||||
timezone: 'Europe/Amsterdam',
|
||||
})
|
||||
|
||||
const logger = new Logger('cron', 'default', LogLevel.DEBUG)
|
||||
|
||||
export const getStatus = () => {
|
||||
const tasks = getCronTasks()
|
||||
if (tasks.get('reserver cron')) {
|
||||
return true
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
export const startTasks = () => {
|
||||
try {
|
||||
if (tasks.length === 0) {
|
||||
const task = schedule(
|
||||
'0 7 * * *',
|
||||
async (timestamp) => {
|
||||
asyncLocalStorage.run(
|
||||
new Logger('cron', v4(), LogLevel.DEBUG),
|
||||
async () => {
|
||||
const childLogger = asyncLocalStorage.getStore()
|
||||
childLogger?.info('Running cron job', { timestamp })
|
||||
try {
|
||||
const result = await reserve()
|
||||
if (!result) {
|
||||
throw new Error('Failed to complete reservation')
|
||||
}
|
||||
childLogger?.info('Completed running cron job')
|
||||
} catch (error) {
|
||||
childLogger?.error('Error running cron job', {
|
||||
error: (error as Error).message,
|
||||
})
|
||||
stopTasks()
|
||||
}
|
||||
}
|
||||
)
|
||||
},
|
||||
getTaskConfig('reserver cron')
|
||||
)
|
||||
logger.debug('Started cron task')
|
||||
tasks.push(task)
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Failed to start tasks', { error: (error as Error).message })
|
||||
}
|
||||
}
|
||||
|
||||
export const stopTasks = () => {
|
||||
try {
|
||||
if (tasks.length > 0) {
|
||||
tasks.map((task) => task.stop())
|
||||
logger.debug('Stopped cron tasks')
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Failed to stop tasks', { error: (error as Error).message })
|
||||
}
|
||||
}
|
||||
|
|
@ -1,51 +0,0 @@
|
|||
import http from 'http'
|
||||
import { v4 } from 'uuid'
|
||||
import { asyncLocalStorage, Logger, LogLevel } from '../../common/logger'
|
||||
import { CronRouter } from './routes/cron'
|
||||
import { ReservationsRouter } from './routes/reservations'
|
||||
import { RunnerRouter } from './routes/runner'
|
||||
|
||||
const cronRouter = new CronRouter()
|
||||
const reservationsRouter = new ReservationsRouter()
|
||||
const runnerRouter = new RunnerRouter()
|
||||
|
||||
// Handles POST requests to /reservations
|
||||
const server = http.createServer(async (req, res) => {
|
||||
await asyncLocalStorage.run(
|
||||
new Logger('request', v4(), LogLevel.DEBUG),
|
||||
async () => {
|
||||
const logger = asyncLocalStorage.getStore()
|
||||
const { url, method } = req
|
||||
logger?.debug('Incoming request', { url, method })
|
||||
|
||||
if (!url || !method) {
|
||||
logger?.info('Weird request', { url, method })
|
||||
res.writeHead(400, 'Bad request')
|
||||
res.end()
|
||||
return
|
||||
}
|
||||
|
||||
switch (true) {
|
||||
case /^\/cron/.test(url): {
|
||||
await cronRouter.handleRequest(req, res)
|
||||
break
|
||||
}
|
||||
case /^\/reservations/.test(url): {
|
||||
await reservationsRouter.handleRequest(req, res)
|
||||
break
|
||||
}
|
||||
case /^\/runner/.test(url): {
|
||||
await runnerRouter.handleRequest(req, res)
|
||||
break
|
||||
}
|
||||
default: {
|
||||
logger?.info('Not found', { url, method, location: 'root' })
|
||||
res.writeHead(404, 'Not found')
|
||||
}
|
||||
}
|
||||
res.end()
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
export default server
|
||||
|
|
@ -1,61 +0,0 @@
|
|||
import { IncomingMessage, ServerResponse } from 'http'
|
||||
import { asyncLocalStorage as l } from '../../../common/logger'
|
||||
import { Router } from './index'
|
||||
import { getStatus, startTasks, stopTasks } from '../../cron'
|
||||
|
||||
export class CronRouter extends Router {
|
||||
public async handleRequest(
|
||||
req: IncomingMessage,
|
||||
res: ServerResponse<IncomingMessage>
|
||||
) {
|
||||
const { url = '', method } = req
|
||||
const [route] = url.split('?')
|
||||
switch (true) {
|
||||
case /^\/cron\/?$/.test(route) && method === 'GET': {
|
||||
await this.GET_cron(req, res)
|
||||
break
|
||||
}
|
||||
case /^\/cron\/enable$/.test(route) && method === 'POST': {
|
||||
await this.POST_cron_enable(req, res)
|
||||
break
|
||||
}
|
||||
case /^\/cron\/disable$/.test(route) && method === 'POST': {
|
||||
await this.POST_cron_disable(req, res)
|
||||
break
|
||||
}
|
||||
default: {
|
||||
this.handle404(req, res)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async GET_cron(
|
||||
_req: IncomingMessage,
|
||||
res: ServerResponse<IncomingMessage>
|
||||
) {
|
||||
l.getStore()?.debug('Checking cron status')
|
||||
const status = getStatus()
|
||||
res.writeHead(200, undefined, {
|
||||
'content-type': 'text/plain',
|
||||
})
|
||||
res.write(status ? 'Enabled' : 'Disabled')
|
||||
}
|
||||
|
||||
private async POST_cron_enable(
|
||||
_req: IncomingMessage,
|
||||
res: ServerResponse<IncomingMessage>
|
||||
) {
|
||||
l.getStore()?.debug('Enabling cron')
|
||||
startTasks()
|
||||
res.writeHead(200)
|
||||
}
|
||||
|
||||
private async POST_cron_disable(
|
||||
_req: IncomingMessage,
|
||||
res: ServerResponse<IncomingMessage>
|
||||
) {
|
||||
l.getStore()?.debug('Disabling cron')
|
||||
stopTasks()
|
||||
res.writeHead(200)
|
||||
}
|
||||
}
|
||||
|
|
@ -1,50 +0,0 @@
|
|||
import { IncomingMessage, ServerResponse } from 'http'
|
||||
import {
|
||||
asyncLocalStorage,
|
||||
asyncLocalStorage as l,
|
||||
LoggableError,
|
||||
} from '../../../common/logger'
|
||||
import { parseJson } from '../../utils'
|
||||
|
||||
export abstract class Router {
|
||||
protected async parseJsonContent(req: IncomingMessage) {
|
||||
let jsonBody: Record<string, unknown>
|
||||
const contentType = req.headers['content-type'] || 'application/json'
|
||||
if (contentType !== 'application/json') {
|
||||
l.getStore()?.error('Invalid content type', { contentType })
|
||||
throw new RouterUnsupportedContentTypeError()
|
||||
}
|
||||
|
||||
try {
|
||||
const length = Number.parseInt(req.headers['content-length'] || '0')
|
||||
const encoding = req.readableEncoding || 'utf8'
|
||||
jsonBody = await parseJson(length, encoding, req)
|
||||
} catch (error: unknown) {
|
||||
l.getStore()?.error('Failed to parse body', {
|
||||
error: (error as Error).message,
|
||||
stack: (error as Error).stack,
|
||||
})
|
||||
throw new RouterBadRequestError()
|
||||
}
|
||||
|
||||
return jsonBody
|
||||
}
|
||||
|
||||
protected handle404(
|
||||
req: IncomingMessage,
|
||||
res: ServerResponse<IncomingMessage>
|
||||
) {
|
||||
const { url, method } = req
|
||||
asyncLocalStorage.getStore()?.info('Not found', { url, method })
|
||||
res.writeHead(404, 'Not found')
|
||||
}
|
||||
|
||||
public abstract handleRequest(
|
||||
req: IncomingMessage,
|
||||
res: ServerResponse<IncomingMessage>
|
||||
): Promise<void>
|
||||
}
|
||||
|
||||
export class RouterError extends LoggableError {}
|
||||
export class RouterBadRequestError extends RouterError {}
|
||||
export class RouterUnsupportedContentTypeError extends RouterError {}
|
||||
|
|
@ -1,137 +0,0 @@
|
|||
import { IncomingMessage, ServerResponse } from 'http'
|
||||
import { schedule } from '../../../common/scheduler'
|
||||
import { asyncLocalStorage as l } from '../../../common/logger'
|
||||
import { Router } from './index'
|
||||
import { Reservation } from '../../../common/reservation'
|
||||
import { parse } from 'querystring'
|
||||
import dayjs from '../../../common/dayjs'
|
||||
import { Dayjs } from 'dayjs'
|
||||
|
||||
export class ReservationsRouter extends Router {
|
||||
public async handleRequest(
|
||||
req: IncomingMessage,
|
||||
res: ServerResponse<IncomingMessage>
|
||||
) {
|
||||
const { url = '', method } = req
|
||||
const [route] = url.split('?')
|
||||
switch (true) {
|
||||
case /^\/reservations$/.test(route) && method === 'GET': {
|
||||
await this.GET_reservations(req, res)
|
||||
break
|
||||
}
|
||||
case /^\/reservations$/.test(route) && method === 'POST': {
|
||||
await this.POST_reservations(req, res)
|
||||
break
|
||||
}
|
||||
case /^\/reservations\/\S+$/.test(route) && method === 'DELETE': {
|
||||
await this.DELETE_reservation(req, res)
|
||||
break
|
||||
}
|
||||
default: {
|
||||
this.handle404(req, res)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async GET_reservations(
|
||||
req: IncomingMessage,
|
||||
res: ServerResponse<IncomingMessage>
|
||||
) {
|
||||
const { url = '' } = req
|
||||
const [, queryParams] = url.split('?')
|
||||
let pageNumber = 0
|
||||
let pageSize = 0
|
||||
let date: Dayjs | undefined = undefined
|
||||
const {
|
||||
pageNumber: rawPageNumber = '0',
|
||||
pageSize: rawPageSize = '50',
|
||||
date: rawDate,
|
||||
} = parse(queryParams)
|
||||
if (typeof rawPageNumber === 'string') {
|
||||
pageNumber = Number.parseInt(rawPageNumber)
|
||||
} else {
|
||||
pageNumber = 0
|
||||
}
|
||||
|
||||
if (typeof rawPageSize === 'string') {
|
||||
pageSize = Math.min(Number.parseInt(rawPageSize), 50)
|
||||
} else {
|
||||
pageSize = 50
|
||||
}
|
||||
|
||||
if (typeof rawDate === 'string') {
|
||||
date = dayjs(rawDate)
|
||||
}
|
||||
|
||||
l.getStore()?.debug('Fetching reservations', {
|
||||
pageNumber,
|
||||
pageSize,
|
||||
date: date?.format(),
|
||||
})
|
||||
|
||||
try {
|
||||
let reservations: Reservation[]
|
||||
if (date) {
|
||||
reservations = await Reservation.fetchByDate(date, 50)
|
||||
} else {
|
||||
reservations = await Reservation.fetchByPage(pageNumber, pageSize)
|
||||
}
|
||||
res.setHeader('content-type', 'application/json')
|
||||
l.getStore()?.debug('Found reservations', {
|
||||
reservations: reservations.map((r) => r.toString(true)),
|
||||
})
|
||||
return new Promise<void>((resolve, reject) => {
|
||||
res.write(
|
||||
`[${reservations.map((r) => r.toString(true)).join(',')}]`,
|
||||
(err) => {
|
||||
if (err) {
|
||||
reject(err)
|
||||
}
|
||||
resolve()
|
||||
}
|
||||
)
|
||||
})
|
||||
} catch (error) {
|
||||
l.getStore()?.error('Failed to get reservations', {
|
||||
message: (error as Error).message,
|
||||
stack: (error as Error).stack,
|
||||
})
|
||||
res.writeHead(500)
|
||||
}
|
||||
}
|
||||
|
||||
private async POST_reservations(
|
||||
req: IncomingMessage,
|
||||
res: ServerResponse<IncomingMessage>
|
||||
) {
|
||||
const jsonBody = await this.parseJsonContent(req)
|
||||
try {
|
||||
await schedule(jsonBody)
|
||||
} catch (error: unknown) {
|
||||
l.getStore()?.error('Failed to create reservation', {
|
||||
message: (error as Error).message,
|
||||
stack: (error as Error).stack,
|
||||
})
|
||||
res.writeHead(400)
|
||||
}
|
||||
}
|
||||
|
||||
private async DELETE_reservation(
|
||||
req: IncomingMessage,
|
||||
res: ServerResponse<IncomingMessage>
|
||||
) {
|
||||
const { url = '' } = req
|
||||
const [, , id] = url.split('/')
|
||||
l.getStore()?.debug('Deleting reservation', { id })
|
||||
try {
|
||||
await Reservation.deleteById(id)
|
||||
res.writeHead(200)
|
||||
} catch (error) {
|
||||
l.getStore()?.error('Failed to get reservations', {
|
||||
message: (error as Error).message,
|
||||
stack: (error as Error).stack,
|
||||
})
|
||||
res.writeHead(500)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,37 +0,0 @@
|
|||
import { IncomingMessage, ServerResponse } from 'http'
|
||||
import { asyncLocalStorage as l } from '../../../common/logger'
|
||||
import { Router } from './index'
|
||||
import { getStatus, startTasks, stopTasks } from '../../cron'
|
||||
import { Runner } from '../../../common/runner'
|
||||
|
||||
export class RunnerRouter extends Router {
|
||||
public async handleRequest(
|
||||
req: IncomingMessage,
|
||||
res: ServerResponse<IncomingMessage>
|
||||
) {
|
||||
const { url = '', method } = req
|
||||
const [route] = url.split('?')
|
||||
switch (true) {
|
||||
case /^\/runner\/test$/.test(route) && method === 'GET': {
|
||||
await this.GET_runner_test(req, res)
|
||||
break
|
||||
}
|
||||
default: {
|
||||
this.handle404(req, res)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async GET_runner_test(
|
||||
_req: IncomingMessage,
|
||||
res: ServerResponse<IncomingMessage>
|
||||
) {
|
||||
try {
|
||||
const runner = new Runner({ headless: true })
|
||||
await runner.test()
|
||||
res.writeHead(200, 'OK')
|
||||
} catch (e) {
|
||||
res.writeHead(500, 'Internal server error')
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,28 +0,0 @@
|
|||
import { init } from '../common/database'
|
||||
import { Logger, LogLevel } from '../common/logger'
|
||||
import server from './http'
|
||||
import { startTasks, stopTasks } from './cron'
|
||||
|
||||
const logger = new Logger('process', 'default', LogLevel.DEBUG)
|
||||
|
||||
process.on('unhandledRejection', (reason) => {
|
||||
logger.error('unhandled rejection', { reason })
|
||||
})
|
||||
|
||||
process.on('uncaughtException', (error, origin) => {
|
||||
logger.error('uncaught exception', { error, origin })
|
||||
})
|
||||
|
||||
process.on('beforeExit', async () => {
|
||||
stopTasks()
|
||||
})
|
||||
|
||||
const port = process.env.SERVER_PORT || 3000
|
||||
|
||||
startTasks()
|
||||
|
||||
server.listen(port, async () => {
|
||||
logger.info('server ready and listening', { port })
|
||||
await init()
|
||||
logger.info('database connection established')
|
||||
})
|
||||
|
|
@ -1,34 +0,0 @@
|
|||
import { Readable } from 'stream'
|
||||
|
||||
export 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)
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
24
test/app.e2e-spec.ts
Normal file
24
test/app.e2e-spec.ts
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
import { Test, TestingModule } from '@nestjs/testing'
|
||||
import { INestApplication } from '@nestjs/common'
|
||||
import * as request from 'supertest'
|
||||
import { AppModule } from './../src/app.module'
|
||||
|
||||
describe('AppController (e2e)', () => {
|
||||
let app: INestApplication
|
||||
|
||||
beforeEach(async () => {
|
||||
const moduleFixture: TestingModule = await Test.createTestingModule({
|
||||
imports: [AppModule],
|
||||
}).compile()
|
||||
|
||||
app = moduleFixture.createNestApplication()
|
||||
await app.init()
|
||||
})
|
||||
|
||||
it('/ (GET)', () => {
|
||||
return request(app.getHttpServer())
|
||||
.get('/')
|
||||
.expect(200)
|
||||
.expect('Hello World!')
|
||||
})
|
||||
})
|
||||
9
test/jest-e2e.json
Normal file
9
test/jest-e2e.json
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
{
|
||||
"moduleFileExtensions": ["js", "json", "ts"],
|
||||
"rootDir": ".",
|
||||
"testEnvironment": "node",
|
||||
"testRegex": ".e2e-spec.ts$",
|
||||
"transform": {
|
||||
"^.+\\.(t|j)s$": "ts-jest"
|
||||
}
|
||||
}
|
||||
|
|
@ -1,5 +0,0 @@
|
|||
describe('failure', () => {
|
||||
test('should fail', () => {
|
||||
expect(true).toBeFalsy()
|
||||
})
|
||||
})
|
||||
|
|
@ -1,165 +0,0 @@
|
|||
// Jest Snapshot v1, https://goo.gl/fbAQLP
|
||||
|
||||
exports[`scheduler should handle valid requests outside of reservation window 1`] = `
|
||||
{
|
||||
"scheduledReservation": {
|
||||
"reservation": {
|
||||
"booked": false,
|
||||
"dateRange": {
|
||||
"end": {
|
||||
"$D": 16,
|
||||
"$H": 1,
|
||||
"$L": "nl",
|
||||
"$M": 0,
|
||||
"$W": 0,
|
||||
"$d": {},
|
||||
"$m": 15,
|
||||
"$ms": 0,
|
||||
"$offset": 60,
|
||||
"$s": 0,
|
||||
"$u": false,
|
||||
"$x": {
|
||||
"$timezone": "Europe/Amsterdam",
|
||||
},
|
||||
"$y": 2022,
|
||||
},
|
||||
"start": {
|
||||
"$D": 16,
|
||||
"$H": 1,
|
||||
"$L": "nl",
|
||||
"$M": 0,
|
||||
"$W": 0,
|
||||
"$d": {},
|
||||
"$m": 0,
|
||||
"$ms": 0,
|
||||
"$offset": 60,
|
||||
"$s": 0,
|
||||
"$u": false,
|
||||
"$x": {
|
||||
"$timezone": "Europe/Amsterdam",
|
||||
},
|
||||
"$y": 2022,
|
||||
},
|
||||
},
|
||||
"id": "1234",
|
||||
"opponent": {
|
||||
"id": "123",
|
||||
"name": "collin",
|
||||
},
|
||||
"possibleDates": [
|
||||
{
|
||||
"$D": 16,
|
||||
"$H": 1,
|
||||
"$L": "nl",
|
||||
"$M": 0,
|
||||
"$W": 0,
|
||||
"$d": {},
|
||||
"$m": 0,
|
||||
"$ms": 0,
|
||||
"$offset": 60,
|
||||
"$s": 0,
|
||||
"$x": {
|
||||
"$timezone": "Europe/Amsterdam",
|
||||
},
|
||||
"$y": 2022,
|
||||
},
|
||||
{
|
||||
"$D": 16,
|
||||
"$H": 1,
|
||||
"$L": "nl",
|
||||
"$M": 0,
|
||||
"$W": 0,
|
||||
"$d": {},
|
||||
"$m": 15,
|
||||
"$ms": 0,
|
||||
"$offset": 60,
|
||||
"$s": 0,
|
||||
"$x": {
|
||||
"$timezone": "Europe/Amsterdam",
|
||||
},
|
||||
"$y": 2022,
|
||||
},
|
||||
],
|
||||
"user": {
|
||||
"password": Any<String>,
|
||||
"username": "collin",
|
||||
},
|
||||
},
|
||||
"scheduledFor": {
|
||||
"$D": 9,
|
||||
"$H": 0,
|
||||
"$L": "nl",
|
||||
"$M": 0,
|
||||
"$W": 0,
|
||||
"$d": {},
|
||||
"$m": 0,
|
||||
"$ms": 0,
|
||||
"$offset": 60,
|
||||
"$s": 0,
|
||||
"$x": {
|
||||
"$timezone": "Europe/Amsterdam",
|
||||
},
|
||||
"$y": 2022,
|
||||
},
|
||||
},
|
||||
}
|
||||
`;
|
||||
|
||||
exports[`scheduler should handle valid requests within reservation window 1`] = `
|
||||
{
|
||||
"scheduledReservation": {
|
||||
"reservation": {
|
||||
"booked": false,
|
||||
"dateRange": {
|
||||
"end": {
|
||||
"$D": 1,
|
||||
"$H": 1,
|
||||
"$L": "nl",
|
||||
"$M": 0,
|
||||
"$W": 6,
|
||||
"$d": {},
|
||||
"$m": 30,
|
||||
"$ms": 0,
|
||||
"$offset": 60,
|
||||
"$s": 0,
|
||||
"$u": false,
|
||||
"$x": {
|
||||
"$timezone": "Europe/Amsterdam",
|
||||
},
|
||||
"$y": 2022,
|
||||
},
|
||||
"start": {
|
||||
"$D": 1,
|
||||
"$H": 1,
|
||||
"$L": "nl",
|
||||
"$M": 0,
|
||||
"$W": 6,
|
||||
"$d": {},
|
||||
"$m": 15,
|
||||
"$ms": 0,
|
||||
"$offset": 60,
|
||||
"$s": 0,
|
||||
"$u": false,
|
||||
"$x": {
|
||||
"$timezone": "Europe/Amsterdam",
|
||||
},
|
||||
"$y": 2022,
|
||||
},
|
||||
},
|
||||
"id": Any<String>,
|
||||
"opponent": {
|
||||
"id": "123",
|
||||
"name": "collin",
|
||||
},
|
||||
"possibleDates": [
|
||||
"2022-01-01T00:15:00.000Z",
|
||||
"2022-01-01T00:30:00.000Z",
|
||||
],
|
||||
"user": {
|
||||
"password": Any<String>,
|
||||
"username": "collin",
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
`;
|
||||
|
|
@ -1,84 +0,0 @@
|
|||
import dayjs from '../../../src/common/dayjs'
|
||||
import { Logger, LogLevel } from '../../../src/common/logger'
|
||||
|
||||
jest.useFakeTimers().setSystemTime(new Date('2023-01-01'))
|
||||
|
||||
describe('Logger', () => {
|
||||
beforeEach(() => {
|
||||
jest.resetAllMocks()
|
||||
})
|
||||
|
||||
test('should log messages', () => {
|
||||
const consoleLogSpy = jest.fn()
|
||||
const consoleErrorSpy = jest.fn()
|
||||
jest.spyOn(console, 'log').mockImplementation(consoleLogSpy)
|
||||
jest.spyOn(console, 'error').mockImplementation(consoleErrorSpy)
|
||||
|
||||
const logger = new Logger('tag', 'abc', LogLevel.DEBUG)
|
||||
logger.debug('first')
|
||||
logger.info('second')
|
||||
logger.error('third', { errorMessage: 'test' })
|
||||
|
||||
expect(consoleLogSpy).toHaveBeenCalledTimes(2)
|
||||
expect(consoleLogSpy).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
'(%s) <%s> [%s] %s: %s',
|
||||
dayjs().format(),
|
||||
'tag',
|
||||
'abc',
|
||||
'DEBUG',
|
||||
'first'
|
||||
)
|
||||
expect(consoleLogSpy).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
'(%s) <%s> [%s] %s: %s',
|
||||
dayjs().format(),
|
||||
'tag',
|
||||
'abc',
|
||||
'INFO',
|
||||
'second'
|
||||
)
|
||||
expect(consoleErrorSpy).toHaveBeenCalledTimes(1)
|
||||
expect(consoleErrorSpy).toHaveBeenCalledWith(
|
||||
'(%s) <%s> [%s] %s: %s - %O',
|
||||
dayjs().format(),
|
||||
'tag',
|
||||
'abc',
|
||||
'ERROR',
|
||||
'third',
|
||||
{ errorMessage: 'test' }
|
||||
)
|
||||
})
|
||||
|
||||
test('should log only when level is >= LogLevel of LoggerInstance', () => {
|
||||
const consoleLogSpy = jest.fn()
|
||||
jest.spyOn(console, 'log').mockImplementationOnce(consoleLogSpy)
|
||||
|
||||
const logger = new Logger('tag', 'abc', LogLevel.INFO)
|
||||
logger.debug("shouldn't appear")
|
||||
|
||||
expect(consoleLogSpy).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
test('should obfuscate password from message', () => {
|
||||
const consoleLogSpy = jest.fn()
|
||||
const consoleErrorSpy = jest.fn()
|
||||
jest.spyOn(console, 'log').mockImplementation(consoleLogSpy)
|
||||
jest.spyOn(console, 'error').mockImplementation(consoleErrorSpy)
|
||||
|
||||
const logger = new Logger('tag', 'abc', LogLevel.DEBUG)
|
||||
logger.info('first', { password: 'test' })
|
||||
|
||||
expect(consoleLogSpy).toHaveBeenCalledTimes(1)
|
||||
expect(consoleLogSpy).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
'(%s) <%s> [%s] %s: %s - %O',
|
||||
dayjs().format(),
|
||||
'tag',
|
||||
'abc',
|
||||
'INFO',
|
||||
'first',
|
||||
{ password: '***' }
|
||||
)
|
||||
})
|
||||
})
|
||||
|
|
@ -1,27 +0,0 @@
|
|||
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\$.+$/)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
@ -1,80 +0,0 @@
|
|||
import dayjs from '../../../src/common/dayjs'
|
||||
|
||||
import {
|
||||
validateJSONRequest,
|
||||
ValidationError,
|
||||
} from '../../../src/common/request'
|
||||
|
||||
describe('request', () => {
|
||||
const testDate = dayjs().add(1, 'day')
|
||||
|
||||
describe('validateJSONRequest', () => {
|
||||
test('should return ReservationRequest', async () => {
|
||||
const body = {
|
||||
username: 'collin',
|
||||
password: '123abc',
|
||||
dateRange: {
|
||||
start: testDate.clone().toISOString(),
|
||||
end: testDate.add(15, 'minutes').toISOString(),
|
||||
},
|
||||
opponent: {
|
||||
id: '123',
|
||||
name: 'collin',
|
||||
},
|
||||
}
|
||||
|
||||
const res = await validateJSONRequest(body)
|
||||
expect(res).toBeDefined()
|
||||
expect(res.dateRange.start.format()).toEqual(testDate.format())
|
||||
})
|
||||
|
||||
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,74 +0,0 @@
|
|||
import { Dayjs } from 'dayjs'
|
||||
import dayjs from '../../../src/common/dayjs'
|
||||
import { DateRange, Reservation } from '../../../src/common/reservation'
|
||||
|
||||
describe('Reservation', () => {
|
||||
test('will create correct possible dates', () => {
|
||||
const startDate = dayjs().hour(12).minute(0).second(0).millisecond(0)
|
||||
const endDate = startDate.add(1, 'hour')
|
||||
const dateRange: DateRange = {
|
||||
start: startDate,
|
||||
end: endDate,
|
||||
}
|
||||
const res = new Reservation(
|
||||
{ username: 'collin', password: 'password' },
|
||||
dateRange,
|
||||
{
|
||||
id: 'collin',
|
||||
name: 'collin',
|
||||
}
|
||||
)
|
||||
|
||||
expect(res.possibleDates).toHaveLength(5)
|
||||
|
||||
console.log(res.possibleDates[0].format())
|
||||
expect(res.possibleDates[0]).toEqual(startDate)
|
||||
expect(res.possibleDates[1]).toEqual(startDate.add(15, 'minute'))
|
||||
expect(res.possibleDates[2]).toEqual(startDate.add(30, 'minute'))
|
||||
expect(res.possibleDates[3]).toEqual(startDate.add(45, 'minute'))
|
||||
expect(res.possibleDates[4]).toEqual(startDate.add(60, 'minute'))
|
||||
})
|
||||
|
||||
test.each([
|
||||
{ reservationDate: dayjs().add(7, 'days'), expected: true },
|
||||
{ reservationDate: dayjs().add(1, 'days'), expected: true },
|
||||
{
|
||||
reservationDate: dayjs().add(8, 'days').add(5, 'minutes'),
|
||||
expected: false,
|
||||
},
|
||||
])(
|
||||
'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,89 +0,0 @@
|
|||
import dayjs from '../../../src/common/dayjs'
|
||||
import {
|
||||
ValidationError,
|
||||
ValidationErrorCode,
|
||||
} from '../../../src/common/request'
|
||||
import { Reservation } from '../../../src/common/reservation'
|
||||
import { schedule, SchedulerInput } from '../../../src/common/scheduler'
|
||||
import * as database from '../../../src/common/database'
|
||||
|
||||
jest.mock('../../../src/common/logger')
|
||||
jest.mock('../../../src/common/reserver')
|
||||
jest.mock('uuid', () => ({ v4: () => '1234' }))
|
||||
jest.useFakeTimers().setSystemTime(new Date('2022-01-01'))
|
||||
|
||||
describe('scheduler', () => {
|
||||
test('should handle valid requests within reservation window', async () => {
|
||||
jest.spyOn(database, 'run').mockResolvedValueOnce()
|
||||
const start = dayjs().add(15, 'minutes')
|
||||
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' },
|
||||
}
|
||||
|
||||
expect(await schedule(payload)).toMatchSnapshot({
|
||||
scheduledReservation: {
|
||||
reservation: {
|
||||
id: expect.any(String),
|
||||
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(await schedule(payload)).toMatchSnapshot({
|
||||
scheduledReservation: {
|
||||
reservation: new Reservation(
|
||||
{ username: 'collin', password: expect.any(String) },
|
||||
{ start, end },
|
||||
{ id: '123', name: 'collin' },
|
||||
undefined,
|
||||
'1234'
|
||||
),
|
||||
scheduledFor: start
|
||||
.subtract(7, 'days')
|
||||
.hour(0)
|
||||
.minute(0)
|
||||
.second(0)
|
||||
.millisecond(0),
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
test('should throw error for invalid requests', async () => {
|
||||
const start = dayjs().add(15, 'days')
|
||||
const end = start.add(15, 'minutes')
|
||||
|
||||
const payload: SchedulerInput = {
|
||||
password: 'password',
|
||||
dateRange: { start: start.toISOString(), end: end.toISOString() },
|
||||
opponent: { id: '123', name: 'collin' },
|
||||
}
|
||||
|
||||
await expect(schedule(payload)).rejects.toThrowError(
|
||||
new ValidationError(
|
||||
'Invalid request',
|
||||
ValidationErrorCode.INVALID_REQUEST_BODY
|
||||
)
|
||||
)
|
||||
})
|
||||
})
|
||||
|
|
@ -1,59 +0,0 @@
|
|||
import axios from 'axios'
|
||||
import server from '../../../src/server/http'
|
||||
import * as scheduler from '../../../src/common/scheduler'
|
||||
import * as utils from '../../../src/server/utils'
|
||||
|
||||
const port = Math.round(Math.random() * 50000 + 10000)
|
||||
const baseUrl = `http://localhost:${port}`
|
||||
|
||||
describe('server', () => {
|
||||
const consoleLogSpy = jest.fn()
|
||||
const consoleErrorSpy = jest.fn()
|
||||
|
||||
beforeAll(() => {
|
||||
server.listen(port)
|
||||
console.log = consoleLogSpy
|
||||
console.error = consoleErrorSpy
|
||||
})
|
||||
|
||||
afterAll(() => {
|
||||
server.close()
|
||||
})
|
||||
|
||||
beforeEach(() => {
|
||||
jest.resetAllMocks()
|
||||
})
|
||||
|
||||
test('should accept POST to /reservations', async () => {
|
||||
jest
|
||||
.spyOn(scheduler, 'schedule')
|
||||
.mockImplementationOnce(() => Promise.resolve({}))
|
||||
const response = await axios.post(
|
||||
`${baseUrl}/reservations`,
|
||||
{},
|
||||
{ headers: { 'content-type': 'application/json' } }
|
||||
)
|
||||
expect(response.status).toBe(200)
|
||||
})
|
||||
|
||||
test('should accept POST to /cron', async () => {
|
||||
jest
|
||||
.spyOn(scheduler, 'schedule')
|
||||
.mockImplementationOnce(() => Promise.resolve({}))
|
||||
const response = await axios.post(
|
||||
`${baseUrl}/cron/disable`,
|
||||
{},
|
||||
{ headers: { 'content-type': 'application/json' } }
|
||||
)
|
||||
expect(response.status).toBe(200)
|
||||
})
|
||||
|
||||
test('should reject request to other route', async () => {
|
||||
jest
|
||||
.spyOn(scheduler, 'schedule')
|
||||
.mockImplementationOnce(() => Promise.resolve({}))
|
||||
await expect(() => axios.post(`${baseUrl}/something-else`)).rejects.toThrow(
|
||||
axios.AxiosError
|
||||
)
|
||||
})
|
||||
})
|
||||
4
tsconfig.build.json
Normal file
4
tsconfig.build.json
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
{
|
||||
"extends": "./tsconfig.json",
|
||||
"exclude": ["node_modules", "test", "dist", "**/*spec.ts"]
|
||||
}
|
||||
|
|
@ -1,21 +1,21 @@
|
|||
{
|
||||
"$schema": "https://json.schemastore.org/tsconfig",
|
||||
|
||||
"compilerOptions": {
|
||||
"lib": ["esnext", "dom"],
|
||||
"module": "commonjs",
|
||||
"moduleResolution": "node",
|
||||
"target": "es6",
|
||||
"allowJs": true,
|
||||
"declaration": true,
|
||||
"removeComments": true,
|
||||
"emitDecoratorMetadata": true,
|
||||
"experimentalDecorators": true,
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"target": "es2017",
|
||||
"sourceMap": true,
|
||||
|
||||
"strict": true,
|
||||
"esModuleInterop": true,
|
||||
"outDir": "./dist",
|
||||
"baseUrl": "./",
|
||||
"incremental": true,
|
||||
"skipLibCheck": true,
|
||||
"strictNullChecks": true,
|
||||
"noImplicitAny": true,
|
||||
"strictBindCallApply": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"outDir": "./dist/"
|
||||
},
|
||||
|
||||
"include": [
|
||||
"./src/**/*.ts"
|
||||
]
|
||||
}
|
||||
"noFallthroughCasesInSwitch": true
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue