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"