diff --git a/controllers/userMigrations.js b/controllers/userMigrations.js new file mode 100644 index 000000000..968383fb6 --- /dev/null +++ b/controllers/userMigrations.js @@ -0,0 +1,27 @@ +const userQuery = require("../models/userMigrations"); +const { SOMETHING_WENT_WRONG } = require("../constants/errorMessages"); + +/** + * Returns the lists of usernames where default colors were added + * + * @param req {Object} - Express request object + * @param res {Object} - Express response object + */ + +const addDefaultColors = async (req, res) => { + try { + const usersDetails = await userQuery.addDefaultColors(); + + return res.json({ + message: "User colors updated successfully!", + usersDetails, + }); + } catch (error) { + logger.error(`Error adding default colors to users: ${error}`); + return res.boom.badImplementation(SOMETHING_WENT_WRONG); + } +}; + +module.exports = { + addDefaultColors, +}; diff --git a/models/userMigrations.js b/models/userMigrations.js new file mode 100644 index 000000000..4e78fcb9a --- /dev/null +++ b/models/userMigrations.js @@ -0,0 +1,62 @@ +const firestore = require("../utils/firestore"); +const userModel = firestore.collection("users"); +const { getRandomIndex } = require("../utils/helper"); +const dataAccess = require("../services/dataAccessLayer"); +const MAX_TRANSACTION_WRITES = 500; +const MAX_USERS_SIZE = 10_000; +const USER_COLORS = 10; + +/** + * Returns the object with details about users to whom user color was added + * + * @param req {Object} - Express request object + * @param res {Object} - Express response object + */ + +const addDefaultColors = async (batchSize = MAX_TRANSACTION_WRITES) => { + try { + const usersSnapshotArr = await dataAccess.retrieveUsers({ query: { size: MAX_USERS_SIZE } }); + const usersArr = usersSnapshotArr.users; + + const batchArray = []; + const users = []; + batchArray.push(firestore.batch()); + let operationCounter = 0; + let batchIndex = 0; + let totalCount = 0; + + for (const user of usersArr) { + const colors = user.colors ?? {}; + + if (!user.colors) { + const userColorIndex = getRandomIndex(USER_COLORS); + colors.color_id = userColorIndex; + const docId = userModel.doc(user.id); + user.colors = colors; + batchArray[parseInt(batchIndex)].set(docId, user); + operationCounter++; + totalCount++; + users.push(user.username); + if (operationCounter === batchSize) { + batchArray.push(firestore.batch()); + batchIndex++; + operationCounter = 0; + } + } + } + batchArray.forEach(async (batch) => await batch.commit()); + + return { + totalUsersFetched: usersArr.length, + totalUsersUpdated: totalCount, + totalUsersUnaffected: usersArr.length - totalCount, + }; + } catch (err) { + logger.error("Error adding default colors to users", err); + throw err; + } +}; + +module.exports = { + addDefaultColors, +}; diff --git a/routes/index.js b/routes/index.js index 737f945ce..b811b05ce 100644 --- a/routes/index.js +++ b/routes/index.js @@ -19,6 +19,7 @@ app.use("/users/status", require("./userStatus.js")); app.use("/users", require("./users.js")); app.use("/profileDiffs", require("./profileDiffs.js")); app.use("/wallet", require("./wallets.js")); +app.use("/migrations", require("./userMigrations.js")); app.use("/extension-requests", require("./extensionRequests")); app.use("/tags", require("./tags.js")); app.use("/levels", require("./levels.js")); diff --git a/routes/userMigrations.js b/routes/userMigrations.js new file mode 100644 index 000000000..e6e2519fb --- /dev/null +++ b/routes/userMigrations.js @@ -0,0 +1,10 @@ +const express = require("express"); +const router = express.Router(); +const authenticate = require("../middlewares/authenticate"); +const authorizeRoles = require("../middlewares/authorizeRoles"); +const { SUPERUSER } = require("../constants/roles"); +const migrations = require("../controllers/userMigrations"); + +router.patch("/user-default-color", authenticate, authorizeRoles([SUPERUSER]), migrations.addDefaultColors); + +module.exports = router; diff --git a/test/fixtures/user/user.js b/test/fixtures/user/user.js index 89947135a..22f006f2d 100644 --- a/test/fixtures/user/user.js +++ b/test/fixtures/user/user.js @@ -170,6 +170,9 @@ module.exports = () => { archived: false, in_discord: true, }, + colors: { + color_id: 2, + }, picture: { publicId: "profile/mtS4DhUvNYsKqI7oCWVB/aenklfhtjldc5ytei3ar", url: "https://res.cloudinary.com/realdevsquad/image/upload/v1667685133/profile/mtS4DhUvNYsKqI7oCWVB/aenklfhtjldc5ytei3ar.jpg", diff --git a/test/integration/userMigrations.test.js b/test/integration/userMigrations.test.js new file mode 100644 index 000000000..4713722da --- /dev/null +++ b/test/integration/userMigrations.test.js @@ -0,0 +1,71 @@ +const chai = require("chai"); +const { expect } = chai; +const chaiHttp = require("chai-http"); + +const app = require("../../server"); +const authService = require("../../services/authService"); +const addUser = require("../utils/addUser"); +const cleanDb = require("../utils/cleanDb"); +// Import fixtures +const userData = require("../fixtures/user/user")(); +const superUser = userData[4]; +const nonSuperUser = userData[0]; +const colorBearingUsernames = [superUser.username, nonSuperUser.username]; + +const config = require("config"); +const cookieName = config.get("userToken.cookieName"); + +chai.use(chaiHttp); + +describe("userColorMigrations", function () { + let superUserId; + let superUserAuthToken; + let userId = ""; + let nonSuperUserId = ""; + beforeEach(async function () { + userId = await addUser(nonSuperUser); + superUserId = await addUser(superUser); + nonSuperUserId = userId; + superUserAuthToken = authService.generateAuthToken({ userId: superUserId }); + }); + + afterEach(async function () { + await cleanDb(); + }); + + describe("PATCH /migrations/user-default-color", function () { + it("Should return 401 if user is not a super user", function (done) { + const nonSuperUserJwt = authService.generateAuthToken({ userId: nonSuperUserId }); + chai + .request(app) + .patch(`/migrations/user-default-color`) + .set("cookie", `${cookieName}=${nonSuperUserJwt}`) + .end((err, res) => { + if (err) { + return done(err); + } + expect(res).to.have.status(401); + expect(res.body).to.be.a("object"); + expect(res.body.message).to.equal("You are not authorized for this action."); + return done(); + }); + }); + it("Should add default color property to all users,using authorized user (super_user)", function (done) { + chai + .request(app) + .patch(`/migrations/user-default-color`) + .set("cookie", `${cookieName}=${superUserAuthToken}`) + .end((err, res) => { + if (err) { + return done(err); + } + + expect(res).to.have.status(200); + expect(res.body.usersDetails.totalUsersFetched).to.be.equal(colorBearingUsernames.length); + expect(res.body.usersDetails.totalUsersUpdated).to.be.equal(colorBearingUsernames.length); + expect(res.body.usersDetails.totalUsersUnaffected).to.be.equal(0); + return done(); + }); + }); + }); +}); diff --git a/test/unit/models/userMigrations.test.js b/test/unit/models/userMigrations.test.js new file mode 100644 index 000000000..31f5dc4c4 --- /dev/null +++ b/test/unit/models/userMigrations.test.js @@ -0,0 +1,52 @@ +const chai = require("chai"); +const { expect } = chai; +const firestore = require("../../../utils/firestore"); +const userModel = firestore.collection("users"); +const cleanDb = require("../../utils/cleanDb"); +const userMigrationModel = require("../../../models/userMigrations"); +const userData = require("../../fixtures/user/user")(); +const addUser = require("../../utils/addUser"); + +describe("userColorMigrations", function () { + const MAX_TRANSACTION_WRITES = 500; + + beforeEach(async function () { + await addUser(userData[0]); + await addUser(userData[1]); + await addUser(userData[2]); + await addUser(userData[3]); + await addUser(userData[4]); + await addUser(userData[6]); + }); + afterEach(async function () { + await cleanDb(); + }); + + it("should add color property to added users which dont have a color property", async function () { + const response = await userMigrationModel.addDefaultColors(); + + expect(response.totalUsersFetched).to.equal(6); + expect(response.totalUsersUpdated).to.equal(5); + expect(response.totalUsersUnaffected).to.equal(1); + }); + it("should make sure that batch updates are working properly by passing smaller batch size", async function () { + const SMALL_BATCH_SIZE = 2; + const response = await userMigrationModel.addDefaultColors(SMALL_BATCH_SIZE); + expect(response.totalUsersFetched).to.equal(6); + expect(response.totalUsersUpdated).to.equal(5); + expect(response.totalUsersUnaffected).to.equal(1); + }); + it("should not affect users already having color property", async function () { + // Manually add a color property to a user + const userId = await addUser(userData[0]); + await userModel.doc(userId).update({ colors: { color_id: 3 } }); + const response = await userMigrationModel.addDefaultColors(MAX_TRANSACTION_WRITES); + expect(response.totalUsersFetched).to.equal(6); + expect(response.totalUsersUpdated).to.equal(4); + expect(response.totalUsersUnaffected).to.equal(2); + + // Check that the user with a color property was unaffected + const updatedUser = await userModel.doc(userId).get(); + expect(updatedUser.data().colors.color_id).to.equal(3); + }); +}); diff --git a/test/unit/utils/helpers.test.js b/test/unit/utils/helpers.test.js new file mode 100644 index 000000000..dac7ad1ef --- /dev/null +++ b/test/unit/utils/helpers.test.js @@ -0,0 +1,20 @@ +const chai = require("chai"); +const { getRandomIndex } = require("../../../utils/helper"); +const { expect } = chai; + +describe("helpers", function () { + describe("getRandom Index from function", function () { + it("should return a random number between 0 and 10 excluding 10 if no index is passed", function () { + const result = getRandomIndex(); + expect(result).to.be.at.least(0); + expect(result).to.be.below(10); + }); + + it("expect a number between 0 and passed number", function () { + const delimiter = 100; + const result = getRandomIndex(delimiter); + expect(result).to.be.at.least(0); + expect(result).to.be.below(delimiter); + }); + }); +}); diff --git a/utils/helper.js b/utils/helper.js index 5ee6636f9..10f129317 100644 --- a/utils/helper.js +++ b/utils/helper.js @@ -67,8 +67,26 @@ const getPaginatedLink = ({ return paginatedLink; }; +/** + * Returns a random object from the array of colors to user + * @param array {array} : array containing objects + * @returns random Index number : index between the range 0 to array.length + */ +const getRandomIndex = (maxLength = 10) => { + if (typeof maxLength !== "number") { + throw new Error("maxLength must be a number"); + } + + if (maxLength <= 0) { + throw new Error("maxLength must be a positive number"); + } + + return Math.floor(Math.random() * maxLength); +}; + module.exports = { getQualifiers, getDateTimeRangeForPRs, getPaginatedLink, + getRandomIndex, };