From c0b4daa8fd8cf00243826320f881356fa331e3e8 Mon Sep 17 00:00:00 2001 From: baalmart Date: Sun, 13 Oct 2024 17:13:56 +0300 Subject: [PATCH 1/3] automated update of default preferences --- .../bin/jobs/preferences-update-job.js | 70 +++- .../bin/jobs/test/ut_active-status-job.js | 162 ++++++++ .../jobs/test/ut_incomplete-profile-job.js | 156 +++++++ .../bin/jobs/test/ut_preferences-log-job.js | 120 ++++++ .../jobs/test/ut_preferences-update-job.js | 79 ++++ .../bin/jobs/test/ut_token-expiration-job.js | 118 ++++++ src/auth-service/bin/server.js | 1 + .../controllers/create-preference.js | 222 ++++++++++ .../controllers/test/ut_create-preference.js | 387 +++++++++++++++++- src/auth-service/models/SelectedSite.js | 250 +++++++++++ .../models/test/ut_selected_site.js | 240 +++++++++++ src/auth-service/routes/v2/preferences.js | 256 ++++++++++++ .../routes/v2/test/ut_preferences.js | 144 +++++++ src/auth-service/utils/create-preference.js | 132 ++++++ src/auth-service/utils/generate-filter.js | 23 ++ .../utils/test/ut_create-preference.js | 199 ++++++++- 16 files changed, 2528 insertions(+), 31 deletions(-) create mode 100644 src/auth-service/bin/jobs/test/ut_active-status-job.js create mode 100644 src/auth-service/bin/jobs/test/ut_incomplete-profile-job.js create mode 100644 src/auth-service/bin/jobs/test/ut_preferences-log-job.js create mode 100644 src/auth-service/bin/jobs/test/ut_preferences-update-job.js create mode 100644 src/auth-service/bin/jobs/test/ut_token-expiration-job.js create mode 100644 src/auth-service/models/SelectedSite.js create mode 100644 src/auth-service/models/test/ut_selected_site.js diff --git a/src/auth-service/bin/jobs/preferences-update-job.js b/src/auth-service/bin/jobs/preferences-update-job.js index a52695c9b2..015e45b409 100644 --- a/src/auth-service/bin/jobs/preferences-update-job.js +++ b/src/auth-service/bin/jobs/preferences-update-job.js @@ -1,6 +1,7 @@ const cron = require("node-cron"); const UserModel = require("@models/User"); const PreferenceModel = require("@models/Preference"); +const SelectedSiteModel = require("@models/SelectedSite"); const constants = require("@config/constants"); const log4js = require("log4js"); const { logText, logObject } = require("@utils/log"); @@ -9,9 +10,7 @@ const logger = log4js.getLogger( ); const stringify = require("@utils/stringify"); const isEmpty = require("is-empty"); - -// Predefined array of 4 site IDs -const defaultSiteIds = constants.SELECTED_SITES; +const BATCH_SIZE = 100; // Default preference object const defaultPreference = { @@ -28,17 +27,53 @@ const defaultPreference = { unitValue: 14, unit: "day", }, - airqloud_id: constants.DEFAULT_AIRQLOUD, - grid_id: constants.DEFAULT_GRID, - network_id: constants.DEFAULT_NETWORK, - group_id: constants.DEFAULT_GROUP, + airqloud_id: constants.DEFAULT_AIRQLOUD || "NA", + grid_id: constants.DEFAULT_GRID || "NA", + network_id: constants.DEFAULT_NETWORK || "NA", + group_id: constants.DEFAULT_GROUP || "NA", }; -const updatePreferences = async () => { +// Function to get selected sites based on the specified method +const getSelectedSites = async (method = "featured") => { try { - const batchSize = 100; + let selectedSites; + if (method === "featured") { + selectedSites = await SelectedSiteModel("airqo") + .find({ isFeatured: true }) + .sort({ createdAt: -1 }) + .limit(4) + .lean(); + } else { + selectedSites = await SelectedSiteModel("airqo") + .find() + .sort({ createdAt: -1 }) + .limit(4) + .lean(); + } + const modifiedSelectedSites = selectedSites.map((site) => ({ + ...site, + _id: site.site_id || null, + })); + return modifiedSelectedSites; + } catch (error) { + logger.error(`🐛🐛 Error fetching selected sites: ${stringify(error)}`); + return []; + } +}; + +const updatePreferences = async (siteSelectionMethod = "featured") => { + try { + const batchSize = BATCH_SIZE; let skip = 0; + // Fetch selected sites data + const selectedSites = await getSelectedSites(siteSelectionMethod); + + if (isEmpty(selectedSites) || selectedSites.length < 4) { + logger.error("🐛🐛 No selected sites found. Aborting preference update."); + return; + } + while (true) { const users = await UserModel("airqo") .find() @@ -59,16 +94,11 @@ const updatePreferences = async () => { .lean(); const preferencesMap = new Map(); + preferences.forEach((pref) => { preferencesMap.set(pref.user_id.toString(), pref); }); - // Initialize selected_sites data - const selectedSitesData = defaultSiteIds.map((siteId) => ({ - _id: siteId, - createdAt: new Date(), - })); - for (const user of users) { const userIdStr = user._id.toString(); const preference = preferencesMap.get(userIdStr); @@ -79,11 +109,11 @@ const updatePreferences = async () => { .create({ ...defaultPreference, user_id: user._id, - selected_sites: selectedSitesData, + selected_sites: selectedSites, }) .catch((error) => { logger.error( - `Failed to create preference for user ${userIdStr}: ${stringify( + `🐛🐛 Failed to create preference for user ${userIdStr}: ${stringify( error )}` ); @@ -96,14 +126,14 @@ const updatePreferences = async () => { { $set: { ...defaultPreference, - selected_sites: selectedSitesData, + selected_sites: selectedSites, }, }, { new: true } ) .catch((error) => { logger.error( - `Failed to update preference for user ${userIdStr}: ${stringify( + `🐛🐛 Failed to update preference for user ${userIdStr}: ${stringify( error )}` ); @@ -120,7 +150,7 @@ const updatePreferences = async () => { }; const schedule = "30 * * * *"; // At minute 30 of every hour -cron.schedule(schedule, updatePreferences, { +cron.schedule(schedule, () => updatePreferences("featured"), { scheduled: true, timezone: "Africa/Nairobi", }); diff --git a/src/auth-service/bin/jobs/test/ut_active-status-job.js b/src/auth-service/bin/jobs/test/ut_active-status-job.js new file mode 100644 index 0000000000..6003674037 --- /dev/null +++ b/src/auth-service/bin/jobs/test/ut_active-status-job.js @@ -0,0 +1,162 @@ +require("module-alias/register"); +const sinon = require("sinon"); +const chai = require("chai"); +const expect = chai.expect; +const sinonChai = require("sinon-chai"); + +describe("checkStatus", () => { + let UserModel; + + beforeEach(() => { + // Set up mocks + UserModel = sinon.mock(UserModel); + + sinon.stub(UserModel.prototype, "find").resolves( + [ + { + _id: "user1", + lastLogin: new Date("2023-01-01T00:00:00Z"), + isActive: true, + }, + { + _id: "user2", + lastLogin: new Date("2023-02-01T00:00:00Z"), + isActive: true, + }, + { _id: "user3", lastLogin: null, isActive: true }, + { + _id: "user4", + lastLogin: new Date("2023-03-01T00:00:00Z"), + isActive: false, + }, + { + _id: "user5", + lastLogin: new Date("2023-04-01T00:00:00Z"), + isActive: true, + }, + ].slice(0, 100) + ); + + sinon + .stub(UserModel.prototype, "updateMany") + .resolves({ modifiedCount: 5 }); + + sinon.stub(console, "error"); + sinon.stub(stringify, "default").returns(JSON.stringify({})); + }); + + afterEach(() => { + // Restore mocks + UserModel.restore(); + console.error.restore(); + stringify.default.restore(); + }); + + describe("successful execution", () => { + it("should mark inactive users and log results", async () => { + await checkStatus(); + + expect(UserModel.prototype.find).to.have.been.calledThrice; + expect(UserModel.prototype.updateMany).to.have.been.calledWith( + { _id: { $in: ["user1", "user2", "user3"] } }, + { isActive: false } + ); + expect(console.error).to.not.have.been.called; + }); + }); + + describe("no inactive users found", () => { + it("should not update any users when no inactive users are found", async () => { + sinon.stub(UserModel.prototype, "find").resolves( + [ + { + _id: "user1", + lastLogin: new Date("2023-05-01T00:00:00Z"), + isActive: true, + }, + { + _id: "user2", + lastLogin: new Date("2023-06-01T00:00:00Z"), + isActive: true, + }, + ].slice(0, 100) + ); + + await checkStatus(); + + expect(UserModel.prototype.updateMany).to.not.have.been.called; + }); + }); + + describe("inactive threshold exceeded", () => { + it("should mark users inactive based on last login time", async () => { + sinon.stub(Date.now, "bind").returns(1697865600000); // Current timestamp + sinon.stub(UserModel.prototype, "find").resolves( + [ + { + _id: "user1", + lastLogin: new Date("2023-01-01T00:00:00Z"), + isActive: true, + }, + { + _id: "user2", + lastLogin: new Date("2023-02-01T00:00:00Z"), + isActive: true, + }, + { _id: "user3", lastLogin: null, isActive: true }, + { + _id: "user4", + lastLogin: new Date("2023-03-01T00:00:00Z"), + isActive: true, + }, + ].slice(0, 100) + ); + + await checkStatus(); + + expect(UserModel.prototype.updateMany).to.have.been.calledWith( + { _id: { $in: ["user1", "user2", "user3"] } }, + { isActive: false } + ); + }); + }); + + describe("internal server error", () => { + it("should log internal server error when executing the function fails", async () => { + sinon.stub(UserModel.prototype, "find").throws(new Error("Test error")); + + await checkStatus(); + + expect(console.error).to.have.been.calledWith( + `Internal Server Error --- Test error` + ); + }); + }); + + describe("isActive false users", () => { + it("should skip already inactive users", async () => { + sinon.stub(UserModel.prototype, "find").resolves( + [ + { + _id: "user1", + lastLogin: new Date("2023-01-01T00:00:00Z"), + isActive: false, + }, + { + _id: "user2", + lastLogin: new Date("2023-02-01T00:00:00Z"), + isActive: true, + }, + { _id: "user3", lastLogin: null, isActive: true }, + ].slice(0, 100) + ); + + await checkStatus(); + + expect(UserModel.prototype.updateMany).to.have.been.calledWith( + { _id: { $in: ["user2", "user3"] } }, + { isActive: false } + ); + }); + }); +}); diff --git a/src/auth-service/bin/jobs/test/ut_incomplete-profile-job.js b/src/auth-service/bin/jobs/test/ut_incomplete-profile-job.js new file mode 100644 index 0000000000..04f1c05074 --- /dev/null +++ b/src/auth-service/bin/jobs/test/ut_incomplete-profile-job.js @@ -0,0 +1,156 @@ +require("module-alias/register"); +const sinon = require("sinon"); +const chai = require("chai"); +const expect = chai.expect; +const sinonChai = require("sinon-chai"); + +describe("checkStatus", () => { + let UserModel; + + beforeEach(() => { + // Set up mocks + UserModel = sinon.mock(UserModel); + + sinon.stub(UserModel.prototype, "find").resolves( + [ + { + _id: "user1", + firstName: "Unknown", + email: "user1@example.com", + isActive: false, + }, + { + _id: "user2", + firstName: "Unknown", + email: "user2@example.com", + isActive: true, + }, + { + _id: "user3", + firstName: "John", + email: "john@example.com", + isActive: false, + }, + ].slice(0, 100) + ); + + sinon.stub(console, "error"); + sinon.stub(stringify, "default").returns(JSON.stringify({})); + sinon.stub(mailer.updateProfileReminder).resolves({ + success: true, + data: {}, + }); + }); + + afterEach(() => { + // Restore mocks + UserModel.restore(); + console.error.restore(); + stringify.default.restore(); + mailer.updateProfileReminder.restore(); + }); + + describe("successful execution", () => { + it("should process users and send emails successfully", async () => { + await checkStatus(); + + expect(UserModel.prototype.find).to.have.been.calledThrice; + expect(mailer.updateProfileReminder).to.have.been.calledTwice; + expect(console.error).to.not.have.been.called; + }); + }); + + describe("no users found", () => { + it("should not log any errors when no users are found", async () => { + sinon.stub(UserModel.prototype, "find").resolves([]); + + await checkStatus(); + + expect(console.error).to.not.have.been.called; + }); + }); + + describe("email sending failure", () => { + it("should log error when sending email fails", async () => { + sinon.stub(mailer.updateProfileReminder).rejects(new Error("Test error")); + + await checkStatus(); + + expect(console.error).to.have.been.calledTwice; + }); + }); + + describe("internal server error", () => { + it("should log internal server error when executing the function fails", async () => { + sinon.stub(UserModel.prototype, "find").throws(new Error("Test error")); + + await checkStatus(); + + expect(console.error).to.have.been.calledWith( + `Internal Server Error --- Test error` + ); + }); + }); + + describe("unknown firstName", () => { + it("should skip users with known firstName", async () => { + sinon.stub(UserModel.prototype, "find").resolves( + [ + { + _id: "user1", + firstName: "John", + email: "john@example.com", + isActive: false, + }, + { + _id: "user2", + firstName: "Unknown", + email: "user2@example.com", + isActive: true, + }, + { + _id: "user3", + firstName: "Jane", + email: "jane@example.com", + isActive: false, + }, + ].slice(0, 100) + ); + + await checkStatus(); + + expect(mailer.updateProfileReminder).to.have.been.calledOnce; + }); + }); + + describe("active users", () => { + it("should skip active users", async () => { + sinon.stub(UserModel.prototype, "find").resolves( + [ + { + _id: "user1", + firstName: "Unknown", + email: "user1@example.com", + isActive: true, + }, + { + _id: "user2", + firstName: "Unknown", + email: "user2@example.com", + isActive: false, + }, + { + _id: "user3", + firstName: "Unknown", + email: "user3@example.com", + isActive: true, + }, + ].slice(0, 100) + ); + + await checkStatus(); + + expect(mailer.updateProfileReminder).to.have.been.calledTwice; + }); + }); +}); diff --git a/src/auth-service/bin/jobs/test/ut_preferences-log-job.js b/src/auth-service/bin/jobs/test/ut_preferences-log-job.js new file mode 100644 index 0000000000..b47960104a --- /dev/null +++ b/src/auth-service/bin/jobs/test/ut_preferences-log-job.js @@ -0,0 +1,120 @@ +require("module-alias/register"); +const sinon = require("sinon"); +const chai = require("chai"); +const expect = chai.expect; +const sinonChai = require("sinon-chai"); + +describe("logUserPreferences", () => { + let UserModel, PreferenceModel; + + beforeEach(() => { + // Set up mocks + UserModel = sinon.mock(UserModel); + PreferenceModel = sinon.mock(PreferenceModel); + + sinon.stub(UserModel.prototype, "find").resolves( + [ + { _id: "user1", email: "user1@example.com" }, + { _id: "user2", email: "user2@example.com" }, + { _id: "user3", email: "user3@example.com" }, + ].slice(0, 100) + ); + + sinon.stub(PreferenceModel.prototype, "find").resolves( + [ + { _id: "pref1", user_id: "user1", selected_sites: ["site1"] }, + { _id: "pref2", user_id: "user2", selected_sites: [] }, + { _id: "pref3", user_id: "user3", selected_sites: undefined }, + ].slice(0, 100) + ); + + sinon.stub(stringify, "default").returns(JSON.stringify({})); + + sinon.stub(console, "info"); + sinon.stub(console, "error"); + }); + + afterEach(() => { + // Restore mocks + UserModel.restore(); + PreferenceModel.restore(); + stringified.restore(); + console.info.restore(); + console.error.restore(); + }); + + describe("successful execution", () => { + it("should log the correct percentage of users without selected sites", async () => { + await logUserPreferences(); + + expect(UserModel.prototype.find).to.have.been.calledThrice; + expect(PreferenceModel.prototype.find).to.have.been.calledThrice; + expect(console.info).to.have.been.calledOnce; + expect(console.info).to.have.been.calledWithMatch( + "Total count of users without any Customised Locations:", + "which is", + "% of all Analytics users." + ); + }); + }); + + describe("no users found", () => { + it("should not log anything when no users are found", async () => { + sinon.stub(UserModel.prototype, "find").resolves([]); + + await logUserPreferences(); + + expect(console.info).to.not.have.been.called; + }); + }); + + describe("error handling", () => { + it("should log error when executing the function fails", async () => { + sinon.stub(UserModel.prototype, "find").throws(new Error("Test error")); + + await logUserPreferences(); + + expect(console.error).to.have.been.calledWith( + `🐛🐛 Error in logUserPreferences: Test error` + ); + }); + }); + + describe("empty selected sites", () => { + it("should count users with empty selected sites", async () => { + sinon.stub(PreferenceModel.prototype, "find").resolves( + [ + { _id: "pref1", user_id: "user1", selected_sites: [] }, + { _id: "pref2", user_id: "user2", selected_sites: undefined }, + ].slice(0, 100) + ); + + await logUserPreferences(); + + expect(console.info).to.have.been.calledWithMatch( + "Total count of users without any Customised Locations:", + "which is", + "% of all Analytics users." + ); + }); + }); + + describe("undefined selected sites", () => { + it("should count users with undefined selected sites", async () => { + sinon.stub(PreferenceModel.prototype, "find").resolves( + [ + { _id: "pref1", user_id: "user1", selected_sites: null }, + { _id: "pref2", user_id: "user2", selected_sites: undefined }, + ].slice(0, 100) + ); + + await logUserPreferences(); + + expect(console.info).to.have.been.calledWithMatch( + "Total count of users without any Customised Locations:", + "which is", + "% of all Analytics users." + ); + }); + }); +}); diff --git a/src/auth-service/bin/jobs/test/ut_preferences-update-job.js b/src/auth-service/bin/jobs/test/ut_preferences-update-job.js new file mode 100644 index 0000000000..d7ca3523c2 --- /dev/null +++ b/src/auth-service/bin/jobs/test/ut_preferences-update-job.js @@ -0,0 +1,79 @@ +require("module-alias/register"); +const sinon = require("sinon"); +const chai = require("chai"); +const expect = chai.expect; +const sinonChai = require("sinon-chai"); + +describe("updatePreferences", () => { + let UserModel, PreferenceModel, SelectedSiteModel; + + beforeEach(() => { + // Set up mocks + sinon.stub(PreferenceModel.prototype, "find").resolves([]); + sinon.stub(PreferenceModel.prototype, "create").resolves({}); + sinon.stub(PreferenceModel.prototype, "findOneAndUpdate").resolves({}); + + sinon + .stub(SelectedSiteModel.prototype, "find") + .resolves([{ site_id: "site1" }, { site_id: "site2" }]); + + sinon + .stub(UserModel.prototype, "find") + .resolves([{ _id: "user1" }, { _id: "user2" }]); + }); + + afterEach(() => { + // Restore mocks + sinon.restore(); + }); + + describe("successful execution", () => { + it("should update preferences for users", async () => { + await updatePreferences(); + + expect(PreferenceModel.prototype.create).to.have.been.calledTwice; + expect(PreferenceModel.prototype.findOneAndUpdate).to.have.been + .calledOnce; + }); + }); + + describe("error handling", () => { + it("should log errors when creating preferences fails", async () => { + const errorMock = new Error("Test error"); + sinon.stub(PreferenceModel.prototype, "create").rejects(errorMock); + + await updatePreferences(); + + expect(logObject).to.have.been.calledWith("error", errorMock); + }); + + it("should log errors when updating preferences fails", async () => { + const errorMock = new Error("Test error"); + sinon + .stub(PreferenceModel.prototype, "findOneAndUpdate") + .rejects(errorMock); + + await updatePreferences(); + + expect(logObject).to.have.been.calledWith("error", errorMock); + }); + }); + + describe("edge cases", () => { + it("should handle empty selected sites", async () => { + sinon.stub(SelectedSiteModel.prototype, "find").resolves([]); + + await updatePreferences(); + + expect(PreferenceModel.prototype.create).to.not.have.been.called; + }); + + it("should handle no users found", async () => { + sinon.stub(UserModel.prototype, "find").resolves([]); + + await updatePreferences(); + + expect(PreferenceModel.prototype.create).to.not.have.been.called; + }); + }); +}); diff --git a/src/auth-service/bin/jobs/test/ut_token-expiration-job.js b/src/auth-service/bin/jobs/test/ut_token-expiration-job.js new file mode 100644 index 0000000000..7d1b1d872f --- /dev/null +++ b/src/auth-service/bin/jobs/test/ut_token-expiration-job.js @@ -0,0 +1,118 @@ +require("module-alias/register"); +const sinon = require("sinon"); +const chai = require("chai"); +const expect = chai.expect; +const sinonChai = require("sinon-chai"); + +describe("sendAlertsForExpiringTokens", () => { + let AccessTokenModel; + + beforeEach(() => { + // Set up mocks + AccessTokenModel = sinon.mock(AccessTokenModel); + sinon.stub(AccessTokenModel.prototype, "getExpiringTokens").returns( + Promise.resolve({ + success: true, + data: [ + { + user: { + email: "test@example.com", + firstName: "John", + lastName: "Doe", + }, + }, + { + user: { + email: "test2@example.com", + firstName: "Jane", + lastName: "Smith", + }, + }, + ], + }) + ); + + sinon.stub(console, "info"); + sinon.stub(console, "error"); + }); + + afterEach(() => { + // Restore mocks + AccessTokenModel.restore(); + console.info.restore(); + console.error.restore(); + }); + + describe("successful execution", () => { + it("should fetch expiring tokens and send emails", async () => { + await sendAlertsForExpiringTokens(); + + expect(AccessTokenModel.prototype.getExpiringTokens).to.have.been + .calledOnce; + expect( + AccessTokenModel.prototype.getExpiringTokens + ).to.have.been.calledWith({ + skip: 0, + limit: 100, + }); + + const emailPromises = [ + { + user: { + email: "test@example.com", + firstName: "John", + lastName: "Doe", + }, + }, + { + user: { + email: "test2@example.com", + firstName: "Jane", + lastName: "Smith", + }, + }, + ]; + + expect(mailer.expandingToken).to.have.been.calledTwice; + expect(mailer.expandingToken).to.have.been.calledWithMatch({ + email: "test@example.com", + firstName: "John", + lastName: "Doe", + }); + expect(mailer.expandingToken).to.have.been.calledWithMatch({ + email: "test2@example.com", + firstName: "Jane", + lastName: "Smith", + }); + }); + }); + + describe("no expiring tokens found", () => { + it("should log info message when no tokens are found", async () => { + AccessTokenModel.mock().getExpiringTokens.resolves({ + success: true, + data: [], + }); + + await sendAlertsForExpiringTokens(); + + expect(console.info).to.have.been.calledWith( + "No expiring tokens found for this month." + ); + }); + }); + + describe("error handling", () => { + it("should log error when fetching tokens fails", async () => { + AccessTokenModel.mock().getExpiringTokens.rejects( + new Error("Test error") + ); + + await sendAlertsForExpiringTokens(); + + expect(console.error).to.have.been.calledWith( + `🐛🐛 Internal Server Error -- Test error` + ); + }); + }); +}); diff --git a/src/auth-service/bin/server.js b/src/auth-service/bin/server.js index cd71c950b4..a9f0b95e49 100644 --- a/src/auth-service/bin/server.js +++ b/src/auth-service/bin/server.js @@ -23,6 +23,7 @@ require("@bin/jobs/active-status-job"); require("@bin/jobs/token-expiration-job"); require("@bin/jobs/incomplete-profile-job"); require("@bin/jobs/preferences-log-job"); +require("@bin/jobs/preferences-update-job"); const log4js = require("log4js"); const debug = require("debug")("auth-service:server"); const isEmpty = require("is-empty"); diff --git a/src/auth-service/controllers/create-preference.js b/src/auth-service/controllers/create-preference.js index d526ec1d0c..90da66586f 100644 --- a/src/auth-service/controllers/create-preference.js +++ b/src/auth-service/controllers/create-preference.js @@ -319,6 +319,228 @@ const preferences = { return; } }, + addSelectedSites: async (req, res, next) => { + try { + const errors = extractErrorsFromRequest(req); + if (errors) { + next( + new HttpError("bad request errors", httpStatus.BAD_REQUEST, errors) + ); + return; + } + const request = req; + const defaultTenant = constants.DEFAULT_TENANT || "airqo"; + request.query.tenant = isEmpty(req.query.tenant) + ? defaultTenant + : req.query.tenant; + + const result = await createPreferenceUtil.addSelectedSites(request, next); + + if (isEmpty(result) || res.headersSent) { + return; + } + + if (result.success === true) { + const status = result.status ? result.status : httpStatus.OK; + res.status(status).json({ + success: true, + message: result.message, + seletected_sites: result.data, + }); + } else if (result.success === false) { + const status = result.status + ? result.status + : httpStatus.INTERNAL_SERVER_ERROR; + res.status(status).json({ + success: false, + message: result.message, + seletected_sites: result.data, + errors: result.errors + ? result.errors + : { message: "Internal Server Error" }, + }); + } + } catch (error) { + logger.error(`🐛🐛 Internal Server Error ${error.message}`); + next( + new HttpError( + "Internal Server Error", + httpStatus.INTERNAL_SERVER_ERROR, + { message: error.message } + ) + ); + return; + } + }, + updateSelectedSite: async (req, res, next) => { + try { + const errors = extractErrorsFromRequest(req); + if (errors) { + next( + new HttpError("bad request errors", httpStatus.BAD_REQUEST, errors) + ); + return; + } + const request = req; + const defaultTenant = constants.DEFAULT_TENANT || "airqo"; + request.query.tenant = isEmpty(req.query.tenant) + ? defaultTenant + : req.query.tenant; + + const result = await createPreferenceUtil.updateSelectedSite( + request, + next + ); + + if (isEmpty(result) || res.headersSent) { + return; + } + + if (result.success === true) { + const status = result.status ? result.status : httpStatus.OK; + res.status(status).json({ + success: true, + message: result.message, + selected_site: result.data, + }); + } else if (result.success === false) { + const status = result.status + ? result.status + : httpStatus.INTERNAL_SERVER_ERROR; + res.status(status).json({ + success: false, + message: result.message, + selected_site: result.data, + errors: result.errors + ? result.errors + : { message: "Internal Server Error" }, + }); + } + } catch (error) { + logger.error(`🐛🐛 Internal Server Error ${error.message}`); + next( + new HttpError( + "Internal Server Error", + httpStatus.INTERNAL_SERVER_ERROR, + { message: error.message } + ) + ); + return; + } + }, + deleteSelectedSite: async (req, res, next) => { + try { + logText("deleting preference.........."); + const errors = extractErrorsFromRequest(req); + if (errors) { + next( + new HttpError("bad request errors", httpStatus.BAD_REQUEST, errors) + ); + return; + } + const request = req; + const defaultTenant = constants.DEFAULT_TENANT || "airqo"; + request.query.tenant = isEmpty(req.query.tenant) + ? defaultTenant + : req.query.tenant; + + const result = await createPreferenceUtil.deleteSelectedSite( + request, + next + ); + + if (isEmpty(result) || res.headersSent) { + return; + } + + if (result.success === true) { + const status = result.status ? result.status : httpStatus.OK; + res.status(status).json({ + success: true, + message: result.message, + selected_site: result.data, + }); + } else if (result.success === false) { + const status = result.status + ? result.status + : httpStatus.INTERNAL_SERVER_ERROR; + res.status(status).json({ + success: false, + message: result.message, + selected_site: result.data, + errors: result.errors + ? result.errors + : { message: "Internal Server Error" }, + }); + } + } catch (error) { + logger.error(`🐛🐛 Internal Server Error ${error.message}`); + next( + new HttpError( + "Internal Server Error", + httpStatus.INTERNAL_SERVER_ERROR, + { message: error.message } + ) + ); + return; + } + }, + listSelectedSites: async (req, res, next) => { + try { + logText("....................................."); + logText("list all preferences by query params provided"); + const errors = extractErrorsFromRequest(req); + if (errors) { + next( + new HttpError("bad request errors", httpStatus.BAD_REQUEST, errors) + ); + return; + } + const request = req; + const defaultTenant = constants.DEFAULT_TENANT || "airqo"; + request.query.tenant = isEmpty(req.query.tenant) + ? defaultTenant + : req.query.tenant; + + const result = await createPreferenceUtil.listSelectedSites( + request, + next + ); + + if (isEmpty(result) || res.headersSent) { + return; + } + + if (result.success === true) { + const status = result.status ? result.status : httpStatus.OK; + res.status(status).json({ + success: true, + message: result.message, + selected_sites: result.data, + }); + } else if (result.success === false) { + const status = result.status + ? result.status + : httpStatus.INTERNAL_SERVER_ERROR; + return res.status(status).json({ + success: false, + message: result.message, + errors: result.errors ? result.errors : { message: "" }, + }); + } + } catch (error) { + logObject("error", error); + logger.error(`🐛🐛 Internal Server Error ${error.message}`); + next( + new HttpError( + "Internal Server Error", + httpStatus.INTERNAL_SERVER_ERROR, + { message: error.message } + ) + ); + return; + } + }, }; module.exports = preferences; diff --git a/src/auth-service/controllers/test/ut_create-preference.js b/src/auth-service/controllers/test/ut_create-preference.js index 26707c1b42..593729d179 100644 --- a/src/auth-service/controllers/test/ut_create-preference.js +++ b/src/auth-service/controllers/test/ut_create-preference.js @@ -1,10 +1,381 @@ require("module-alias/register"); const sinon = require("sinon"); -const { expect } = require("chai"); -const { badRequest, convertErrorArrayToObject } = require("@utils/errors"); -const constants = require("@config/constants"); -const httpStatus = require("http-status"); -const createPreferenceController = require("@utils/create-preference"); -const { validationResult } = require("express-validator"); - -describe("create preference controller", () => {}); +const chai = require("chai"); +const expect = chai.expect; +const sinonChai = require("sinon-chai"); + +describe("preferences", () => { + let createPreferenceUtil; + + beforeEach(() => { + // Set up mocks + createPreferenceUtil = sinon.mock(); + + sinon + .stub(createPreferenceUtil, "update") + .resolves({ + success: true, + status: 200, + message: "Update successful", + data: {}, + }); + sinon + .stub(createPreferenceUtil, "create") + .resolves({ + success: true, + status: 201, + message: "Create successful", + data: {}, + }); + sinon + .stub(createPreferenceUtil, "upsert") + .resolves({ + success: true, + status: 200, + message: "Upsert successful", + data: {}, + }); + sinon + .stub(createPreferenceUtil, "replace") + .resolves({ + success: true, + status: 200, + message: "Replace successful", + data: {}, + }); + sinon + .stub(createPreferenceUtil, "list") + .resolves({ + success: true, + status: 200, + message: "List successful", + data: [], + }); + sinon + .stub(createPreferenceUtil, "delete") + .resolves({ success: true, status: 204, message: "Delete successful" }); + sinon + .stub(createPreferenceUtil, "addSelectedSites") + .resolves({ + success: true, + status: 200, + message: "Add selected sites successful", + data: {}, + }); + sinon + .stub(createPreferenceUtil, "updateSelectedSite") + .resolves({ + success: true, + status: 200, + message: "Update selected site successful", + data: {}, + }); + sinon + .stub(createPreferenceUtil, "deleteSelectedSite") + .resolves({ + success: true, + status: 204, + message: "Delete selected site successful", + }); + + sinon.stub(extractErrorsFromRequest).returns(null); + sinon.stub(HttpError, "default").throws(new Error("HttpError")); + }); + + afterEach(() => { + // Restore mocks + createPreferenceUtil.restore(); + extractErrorsFromRequest.restore(); + HttpError.restore(); + }); + + describe("update", () => { + it("should update preference successfully", async () => { + const req = { query: {}, body: {} }; + const res = { json: sinon.spy() }; + + await preferences.update(req, res); + + expect(res.json).to.have.been.calledWith({ + success: true, + message: "Update successful", + preference: {}, + }); + }); + + it("should handle errors from createPreferenceUtil", async () => { + sinon.stub(createPreferenceUtil.update).rejects(new Error("Test error")); + + const req = { query: {}, body: {} }; + const res = { json: sinon.spy() }; + + await preferences.update(req, res); + + expect(res.status).to.have.been.calledWith(500); + expect(res.json).to.have.been.calledWith({ + success: false, + message: "Internal Server Error", + preference: {}, + errors: { message: "Test error" }, + }); + }); + }); + + describe("create", () => { + it("should create preference successfully", async () => { + const req = { query: {}, body: {} }; + const res = { json: sinon.spy() }; + + await preferences.create(req, res); + + expect(res.json).to.have.been.calledWith({ + success: true, + message: "Create successful", + preference: {}, + }); + }); + + it("should handle errors from createPreferenceUtil", async () => { + sinon.stub(createPreferenceUtil.create).rejects(new Error("Test error")); + + const req = { query: {}, body: {} }; + const res = { json: sinon.spy() }; + + await preferences.create(req, res); + + expect(res.status).to.have.been.calledWith(500); + expect(res.json).to.have.been.calledWith({ + success: false, + message: "Internal Server Error", + preference: {}, + errors: { message: "Test error" }, + }); + }); + }); + + describe("upsert", () => { + it("should upsert preference successfully", async () => { + const req = { query: {}, body: {} }; + const res = { json: sinon.spy() }; + + await preferences.upsert(req, res); + + expect(res.json).to.have.been.calledWith({ + success: true, + message: "Upsert successful", + preference: {}, + }); + }); + + it("should handle errors from createPreferenceUtil", async () => { + sinon.stub(createPreferenceUtil.upsert).rejects(new Error("Test error")); + + const req = { query: {}, body: {} }; + const res = { json: sinon.spy() }; + + await preferences.upsert(req, res); + + expect(res.status).to.have.been.calledWith(500); + expect(res.json).to.have.been.calledWith({ + success: false, + message: "Internal Server Error", + preference: {}, + errors: { message: "Test error" }, + }); + }); + }); + + describe("replace", () => { + it("should replace preference successfully", async () => { + const req = { query: {}, body: {} }; + const res = { json: sinon.spy() }; + + await preferences.replace(req, res); + + expect(res.json).to.have.been.calledWith({ + success: true, + message: "Replace successful", + preference: {}, + }); + }); + + it("should handle errors from createPreferenceUtil", async () => { + sinon.stub(createPreferenceUtil.replace).rejects(new Error("Test error")); + + const req = { query: {}, body: {} }; + const res = { json: sinon.spy() }; + + await preferences.replace(req, res); + + expect(res.status).to.have.been.calledWith(500); + expect(res.json).to.have.been.calledWith({ + success: false, + message: "Internal Server Error", + preference: {}, + errors: { message: "Test error" }, + }); + }); + }); + + describe("list", () => { + it("should list preferences successfully", async () => { + const req = { query: {}, body: {} }; + const res = { json: sinon.spy() }; + + await preferences.list(req, res); + + expect(res.json).to.have.been.calledWith({ + success: true, + message: "List successful", + preferences: [], + }); + }); + }); + + describe("delete", () => { + it("should delete preference successfully", async () => { + const req = { query: {}, body: {} }; + const res = { json: sinon.spy() }; + + await preferences.delete(req, res); + + expect(res.json).to.have.been.calledWith({ + success: true, + message: "Delete successful", + }); + }); + + it("should handle errors from createPreferenceUtil", async () => { + sinon.stub(createPreferenceUtil.delete).rejects(new Error("Test error")); + + const req = { query: {}, body: {} }; + const res = { json: sinon.spy() }; + + await preferences.delete(req, res); + + expect(res.status).to.have.been.calledWith(500); + expect(res.json).to.have.been.calledWith({ + success: false, + message: "Internal Server Error", + preference: {}, + errors: { message: "Test error" }, + }); + }); + }); + + describe("addSelectedSites", () => { + it("should add selected sites successfully", async () => { + const req = { query: {}, body: {} }; + const res = { json: sinon.spy() }; + + await preferences.addSelectedSites(req, res); + + expect(res.json).to.have.been.calledWith({ + success: true, + message: "Add selected sites successful", + seletected_sites: {}, + }); + }); + + it("should handle errors from createPreferenceUtil", async () => { + sinon + .stub(createPreferenceUtil.addSelectedSites) + .rejects(new Error("Test error")); + + const req = { query: {}, body: {} }; + const res = { json: sinon.spy() }; + + await preferences.addSelectedSites(req, res); + + expect(res.status).to.have.been.calledWith(500); + expect(res.json).to.have.been.calledWith({ + success: false, + message: "Internal Server Error", + seletected_sites: {}, + errors: { message: "Test error" }, + }); + }); + }); + + describe("updateSelectedSite", () => { + it("should update selected site successfully", async () => { + const req = { query: {}, body: {} }; + const res = { json: sinon.spy() }; + + await preferences.updateSelectedSite(req, res); + + expect(res.json).to.have.been.calledWith({ + success: true, + message: "Update selected site successful", + selected_site: {}, + }); + }); + + it("should handle errors from createPreferenceUtil", async () => { + sinon + .stub(createPreferenceUtil.updateSelectedSite) + .rejects(new Error("Test error")); + + const req = { query: {}, body: {} }; + const res = { json: sinon.spy() }; + + await preferences.updateSelectedSite(req, res); + + expect(res.status).to.have.been.calledWith(500); + expect(res.json).to.have.been.calledWith({ + success: false, + message: "Internal Server Error", + selected_site: {}, + errors: { message: "Test error" }, + }); + }); + }); + + describe("deleteSelectedSite", () => { + it("should delete selected site successfully", async () => { + const req = { query: {}, body: {} }; + const res = { json: sinon.spy() }; + + await preferences.deleteSelectedSite(req, res); + + expect(res.json).to.have.been.calledWith({ + success: true, + message: "Delete selected site successful", + }); + }); + + it("should handle errors from createPreferenceUtil", async () => { + sinon + .stub(createPreferenceUtil.deleteSelectedSite) + .rejects(new Error("Test error")); + + const req = { query: {}, body: {} }; + const res = { json: sinon.spy() }; + + await preferences.deleteSelectedSite(req, res); + + expect(res.status).to.have.been.calledWith(500); + expect(res.json).to.have.been.calledWith({ + success: false, + message: "Internal Server Error", + selected_site: {}, + errors: { message: "Test error" }, + }); + }); + }); +}); + +// Helper functions +function extractErrorsFromRequest(req) { + // Mock implementation + return null; +} + +class HttpError extends Error { + constructor(message, status, details) { + super(message); + this.name = "HttpError"; + this.status = status; + this.details = details || {}; + } +} diff --git a/src/auth-service/models/SelectedSite.js b/src/auth-service/models/SelectedSite.js new file mode 100644 index 0000000000..dc8b9237a1 --- /dev/null +++ b/src/auth-service/models/SelectedSite.js @@ -0,0 +1,250 @@ +const mongoose = require("mongoose").set("debug", true); +const Schema = mongoose.Schema; +const { getModelByTenant } = require("@config/database"); +const constants = require("@config/constants"); +const logger = require("log4js").getLogger( + `${constants.ENVIRONMENT} -- selected-sites-model` +); +const { HttpError } = require("@utils/errors"); +const isEmpty = require("is-empty"); +const httpStatus = require("http-status"); + +const SelectedSiteSchema = new Schema( + { + site_id: { type: String }, + latitude: { type: Number }, + longitude: { type: Number }, + site_tags: [{ type: String }], + country: { type: String }, + district: { type: String }, + sub_county: { type: String }, + parish: { type: String }, + county: { type: String }, + generated_name: { type: String }, + name: { type: String }, + lat_long: { type: String }, + city: { type: String }, + formatted_name: { type: String }, + region: { type: String }, + search_name: { type: String }, + approximate_latitude: { type: Number }, + approximate_longitude: { type: Number }, + isFeatured: { type: Boolean, default: false }, + }, + { timestamps: true } +); + +SelectedSiteSchema.pre("save", function (next) { + return next(); +}); +SelectedSiteSchema.pre("update", function (next) { + return next(); +}); + +SelectedSiteSchema.index({ name: 1 }, { unique: true }); +SelectedSiteSchema.index({ site_id: 1 }, { unique: true }); +SelectedSiteSchema.index({ lat_long: 1 }, { unique: true }); +SelectedSiteSchema.index({ generated_name: 1 }, { unique: true }); +SelectedSiteSchema.index({ isFeatured: 1, createdAt: -1 }); + +SelectedSiteSchema.statics = { + async register(args, next) { + try { + const data = await this.create({ + ...args, + }); + if (!isEmpty(data)) { + return { + success: true, + data, + message: "selected site created", + status: httpStatus.OK, + }; + } else if (isEmpty(data)) { + return { + success: true, + data, + message: + "Operation successful but selected site NOT successfully created", + status: httpStatus.OK, + }; + } + } catch (err) { + logObject("the error", err); + let response = {}; + let message = "validation errors for some of the provided fields"; + let status = httpStatus.CONFLICT; + if (err.code === 11000) { + logObject("the err.code again", err.code); + const duplicate_record = args.email ? args.email : args.userName; + response[duplicate_record] = `${duplicate_record} must be unique`; + response["message"] = + "the email and userName must be unique for every selected site"; + } else if (err.keyValue) { + Object.entries(err.keyValue).forEach(([key, value]) => { + return (response[key] = `the ${key} must be unique`); + }); + } else if (err.errors) { + Object.entries(err.errors).forEach(([key, value]) => { + return (response[key] = value.message); + }); + } + logger.error(`🐛🐛 Internal Server Error -- ${err.message}`); + next(new HttpError(message, status, response)); + } + }, + async list({ skip = 0, limit = 100, filter = {} } = {}, next) { + try { + const response = await this.aggregate() + .match(filter) + .sort({ createdAt: -1 }) + .skip(skip ? skip : 0) + .limit(limit ? limit : parseInt(constants.DEFAULT_LIMIT)) + .allowDiskUse(true); + + if (!isEmpty(response)) { + return { + success: true, + message: "successfully retrieved the selected site details", + data: response, + status: httpStatus.OK, + }; + } else if (isEmpty(response)) { + return { + success: true, + message: "no selected sites exist", + data: [], + status: httpStatus.OK, + }; + } + } catch (error) { + logger.error(`🐛🐛 Internal Server Error -- ${error.message}`); + next( + new HttpError( + "Internal Server Error", + httpStatus.INTERNAL_SERVER_ERROR, + { message: error.message } + ) + ); + } + }, + async modify({ filter = {}, update = {} } = {}, next) { + try { + logText("the selected site modification function........"); + let options = { new: true }; + const fieldNames = Object.keys(update); + const fieldsString = fieldNames.join(" "); + let modifiedUpdate = update; + const updatedSelectedSite = await this.findOneAndUpdate( + filter, + modifiedUpdate, + options + ).select(fieldsString); + + if (!isEmpty(updatedSelectedSite)) { + return { + success: true, + message: "successfully modified the selected site", + data: updatedSelectedSite._doc, + status: httpStatus.OK, + }; + } else if (isEmpty(updatedSelectedSite)) { + next( + new HttpError("Bad Request Error", httpStatus.BAD_REQUEST, { + message: "selected site does not exist, please crosscheck", + }) + ); + } + } catch (error) { + logger.error(`🐛🐛 Internal Server Error -- ${error.message}`); + next( + new HttpError( + "Internal Server Error", + httpStatus.INTERNAL_SERVER_ERROR, + { message: error.message } + ) + ); + } + }, + async remove({ filter = {} } = {}, next) { + try { + const options = { + projection: { + _id: 0, + site_id: 1, + name: 1, + generated_name: 1, + lat_long: 1, + }, + }; + const removedSelectedSite = await this.findOneAndRemove( + filter, + options + ).exec(); + + if (!isEmpty(removedSelectedSite)) { + return { + success: true, + message: "Successfully removed the selected site", + data: removedSelectedSite._doc, + status: httpStatus.OK, + }; + } else if (isEmpty(removedSelectedSite)) { + next( + new HttpError("Bad Request Error", httpStatus.BAD_REQUEST, { + message: "Provided User does not exist, please crosscheck", + }) + ); + } + } catch (error) { + logObject("the models error", error); + logger.error(`🐛🐛 Internal Server Error -- ${error.message}`); + next( + new HttpError( + "Internal Server Error", + httpStatus.INTERNAL_SERVER_ERROR, + { message: error.message } + ) + ); + } + }, +}; + +SelectedSiteSchema.methods = { + toJSON() { + return { + _id: this._id, + site_id: this.site_id, + latitude: this.latitude, + longitude: this.longitude, + site_tags: this.site_tags, + country: this.country, + district: this.district, + sub_county: this.sub_county, + parish: this.parish, + county: this.county, + generated_name: this.generated_name, + name: this.name, + lat_long: this.lat_long, + city: this.city, + formatted_name: this.formatted_name, + region: this.region, + search_name: this.search_name, + approximate_latitude: this.approximate_latitude, + approximate_longitude: this.approximate_longitude, + isFeatured: this.isFeatured, + }; + }, +}; + +const SelectedSiteModel = (tenant) => { + try { + let sites = mongoose.model("selected_sites"); + return sites; + } catch (error) { + let sites = getModelByTenant(tenant, "selected_site", SelectedSiteSchema); + return sites; + } +}; + +module.exports = SelectedSiteModel; diff --git a/src/auth-service/models/test/ut_selected_site.js b/src/auth-service/models/test/ut_selected_site.js new file mode 100644 index 0000000000..e70f59a93b --- /dev/null +++ b/src/auth-service/models/test/ut_selected_site.js @@ -0,0 +1,240 @@ +require("module-alias/register"); +const sinon = require("sinon"); +const chai = require("chai"); +const expect = chai.expect; +const sinonChai = require("sinon-chai"); +const mongoose = require("mongoose"); +const constants = require("@config/constants"); + +describe("SelectedSiteModel", () => { + let SelectedSiteModel; + + beforeEach(() => { + // Mock the SelectedSiteModel + SelectedSiteModel = sinon.mock(mongoose.Model); + }); + + afterEach(() => { + // Restore the original SelectedSiteModel + SelectedSiteModel.restore(); + }); + + describe("static methods", () => { + describe("register", () => { + it("should create a new selected site", async () => { + const args = { site_id: "test-site", name: "Test Site" }; + const next = sinon.spy(); + + SelectedSiteModel.prototype.create.resolves({}); + + const result = await SelectedSiteModel.register(args, next); + + expect(result).to.deep.equal({ + success: true, + data: {}, + message: "selected site created", + status: 200, + }); + expect(next).not.to.have.been.called; + }); + + it("should handle validation errors", async () => { + const args = { site_id: "test-site" }; + const next = sinon.spy(); + + SelectedSiteModel.prototype.create.rejects( + new mongoose.Error.ValidationError() + ); + + const result = await SelectedSiteModel.register(args, next); + + expect(result).to.deep.equal({ + success: false, + message: "validation errors for some of the provided fields", + status: 409, + }); + expect(next).not.to.have.been.called; + }); + + it("should handle duplicate key errors", async () => { + const args = { site_id: "existing-site" }; + const next = sinon.spy(); + + SelectedSiteModel.prototype.create.rejects( + new mongoose.Error.ValidatorFailure({ + keyValue: { site_id: "existing-site" }, + }) + ); + + const result = await SelectedSiteModel.register(args, next); + + expect(result).to.deep.equal({ + success: false, + message: "the site_id must be unique", + status: 409, + }); + expect(next).not.to.have.been.called; + }); + }); + + describe("list", () => { + it("should return selected sites successfully", async () => { + const filter = {}; + const skip = 0; + const limit = 100; + + const mockResponse = [ + { id: "site1", name: "Site 1" }, + { id: "site2", name: "Site 2" }, + ]; + + SelectedSiteModel.aggregate.resolves(mockResponse); + + const result = await SelectedSiteModel.list( + { filter, skip, limit }, + null + ); + + expect(result).to.deep.equal({ + success: true, + message: "successfully retrieved the selected site details", + data: mockResponse, + status: 200, + }); + }); + + it("should return empty array when no sites exist", async () => { + const filter = {}; + const skip = 0; + const limit = 100; + + const mockResponse = []; + + SelectedSiteModel.aggregate.resolves(mockResponse); + + const result = await SelectedSiteModel.list( + { filter, skip, limit }, + null + ); + + expect(result).to.deep.equal({ + success: true, + message: "no selected sites exist", + data: [], + status: 200, + }); + }); + }); + + describe("modify", () => { + it("should modify a selected site successfully", async () => { + const filter = { site_id: "test-site" }; + const update = { name: "Updated Test Site" }; + + const mockResponse = { _doc: { ...update, site_id: "test-site" } }; + + SelectedSiteModel.findOneAndUpdate.resolves(mockResponse); + + const result = await SelectedSiteModel.modify({ filter, update }, null); + + expect(result).to.deep.equal({ + success: true, + message: "successfully modified the selected site", + data: { ...update, site_id: "test-site" }, + status: 200, + }); + }); + + it("should handle missing document error", async () => { + const filter = { site_id: "non-existent-site" }; + const update = { name: "Non-existent Site" }; + + SelectedSiteModel.findOneAndUpdate.rejects( + new Error("Document not found") + ); + + const result = await SelectedSiteModel.modify({ filter, update }, null); + + expect(result).to.deep.equal({ + success: false, + message: "selected site does not exist, please crosscheck", + status: 400, + }); + }); + }); + + describe("remove", () => { + it("should remove a selected site successfully", async () => { + const filter = { site_id: "test-site" }; + + const mockRemovedSite = { + _doc: { site_id: "test-site", name: "Test Site" }, + }; + + SelectedSiteModel.findOneAndRemove.resolves(mockRemovedSite); + + const result = await SelectedSiteModel.remove(filter, null); + + expect(result).to.deep.equal({ + success: true, + message: "Successfully removed the selected site", + data: { site_id: "test-site", name: "Test Site" }, + status: 200, + }); + }); + + it("should handle missing document error", async () => { + const filter = { site_id: "non-existent-site" }; + + SelectedSiteModel.findOneAndRemove.rejects( + new Error("Document not found") + ); + + const result = await SelectedSiteModel.remove(filter, null); + + expect(result).to.deep.equal({ + success: false, + message: "Provided User does not exist, please crosscheck", + status: 400, + }); + }); + }); + }); + + describe("instance methods", () => { + describe("toJSON", () => { + it("should return a formatted object", () => { + const instance = new SelectedSiteModel(); + instance.site_id = "test-site"; + instance.latitude = 1.2345; + instance.longitude = 2.3456; + instance.name = "Test Site"; + + const result = instance.toJSON(); + + expect(result).to.deep.equal({ + _id: undefined, + site_id: "test-site", + latitude: 1.2345, + longitude: 2.3456, + site_tags: undefined, + country: undefined, + district: undefined, + sub_county: undefined, + parish: undefined, + county: undefined, + generated_name: undefined, + name: "Test Site", + lat_long: undefined, + city: undefined, + formatted_name: undefined, + region: undefined, + search_name: undefined, + approximate_latitude: undefined, + approximate_longitude: undefined, + isFeatured: undefined, + }); + }); + }); + }); +}); diff --git a/src/auth-service/routes/v2/preferences.js b/src/auth-service/routes/v2/preferences.js index 56375eb662..df4a09fcb8 100644 --- a/src/auth-service/routes/v2/preferences.js +++ b/src/auth-service/routes/v2/preferences.js @@ -104,6 +104,82 @@ function validateSelectedSitesField(value) { return numericValid && stringValid && tagValid; } +function validateDefaultSelectedSitesField(value) { + const requiredFields = ["site_id", "search_name", "name"]; + // Check if all required fields exist + if (!requiredFields.every((field) => field in value)) { + return false; + } + + function validateNumericFields(fields) { + let isValid = true; + + fields.forEach((field) => { + if (!(field in value)) { + isValid = false; + return; + } + const numValue = parseFloat(value[field]); + if (Number.isNaN(numValue)) { + isValid = false; + return; + } else if ( + field === "latitude" || + field === "longitude" || + field === "approximate_latitude" || + field === "approximate_longitude" + ) { + if (Math.abs(numValue) > 90) { + isValid = false; + return; + } + } else if (field === "search_radius") { + if (numValue <= 0) { + isValid = false; + return; + } + } + }); + + return isValid; + } + + function validateStringFields(fields) { + let isValid = true; + fields.forEach((field) => { + if (typeof value[field] !== "string" || value[field].trim() === "") { + isValid = false; + return; + } + }); + return isValid; + } + + function validateTags(tags) { + if (isEmpty(tags)) { + return true; + } else if (!Array.isArray(tags)) { + return false; + } else { + return tags.every((tag) => typeof tag === "string"); + } + } + + const numericValid = validateNumericFields([ + "latitude", + "longitude", + "approximate_latitude", + "approximate_longitude", + ]); + + const stringValid = validateStringFields(["name", "search_name"]); + + const tags = value && value.site_tags; + const tagValid = validateTags(tags); + + return numericValid && stringValid && tagValid; +} + router.use(headers); router.use(validatePagination); @@ -1006,6 +1082,186 @@ router.delete( authJWT, createPreferenceController.delete ); +router.get( + "/selected-sites", + oneOf([ + [ + query("tenant") + .optional() + .notEmpty() + .withMessage("tenant should not be empty if provided") + .trim() + .toLowerCase() + .bail() + .isIn(["kcca", "airqo"]) + .withMessage("the tenant value is not among the expected ones"), + ], + ]), + oneOf([ + [ + query("airqloud_id") + .optional() + .notEmpty() + .withMessage("the provided airqloud_id should not be empty IF provided") + .bail() + .trim() + .isMongoId() + .withMessage("the airqloud_id must be an object ID") + .bail() + .customSanitizer((value) => { + return ObjectId(value); + }), + query("cohort_id") + .optional() + .notEmpty() + .withMessage("the provided cohort_id should not be empty IF provided") + .bail() + .trim() + .isMongoId() + .withMessage("the cohort_id must be an object ID") + .bail() + .customSanitizer((value) => { + return ObjectId(value); + }), + query("grid_id") + .optional() + .notEmpty() + .withMessage("the provided grid_id should not be empty IF provided") + .bail() + .trim() + .isMongoId() + .withMessage("the grid_id must be an object ID") + .bail() + .customSanitizer((value) => { + return ObjectId(value); + }), + query("site_id") + .optional() + .notEmpty() + .withMessage("the provided site_id should not be empty IF provided") + .bail() + .trim() + .isMongoId() + .withMessage("the site_id must be an object ID") + .bail() + .customSanitizer((value) => { + return ObjectId(value); + }), + ], + ]), + createPreferenceController.listSelectedSites +); + +router.post( + "/selected-sites", + oneOf([ + [ + query("tenant") + .optional() + .notEmpty() + .withMessage("tenant should not be empty if provided") + .trim() + .toLowerCase() + .bail() + .isIn(["kcca", "airqo"]) + .withMessage("the tenant value is not among the expected ones"), + ], + ]), + oneOf([ + [ + body("selected_sites") + .exists() + .withMessage("selected_sites should be provided") + .bail() + .isArray() + .withMessage("selected_sites should be an array") + .bail() + .notEmpty() + .withMessage("selected_sites should not be empty"), + body("selected_sites.*") + .custom(validateDefaultSelectedSitesField) + .withMessage( + "Invalid selected_sites format. Verify required fields and data types." + ), + ], + ]), + setJWTAuth, + authJWT, + createPreferenceController.addSelectedSites +); + +router.put( + "/selected-sites/:site_id", + oneOf([ + [ + query("tenant") + .optional() + .notEmpty() + .withMessage("tenant should not be empty if provided") + .trim() + .toLowerCase() + .bail() + .isIn(["kcca", "airqo"]) + .withMessage("the tenant value is not among the expected ones"), + ], + ]), + oneOf([ + [ + param("site_id") + .exists() + .withMessage("the site_id parameter is required") + .bail() + .isMongoId() + .withMessage("site_id must be a valid MongoDB ObjectId") + .bail() + .customSanitizer((value) => { + return ObjectId(value); + }), + body() + .custom(validateDefaultSelectedSitesField) + .withMessage( + "Invalid selected site data. Verify required fields and data types." + ), + ], + ]), + setJWTAuth, + authJWT, + createPreferenceController.updateSelectedSite +); + +router.delete( + "/selected-sites/:site_id", + oneOf([ + [ + query("tenant") + .optional() + .notEmpty() + .withMessage("tenant should not be empty if provided") + .trim() + .toLowerCase() + .bail() + .isIn(["kcca", "airqo"]) + .withMessage("the tenant value is not among the expected ones"), + ], + ]), + oneOf([ + [ + param("site_id") + .exists() + .withMessage("the site_id parameter is required") + .bail() + .isMongoId() + .withMessage("site_id must be a valid MongoDB ObjectId") + .bail() + .customSanitizer((value) => { + return ObjectId(value); + }), + ], + ]), + setJWTAuth, + authJWT, + createPreferenceController.deleteSelectedSite +); router.get( "/:user_id", diff --git a/src/auth-service/routes/v2/test/ut_preferences.js b/src/auth-service/routes/v2/test/ut_preferences.js index e69de29bb2..dbac9e980b 100644 --- a/src/auth-service/routes/v2/test/ut_preferences.js +++ b/src/auth-service/routes/v2/test/ut_preferences.js @@ -0,0 +1,144 @@ +require("module-alias/register"); +const express = require("express"); +const request = require("supertest"); +const mongoose = require("mongoose"); +const ObjectId = mongoose.Types.ObjectId; + +// Mock the router +jest.mock("@controllers/create-preference", () => ({ + create: jest.fn(), + list: jest.fn(), + delete: jest.fn(), + addSelectedSites: jest.fn(), + updateSelectedSite: jest.fn(), + deleteSelectedSite: jest.fn(), +})); + +describe("Preference Controller Tests", () => { + let app; + + beforeEach(() => { + app = express(); + app.use(express.json()); + app.use((req, res, next) => { + req.query = {}; + req.body = {}; + req.params = {}; + next(); + }); + + // Import and apply middleware + const { validatePagination, headers } = require("./middleware"); + + app.use(validatePagination); + app.use(headers); + + // Import routes + const router = require("./routes"); + app.use("/api/preferences", router); + }); + + describe("POST /api/preferences", () => { + it("should validate required fields", async () => { + const response = await request(app) + .post("/api/preferences") + .send({ + user_id: "1234567890abcdef1234567890abcdef", + chartTitle: "Test Chart Title", + period: {}, + site_ids: ["1234567890abcdef1234567890abcdef"], + device_ids: ["1234567890abcdef1234567890abcdef"], + selected_sites: [], + }) + .expect(200); + + expect(response.body).toBeDefined(); + }); + + it("should fail validation for missing required fields", async () => { + await request(app).post("/api/preferences").send({}).expect(400); + }); + }); + + describe("GET /api/preferences", () => { + it("should return preferences", async () => { + const response = await request(app).get("/api/preferences").expect(200); + expect(response.body).toBeDefined(); + }); + }); + + describe("DELETE /api/preferences/:user_id", () => { + it("should delete preference", async () => { + const response = await request(app) + .delete("/api/preferences/1234567890abcdef1234567890abcdef") + .expect(204); + expect(createPreferenceController.delete).toHaveBeenCalled(); + }); + }); + + describe("GET /api/preferences/:user_id", () => { + it("should return preference details", async () => { + const response = await request(app) + .get("/api/preferences/1234567890abcdef1234567890abcdef") + .expect(200); + expect(response.body).toBeDefined(); + }); + }); + + describe("POST /api/preferences/selected-sites", () => { + it("should add selected sites", async () => { + const response = await request(app) + .post("/api/preferences/selected-sites") + .send({ + selected_sites: [ + { + _id: "1234567890abcdef1234567890abcdef", + search_name: "Test Site", + name: "Test Name", + latitude: 37.7749, + longitude: -122.4194, + approximate_latitude: 37.775, + approximate_longitude: -122.42, + search_radius: 100, + site_tags: ["tag1", "tag2"], + }, + ], + }) + .expect(200); + expect(createPreferenceController.addSelectedSites).toHaveBeenCalled(); + }); + }); + + describe("PUT /api/preferences/selected-sites/:site_id", () => { + it("should update selected site", async () => { + const response = await request(app) + .put("/api/preferences/selected-sites/1234567890abcdef1234567890abcdef") + .send({ + selected_site: { + _id: "1234567890abcdef1234567890abcdef", + search_name: "Updated Test Site", + name: "Updated Test Name", + latitude: 37.775, + longitude: -122.42, + approximate_latitude: 37.775, + approximate_longitude: -122.42, + search_radius: 100, + site_tags: ["updated_tag1", "updated_tag2"], + }, + }) + .expect(200); + expect(createPreferenceController.updateSelectedSite).toHaveBeenCalled(); + }); + }); + + describe("DELETE /api/preferences/selected-sites/:site_id", () => { + it("should delete selected site", async () => { + const response = await request(app) + .delete( + "/api/preferences/selected-sites/1234567890abcdef1234567890abcdef" + ) + .expect(204); + expect(createPreferenceController.deleteSelectedSite).toHaveBeenCalled(); + }); + }); +}); diff --git a/src/auth-service/utils/create-preference.js b/src/auth-service/utils/create-preference.js index 81f5fc39ff..f455551c25 100644 --- a/src/auth-service/utils/create-preference.js +++ b/src/auth-service/utils/create-preference.js @@ -1,5 +1,6 @@ const PreferenceModel = require("@models/Preference"); const UserModel = require("@models/User"); +const SelectedSiteModel = require("@models/SelectedSite"); const { logElement, logText, logObject } = require("./log"); const generateFilter = require("./generate-filter"); const httpStatus = require("http-status"); @@ -346,6 +347,137 @@ const preferences = { ); } }, + addSelectedSites: async (request, next) => { + try { + const { tenant, selected_sites } = { + ...body, + ...query, + ...params, + }; + + const result = await SelectedSiteModel(tenant).insertMany( + selected_sites, + { + ordered: false, + } + ); + + const successCount = result.length; + const failureCount = selected_sites.length - successCount; + + return { + success: true, + message: `Successfully added ${successCount} selected sites. ${failureCount} failed.`, + data: result, + status: httpStatus.OK, + }; + } catch (error) { + logger.error(`🐛🐛 Internal Server Error ${error.message}`); + if (error.code === 11000) { + // Handle duplicate key errors + return next( + new HttpError("Conflict", httpStatus.CONFLICT, { + message: "One or more selected sites already exist.", + details: error.writeErrors || error.message, + }) + ); + } + return next( + new HttpError( + "Internal Server Error", + httpStatus.INTERNAL_SERVER_ERROR, + { + message: error.message, + } + ) + ); + } + }, + updateSelectedSite: async (request, next) => { + try { + const { query, params, body } = request; + const { tenant, site_id } = { ...query, ...params }; + const filter = { site_id }; + const update = body; + const modifyResponse = await SelectedSiteModel(tenant).modify( + { + filter, + update, + }, + next + ); + return modifyResponse; + } catch (error) { + logger.error(`🐛🐛 Internal Server Error ${error.message}`); + next( + new HttpError( + "Internal Server Error", + httpStatus.INTERNAL_SERVER_ERROR, + { message: error.message } + ) + ); + } + }, + deleteSelectedSite: async (request, next) => { + try { + return { + success: false, + message: "Service Temporarily Unavailable", + errors: { + message: "Service Temporarily Unavailable", + }, + status: httpStatus.SERVICE_UNAVAILABLE, + }; + const { query, params, body } = request; + const { tenant, site_id } = { ...query, ...params }; + const filter = { site_id }; + const responseFromRemoveSelectedSite = await SelectedSiteModel( + tenant + ).remove( + { + filter, + }, + next + ); + return responseFromRemoveSelectedSite; + } catch (error) { + logger.error(`🐛🐛 Internal Server Error ${error.message}`); + next( + new HttpError( + "Internal Server Error", + httpStatus.INTERNAL_SERVER_ERROR, + { message: error.message } + ) + ); + } + }, + listSelectedSites: async (request, next) => { + try { + const { + query: { tenant, site_id, limit, skip }, + } = request; + const filter = generateFilter.selected_sites(request, next); + const listResponse = await SelectedSiteModel(tenant).list( + { + filter, + limit, + skip, + }, + next + ); + return listResponse; + } catch (error) { + logObject("error", error); + logger.error(`🐛🐛 Internal Server Error ${error.message}`); + next( + new HttpError( + "Internal Server Error", + httpStatus.INTERNAL_SERVER_ERROR, + { message: error.message } + ) + ); + } + }, }; module.exports = preferences; diff --git a/src/auth-service/utils/generate-filter.js b/src/auth-service/utils/generate-filter.js index 4079ae7f47..5ce7eff2af 100644 --- a/src/auth-service/utils/generate-filter.js +++ b/src/auth-service/utils/generate-filter.js @@ -333,6 +333,29 @@ const filter = { ); } }, + selected_sites: (req, next) => { + try { + let { site_id } = { + ...req.body, + ...req.query, + ...req.params, + }; + let filter = {}; + if (site_id) { + filter["site_id"] = site_id; + } + return filter; + } catch (error) { + logger.error(`🐛🐛 Internal Server Error ${error.message}`); + next( + new HttpError( + "Internal Server Error", + httpStatus.INTERNAL_SERVER_ERROR, + { message: error.message } + ) + ); + } + }, checklists: (req, next) => { try { let { id, user_id } = { diff --git a/src/auth-service/utils/test/ut_create-preference.js b/src/auth-service/utils/test/ut_create-preference.js index 3f76a45007..ffdeacc2a0 100644 --- a/src/auth-service/utils/test/ut_create-preference.js +++ b/src/auth-service/utils/test/ut_create-preference.js @@ -3,15 +3,14 @@ const chai = require("chai"); const { expect } = chai; const sinon = require("sinon"); const httpStatus = require("http-status"); -const accessCodeGenerator = require("generate-password"); -const constants = require("@config/constants"); const generateFilter = require("@utils/generate-filter"); const PreferenceModel = require("@models/Preference"); -const ObjectId = require("mongoose").Types.ObjectId; const chaiHttp = require("chai-http"); chai.use(chaiHttp); const createPreferenceUtil = require("@utils/create-preference"); const UserModel = require("@models/User"); +const sinonChai = require("sinon-chai"); +const mongoose = require("mongoose"); describe("create preference UTIL", function () { describe("list function", function () { @@ -298,4 +297,198 @@ describe("create preference UTIL", function () { }); }); }); + describe("selectedSites", () => { + let SelectedSiteModel; + + beforeEach(() => { + // Mock the SelectedSiteModel + SelectedSiteModel = sinon.mock(mongoose.model("SelectedSite")); + }); + + afterEach(() => { + // Restore the original SelectedSiteModel + SelectedSiteModel.restore(); + }); + + describe("addSelectedSites", () => { + it("should add selected sites successfully", async () => { + const request = { + query: { tenant: "testTenant" }, + body: { selected_sites: [{ id: "site1" }, { id: "site2" }] }, + }; + const next = sinon.spy(); + + SelectedSiteModel.insertMany.resolves([ + { _id: "site1" }, + { _id: "site2" }, + ]); + + const result = + await require("@utils/create-preferences").addSelectedSites( + request, + next + ); + + expect(result).to.deep.equal({ + success: true, + message: "Successfully added 2 selected sites. 0 failed.", + data: [{ _id: "site1" }, { _id: "site2" }], + status: 200, + }); + expect(next).not.to.have.been.called; + }); + + it("should handle duplicate key errors", async () => { + const request = { + query: { tenant: "testTenant" }, + body: { selected_sites: [{ id: "existingSite" }] }, + }; + const next = sinon.spy(); + + SelectedSiteModel.insertMany.rejects( + new mongoose.Error.ValidatorFailure() + ); + + const result = + await require("@utils/create-preferences").addSelectedSites( + request, + next + ); + + expect(result).to.deep.equal({ + success: false, + message: "One or more selected sites already exist.", + details: { message: "ValidatorFailure: Path 'id' is required." }, + status: 409, + }); + expect(next).not.to.have.been.called; + }); + + it("should handle other errors", async () => { + const request = { + query: { tenant: "testTenant" }, + body: { selected_sites: [{ id: "newSite" }] }, + }; + const next = sinon.spy(); + + SelectedSiteModel.insertMany.rejects(new Error("MongoDB error")); + + const result = + await require("@utils/create-preferences").addSelectedSites( + request, + next + ); + + expect(result).to.deep.equal({ + success: false, + message: "Internal Server Error", + errors: { message: "MongoDB error" }, + status: 500, + }); + expect(next).to.have.been.calledOnce; + }); + }); + + describe("updateSelectedSite", () => { + it("should update selected site successfully", async () => { + const request = { + query: { tenant: "testTenant", site_id: "site1" }, + params: { site_id: "site1" }, + body: { name: "Updated Name" }, + }; + const next = sinon.spy(); + + SelectedSiteModel.modify.resolves({ modifiedCount: 1 }); + + const result = + await require("@utils/create-preferences").updateSelectedSite( + request, + next + ); + + expect(result).to.deep.equal({ + success: true, + message: "Successfully updated 1 document(s)", + modifiedCount: 1, + status: 200, + }); + expect(next).not.to.have.been.called; + }); + + it("should handle errors", async () => { + const request = { + query: { tenant: "testTenant", site_id: "site1" }, + params: { site_id: "site1" }, + body: { name: "Updated Name" }, + }; + const next = sinon.spy(); + + SelectedSiteModel.modify.rejects(new Error("MongoDB error")); + + const result = + await require("@utils/create-preferences").updateSelectedSite( + request, + next + ); + + expect(result).to.deep.equal({ + success: false, + message: "Internal Server Error", + errors: { message: "MongoDB error" }, + status: 500, + }); + expect(next).to.have.been.calledOnce; + }); + }); + + describe("deleteSelectedSite", () => { + it("should delete selected site successfully", async () => { + const request = { + query: { tenant: "testTenant", site_id: "site1" }, + params: { site_id: "site1" }, + }; + const next = sinon.spy(); + + SelectedSiteModel.remove.resolves({ removedCount: 1 }); + + const result = + await require("@utils/create-preferences").deleteSelectedSite( + request, + next + ); + + expect(result).to.deep.equal({ + success: true, + message: "Successfully deleted 1 document(s)", + removedCount: 1, + status: 200, + }); + expect(next).not.to.have.been.called; + }); + + it("should handle errors", async () => { + const request = { + query: { tenant: "testTenant", site_id: "site1" }, + params: { site_id: "site1" }, + }; + const next = sinon.spy(); + + SelectedSiteModel.remove.rejects(new Error("MongoDB error")); + + const result = + await require("@utils/create-preferences").deleteSelectedSite( + request, + next + ); + + expect(result).to.deep.equal({ + success: false, + message: "Service Temporarily Unavailable", + errors: { message: "Service Temporarily Unavailable" }, + status: 503, + }); + expect(next).to.have.been.calledOnce; + }); + }); + }); }); From c96f4e5cc77392d6188beceedb97bc8a00c36961 Mon Sep 17 00:00:00 2001 From: baalmart Date: Sun, 13 Oct 2024 23:14:04 +0300 Subject: [PATCH 2/3] fixing typo and enhancing unit tests --- .../jobs/test/ut_preferences-update-job.js | 74 ++++++++++++++++--- .../controllers/create-preference.js | 2 +- .../routes/v2/test/ut_preferences.js | 58 ++++++++++++--- 3 files changed, 115 insertions(+), 19 deletions(-) diff --git a/src/auth-service/bin/jobs/test/ut_preferences-update-job.js b/src/auth-service/bin/jobs/test/ut_preferences-update-job.js index d7ca3523c2..fee1d13c93 100644 --- a/src/auth-service/bin/jobs/test/ut_preferences-update-job.js +++ b/src/auth-service/bin/jobs/test/ut_preferences-update-job.js @@ -9,17 +9,22 @@ describe("updatePreferences", () => { beforeEach(() => { // Set up mocks - sinon.stub(PreferenceModel.prototype, "find").resolves([]); - sinon.stub(PreferenceModel.prototype, "create").resolves({}); - sinon.stub(PreferenceModel.prototype, "findOneAndUpdate").resolves({}); + try { + sinon.stub(PreferenceModel.prototype, "find").resolves([]); + sinon.stub(PreferenceModel.prototype, "create").resolves({}); + sinon.stub(PreferenceModel.prototype, "findOneAndUpdate").resolves({}); - sinon - .stub(SelectedSiteModel.prototype, "find") - .resolves([{ site_id: "site1" }, { site_id: "site2" }]); + sinon + .stub(SelectedSiteModel.prototype, "find") + .resolves([{ site_id: "site1" }, { site_id: "site2" }]); - sinon - .stub(UserModel.prototype, "find") - .resolves([{ _id: "user1" }, { _id: "user2" }]); + sinon + .stub(UserModel.prototype, "find") + .resolves([{ _id: "user1" }, { _id: "user2" }]); + } catch (error) { + console.error("Error in test setup:", error); + throw error; + } }); afterEach(() => { @@ -34,6 +39,15 @@ describe("updatePreferences", () => { expect(PreferenceModel.prototype.create).to.have.been.calledTwice; expect(PreferenceModel.prototype.findOneAndUpdate).to.have.been .calledOnce; + expect( + PreferenceModel.prototype.create.getCall(0).args[0] + ).to.deep.equal(/* expected first create call args */); + expect( + PreferenceModel.prototype.create.getCall(1).args[0] + ).to.deep.equal(/* expected second create call args */); + expect( + PreferenceModel.prototype.findOneAndUpdate.getCall(0).args + ).to.deep.equal(/* expected findOneAndUpdate call args */); }); }); @@ -57,6 +71,24 @@ describe("updatePreferences", () => { expect(logObject).to.have.been.calledWith("error", errorMock); }); + + it("should log errors when fetching selected sites fails", async () => { + const errorMock = new Error("Test error"); + SelectedSiteModel.prototype.find.rejects(errorMock); + + await updatePreferences(); + + expect(logObject).to.have.been.calledWith("error", errorMock); + }); + + it("should log errors when fetching users fails", async () => { + const errorMock = new Error("Test error"); + UserModel.prototype.find.rejects(errorMock); + + await updatePreferences(); + + expect(logObject).to.have.been.calledWith("error", errorMock); + }); }); describe("edge cases", () => { @@ -75,5 +107,29 @@ describe("updatePreferences", () => { expect(PreferenceModel.prototype.create).to.not.have.been.called; }); + + it("should handle empty selected sites", async () => { + SelectedSiteModel.prototype.find.resolves([]); + + await updatePreferences(); + + expect(PreferenceModel.prototype.create).to.not.have.been.called; + expect(logObject).to.have.been.calledWith( + "info", + "No selected sites found. Skipping preference update." + ); + }); + + it("should handle no users found", async () => { + UserModel.prototype.find.resolves([]); + + await updatePreferences(); + + expect(PreferenceModel.prototype.create).to.not.have.been.called; + expect(logObject).to.have.been.calledWith( + "info", + "No users found. Skipping preference update." + ); + }); }); }); diff --git a/src/auth-service/controllers/create-preference.js b/src/auth-service/controllers/create-preference.js index 90da66586f..505da09ee1 100644 --- a/src/auth-service/controllers/create-preference.js +++ b/src/auth-service/controllers/create-preference.js @@ -345,7 +345,7 @@ const preferences = { res.status(status).json({ success: true, message: result.message, - seletected_sites: result.data, + selected_sites: result.data, }); } else if (result.success === false) { const status = result.status diff --git a/src/auth-service/routes/v2/test/ut_preferences.js b/src/auth-service/routes/v2/test/ut_preferences.js index dbac9e980b..281f9cb2a0 100644 --- a/src/auth-service/routes/v2/test/ut_preferences.js +++ b/src/auth-service/routes/v2/test/ut_preferences.js @@ -1,18 +1,18 @@ -require("module-alias/register"); const express = require("express"); const request = require("supertest"); const mongoose = require("mongoose"); const ObjectId = mongoose.Types.ObjectId; // Mock the router -jest.mock("@controllers/create-preference", () => ({ - create: jest.fn(), - list: jest.fn(), - delete: jest.fn(), - addSelectedSites: jest.fn(), - updateSelectedSite: jest.fn(), - deleteSelectedSite: jest.fn(), -})); +const sinon = require("sinon"); +const createPreferenceController = { + create: sinon.stub(), + list: sinon.stub(), + delete: sinon.stub(), + addSelectedSites: sinon.stub(), + updateSelectedSite: sinon.stub(), + deleteSelectedSite: sinon.stub(), +}; describe("Preference Controller Tests", () => { let app; @@ -58,6 +58,46 @@ describe("Preference Controller Tests", () => { it("should fail validation for missing required fields", async () => { await request(app).post("/api/preferences").send({}).expect(400); }); + + it("should handle partial valid data", async () => { + const response = await request(app) + .post("/api/preferences") + .send({ + user_id: "1234567890abcdef1234567890abcdef", + chartTitle: "Partial Data Test", + }) + .expect(200); + + expect(response.body).toBeDefined(); + // Add more specific assertions based on your API's behavior with partial data + }); + + it("should reject invalid data types", async () => { + const response = await request(app) + .post("/api/preferences") + .send({ + user_id: 12345, // Should be a string + chartTitle: ["Invalid Title"], // Should be a string + period: "Invalid Period", // Should be an object + }) + .expect(400); + + expect(response.body).toHaveProperty("error"); + // Add more specific assertions based on your error response structure + }); + + it("should return the correct response structure", async () => { + const response = await request(app) + .post("/api/preferences") + .send({ + // ... valid data ... + }) + .expect(200); + + expect(response.body).toHaveProperty("id"); + expect(response.body).toHaveProperty("user_id"); + // Add more assertions to check all expected properties + }); }); describe("GET /api/preferences", () => { From ee59b0eafba7d4d682a5a41b247d9b67e80fa48d Mon Sep 17 00:00:00 2001 From: baalmart Date: Tue, 15 Oct 2024 00:18:30 +0300 Subject: [PATCH 3/3] input validation for adding default preferences --- src/auth-service/models/SelectedSite.js | 3 +- src/auth-service/routes/v2/preferences.js | 294 +++++++++--------- .../routes/v2/test/ut_preferences.js | 17 +- src/auth-service/utils/create-preference.js | 6 +- 4 files changed, 161 insertions(+), 159 deletions(-) diff --git a/src/auth-service/models/SelectedSite.js b/src/auth-service/models/SelectedSite.js index dc8b9237a1..1f23d15f17 100644 --- a/src/auth-service/models/SelectedSite.js +++ b/src/auth-service/models/SelectedSite.js @@ -43,8 +43,7 @@ SelectedSiteSchema.pre("update", function (next) { SelectedSiteSchema.index({ name: 1 }, { unique: true }); SelectedSiteSchema.index({ site_id: 1 }, { unique: true }); -SelectedSiteSchema.index({ lat_long: 1 }, { unique: true }); -SelectedSiteSchema.index({ generated_name: 1 }, { unique: true }); +SelectedSiteSchema.index({ search_name: 1 }, { unique: true }); SelectedSiteSchema.index({ isFeatured: 1, createdAt: -1 }); SelectedSiteSchema.statics = { diff --git a/src/auth-service/routes/v2/preferences.js b/src/auth-service/routes/v2/preferences.js index df4a09fcb8..44d7ed6600 100644 --- a/src/auth-service/routes/v2/preferences.js +++ b/src/auth-service/routes/v2/preferences.js @@ -7,6 +7,8 @@ const mongoose = require("mongoose"); const ObjectId = mongoose.Types.ObjectId; // const { logText, logObject } = require("@utils/log"); const isEmpty = require("is-empty"); +const { logText, logObject } = require("@utils/log"); +const { isMongoId } = require("validator"); // const stringify = require("@utils/stringify"); const validatePagination = (req, res, next) => { @@ -27,159 +29,153 @@ const headers = (req, res, next) => { next(); }; -function validateSelectedSitesField(value) { - const requiredFields = ["_id", "search_name", "name"]; - - // Check if all required fields exist - if (!requiredFields.every((field) => field in value)) { - return false; - } - - function validateNumericFields(fields) { - let isValid = true; +function createValidateSelectedSitesField(requiredFields) { + return function (value) { + if (!requiredFields.every((field) => field in value)) { + throw new Error( + `Missing required fields: ${requiredFields + .filter((field) => !(field in value)) + .join(", ")}` + ); + } - fields.forEach((field) => { - if (!(field in value)) { - isValid = false; - return; - } - const numValue = parseFloat(value[field]); - if (Number.isNaN(numValue)) { - isValid = false; - return; - } else if ( - field === "latitude" || - field === "longitude" || - field === "approximate_latitude" || - field === "approximate_longitude" - ) { - if (Math.abs(numValue) > 90) { - isValid = false; - return; - } - } else if (field === "search_radius") { - if (numValue <= 0) { - isValid = false; - return; + function validateNumericFields(fields) { + for (const field of fields) { + if (field in value) { + const numValue = parseFloat(value[field]); + if (Number.isNaN(numValue)) { + throw new Error(`${field} must be a valid number`); + } else if ( + field === "latitude" || + field === "longitude" || + field === "approximate_latitude" || + field === "approximate_longitude" + ) { + if (Math.abs(numValue) > 90) { + throw new Error(`${field} must be between -90 and 90`); + } + } else if (field === "search_radius") { + if (numValue <= 0) { + throw new Error(`${field} must be greater than 0`); + } + } } } - }); - - return isValid; - } + return true; + } - function validateStringFields(fields) { - let isValid = true; - fields.forEach((field) => { - if (typeof value[field] !== "string" || value[field].trim() === "") { - isValid = false; - return; + function validateStringFields(fields) { + for (const field of fields) { + if ( + field in value && + (typeof value[field] !== "string" || value[field].trim() === "") + ) { + throw new Error(`${field} must be a non-empty string`); + } } - }); - return isValid; - } - - function validateTags(tags) { - if (isEmpty(tags)) { return true; - } else if (!Array.isArray(tags)) { - return false; - } else { - return tags.every((tag) => typeof tag === "string"); } - } - const numericValid = validateNumericFields([ - "latitude", - "longitude", - "approximate_latitude", - "approximate_longitude", - ]); + function validateTags(tags) { + if (isEmpty(tags)) { + return true; + } else if (!Array.isArray(tags)) { + throw new Error("site_tags must be an array"); + } else { + tags.forEach((tag, index) => { + if (typeof tag !== "string") { + throw new Error(`site_tags[${index}] must be a string`); + } + }); + } + } - const stringValid = validateStringFields(["name", "search_name"]); + const isValidSiteId = "site_id" in value && isMongoId(value.site_id); + if (!isValidSiteId) { + throw new Error("site_id must be a valid MongoDB ObjectId"); + } - const tags = value && value.site_tags; - const tagValid = validateTags(tags); + const numericValid = validateNumericFields([ + "latitude", + "longitude", + "approximate_latitude", + "approximate_longitude", + ]); - return numericValid && stringValid && tagValid; -} + const stringValid = validateStringFields(["name", "search_name"]); -function validateDefaultSelectedSitesField(value) { - const requiredFields = ["site_id", "search_name", "name"]; - // Check if all required fields exist - if (!requiredFields.every((field) => field in value)) { - return false; - } + const tags = value && value.site_tags; + const tagValid = validateTags(tags); - function validateNumericFields(fields) { - let isValid = true; + return numericValid && stringValid && tagValid && isValidSiteId; + }; +} +const validateUniqueFieldsInSelectedSites = (req, res, next) => { + const selectedSites = req.body.selected_sites; - fields.forEach((field) => { - if (!(field in value)) { - isValid = false; - return; - } - const numValue = parseFloat(value[field]); - if (Number.isNaN(numValue)) { - isValid = false; - return; - } else if ( - field === "latitude" || - field === "longitude" || - field === "approximate_latitude" || - field === "approximate_longitude" - ) { - if (Math.abs(numValue) > 90) { - isValid = false; - return; - } - } else if (field === "search_radius") { - if (numValue <= 0) { - isValid = false; - return; - } - } - }); + // Create Sets to track unique values for each field + const uniqueSiteIds = new Set(); + const uniqueSearchNames = new Set(); + const uniqueNames = new Set(); - return isValid; - } + const duplicateSiteIds = []; + const duplicateSearchNames = []; + const duplicateNames = []; - function validateStringFields(fields) { - let isValid = true; - fields.forEach((field) => { - if (typeof value[field] !== "string" || value[field].trim() === "") { - isValid = false; - return; - } - }); - return isValid; - } - - function validateTags(tags) { - if (isEmpty(tags)) { - return true; - } else if (!Array.isArray(tags)) { - return false; + selectedSites.forEach((item) => { + // Check for duplicate site_id + if (uniqueSiteIds.has(item.site_id)) { + duplicateSiteIds.push(item.site_id); } else { - return tags.every((tag) => typeof tag === "string"); + uniqueSiteIds.add(item.site_id); } - } - const numericValid = validateNumericFields([ - "latitude", - "longitude", - "approximate_latitude", - "approximate_longitude", - ]); + // Check for duplicate search_name + if (uniqueSearchNames.has(item.search_name)) { + duplicateSearchNames.push(item.search_name); + } else { + uniqueSearchNames.add(item.search_name); + } - const stringValid = validateStringFields(["name", "search_name"]); + // Check for duplicate name + if (uniqueNames.has(item.name)) { + duplicateNames.push(item.name); + } else { + uniqueNames.add(item.name); + } + }); - const tags = value && value.site_tags; - const tagValid = validateTags(tags); + // Prepare error messages based on duplicates found + let errorMessage = ""; + if (duplicateSiteIds.length > 0) { + errorMessage += + "Duplicate site_ids found: " + + [...new Set(duplicateSiteIds)].join(", ") + + ". "; + } + if (duplicateSearchNames.length > 0) { + errorMessage += + "Duplicate search_names found: " + + [...new Set(duplicateSearchNames)].join(", ") + + ". "; + } + if (duplicateNames.length > 0) { + errorMessage += + "Duplicate names found: " + + [...new Set(duplicateNames)].join(", ") + + ". "; + } - return numericValid && stringValid && tagValid; -} + // If any duplicates were found, respond with an error + if (errorMessage) { + return res.status(400).json({ + success: false, + message: errorMessage.trim(), + }); + } + next(); +}; router.use(headers); router.use(validatePagination); @@ -375,7 +371,9 @@ router.post( .withMessage("the selected_sites should be an array"), body("selected_sites.*") .optional() - .custom(validateSelectedSitesField) + .custom( + createValidateSelectedSitesField(["_id", "search_name", "name"]) + ) .withMessage( "Invalid selected_sites format. Verify required fields (latitude, longitude, search_name, name, approximate_latitude, approximate_longitude), numeric fields (latitude, longitude, approximate_latitude, approximate_longitude, search_radius if present), string fields (name, search_name), and ensure site_tags is an array of strings." ), @@ -383,7 +381,6 @@ router.post( ]), createPreferenceController.upsert ); - router.patch( "/replace", oneOf([ @@ -576,7 +573,9 @@ router.patch( .withMessage("the selected_sites should be an array"), body("selected_sites.*") .optional() - .custom(validateSelectedSitesField) + .custom( + createValidateSelectedSitesField(["_id", "search_name", "name"]) + ) .withMessage( "Invalid selected_sites format. Verify required fields (latitude, longitude, search_name, name, approximate_latitude, approximate_longitude), numeric fields (latitude, longitude, approximate_latitude, approximate_longitude, search_radius if present), string fields (name, search_name), and ensure site_tags is an array of strings." ), @@ -584,7 +583,6 @@ router.patch( ]), createPreferenceController.replace ); - router.put( "/:user_id", oneOf([ @@ -778,7 +776,9 @@ router.put( .withMessage("the selected_sites should be an array"), body("selected_sites.*") .optional() - .custom(validateSelectedSitesField) + .custom( + createValidateSelectedSitesField(["_id", "search_name", "name"]) + ) .withMessage( "Invalid selected_sites format. Verify required fields (latitude, longitude, search_name, name, approximate_latitude, approximate_longitude), numeric fields (latitude, longitude, approximate_latitude, approximate_longitude, search_radius if present), string fields (name, search_name), and ensure site_tags is an array of strings." ), @@ -786,7 +786,6 @@ router.put( ]), createPreferenceController.update ); - router.post( "/", oneOf([ @@ -947,7 +946,9 @@ router.post( .withMessage("the selected_sites should be an array"), body("selected_sites.*") .optional() - .custom(validateSelectedSitesField) + .custom( + createValidateSelectedSitesField(["_id", "search_name", "name"]) + ) .withMessage( "Invalid selected_sites format. Verify required fields (latitude, longitude, search_name, name, approximate_latitude, approximate_longitude), numeric fields (latitude, longitude, approximate_latitude, approximate_longitude, search_radius if present), string fields (name, search_name), and ensure site_tags is an array of strings." ), @@ -955,7 +956,6 @@ router.post( ]), createPreferenceController.create ); - router.get( "/", oneOf([ @@ -1049,7 +1049,6 @@ router.get( ]), createPreferenceController.list ); - router.delete( "/:user_id", oneOf([ @@ -1151,9 +1150,9 @@ router.get( ]), createPreferenceController.listSelectedSites ); - router.post( "/selected-sites", + validateUniqueFieldsInSelectedSites, oneOf([ [ query("tenant") @@ -1178,18 +1177,15 @@ router.post( .bail() .notEmpty() .withMessage("selected_sites should not be empty"), - body("selected_sites.*") - .custom(validateDefaultSelectedSitesField) - .withMessage( - "Invalid selected_sites format. Verify required fields and data types." - ), + body("selected_sites.*").custom( + createValidateSelectedSitesField(["site_id", "search_name", "name"]) + ), ], ]), setJWTAuth, authJWT, createPreferenceController.addSelectedSites ); - router.put( "/selected-sites/:site_id", oneOf([ @@ -1217,8 +1213,8 @@ router.put( .customSanitizer((value) => { return ObjectId(value); }), - body() - .custom(validateDefaultSelectedSitesField) + body("selected_site") + .custom(createValidateSelectedSitesField([])) .withMessage( "Invalid selected site data. Verify required fields and data types." ), @@ -1228,7 +1224,6 @@ router.put( authJWT, createPreferenceController.updateSelectedSite ); - router.delete( "/selected-sites/:site_id", oneOf([ @@ -1262,7 +1257,6 @@ router.delete( authJWT, createPreferenceController.deleteSelectedSite ); - router.get( "/:user_id", oneOf([ diff --git a/src/auth-service/routes/v2/test/ut_preferences.js b/src/auth-service/routes/v2/test/ut_preferences.js index 281f9cb2a0..8aa0583dca 100644 --- a/src/auth-service/routes/v2/test/ut_preferences.js +++ b/src/auth-service/routes/v2/test/ut_preferences.js @@ -116,12 +116,12 @@ describe("Preference Controller Tests", () => { }); }); - describe("GET /api/preferences/:user_id", () => { - it("should return preference details", async () => { + describe("GET /api/preferences/selected-sites", () => { + it("should return selected sites", async () => { const response = await request(app) - .get("/api/preferences/1234567890abcdef1234567890abcdef") + .get("/api/preferences/selected-sites") .expect(200); - expect(response.body).toBeDefined(); + expect(createPreferenceController.listSelectedSites).toHaveBeenCalled(); }); }); @@ -181,4 +181,13 @@ describe("Preference Controller Tests", () => { expect(createPreferenceController.deleteSelectedSite).toHaveBeenCalled(); }); }); + + describe("GET /api/preferences/:user_id", () => { + it("should return preference details", async () => { + const response = await request(app) + .get("/api/preferences/1234567890abcdef1234567890abcdef") + .expect(200); + expect(response.body).toBeDefined(); + }); + }); }); diff --git a/src/auth-service/utils/create-preference.js b/src/auth-service/utils/create-preference.js index f455551c25..22b7f72c8a 100644 --- a/src/auth-service/utils/create-preference.js +++ b/src/auth-service/utils/create-preference.js @@ -350,9 +350,9 @@ const preferences = { addSelectedSites: async (request, next) => { try { const { tenant, selected_sites } = { - ...body, - ...query, - ...params, + ...request.body, + ...request.query, + ...request.params, }; const result = await SelectedSiteModel(tenant).insertMany(