diff --git a/.env.faf-stack b/.env.faf-stack index 4404ce8d..39514b35 100644 --- a/.env.faf-stack +++ b/.env.faf-stack @@ -18,8 +18,8 @@ OAUTH_URL=http://faf-ory-hydra:4444 # you can omit this env and it will fallback to OAUTH_URL if you know what you are doing. OAUTH_PUBLIC_URL=http://localhost:4444 -# unsing the "production" wordpress because the faf-local-stack is just an empty instance without any news etc. -WP_URL=https://direct.faforever.com +# unsing the "xyz" wordpress because the faf-local-stack is just an empty instance without any news etc. +WP_URL=https://direct.faforever.xyz OAUTH_CLIENT_ID=faf-website OAUTH_CLIENT_SECRET=banana diff --git a/.gitignore b/.gitignore index d39abbe8..c72cc3a3 100644 --- a/.gitignore +++ b/.gitignore @@ -44,6 +44,7 @@ public/styles/css/* #Ignore environment .env +.env.faf.xyz public/js/*.js diff --git a/express.js b/express.js index ff3334ce..3def79ca 100644 --- a/express.js +++ b/express.js @@ -6,12 +6,12 @@ const session = require('express-session'); const FileStore = require('session-file-store')(session); const bodyParser = require('body-parser'); const flash = require('connect-flash'); -const fs = require('fs'); const setupCronJobs = require('./scripts/cron-jobs'); const middleware = require('./routes/middleware'); const app = express(); const newsRouter = require('./routes/views/news'); const staticMarkdownRouter = require('./routes/views/staticMarkdownRouter'); +const leaderboardRouter = require('./routes/views/leaderboardRouter'); const authRouter = require('./routes/views/auth'); app.locals.clanInvitations = {}; @@ -71,19 +71,27 @@ function loggedIn(req, res, next) { } } -app.use('/news', newsRouter) -app.use('/', staticMarkdownRouter) +//Start and listen on port + + + +// --- R O U T E S --- +// when the website is asked to render "/pageName" it will come here and see what are the "instructions" to render said page. If the page isn't here, then the website won't render it properly. + app.use('/', authRouter) +app.use('/', staticMarkdownRouter) +app.use('/news', newsRouter) +app.use('/leaderboards', leaderboardRouter) // --- UNPROTECTED ROUTES --- const appGetRouteArray = [ // This first '' is the home/index page - '', 'newshub', 'campaign-missions', 'scfa-vs-faf', 'donation', 'tutorials-guides', 'ai', 'patchnotes', 'faf-teams', 'contribution', 'content-creators', 'tournaments', 'training', 'leaderboards', 'play', 'clans',]; + '', 'newshub', 'campaign-missions', 'scfa-vs-faf', 'donation', 'tutorials-guides', 'ai', 'patchnotes', 'faf-teams', 'contribution', 'content-creators', 'tournaments', 'training', 'play', 'clans',]; //Renders every page written above appGetRouteArray.forEach(page => app.get(`/${page}`, (req, res) => { // disabled due https://github.com/FAForever/website/issues/445 - if (['leaderboards', 'clans'].includes(page)) { + if (page === 'clans') { return res.status(503).render('errors/503-known-issue') } res.render(page); diff --git a/lib/LeaderboardRepository.js b/lib/LeaderboardRepository.js new file mode 100644 index 00000000..d8f75f3b --- /dev/null +++ b/lib/LeaderboardRepository.js @@ -0,0 +1,61 @@ +class LeaderboardRepository { + constructor(javaApiClient, monthsInThePast = 12) { + this.javaApiClient = javaApiClient + this.monthsInThePast = monthsInThePast + } + + getUpdateTimeForApiEntries() { + const date = new Date(); + date.setMonth(date.getMonth() - this.monthsInThePast); + + return date.toISOString() + } + + async fetchLeaderboard(id) { + const updateTime = this.getUpdateTimeForApiEntries() + + let response = await this.javaApiClient.get(`/data/leaderboardRating?include=player&sort=-rating&filter=leaderboard.id==${id};updateTime=ge=${updateTime}&page[size]=9999`); + + if (response.status !== 200) { + throw new Error('LeaderboardRepository::fetchLeaderboard failed with response status "' + response.status + '"') + } + + return this.mapResponse(JSON.parse(response.data)) + } + + mapResponse(data) { + if (typeof data !== 'object' || data === null) { + throw new Error('LeaderboardRepository::mapResponse malformed response, not an object') + } + + if (!data.hasOwnProperty('data')) { + throw new Error('LeaderboardRepository::mapResponse malformed response, expected "data"') + } + + if (data.data.length === 0) { + console.log('[info] leaderboard empty') + + return [] + } + + if (!data.hasOwnProperty('included')) { + throw new Error('LeaderboardRepository::mapResponse malformed response, expected "included"') + } + + let leaderboardData = [] + + data.data.forEach((item, index) => { + leaderboardData.push({ + rating: item.attributes.rating, + totalgames: item.attributes.totalGames, + wonGames: item.attributes.wonGames, + date: item.attributes.updateTime, + label: data.included[index].attributes.login, + }) + }) + + return leaderboardData + } +} + +module.exports = LeaderboardRepository diff --git a/lib/LeaderboardService.js b/lib/LeaderboardService.js new file mode 100644 index 00000000..4447c999 --- /dev/null +++ b/lib/LeaderboardService.js @@ -0,0 +1,36 @@ +class LeaderboardService { + constructor(cacheService, lockService, leaderboardRepository, lockTimeout = 3000) { + this.lockTimeout = lockTimeout + this.cacheService = cacheService + this.lockService = lockService + this.leaderboardRepository = leaderboardRepository + } + + async getLeaderboard(id) { + + if (typeof (id) !== 'number') { + throw new Error('LeaderboardService:getLeaderboard id must be a number') + } + + const cacheKey = 'leaderboard-' + id + + if (this.cacheService.has(cacheKey)) { + return this.cacheService.get(cacheKey) + } + + if (this.lockService.locked) { + await this.lockService.lock(() => { + }, this.lockTimeout) + return this.getLeaderboard(id) + } + + await this.lockService.lock(async () => { + const result = await this.leaderboardRepository.fetchLeaderboard(id) + this.cacheService.set(cacheKey, result); + }) + + return this.getLeaderboard(id) + } +} + +module.exports = LeaderboardService diff --git a/lib/LeaderboardServiceFactory.js b/lib/LeaderboardServiceFactory.js new file mode 100644 index 00000000..d614de44 --- /dev/null +++ b/lib/LeaderboardServiceFactory.js @@ -0,0 +1,23 @@ +const LeaderboardService = require("./LeaderboardService"); +const LeaderboardRepository = require("./LeaderboardRepository"); +const {LockService} = require("./LockService"); +const NodeCache = require("node-cache"); +const {Axios} = require("axios"); + +const leaderboardLock = new LockService() +const cacheService = new NodeCache( + { + stdTTL: 300, // use 5 min for all caches if not changed with ttl + checkperiod: 600 // cleanup memory every 10 min + } +); + +module.exports = (javaApiBaseURL, token) => { + const config = { + baseURL: javaApiBaseURL, + headers: {Authorization: `Bearer ${token}`} + }; + const javaApiClient = new Axios(config) + + return new LeaderboardService(cacheService, leaderboardLock, new LeaderboardRepository(javaApiClient)) +} diff --git a/lib/LockService.js b/lib/LockService.js new file mode 100644 index 00000000..76a5e5c8 --- /dev/null +++ b/lib/LockService.js @@ -0,0 +1,70 @@ +class LockoutTimeoutError extends Error { +} + +class LockService { + constructor() { + this.queue = []; + this.locked = false; + } + + async lock(callback, timeLimitMS = 500) { + let timeoutHandle; + const lockHandler = {} + + const timeoutPromise = new Promise((resolve, reject) => { + lockHandler.resolve = resolve + lockHandler.reject = reject + + timeoutHandle = setTimeout( + () => reject(new LockoutTimeoutError('LockService timeout reached')), + timeLimitMS + ); + }); + + const asyncPromise = new Promise((resolve, reject) => { + if (this.locked) { + lockHandler.resolve = resolve + lockHandler.reject = reject + + this.queue.push(lockHandler); + } else { + this.locked = true; + resolve(); + } + }); + + await Promise.race([asyncPromise, timeoutPromise]).then(async () => { + clearTimeout(timeoutHandle); + try { + if (callback[Symbol.toStringTag] === 'AsyncFunction') { + await callback() + return + } + + callback() + } finally { + this.release() + } + }).catch(e => { + let index = this.queue.indexOf(lockHandler); + + if (index !== -1) { + this.queue.splice(index, 1); + } + + throw e + }) + } + + release() { + if (this.queue.length > 0) { + const queueItem = this.queue.shift(); + queueItem.resolve(); + } else { + this.locked = false; + } + } +} + +module.exports.LockService = LockService +module.exports.LockoutTimeoutError = LockoutTimeoutError diff --git a/package.json b/package.json index 801f8236..9cfd21a5 100644 --- a/package.json +++ b/package.json @@ -12,6 +12,7 @@ "express-session": "^1.17.3", "express-validator": "7.0.1", "moment": "^2.29.4", + "node-cache": "^5.1.2", "node-fetch": "^2.6.7", "npm-check": "^6.0.1", "passport": "^0.6.0", diff --git a/public/js/app/leaderboards.js b/public/js/app/leaderboards.js index 21988f43..180e9f0a 100644 --- a/public/js/app/leaderboards.js +++ b/public/js/app/leaderboards.js @@ -16,7 +16,7 @@ let currentDate = new Date(minusTimeFilter).toISOString(); async function leaderboardOneJSON(leaderboardFile) { //Check which category is active - const response = await fetch(`js/app/members/${leaderboardFile}.json`); + const response = await fetch(`leaderboards/${leaderboardFile}.json`); currentLeaderboard = leaderboardFile; const data = await response.json(); return await data; @@ -37,6 +37,10 @@ function leaderboardUpdate() { //determines the current page, whether to add or substract the missing players in case we pressed next or previous then it will add or substract players let playerIndex = pageNumber * 100; let next100Players = (1 + pageNumber) * 100; + + if (next100Players > playerList.length) { + next100Players = playerList.length + } // Function to add player first second and third background if (playerIndex === 0) { @@ -52,14 +56,14 @@ function leaderboardUpdate() { if (playerIndex < 0) { playerIndex = 0; } - let rating = playerList[playerIndex][1].rating; - let winRate = playerList[playerIndex][1].wonGames / playerList[playerIndex][1].totalgames * 100; + let rating = playerList[playerIndex].rating; + let winRate = playerList[playerIndex].wonGames / playerList[playerIndex].totalgames * 100; insertPlayer.insertAdjacentHTML('beforebegin', `

${playerIndex + 1}

-

${playerList[playerIndex][0].label}

+

${playerList[playerIndex].label}

${rating.toFixed(0)}

@@ -68,7 +72,7 @@ function leaderboardUpdate() {

${winRate.toFixed(1)}%

-

${playerList[playerIndex][1].totalgames}

+

${playerList[playerIndex].totalgames}

` ); @@ -115,7 +119,7 @@ function timeCheck(timeSelected) { playerList.push(timedOutPlayers[i]); } // Sort players by their rating - playerList.sort((playerA, playerB) => playerB[1].rating - playerA[1].rating); + playerList.sort((playerA, playerB) => playerB.rating - playerA.rating); //clean slate timedOutPlayers = []; @@ -123,7 +127,7 @@ function timeCheck(timeSelected) { //kick all the players that dont meet the time filter for (let i = 0; i < playerList.length; i++) { - if (currentDate > playerList[i][1].date) { + if (currentDate > playerList[i].date) { timedOutPlayers.push(playerList[i]); playerList.splice(i, 1); @@ -183,16 +187,16 @@ function findPlayer(playerName) { leaderboardOneJSON(currentLeaderboard) .then(() => { //input from the searchbar becomes playerName and then searchPlayer is their index number - let searchPlayer = playerList.findIndex(element => element[0].label.toLowerCase() === playerName.toLowerCase()); + let searchPlayer = playerList.findIndex(element => element.label.toLowerCase() === playerName.toLowerCase()); - let rating = playerList[searchPlayer][1].rating; - let winRate = playerList[searchPlayer][1].wonGames / playerList[searchPlayer][1].totalgames * 100; + let rating = playerList[searchPlayer].rating; + let winRate = playerList[searchPlayer].wonGames / playerList[searchPlayer].totalgames * 100; insertSearch.insertAdjacentHTML('beforebegin', `

${searchPlayer + 1}

-

${playerList[searchPlayer][0].label} ${currentLeaderboard}

+

${playerList[searchPlayer].label} ${currentLeaderboard}

${rating.toFixed(0)}

@@ -201,7 +205,7 @@ function findPlayer(playerName) {

${winRate.toFixed(1)}%

-

${playerList[searchPlayer][1].totalgames}

+

${playerList[searchPlayer].totalgames}

`); @@ -212,6 +216,12 @@ function findPlayer(playerName) { }); } +function selectPlayer(name) { + const element = document.getElementById('input') + element.value = name + element.dispatchEvent(new KeyboardEvent('keyup', {'key': 'Enter'})); +} + //Gets called from the HTML search input form function pressEnter(event) { let inputText = event.target.value; @@ -220,17 +230,17 @@ function pressEnter(event) { document.querySelectorAll('.removeOldSearch').forEach(element => element.remove()); } else { let regex = `^${inputText.toLowerCase()}`; - let searchName = playerList.filter(element => element[0].label.toLowerCase().match(regex)); + let searchName = playerList.filter(element => element.label.toLowerCase().match(regex)); document.querySelectorAll('.removeOldSearch').forEach(element => element.remove()); for (let player of searchName.slice(0, 5)) { - document.querySelector('#placeMe').insertAdjacentHTML('afterend', `
  • ${player[0].label}
  • `); + document.querySelector('#placeMe').insertAdjacentHTML('afterend', `
  • ${player.label}
  • `); } if (event.key === 'Enter') { document.querySelector('#searchResults').classList.remove('appearWhenSearching'); document.querySelector('#clearSearch').classList.remove('appearWhenSearching'); - findPlayer(inputText); + findPlayer(inputText.trim()); } } document.querySelector('#errorLog').innerText = ''; diff --git a/routes/views/leaderboardRouter.js b/routes/views/leaderboardRouter.js new file mode 100644 index 00000000..2282a847 --- /dev/null +++ b/routes/views/leaderboardRouter.js @@ -0,0 +1,60 @@ +const express = require('express'); +const router = express.Router(); +const LeaderboardServiceFactory = require('../../lib/LeaderboardServiceFactory') +const {LockoutTimeoutError} = require("../../lib/LockService"); + +const getLeaderboardId = (leaderboardName) => { + const mapping = { + 'global': 1, + '1v1': 2, + '2v2': 3, + '4v4': 4 + } + + if (leaderboardName in mapping) { + return mapping[leaderboardName] + } + + return null +} + +router.get('/', (req, res) => { + if (!req.isAuthenticated()) { + return res.redirect('/login'); + } + + return res.render('leaderboards') +}) + +router.get('/:leaderboard.json', async (req, res) => { + if (!req.isAuthenticated()) { + return res.status(403).json({error: 'Not Authenticated'}) + } + + try { + const leaderboardId = getLeaderboardId(req.params.leaderboard ?? null); + + if (leaderboardId === null) { + return res.status(404).json({error: 'Leaderboard "' + req.params.leaderboard + '"does not exist'}) + } + + const token = req.user.data.attributes.token + const leaderboardService = LeaderboardServiceFactory(process.env.API_URL, token) + + return res.json(await leaderboardService.getLeaderboard(leaderboardId)) + } catch (e) { + if (e instanceof LockoutTimeoutError) { + return res.status(503).json({error: 'timeout reached'}) + } + + console.error('[error] leaderboardRouter::get:leaderboard.json failed with "' + e.toString() + '"') + + if (!res.headersSent) { + return res.status(500).json({error: 'unexpected error'}) + } + + throw e + } +}) + +module.exports = router diff --git a/scripts/extractor.js b/scripts/extractor.js index 80b0ffc3..df8c9261 100644 --- a/scripts/extractor.js +++ b/scripts/extractor.js @@ -176,48 +176,15 @@ async function getAllClans() { } - -async function getLeaderboards(leaderboardID) { - return []; - //disabled due https://github.com/FAForever/website/issues/445 - // try { - // let response = await axios.get(`${process.env.API_URL}/data/leaderboardRating?include=player&sort=-rating&filter=leaderboard.id==${leaderboardID};updateTime=ge=${currentDate}&page[size]=9999`); - // - // - // let dataObjectToArray = await Object.values(response.data); - // - // let playerLogin = dataObjectToArray[2].map(item => ({ - // label: item.attributes.login - // })); - // let playerValues = dataObjectToArray[0].map(item => ({ - // rating: item.attributes.rating, - // totalgames: item.attributes.totalGames, - // wonGames: item.attributes.wonGames, - // date: item.attributes.updateTime, - // })); - // const combineArrays = (array1, array2) => array1.map((x, i) => [x, array2[i]]); - // let leaderboardData = combineArrays(playerLogin, playerValues); - // leaderboardData.sort((playerA, playerB) => playerB[1].rating - playerA[1].rating); - // return await leaderboardData; - // - // } catch (e) { - // console.log(e); - // return null; - // - // } -} - module.exports.run = function run() { // Do not change the order of these/make sure they match the order of fileNames below const extractorFunctions = [ getTournamentNews(), flashMessage(), news(), contentCreators(), newshub(), fafTeams(), getAllClans(), - getLeaderboards(1), getLeaderboards(2), getLeaderboards(3), getLeaderboards(4), ]; //Make sure to not change the order of these since they match the order of extractorFunctions const fileNames = [ 'tournament-news','flashMessage', 'news', 'content-creators', 'newshub', 'faf-teams', 'getAllClans', - 'global', '1v1', '2v2', '4v4', ]; fileNames.forEach((fileName, index) => { diff --git a/templates/views/leaderboards.pug b/templates/views/leaderboards.pug index 2f561b92..b9bcc1f0 100644 --- a/templates/views/leaderboards.pug +++ b/templates/views/leaderboards.pug @@ -3,7 +3,6 @@ extends ../layouts/default block bannerMixin block content - script(type='module' src="../../js/app/leaderboards.js") .leaderboardBackground .mainLeaderboardContainer .leaderboardContainer.column12.centerYourself @@ -89,6 +88,5 @@ block content .categoryButton(onclick= `pageChange(pageNumber + 1)`).pageButton Next block js - script( src="../../js/app/leaderboards.js") diff --git a/tests/LeaderboardService.test.js b/tests/LeaderboardService.test.js new file mode 100644 index 00000000..95a90f65 --- /dev/null +++ b/tests/LeaderboardService.test.js @@ -0,0 +1,135 @@ +const LeaderboardService = require("../lib/LeaderboardService") +const LeaderboardRepository = require("../lib/LeaderboardRepository") +const {LockService} = require("../lib/LockService") +const NodeCache = require("node-cache") +const {Axios} = require("axios"); + +jest.mock("axios") + +let leaderboardService = null +let axios = new Axios() +const fakeEntry = JSON.stringify({ + data: [ + { + attributes: { + rating: -1000, + totalGames: 100, + wonGames: 30, + updateTime: '2023-12-1' + } + } + ], + included: [ + { + attributes: { + login: 'player1' + } + } + ] +}) + +beforeEach(() => { + jest.useFakeTimers() + leaderboardService = new LeaderboardService( + new NodeCache( + { stdTTL: 300, checkperiod: 600 } + ), + new LockService(), + new LeaderboardRepository(axios) + ) +}) +afterEach(() => { + jest.runOnlyPendingTimers() + jest.useRealTimers() + axios.get.mockReset() +}) +test('non 200 will throw', async () => { + axios.get.mockImplementationOnce(() => Promise.resolve({ status: 403 })) + await expect(leaderboardService.getLeaderboard(0)).rejects.toThrowError('LeaderboardRepository::fetchLeaderboard failed with response status "403"') +}) + +test('malformed empty response', async () => { + axios.get.mockImplementationOnce(() => Promise.resolve({ status: 200, data: null })) + await expect(leaderboardService.getLeaderboard(0)).rejects.toThrowError('LeaderboardRepository::mapResponse malformed response, not an object') +}) + +test('malformed response data missing', async () => { + axios.get.mockImplementationOnce(() => Promise.resolve({ status: 200, data: JSON.stringify({included: []}) })) + await expect(leaderboardService.getLeaderboard(0)).rejects.toThrowError('LeaderboardRepository::mapResponse malformed response, expected "data"') +}) + +test('malformed response included missing', async () => { + axios.get.mockImplementationOnce(() => Promise.resolve({ status: 200, data: JSON.stringify({data: [{}]}) })) + await expect(leaderboardService.getLeaderboard(0)).rejects.toThrowError('LeaderboardRepository::mapResponse malformed response, expected "included"') +}) + +test('empty response will log and not throw an error', async () => { + const warn = jest.spyOn(console, "log").mockImplementationOnce(() => {}); + axios.get.mockImplementationOnce(() => Promise.resolve({ status: 200, data: JSON.stringify({data: []}) })) + await leaderboardService.getLeaderboard(0) + + expect(warn).toBeCalledWith('[info] leaderboard empty') +}); + +test('response is mapped correctly', async () => { + axios.get.mockImplementationOnce(() => Promise.resolve({ status: 200, data: fakeEntry })) + const result = await leaderboardService.getLeaderboard(0) + + expect(result).toHaveLength(1) + expect(result[0].rating).toBe(-1000) + expect(result[0].totalgames).toBe(100) + expect(result[0].wonGames).toBe(30) + expect(result[0].date).toBe('2023-12-1') + expect(result[0].label).toBe('player1') +}); + +test('only numbers valid', async () => { + expect.assertions(1); + + try { + await leaderboardService.getLeaderboard() + } catch (e) { + expect(e.toString()).toBe('Error: LeaderboardService:getLeaderboard id must be a number') + } +}); + +test('timeout for cache creation throws an error', async () => { + expect.assertions(1); + axios.get.mockImplementationOnce(() => Promise.resolve({ status: 200, data: fakeEntry })) + + leaderboardService.lockService.locked = true + leaderboardService.getLeaderboard(0).then(() => {}).catch((e) => { + expect(e.toString()).toBe('Error: LockService timeout reached') + }) + + jest.runOnlyPendingTimers() +}); + +test('full scenario', async () => { + const cacheSetSpy = jest.spyOn(leaderboardService.cacheService, 'set'); + const mock = axios.get.mockImplementation(() => Promise.resolve({ status: 200, data: fakeEntry })) + + // starting two promises simultaneously + const creatingTheCache = leaderboardService.getLeaderboard(0) + const waitingForCache = leaderboardService.getLeaderboard(0) + + await Promise.all([creatingTheCache, waitingForCache]) + + // start another one, that will get the cache directly + await leaderboardService.getLeaderboard(0) + + expect(mock.mock.calls.length).toBe(1) + expect(cacheSetSpy).toHaveBeenCalledTimes(1); + + const date = new Date() + date.setSeconds(date.getSeconds() + 301) + jest.setSystemTime(date); + + // start another with when the cache is stale + await leaderboardService.getLeaderboard(0) + expect(cacheSetSpy).toHaveBeenCalledTimes(2); + expect(mock.mock.calls.length).toBe(2) + jest.setSystemTime(new Date()); + + cacheSetSpy.mockReset() +}); diff --git a/tests/LockService.test.js b/tests/LockService.test.js new file mode 100644 index 00000000..216f1d7f --- /dev/null +++ b/tests/LockService.test.js @@ -0,0 +1,64 @@ +const {LockoutTimeoutError, LockService} = require('../lib/LockService') +function Sleep(milliseconds) { + return new Promise(resolve => setTimeout(resolve, milliseconds)); +} +test('release will unlock the queue', async () => { + const lockService = new LockService() + expect(lockService.locked).toBe(false) + + await lockService.lock(() => { + expect(lockService.locked).toBe(true) + }) + + expect(lockService.locked).toBe(false) +}) + +test('call lock twice will fill the queue', async () => { + let oneCalled = false + let twoCalled = false + const lockService = new LockService() + const one = lockService.lock(() => oneCalled = true) + const two = lockService.lock(() => twoCalled = true) + + expect(lockService.queue).toHaveLength(1) + expect(lockService.locked).toBe(true) + + await one + await two + expect(oneCalled).toBe(true) + expect(twoCalled).toBe(true) + expect(lockService.queue).toHaveLength(0) + expect(lockService.locked).toBe(false) +}) + +test('lock timeout will trow an error if locked by another "process" for too long', async () => { + expect.assertions(1); + + const lockService = new LockService() + + await lockService.lock(async () => { + try { + await lockService.lock(() => {}, 1) + } catch (e) { + expect(e).toBeInstanceOf(LockoutTimeoutError) + } + }) +}); + +test('lock timeout will remove it from queue', async () => { + expect.assertions(2); + + const lockService = new LockService() + + await lockService.lock(async () => { + try { + await lockService.lock(() => { + }, 1) + } catch (e) { + expect(e).toBeInstanceOf(LockoutTimeoutError) + expect(lockService.queue).toHaveLength(0); + } + + }) +}) + diff --git a/yarn.lock b/yarn.lock index dbc37ff3..22f0888d 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1626,6 +1626,11 @@ clone-response@^1.0.2: dependencies: mimic-response "^1.0.0" +clone@2.x: + version "2.1.2" + resolved "https://registry.yarnpkg.com/clone/-/clone-2.1.2.tgz#1b7f4b9f591f1e8f83670401600345a02887435f" + integrity sha512-3Pe/CF1Nn94hyhIYpjtiLhdCoEoz0DqQ+988E9gmeEdQZlojxnOb74wctFyuwWQHzqyf9X7C7MG8juUpqBJT8w== + clone@^1.0.2: version "1.0.4" resolved "https://registry.yarnpkg.com/clone/-/clone-1.0.4.tgz#da309cc263df15994c688ca902179ca3c7cd7c7e" @@ -4889,6 +4894,13 @@ negotiator@0.6.3: resolved "https://registry.yarnpkg.com/negotiator/-/negotiator-0.6.3.tgz#58e323a72fedc0d6f9cd4d31fe49f51479590ccd" integrity sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg== +node-cache@^5.1.2: + version "5.1.2" + resolved "https://registry.yarnpkg.com/node-cache/-/node-cache-5.1.2.tgz#f264dc2ccad0a780e76253a694e9fd0ed19c398d" + integrity sha512-t1QzWwnk4sjLWaQAS8CHgOJ+RAfmHpxFWmc36IWTiWHQfs0w5JDMBS1b1ZxQteo0vVVuWJvIUKHDkkeK7vIGCg== + dependencies: + clone "2.x" + node-emoji@^1.10.0: version "1.11.0" resolved "https://registry.yarnpkg.com/node-emoji/-/node-emoji-1.11.0.tgz#69a0150e6946e2f115e9d7ea4df7971e2628301c"