diff --git a/backend/src/api/controllers/dev.ts b/backend/src/api/controllers/dev.ts new file mode 100644 index 000000000000..6c4e854d53b4 --- /dev/null +++ b/backend/src/api/controllers/dev.ts @@ -0,0 +1,388 @@ +import { MonkeyResponse } from "../../utils/monkey-response"; +import * as UserDal from "../../dal/user"; +import FirebaseAdmin from "../../init/firebase-admin"; +import Logger from "../../utils/logger"; +import * as DateUtils from "date-fns"; +import { UTCDate } from "@date-fns/utc"; +import * as ResultDal from "../../dal/result"; +import { roundTo2 } from "../../utils/misc"; +import { ObjectId } from "mongodb"; +import * as LeaderboardDal from "../../dal/leaderboards"; +import { isNumber } from "lodash"; +import MonkeyError from "../../utils/error"; + +type GenerateDataOptions = { + firstTestTimestamp: Date; + lastTestTimestamp: Date; + minTestsPerDay: number; + maxTestsPerDay: number; +}; + +const CREATE_RESULT_DEFAULT_OPTIONS: GenerateDataOptions = { + firstTestTimestamp: DateUtils.startOfDay(new UTCDate(Date.now())), + lastTestTimestamp: DateUtils.endOfDay(new UTCDate(Date.now())), + minTestsPerDay: 0, + maxTestsPerDay: 50, +}; + +export async function createTestData( + req: MonkeyTypes.Request +): Promise { + const { username, createUser } = req.body; + const user = await getOrCreateUser(username, "password", createUser); + + const { uid, email } = user; + + await createTestResults(user, req.body); + await updateUser(uid); + await updateLeaderboard(); + + return new MonkeyResponse("test data created", { uid, email }, 200); +} + +async function getOrCreateUser( + username: string, + password: string, + createUser = false +): Promise { + const existingUser = await UserDal.findByName(username); + + if (existingUser !== undefined && existingUser !== null) { + return existingUser; + } else if (createUser === false) { + throw new MonkeyError(404, `User ${username} does not exist.`); + } + + const email = username + "@example.com"; + Logger.success("create user " + username); + const { uid } = await FirebaseAdmin().auth().createUser({ + displayName: username, + password: password, + email, + emailVerified: true, + }); + + await UserDal.addUser(username, email, uid); + return UserDal.getUser(uid, "getOrCreateUser"); +} + +async function createTestResults( + user: MonkeyTypes.DBUser, + configOptions: Partial +): Promise { + const config = { + ...CREATE_RESULT_DEFAULT_OPTIONS, + ...configOptions, + }; + if (isNumber(config.firstTestTimestamp)) + config.firstTestTimestamp = toDate(config.firstTestTimestamp); + if (isNumber(config.lastTestTimestamp)) + config.lastTestTimestamp = toDate(config.lastTestTimestamp); + + const days = DateUtils.eachDayOfInterval({ + start: config.firstTestTimestamp, + end: config.lastTestTimestamp, + }).map((day) => ({ + timestamp: DateUtils.startOfDay(day), + amount: Math.round(random(config.minTestsPerDay, config.maxTestsPerDay)), + })); + + for (const day of days) { + Logger.success( + `User ${user.name} insert ${day.amount} results on ${new Date( + day.timestamp + )}` + ); + const results = createArray(day.amount, () => + createResult(user, day.timestamp) + ); + if (results.length > 0) + await ResultDal.getResultCollection().insertMany(results); + } +} + +function toDate(value: number): Date { + return new UTCDate(value); +} + +function random(min: number, max: number): number { + return roundTo2(Math.random() * (max - min) + min); +} + +function createResult( + user: MonkeyTypes.DBUser, + timestamp: Date //evil, we modify this value +): MonkeyTypes.DBResult { + const mode: SharedTypes.Config.Mode = randomValue(["time", "words"]); + const mode2: number = + mode === "time" + ? randomValue([15, 30, 60, 120]) + : randomValue([10, 25, 50, 100]); + const testDuration = mode2; + + timestamp = DateUtils.addSeconds(timestamp, testDuration); + return { + _id: new ObjectId(), + uid: user.uid, + wpm: random(80, 120), + rawWpm: random(80, 120), + charStats: [131, 0, 0, 0], + acc: random(80, 100), + language: "english", + mode: mode as SharedTypes.Config.Mode, + mode2: mode2 as unknown as never, + timestamp: timestamp.valueOf(), + testDuration: testDuration, + consistency: random(80, 100), + keyConsistency: 33.18, + chartData: { + wpm: createArray(testDuration, () => random(80, 120)), + raw: createArray(testDuration, () => random(80, 120)), + err: createArray(testDuration, () => (Math.random() < 0.1 ? 1 : 0)), + }, + keySpacingStats: { + average: 113.88, + sd: 77.3, + }, + keyDurationStats: { + average: 107.13, + sd: 39.86, + }, + isPb: Math.random() < 0.1, + name: user.name, + }; +} + +async function updateUser(uid: string): Promise { + //update timetyping and completedTests + const stats = await ResultDal.getResultCollection() + .aggregate([ + { + $match: { + uid, + }, + }, + { + $group: { + _id: { + language: "$language", + mode: "$mode", + mode2: "$mode2", + }, + timeTyping: { + $sum: "$testDuration", + }, + completedTests: { + $count: {}, + }, + }, + }, + ]) + .toArray(); + + const timeTyping = stats.reduce((a, c) => a + c["timeTyping"], 0); + const completedTests = stats.reduce((a, c) => a + c["completedTests"], 0); + + //update PBs + const lbPersonalBests: MonkeyTypes.LbPersonalBests = { + time: { + 15: {}, + 60: {}, + }, + }; + + const personalBests: SharedTypes.PersonalBests = { + time: {}, + custom: {}, + words: {}, + zen: {}, + quote: {}, + }; + const modes = stats.map((it) => it["_id"]); + for (const mode of modes) { + const best = ( + await ResultDal.getResultCollection() + .find({ + uid, + language: mode.language, + mode: mode.mode, + mode2: mode.mode2, + }) + .sort({ wpm: -1, timestamp: 1 }) + .limit(1) + .toArray() + )[0] as MonkeyTypes.DBResult; + + if (personalBests[mode.mode] === undefined) personalBests[mode.mode] = {}; + if (personalBests[mode.mode][mode.mode2] === undefined) + personalBests[mode.mode][mode.mode2] = []; + + const entry = { + acc: best.acc, + consistency: best.consistency, + difficulty: best.difficulty ?? "normal", + lazyMode: best.lazyMode, + language: mode.language, + punctuation: best.punctuation, + raw: best.rawWpm, + wpm: best.wpm, + numbers: best.numbers, + timestamp: best.timestamp, + } as SharedTypes.PersonalBest; + + personalBests[mode.mode][mode.mode2].push(entry); + + if (mode.mode === "time") { + if (lbPersonalBests[mode.mode][mode.mode2] === undefined) + lbPersonalBests[mode.mode][mode.mode2] = {}; + + lbPersonalBests[mode.mode][mode.mode2][mode.language] = entry; + } + + //update testActivity + await updateTestActicity(uid); + } + + //update the user + await UserDal.getUsersCollection().updateOne( + { uid }, + { + $set: { + timeTyping: timeTyping, + completedTests: completedTests, + startedTests: Math.round(completedTests * 1.25), + personalBests: personalBests as SharedTypes.PersonalBests, + lbPersonalBests: lbPersonalBests, + }, + } + ); +} + +async function updateLeaderboard(): Promise { + await LeaderboardDal.update("time", "15", "english"); + await LeaderboardDal.update("time", "60", "english"); +} + +function randomValue(values: T[]): T { + const rnd = Math.round(Math.random() * (values.length - 1)); + return values[rnd] as T; +} + +function createArray(size: number, builder: () => T): T[] { + return new Array(size).fill(0).map((it) => builder()); +} + +async function updateTestActicity(uid: string): Promise { + await ResultDal.getResultCollection() + .aggregate( + [ + { + $match: { + uid, + }, + }, + { + $project: { + _id: 0, + timestamp: -1, + uid: 1, + }, + }, + { + $addFields: { + date: { + $toDate: "$timestamp", + }, + }, + }, + { + $replaceWith: { + uid: "$uid", + year: { + $year: "$date", + }, + day: { + $dayOfYear: "$date", + }, + }, + }, + { + $group: { + _id: { + uid: "$uid", + year: "$year", + day: "$day", + }, + count: { + $sum: 1, + }, + }, + }, + { + $group: { + _id: { + uid: "$_id.uid", + year: "$_id.year", + }, + days: { + $addToSet: { + day: "$_id.day", + tests: "$count", + }, + }, + }, + }, + { + $replaceWith: { + uid: "$_id.uid", + days: { + $function: { + lang: "js", + args: ["$days", "$_id.year"], + body: `function (days, year) { + var max = Math.max( + ...days.map((it) => it.day) + )-1; + var arr = new Array(max).fill(null); + for (day of days) { + arr[day.day-1] = day.tests; + } + let result = {}; + result[year] = arr; + return result; + }`, + }, + }, + }, + }, + { + $group: { + _id: "$uid", + testActivity: { + $mergeObjects: "$days", + }, + }, + }, + { + $addFields: { + uid: "$_id", + }, + }, + { + $project: { + _id: 0, + }, + }, + { + $merge: { + into: "users", + on: "uid", + whenMatched: "merge", + whenNotMatched: "discard", + }, + }, + ], + { allowDiskUse: true } + ) + .toArray(); +} diff --git a/backend/src/api/routes/dev.ts b/backend/src/api/routes/dev.ts new file mode 100644 index 000000000000..b251f58e297a --- /dev/null +++ b/backend/src/api/routes/dev.ts @@ -0,0 +1,38 @@ +import { Router } from "express"; +import { authenticateRequest } from "../../middlewares/auth"; +import { + asyncHandler, + validateConfiguration, + validateRequest, +} from "../../middlewares/api-utils"; +import joi from "joi"; +import { createTestData } from "../controllers/dev"; +import { isDevEnvironment } from "../../utils/misc"; + +const router = Router(); + +router.use( + validateConfiguration({ + criteria: () => { + return isDevEnvironment(); + }, + invalidMessage: "Development endpoints are only available in DEV mode.", + }) +); + +router.post( + "/generateData", + validateRequest({ + body: { + username: joi.string().required(), + createUser: joi.boolean().optional(), + firstTestTimestamp: joi.number().optional(), + lastTestTimestamp: joi.number().optional(), + minTestsPerDay: joi.number().optional(), + maxTestsPerDay: joi.number().optional(), + }, + }), + asyncHandler(createTestData) +); + +export default router; diff --git a/backend/src/api/routes/index.ts b/backend/src/api/routes/index.ts index 04ad80b254fb..44c92c133de6 100644 --- a/backend/src/api/routes/index.ts +++ b/backend/src/api/routes/index.ts @@ -10,6 +10,7 @@ import presets from "./presets"; import apeKeys from "./ape-keys"; import admin from "./admin"; import webhooks from "./webhooks"; +import dev from "./dev"; import configuration from "./configuration"; import { version } from "../../version"; import leaderboards from "./leaderboards"; @@ -67,6 +68,9 @@ function addApiRoutes(app: Application): void { } next(); }); + + //enable dev edpoints + app.use("/dev", dev); } // Cannot be added to the route map because it needs to be added before the maintenance handler diff --git a/backend/src/dal/result.ts b/backend/src/dal/result.ts index 85128d10996c..5e19576bbf5d 100644 --- a/backend/src/dal/result.ts +++ b/backend/src/dal/result.ts @@ -1,10 +1,17 @@ import _ from "lodash"; -import { DeleteResult, ObjectId, UpdateResult } from "mongodb"; +import { Collection, DeleteResult, ObjectId, UpdateResult } from "mongodb"; import MonkeyError from "../utils/error"; import * as db from "../init/db"; import { getUser, getTags } from "./user"; +type DBResult = MonkeyTypes.WithObjectId< + SharedTypes.DBResult +>; + +export const getResultCollection = (): Collection => + db.collection("results"); + export async function addResult( uid: string, result: MonkeyTypes.DBResult @@ -18,18 +25,14 @@ export async function addResult( if (!user) throw new MonkeyError(404, "User not found", "add result"); if (result.uid === undefined) result.uid = uid; // result.ir = true; - const res = await db - .collection("results") - .insertOne(result); + const res = await getResultCollection().insertOne(result); return { insertedId: res.insertedId, }; } export async function deleteAll(uid: string): Promise { - return await db - .collection("results") - .deleteMany({ uid }); + return await getResultCollection().deleteMany({ uid }); } export async function updateTags( @@ -37,9 +40,10 @@ export async function updateTags( resultId: string, tags: string[] ): Promise { - const result = await db - .collection("results") - .findOne({ _id: new ObjectId(resultId), uid }); + const result = await getResultCollection().findOne({ + _id: new ObjectId(resultId), + uid, + }); if (!result) throw new MonkeyError(404, "Result not found"); const userTags = await getTags(uid); const userTagIds = userTags.map((tag) => tag._id.toString()); @@ -50,18 +54,20 @@ export async function updateTags( if (!validTags) { throw new MonkeyError(422, "One of the tag id's is not valid"); } - return await db - .collection("results") - .updateOne({ _id: new ObjectId(resultId), uid }, { $set: { tags } }); + return await getResultCollection().updateOne( + { _id: new ObjectId(resultId), uid }, + { $set: { tags } } + ); } export async function getResult( uid: string, id: string ): Promise { - const result = await db - .collection("results") - .findOne({ _id: new ObjectId(id), uid }); + const result = await getResultCollection().findOne({ + _id: new ObjectId(id), + uid, + }); if (!result) throw new MonkeyError(404, "Result not found"); return result; } @@ -69,8 +75,7 @@ export async function getResult( export async function getLastResult( uid: string ): Promise> { - const [lastResult] = await db - .collection("results") + const [lastResult] = await getResultCollection() .find({ uid }) .sort({ timestamp: -1 }) .limit(1) @@ -83,9 +88,7 @@ export async function getResultByTimestamp( uid: string, timestamp ): Promise { - return await db - .collection("results") - .findOne({ uid, timestamp }); + return await getResultCollection().findOne({ uid, timestamp }); } type GetResultsOpts = { @@ -99,8 +102,7 @@ export async function getResults( opts?: GetResultsOpts ): Promise { const { onOrAfterTimestamp, offset, limit } = opts ?? {}; - let query = db - .collection("results") + let query = getResultCollection() .find({ uid, ...(!_.isNil(onOrAfterTimestamp) && diff --git a/backend/src/dal/user.ts b/backend/src/dal/user.ts index d70d96453d6d..f3c47eb92585 100644 --- a/backend/src/dal/user.ts +++ b/backend/src/dal/user.ts @@ -202,7 +202,7 @@ export async function getUser( return user; } -async function findByName( +export async function findByName( name: string ): Promise { return ( diff --git a/frontend/__tests__/utils/tag-builder.spec.ts b/frontend/__tests__/utils/tag-builder.spec.ts new file mode 100644 index 000000000000..ed7340f29d32 --- /dev/null +++ b/frontend/__tests__/utils/tag-builder.spec.ts @@ -0,0 +1,51 @@ +import { buildTag } from "../../src/ts/utils/tag-builder"; + +describe("simple-modals", () => { + describe("buildTag", () => { + it("builds with mandatory", () => { + expect(buildTag({ tagname: "input" })).toBe(""); + }); + it("builds with classes", () => { + expect(buildTag({ tagname: "input", classes: ["hidden", "bold"] })).toBe( + '' + ); + }); + it("builds with attributes", () => { + expect( + buildTag({ + tagname: "input", + attributes: { + id: "4711", + oninput: "console.log()", + required: true, + checked: true, + missing: undefined, + }, + }) + ).toBe(''); + }); + + it("builds with innerHtml", () => { + expect( + buildTag({ tagname: "textarea", innerHTML: "

Hello

" }) + ).toBe(""); + }); + it("builds with everything", () => { + expect( + buildTag({ + tagname: "textarea", + classes: ["hidden", "bold"], + attributes: { + id: "4711", + oninput: "console.log()", + readonly: true, + required: true, + }, + innerHTML: "

Hello

", + }) + ).toBe( + '' + ); + }); + }); +}); diff --git a/frontend/src/html/popups.html b/frontend/src/html/popups.html index 0f073bf0933c..6b0cdbc3f532 100644 --- a/frontend/src/html/popups.html +++ b/frontend/src/html/popups.html @@ -4,6 +4,13 @@ + +