diff --git a/models/userStatus.js b/models/userStatus.js index 33318ee6c0..51123cfccf 100644 --- a/models/userStatus.js +++ b/models/userStatus.js @@ -27,6 +27,7 @@ const usersCollection = firestore.collection("users"); const config = require("config"); const DISCORD_BASE_URL = config.get("services.discordBot.baseUrl"); const { generateAuthTokenForCloudflare } = require("../utils/discord-actions"); +const logger = require("../utils/logger"); const { BATCH_SIZE_IN_CLAUSE } = require("../constants/firebase"); // added this function here to avoid circular dependency @@ -265,10 +266,13 @@ const updateAllUserStatus = async () => { nonOooUsersUnaltered: 0, }; try { - const userStatusDocs = await userStatusModel.where("futureStatus.state", "in", ["ACTIVE", "IDLE", "OOO"]).get(); - summary.usersCount = userStatusDocs._size; + const today = Date.now(); + const userStatusDocs = await userStatusModel + .where("futureStatus.state", "in", ["ACTIVE", "IDLE", "OOO"]) + .where("futureStatus.from", "<=", today) + .get(); + summary.usersCount = userStatusDocs.size; const batch = firestore.batch(); - const today = new Date().getTime(); for (const document of userStatusDocs.docs) { const doc = document.data(); const docRef = document.ref; @@ -280,24 +284,20 @@ const updateAllUserStatus = async () => { const currentState = currentStatus?.state; const currentUntil = currentStatus?.until; if (futureState === "ACTIVE" || futureState === "IDLE") { - if (today >= futureStatus.from) { - // OOO period is over and we need to update their current status - newStatusData.currentStatus = { ...futureStatus, until: "", updatedAt: today }; - delete newStatusData.futureStatus; - const lastOooUntilUpdate = resolveLastOooUntil({ - previousState: currentState, - previousUntil: currentUntil, - nextState: futureState, - fallbackTimestamp: today, - }); - if (lastOooUntilUpdate !== undefined) { - newStatusData.lastOooUntil = lastOooUntilUpdate; - } - toUpdate = !toUpdate; - summary.oooUsersAltered++; - } else { - summary.oooUsersUnaltered++; + // OOO period is over and we need to update their current status + newStatusData.currentStatus = { ...futureStatus, until: "", updatedAt: today }; + delete newStatusData.futureStatus; + const lastOooUntilUpdate = resolveLastOooUntil({ + previousState: currentState, + previousUntil: currentUntil, + nextState: futureState, + fallbackTimestamp: today, + }); + if (lastOooUntilUpdate !== undefined) { + newStatusData.lastOooUntil = lastOooUntilUpdate; } + toUpdate = !toUpdate; + summary.oooUsersAltered++; } else { // futureState is OOO if (today > futureStatus.until) { @@ -305,7 +305,7 @@ const updateAllUserStatus = async () => { delete newStatusData.futureStatus; toUpdate = !toUpdate; summary.nonOooUsersAltered++; - } else if (today <= doc.futureStatus.until && today >= doc.futureStatus.from) { + } else { // the current date i.e today lies in between the from and until so we need to swap the status let newCurrentStatus = {}; let newFutureStatus = {}; @@ -318,8 +318,6 @@ const updateAllUserStatus = async () => { newStatusData.lastOooUntil = null; toUpdate = !toUpdate; summary.nonOooUsersAltered++; - } else { - summary.nonOooUsersUnaltered++; } } if (toUpdate) { diff --git a/test/fixtures/userStatus/userStatus.js b/test/fixtures/userStatus/userStatus.js index 1401f2fbc3..23b5eef42b 100644 --- a/test/fixtures/userStatus/userStatus.js +++ b/test/fixtures/userStatus/userStatus.js @@ -224,6 +224,39 @@ const generateDefaultFutureStatus = (state, from, until) => { return futureStatusData; }; +const generateOooUserStatusDoc = ( + userId, + today, + { + currentStatusFromOffset = -24 * 60 * 60 * 1000, + currentStatusUntilOffset = 2 * 24 * 60 * 60 * 1000, + futureStatusFromOffset, + } = {} +) => { + const currentFrom = today + currentStatusFromOffset; + return { + userId, + currentStatus: { + state: userState.OOO, + from: currentFrom, + until: today + currentStatusUntilOffset, + message: "On leave", + updatedAt: currentFrom, + }, + futureStatus: { + state: userState.ACTIVE, + from: today + futureStatusFromOffset, + until: "", + message: "", + updatedAt: today - 24 * 60 * 60 * 1000, + }, + monthlyHours: { + committed: 40, + updatedAt: currentFrom, + }, + }; +}; + module.exports = { userStatusDataForNewUser, userStatusDataAfterSignup, @@ -239,4 +272,5 @@ module.exports = { inputFixtureForFnConvertTimestampsToUTC, OutputFixtureForFnConvertTimestampsToUTC, generateDefaultFutureStatus, + generateOooUserStatusDoc, }; diff --git a/test/integration/userStatus.test.js b/test/integration/userStatus.test.js index d6aea47436..77986d10f5 100644 --- a/test/integration/userStatus.test.js +++ b/test/integration/userStatus.test.js @@ -8,13 +8,13 @@ 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 { userStatusDataForNewUser, userStatusDataForOooState, generateUserStatusData, + generateOooUserStatusDoc, } = require("../fixtures/userStatus/userStatus"); const config = require("config"); @@ -183,6 +183,26 @@ describe("UserStatus", function () { }); describe("PATCH /users/status/update", function () { + let clock; + + beforeEach(function () { + clock = sinon.useFakeTimers({ + now: new Date("2026-07-14T02:00:00.000Z").getTime(), + toFake: ["Date"], + }); + }); + + afterEach(function () { + clock.restore(); + }); + + const setupOooUserStatus = async (userFixture, today, offsets) => { + const userId = await addUser(userFixture); + const docRef = firestore.collection("usersStatus").doc(); + await docRef.set(generateOooUserStatusDoc(userId, today, offsets)); + return { userId, docRef }; + }; + it("Should return 401 for unauthorized request", async function () { const response = await chai.request(app).patch("/users/status/update"); expect(response).to.have.status(401); @@ -192,6 +212,75 @@ describe("UserStatus", function () { const response = await chai.request(app).patch("/users/status/update").set("cookie", `${cookieName}=${jwt}`); expect(response).to.have.status(401); }); + + it("Should transition OOO → ACTIVE when futureStatus.from is in the past", async function () { + const today = Date.now(); + const { docRef } = await setupOooUserStatus(userData[1], today, { + currentStatusFromOffset: -2 * 24 * 60 * 60 * 1000, + currentStatusUntilOffset: 2 * 24 * 60 * 60 * 1000, + futureStatusFromOffset: -24 * 60 * 60 * 1000, + }); + + const response = await chai + .request(app) + .patch("/users/status/update") + .set("cookie", `${cookieName}=${superUserAuthToken}`); + + expect(response).to.have.status(200); + expect(response.body.message).to.equal("All User Status updated successfully."); + expect(response.body.data.usersCount).to.equal(1); + expect(response.body.data.oooUsersAltered).to.equal(1); + + const updatedDoc = await docRef.get(); + expect(updatedDoc.data().currentStatus.state).to.equal(userState.ACTIVE); + expect(updatedDoc.data().futureStatus).to.equal(undefined); + }); + + it("Should NOT update status when futureStatus.from is in the future", async function () { + const today = Date.now(); + const { docRef } = await setupOooUserStatus(userData[2], today, { + currentStatusFromOffset: -24 * 60 * 60 * 1000, + currentStatusUntilOffset: 2 * 24 * 60 * 60 * 1000, + futureStatusFromOffset: 24 * 60 * 60 * 1000, + }); + + const response = await chai + .request(app) + .patch("/users/status/update") + .set("cookie", `${cookieName}=${superUserAuthToken}`); + + expect(response).to.have.status(200); + expect(response.body.message).to.equal("All User Status updated successfully."); + expect(response.body.data.usersCount).to.equal(0); + expect(response.body.data.oooUsersAltered).to.equal(0); + + const doc = await docRef.get(); + expect(doc.data().currentStatus.state).to.equal(userState.OOO); + expect(doc.data().futureStatus.state).to.equal(userState.ACTIVE); + }); + + it("Should transition OOO → ACTIVE when futureStatus.from === today (boundary)", async function () { + const today = Date.now(); + const { docRef } = await setupOooUserStatus(userData[3], today, { + currentStatusFromOffset: -24 * 60 * 60 * 1000, + currentStatusUntilOffset: 24 * 60 * 60 * 1000, + futureStatusFromOffset: 0, + }); + + const response = await chai + .request(app) + .patch("/users/status/update") + .set("cookie", `${cookieName}=${superUserAuthToken}`); + + expect(response).to.have.status(200); + expect(response.body.message).to.equal("All User Status updated successfully."); + expect(response.body.data.usersCount).to.equal(1); + expect(response.body.data.oooUsersAltered).to.equal(1); + + const updatedDoc = await docRef.get(); + expect(updatedDoc.data().currentStatus.state).to.equal(userState.ACTIVE); + expect(updatedDoc.data().futureStatus).to.equal(undefined); + }); }); describe("PATCH /users/status/:userid", function () { @@ -265,7 +354,6 @@ describe("UserStatus", function () { }); it("Should return 401 for unauthorized request for user and superuser", function (done) { - // Using ONBOARDING state since OOO is now blocked by the validator chai .request(app) .patch(`/users/status/${testUserId}`) diff --git a/test/unit/models/userStatus.js b/test/unit/models/userStatus.js index 0e68e63109..aba9cc127d 100644 --- a/test/unit/models/userStatus.js +++ b/test/unit/models/userStatus.js @@ -1,4 +1,4 @@ -import { userFutureStatusData } from "../../fixtures/userFutureStatus/userFutureStatusData"; +const { userFutureStatusData } = require("../../fixtures/userFutureStatus/userFutureStatusData"); const chai = require("chai"); const sinon = require("sinon"); const { NotFound, Forbidden } = require("http-errors"); @@ -6,11 +6,20 @@ const { expect } = chai; const firestore = require("../../../utils/firestore"); const userStatusModel = firestore.collection("usersStatus"); const tasksModel = firestore.collection("tasks"); -const { cancelOooStatus, addFutureStatus, getUserStatusForUserIds } = require("../../../models/userStatus"); +const { + cancelOooStatus, + addFutureStatus, + getUserStatusForUserIds, + updateAllUserStatus, +} = require("../../../models/userStatus"); const cleanDb = require("../../utils/cleanDb"); const addUser = require("../../utils/addUser"); const { userState } = require("../../../constants/userStatus"); -const { generateStatusDataForCancelOOO, generateDefaultFutureStatus } = require("../../fixtures/userStatus/userStatus"); +const { + generateStatusDataForCancelOOO, + generateDefaultFutureStatus, + generateOooUserStatusDoc, +} = require("../../fixtures/userStatus/userStatus"); describe("tasks", function () { let userId; @@ -38,7 +47,7 @@ describe("tasks", function () { it("Should clear the future Status if the User cancels OOO", async function () { const data = generateStatusDataForCancelOOO(userId, userState.OOO); - const from = new Date().getTime() + 24 * 60 * 60 * 1000; // 1 day offset from current time + const from = new Date().getTime() + 24 * 60 * 60 * 1000; data.futureStatus = generateDefaultFutureStatus(userState.IDLE, from, ""); await docRefUser0.set(data); const response = await cancelOooStatus(userId); @@ -87,6 +96,86 @@ describe("tasks", function () { expect(response.data.futureStatus.state).to.equal("UPCOMING"); }); + describe("updateAllUserStatus", function () { + let clock; + + beforeEach(async function () { + clock = sinon.useFakeTimers({ + now: new Date("2026-07-14T02:00:00.000Z").getTime(), + toFake: ["Date"], + }); + }); + + afterEach(async function () { + clock.restore(); + await cleanDb(); + }); + + it("Should update user status when futureStatus.from <= today (e.g. from is in the past)", async function () { + const today = Date.now(); + const docRef = userStatusModel.doc(); + + const userStatusData = generateOooUserStatusDoc(userId, today, { + currentStatusFromOffset: -2 * 24 * 60 * 60 * 1000, + futureStatusFromOffset: -24 * 60 * 60 * 1000, + }); + await docRef.set(userStatusData); + + const summary = await updateAllUserStatus(); + expect(summary.usersCount).to.equal(1); + expect(summary.oooUsersAltered).to.equal(1); + + const doc = await docRef.get(); + const data = doc.data(); + + expect(data.currentStatus.state).to.equal(userState.ACTIVE); + expect(data.currentStatus.from).to.equal(today - 24 * 60 * 60 * 1000); + expect(data.futureStatus).to.equal(undefined); + }); + + it("Should update user status when futureStatus.from === today (boundary case)", async function () { + const today = Date.now(); + const docRef = userStatusModel.doc(); + + const userStatusData = generateOooUserStatusDoc(userId, today, { + currentStatusUntilOffset: 24 * 60 * 60 * 1000, + futureStatusFromOffset: 0, + }); + await docRef.set(userStatusData); + + const summary = await updateAllUserStatus(); + expect(summary.usersCount).to.equal(1); + expect(summary.oooUsersAltered).to.equal(1); + + const doc = await docRef.get(); + const data = doc.data(); + + expect(data.currentStatus.state).to.equal(userState.ACTIVE); + expect(data.currentStatus.from).to.equal(today); + expect(data.futureStatus).to.equal(undefined); + }); + + it("Should not update user status when futureStatus.from > today (e.g. from is in the future)", async function () { + const today = Date.now(); + const docRef = userStatusModel.doc(); + + const userStatusData = generateOooUserStatusDoc(userId, today, { + futureStatusFromOffset: 24 * 60 * 60 * 1000, + }); + await docRef.set(userStatusData); + + const summary = await updateAllUserStatus(); + expect(summary.usersCount).to.equal(0); + expect(summary.oooUsersAltered).to.equal(0); + + const doc = await docRef.get(); + const data = doc.data(); + + expect(data.currentStatus.state).to.equal(userState.OOO); + expect(data.futureStatus.state).to.equal(userState.ACTIVE); + }); + }); + describe("getUserStatusForUserIds", function () { it("returns statuses keyed by userId for the given ids", async function () { await userStatusModel.add({ userId: "user-idle-1", currentStatus: { state: userState.IDLE } });