From dde883e411154b9a100b5557d2c10a273d83ef6c Mon Sep 17 00:00:00 2001 From: Germaine Date: Fri, 11 Oct 2024 20:41:06 +0800 Subject: [PATCH 1/3] SCRUM-137 backend endpoints for points and leaderboard and unit test --- .../accountsGamificationController.test.js | 149 ++++++++++ .../accountsGamificationService.test.js | 271 ++++++++++++++++++ backend/dist/app.js | 2 + .../accountsGamificationController.js | 85 ++++++ backend/dist/models/accountsGamification.js | 12 + .../dist/models/accountsGamificationModel.js | 21 ++ .../dist/routes/accountsGamificationRouter.js | 34 +++ .../services/accountsGamificationService.js | 105 +++++++ backend/src/app.ts | 2 + backend/src/config/database.types.ts | 47 +++ .../accountsGamificationController.ts | 54 ++++ .../src/models/accountsGamificationModel.ts | 31 ++ .../src/routes/accountsGamificationRouter.ts | 15 + .../services/accountsGamificationService.ts | 93 ++++++ 14 files changed, 921 insertions(+) create mode 100644 backend/__tests__/controllers/accountsGamificationController.test.js create mode 100644 backend/__tests__/services/accountsGamificationService.test.js create mode 100644 backend/dist/controllers/accountsGamificationController.js create mode 100644 backend/dist/models/accountsGamification.js create mode 100644 backend/dist/models/accountsGamificationModel.js create mode 100644 backend/dist/routes/accountsGamificationRouter.js create mode 100644 backend/dist/services/accountsGamificationService.js create mode 100644 backend/src/controllers/accountsGamificationController.ts create mode 100644 backend/src/models/accountsGamificationModel.ts create mode 100644 backend/src/routes/accountsGamificationRouter.ts create mode 100644 backend/src/services/accountsGamificationService.ts diff --git a/backend/__tests__/controllers/accountsGamificationController.test.js b/backend/__tests__/controllers/accountsGamificationController.test.js new file mode 100644 index 0000000..8220f2b --- /dev/null +++ b/backend/__tests__/controllers/accountsGamificationController.test.js @@ -0,0 +1,149 @@ +// accountsGamificationController.test.js + +const request = require("supertest"); +const express = require("express"); +const accountsGamificationController = require("../../dist/controllers/accountsGamificationController"); +const accountsGamificationService = require("../../dist/services/accountsGamificationService"); +const accountsGamificationRouter = require("../../dist/routes/accountsGamificationRouter").default; +const supabase = require("../../dist/config/supabaseConfig"); + +jest.mock("../../dist/config/supabaseConfig", () => ({ + from: jest.fn(), +})); + +jest.mock("../../dist/services/accountsGamificationService"); + +beforeEach(() => { + jest.resetAllMocks(); +}); + +const app = express(); +app.use(express.json()); +app.use("/accounts", accountsGamificationRouter); + +describe("GET /accounts/leaderboard", () => { + it("should return 200 and the list of accounts on success", async () => { + const mockAccounts = [ + { + rank: 1, + name: "test USER", + points: 150, + }, + { + rank: 2, + name: "test 3", + points: 100, + }, + { + rank: 3, + name: "test 4", + points: 80, + }, + { + rank: 4, + name: "test 5", + points: 50, + }, + { + rank: 5, + name: "test 6", + points: 20, + }, + ]; + + accountsGamificationService.getTop5Accounts.mockResolvedValue(mockAccounts); + + const response = await request(app).get(`/accounts/leaderboard/1`); + + expect(response.status).toBe(200); + expect(response.body).toEqual(mockAccounts); + expect(accountsGamificationService.getTop5Accounts).toHaveBeenCalledTimes(1); + }); + + it("should return 500 and an error message on failure", async () => { + const mockError = new Error("Database error"); + + accountsGamificationService.getTop5Accounts.mockRejectedValue(mockError); + + const response = await request(app).get(`/accounts/leaderboard/1`); + + expect(response.status).toBe(500); + expect(accountsGamificationService.getTop5Accounts).toHaveBeenCalledTimes(1); + }); +}); + +describe("GET /accounts/gamificationdata", () => { + const mockAccounts = { + userID: "2", + points: 0, + streaks: 0, + lastUnitCompletionDate: expect.anything(), + }; + + it("should return 200 and the account on success", async () => { + accountsGamificationService.getGamificationData.mockResolvedValue(mockAccounts); + + const response = await request(app).get( + `/accounts/gamificationdata/${mockAccounts.userID}` + ); + + expect(response.status).toBe(200); + expect(response.body).toEqual(mockAccounts); + expect(accountsGamificationService.getGamificationData).toHaveBeenCalledTimes(1); + }); + + it("should return 500 and an error message on failure", async () => { + const mockError = new Error("Database error"); + + accountsGamificationService.getGamificationData.mockRejectedValue(mockError); + + const response = await request(app).get( + `/accounts/gamificationdata/${mockAccounts.userID}` + ); + + expect(response.status).toBe(500); + expect(accountsGamificationService.getGamificationData).toHaveBeenCalledTimes(1); + }); +}); + +describe("PATCH /accounts/updatepoints", () => { + const mockAccount = { + userID: "1", + points: 100, + }; + + it("should update an account and return 204 on success", async () => { + const mockResponse = { status: 204, statusText: "OK" }; + + accountsGamificationService.updatePoints.mockResolvedValue(mockResponse); + + const response = await request(app) + .patch("/accounts/updatepoints") + .send(mockAccount); + + expect(response.status).toBe(200); + expect(response.body.statusText).toBe("Points Updated Successfully"); + expect(accountsGamificationService.updatePoints).toHaveBeenCalledTimes(1); + expect(accountsGamificationService.updatePoints).toHaveBeenCalledWith( + mockAccount.userID, + mockAccount.points + ); + }); + + it("should return 500 and an error message on failure", async () => { + const mockError = new Error("Database error"); + + accountsGamificationService.updatePoints.mockRejectedValue(mockError); + + const response = await request(app) + .patch("/accounts/updatepoints") + .send(mockAccount); + + expect(response.status).toBe(500); + expect(accountsGamificationService.updatePoints).toHaveBeenCalledTimes(1); + expect(accountsGamificationService.updatePoints).toHaveBeenCalledWith( + mockAccount.userID, + mockAccount.points + ); + }); +}); diff --git a/backend/__tests__/services/accountsGamificationService.test.js b/backend/__tests__/services/accountsGamificationService.test.js new file mode 100644 index 0000000..1f887ac --- /dev/null +++ b/backend/__tests__/services/accountsGamificationService.test.js @@ -0,0 +1,271 @@ +const accountsGamificationService = require("../../dist/services/accountsGamificationService"); +const supabase = require("../../dist/config/supabaseConfig"); +const accountsGamificationModel = require("../../dist/models/accountsGamificationModel"); + +jest.mock("../../dist/config/supabaseConfig", () => ({ + from: jest.fn(), +})); + +let consoleErrorSpy; + +beforeEach(() => { + jest.resetAllMocks(); + consoleErrorSpy = jest.spyOn(console, "error").mockImplementation(() => { }); +}); + +afterEach(() => { + consoleErrorSpy.mockRestore(); +}); + +describe("getTop5Accounts", () => { + + userID = "2"; + + const mockResult = [ + { + points: 150, + accounts: { + userID: "1", + lastName: "USER", + firstName: "test", + }, + }, + { + points: 100, + accounts: { + userID: "3", + lastName: "3", + firstName: "test", + }, + }, + { + points: 80, + accounts: { + userID: "8", + lastName: "4", + firstName: "test", + }, + }, + { + points: 50, + accounts: { + userID: "2", + lastName: "5", + firstName: "test", + }, + }, + { + points: 20, + accounts: { + userID: "5", + lastName: "6", + firstName: "test", + }, + }, + { + points: 0, + accounts: { + userID: "1041", + lastName: "Smith", + firstName: "LEARN", + }, + }, + { + points: 0, + accounts: { + userID: "jd", + lastName: "Doe", + firstName: "John" + }, + }, + ]; + + const expectedResult = [ + { + rank: 1, + name: "test USER", + points: 150, + }, + { + rank: 2, + name: "test 3", + points: 100, + }, + { + rank: 3, + name: "test 4", + points: 80, + }, + { + rank: 4, + name: "test 5", + points: 50, + }, + { + rank: 5, + name: "test 6", + points: 20, + }, + ]; + + it("returns accounts ranked top 5 & user rank", async () => { + + const mockOrder = jest.fn().mockReturnValue({ data: mockResult, error: null }); + + const mockSelect = jest.fn().mockReturnValue({ order: mockOrder }); + + supabase.from.mockReturnValue({ select: mockSelect }); + + const accounts = await accountsGamificationService.getTop5Accounts(userID); + expect(accounts).toEqual(expectedResult); + }); + + it("returns accounts ranked top 5 & user rank if user is not within top 5", async () => { + const userID = "jd"; + + const mockOrder = jest + .fn() + .mockReturnValue({ data: mockResult, error: null }); + + const mockSelect = jest.fn().mockReturnValue({ order: mockOrder }); + + supabase.from.mockReturnValue({ select: mockSelect }); + + const accounts = await accountsGamificationService.getTop5Accounts( + userID + ); + + expect(accounts).toEqual([ + ...expectedResult, + { + rank: 6, + name: "John Doe", + points: 0, + }, + ]); + }); + + it("returns more than 5 accounts if there is any same rank & user rank", async () => { + + const mockResult2 = [ + ...mockResult, + { + points: 20, + accounts: { + userID: "mockPair", + lastName: "pair", + firstName: "mock", + }, + }, + ]; + + mockResult2.sort((a, b) => b.points - a.points); + + const mockOrder = jest + .fn() + .mockReturnValue({ data: mockResult2, error: null }); + + const mockSelect = jest.fn().mockReturnValue({ order: mockOrder }); + + supabase.from.mockReturnValue({ select: mockSelect }); + + const accounts = await accountsGamificationService.getTop5Accounts( + userID + ); + + expect(accounts).toEqual([ + ...expectedResult, + { + rank: 5, + name: "mock pair", + points: 20, + }, + ]); + }); + + it("handles error correctly and logs it to console.error", async () => { + const errorMessage = "Failed to fetch all accounts"; + + const mockOrder = jest.fn().mockResolvedValue({ data: null, error: new Error(errorMessage) }); + + const mockSelect = jest + .fn() + .mockReturnValue({ order: mockOrder }); + + supabase.from.mockReturnValue({ select: mockSelect }); + + await expect( + accountsGamificationService.getTop5Accounts() + ).rejects.toThrow(errorMessage); + expect(consoleErrorSpy).toHaveBeenCalledWith(new Error(errorMessage)); + }); +}); + +describe("getGamificationData", () => { + const mockData = { + userID: "123", + points: 10, + streaks: 5, + lastUnitCompletionDate: expect.anything(), + }; + + it("should return an AccountsGamification object", async () => { + const mockSingle = jest + .fn() + .mockResolvedValue({ data: mockData, error: null }); + const mockEq = jest.fn().mockReturnValue({ single: mockSingle }); + const mockSelect = jest.fn().mockReturnValue({ eq: mockEq }); + supabase.from.mockReturnValue({ select: mockSelect }); + + const result = await accountsGamificationService.getGamificationData( + mockData.userID + ); + + expect(result).toBeInstanceOf(accountsGamificationModel.AccountsGamification); + expect(result).toEqual(mockData); + }); + + it("should return an AccountsGamification object & lastUnitCompletionDate is null", async () => { + + const mockData = { + userID: "123", + points: 10, + streaks: 5, + lastUnitCompletionDate: null, + }; + + const mockSingle = jest + .fn() + .mockResolvedValue({ data: mockData, error: null }); + const mockEq = jest.fn().mockReturnValue({ single: mockSingle }); + const mockSelect = jest.fn().mockReturnValue({ eq: mockEq }); + supabase.from.mockReturnValue({ select: mockSelect }); + + const result = await accountsGamificationService.getGamificationData( + mockData.userID + ); + + expect(result).toBeInstanceOf( + accountsGamificationModel.AccountsGamification + ); + expect(result).toEqual(mockData); + }); + + it("should throw an error when there is an error from supabase", async () => { + const errorMessage = "Failed to fetch account"; + + const mockSingle = jest.fn().mockResolvedValue({ + data: mockData, + error: new Error(errorMessage), + }); + const mockEq = jest.fn().mockReturnValue({ single: mockSingle }); + const mockSelect = jest.fn().mockReturnValue({ eq: mockEq }); + supabase.from.mockReturnValue({ select: mockSelect }); + + await expect( + accountsGamificationService.getGamificationData("789") + ).rejects.toThrow(errorMessage); + expect(consoleErrorSpy).toHaveBeenCalledWith(new Error(errorMessage)); + }); +}); + diff --git a/backend/dist/app.js b/backend/dist/app.js index 9b2846d..5a6ac8d 100644 --- a/backend/dist/app.js +++ b/backend/dist/app.js @@ -20,6 +20,7 @@ const quizRouter_1 = __importDefault(require("./routes/quizRouter")); const resultRouter_1 = __importDefault(require("./routes/resultRouter")); const sectionRouter_1 = __importDefault(require("./routes/sectionRouter")); const unitRouter_1 = __importDefault(require("./routes/unitRouter")); +const accountsGamificationRouter_1 = __importDefault(require("./routes/accountsGamificationRouter")); const app = (0, express_1.default)(); const port = 3000; // Middleware @@ -40,6 +41,7 @@ app.use("/chat", chatRouter_1.default); app.use("/lesson", lessonRouter_1.default); app.use("/section", sectionRouter_1.default); app.use("/clickstream", clickstreamRouter_1.default); +app.use("/accounts", accountsGamificationRouter_1.default); // RabbitMQ Producer: Sends "timeTaken" data to RabbitMQ app.post("/rabbitmq", (req, res) => { const { timeTaken } = req.body; diff --git a/backend/dist/controllers/accountsGamificationController.js b/backend/dist/controllers/accountsGamificationController.js new file mode 100644 index 0000000..56e57fe --- /dev/null +++ b/backend/dist/controllers/accountsGamificationController.js @@ -0,0 +1,85 @@ +"use strict"; +var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) { + if (k2 === undefined) k2 = k; + var desc = Object.getOwnPropertyDescriptor(m, k); + if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) { + desc = { enumerable: true, get: function() { return m[k]; } }; + } + Object.defineProperty(o, k2, desc); +}) : (function(o, m, k, k2) { + if (k2 === undefined) k2 = k; + o[k2] = m[k]; +})); +var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) { + Object.defineProperty(o, "default", { enumerable: true, value: v }); +}) : function(o, v) { + o["default"] = v; +}); +var __importStar = (this && this.__importStar) || function (mod) { + if (mod && mod.__esModule) return mod; + var result = {}; + if (mod != null) for (var k in mod) if (k !== "default" && Object.prototype.hasOwnProperty.call(mod, k)) __createBinding(result, mod, k); + __setModuleDefault(result, mod); + return result; +}; +var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, generator) { + function adopt(value) { return value instanceof P ? value : new P(function (resolve) { resolve(value); }); } + return new (P || (P = Promise))(function (resolve, reject) { + function fulfilled(value) { try { step(generator.next(value)); } catch (e) { reject(e); } } + function rejected(value) { try { step(generator["throw"](value)); } catch (e) { reject(e); } } + function step(result) { result.done ? resolve(result.value) : adopt(result.value).then(fulfilled, rejected); } + step((generator = generator.apply(thisArg, _arguments || [])).next()); + }); +}; +var __importDefault = (this && this.__importDefault) || function (mod) { + return (mod && mod.__esModule) ? mod : { "default": mod }; +}; +Object.defineProperty(exports, "__esModule", { value: true }); +exports.updatePoints = exports.getGamificationData = exports.getTop5Accounts = void 0; +const accountsGamificationService = __importStar(require("../services/accountsGamificationService")); +const errorHandling_1 = __importDefault(require("../errors/errorHandling")); +/* READ */ +const getTop5Accounts = (req, res) => __awaiter(void 0, void 0, void 0, function* () { + try { + const accounts = yield accountsGamificationService.getTop5Accounts(req.params.userid); + res.status(200).json(accounts); + } + catch (error) { + const errorResponse = (0, errorHandling_1.default)(error); + if (errorResponse) { + res.status(errorResponse.status).json(errorResponse); + } + } +}); +exports.getTop5Accounts = getTop5Accounts; +const getGamificationData = (req, res) => __awaiter(void 0, void 0, void 0, function* () { + try { + const gamificationData = yield accountsGamificationService.getGamificationData(req.params.userid); + res.status(200).json(gamificationData); + } + catch (error) { + const errorResponse = (0, errorHandling_1.default)(error); + if (errorResponse) { + res.status(errorResponse.status).json(errorResponse); + } + } +}); +exports.getGamificationData = getGamificationData; +/* UPDATE */ +const updatePoints = (req, res) => __awaiter(void 0, void 0, void 0, function* () { + const { userID, points } = req.body; + try { + const updatedPoints = yield accountsGamificationService.updatePoints(userID, points); + res.status(200).json({ + status: 200, + statusText: "Points Updated Successfully", + }); + } + catch (error) { + const errorResponse = (0, errorHandling_1.default)(error); + if (errorResponse) { + res.status(errorResponse.status).json(errorResponse); + } + } +}); +exports.updatePoints = updatePoints; diff --git a/backend/dist/models/accountsGamification.js b/backend/dist/models/accountsGamification.js new file mode 100644 index 0000000..ddff764 --- /dev/null +++ b/backend/dist/models/accountsGamification.js @@ -0,0 +1,12 @@ +"use strict"; +Object.defineProperty(exports, "__esModule", { value: true }); +exports.AccountsGamification = void 0; +class AccountsGamification { + constructor(userID, points, streaks, lastUnitCompletionDate) { + this.userID = userID; + this.points = points; + this.streaks = streaks; + this.lastUnitCompletionDate = lastUnitCompletionDate; + } +} +exports.AccountsGamification = AccountsGamification; diff --git a/backend/dist/models/accountsGamificationModel.js b/backend/dist/models/accountsGamificationModel.js new file mode 100644 index 0000000..45b566f --- /dev/null +++ b/backend/dist/models/accountsGamificationModel.js @@ -0,0 +1,21 @@ +"use strict"; +Object.defineProperty(exports, "__esModule", { value: true }); +exports.AccountsGamification = void 0; +class AccountsGamification { + constructor(userID, points, streaks, lastUnitCompletionDate) { + this.userID = userID; + this.points = points; + this.streaks = streaks; + this.lastUnitCompletionDate = lastUnitCompletionDate; + } + getPoints() { + return this.points; + } + getStreaks() { + return this.streaks; + } + getLastUnitCompletionDate() { + return this.lastUnitCompletionDate; + } +} +exports.AccountsGamification = AccountsGamification; diff --git a/backend/dist/routes/accountsGamificationRouter.js b/backend/dist/routes/accountsGamificationRouter.js new file mode 100644 index 0000000..a61e3a5 --- /dev/null +++ b/backend/dist/routes/accountsGamificationRouter.js @@ -0,0 +1,34 @@ +"use strict"; +var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) { + if (k2 === undefined) k2 = k; + var desc = Object.getOwnPropertyDescriptor(m, k); + if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) { + desc = { enumerable: true, get: function() { return m[k]; } }; + } + Object.defineProperty(o, k2, desc); +}) : (function(o, m, k, k2) { + if (k2 === undefined) k2 = k; + o[k2] = m[k]; +})); +var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) { + Object.defineProperty(o, "default", { enumerable: true, value: v }); +}) : function(o, v) { + o["default"] = v; +}); +var __importStar = (this && this.__importStar) || function (mod) { + if (mod && mod.__esModule) return mod; + var result = {}; + if (mod != null) for (var k in mod) if (k !== "default" && Object.prototype.hasOwnProperty.call(mod, k)) __createBinding(result, mod, k); + __setModuleDefault(result, mod); + return result; +}; +Object.defineProperty(exports, "__esModule", { value: true }); +const accountsGamificationController = __importStar(require("../controllers/accountsGamificationController")); +const express_1 = require("express"); +const router = (0, express_1.Router)(); +/* READ */ +router.get('/gamificationdata/:userid', accountsGamificationController.getGamificationData); +router.get('/leaderboard/:userid', accountsGamificationController.getTop5Accounts); +/* UPDATE */ +router.patch('/updatepoints', accountsGamificationController.updatePoints); +exports.default = router; diff --git a/backend/dist/services/accountsGamificationService.js b/backend/dist/services/accountsGamificationService.js new file mode 100644 index 0000000..a86b50f --- /dev/null +++ b/backend/dist/services/accountsGamificationService.js @@ -0,0 +1,105 @@ +"use strict"; +var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, generator) { + function adopt(value) { return value instanceof P ? value : new P(function (resolve) { resolve(value); }); } + return new (P || (P = Promise))(function (resolve, reject) { + function fulfilled(value) { try { step(generator.next(value)); } catch (e) { reject(e); } } + function rejected(value) { try { step(generator["throw"](value)); } catch (e) { reject(e); } } + function step(result) { result.done ? resolve(result.value) : adopt(result.value).then(fulfilled, rejected); } + step((generator = generator.apply(thisArg, _arguments || [])).next()); + }); +}; +var __rest = (this && this.__rest) || function (s, e) { + var t = {}; + for (var p in s) if (Object.prototype.hasOwnProperty.call(s, p) && e.indexOf(p) < 0) + t[p] = s[p]; + if (s != null && typeof Object.getOwnPropertySymbols === "function") + for (var i = 0, p = Object.getOwnPropertySymbols(s); i < p.length; i++) { + if (e.indexOf(p[i]) < 0 && Object.prototype.propertyIsEnumerable.call(s, p[i])) + t[p[i]] = s[p[i]]; + } + return t; +}; +var __importDefault = (this && this.__importDefault) || function (mod) { + return (mod && mod.__esModule) ? mod : { "default": mod }; +}; +Object.defineProperty(exports, "__esModule", { value: true }); +exports.getTop5Accounts = getTop5Accounts; +exports.getGamificationData = getGamificationData; +exports.updatePoints = updatePoints; +const supabaseConfig_1 = __importDefault(require("../config/supabaseConfig")); +const accountsGamificationModel_1 = require("../models/accountsGamificationModel"); +/* READ */ +function getTop5Accounts(userID) { + return __awaiter(this, void 0, void 0, function* () { + const { data, error } = yield supabaseConfig_1.default + .from("accountsgamification") + .select("points, accounts!inner(userID, firstName, lastName)") + .order("points", { ascending: false }); + if (error) { + console.error(error); + throw error; + } + else { + let rank = 1; + let previousPoints = data[0].points; + const rankedData = data.map((record, index) => { + if (index > 0 && record.points < previousPoints) { + rank = rank + 1; + } + previousPoints = record.points; + return { + rank: rank, + name: record.accounts.firstName + " " + record.accounts.lastName, + points: record.points, + userID: record.accounts.userID, + }; + }); + // Filter accounts with rank <= 5 or userID = userID + const filteredData = rankedData + .filter((record) => record.rank <= 5 || record.userID === userID) + .map((_a) => { + var { userID } = _a, rest = __rest(_a, ["userID"]); + return rest; + }); + return filteredData; + } + }); +} +function getGamificationData(userID) { + return __awaiter(this, void 0, void 0, function* () { + const { data, error } = yield supabaseConfig_1.default + .from("accountsgamification") + .select("*") + .eq("userID", userID) + .single(); + if (error) { + console.error(error); + throw error; + } + else { + if (!data.lastUnitCompletionDate) { + return new accountsGamificationModel_1.AccountsGamification(data.userID, data.points, data.streaks, null); + } + return new accountsGamificationModel_1.AccountsGamification(data.userID, data.points, data.streaks, new Date(data.lastUnitCompletionDate)); + } + }); +} +/* UPDATE */ +function updatePoints(userID, points) { + return __awaiter(this, void 0, void 0, function* () { + const accountGamificationData = yield getGamificationData(userID); + const { status, statusText, error } = yield supabaseConfig_1.default + .from("accountsgamification") + .update({ + points: accountGamificationData.getPoints() + points, + }) + .eq("userID", userID); + if (error) { + console.error(error); + throw error; + } + else { + return { status, statusText }; + } + }); +} diff --git a/backend/src/app.ts b/backend/src/app.ts index 3195e60..05da319 100644 --- a/backend/src/app.ts +++ b/backend/src/app.ts @@ -15,6 +15,7 @@ import quizRouter from "./routes/quizRouter"; import resultRouter from "./routes/resultRouter"; import sectionRouter from "./routes/sectionRouter"; import unitRouter from "./routes/unitRouter"; +import accountsGamificationRouter from "./routes/accountsGamificationRouter"; const app = express(); const port = 3000; @@ -38,6 +39,7 @@ app.use("/chat", chatRouter); app.use("/lesson", lessonRouter); app.use("/section", sectionRouter); app.use("/clickstream", clickstreamRouter); +app.use("/accounts", accountsGamificationRouter); // RabbitMQ Producer: Sends "timeTaken" data to RabbitMQ app.post("/rabbitmq", (req, res) => { diff --git a/backend/src/config/database.types.ts b/backend/src/config/database.types.ts index 1ef4902..c0ee435 100644 --- a/backend/src/config/database.types.ts +++ b/backend/src/config/database.types.ts @@ -153,6 +153,35 @@ export type Database = { }, ] } + accountsgamification: { + Row: { + lastUnitCompletionDate: string | null + points: number + streaks: number + userID: string + } + Insert: { + lastUnitCompletionDate?: string | null + points?: number + streaks?: number + userID: string + } + Update: { + lastUnitCompletionDate?: string | null + points?: number + streaks?: number + userID?: string + } + Relationships: [ + { + foreignKeyName: "accountsgamification_userID_fkey" + columns: ["userID"] + isOneToOne: true + referencedRelation: "accounts" + referencedColumns: ["userID"] + }, + ] + } accountssocial: { Row: { compLiteracy: Database["public"]["Enums"]["computer_literacy_type"] @@ -185,6 +214,24 @@ export type Database = { }, ] } + badge: { + Row: { + badgeID: number + badgeName: string + badgeURI: string | null + } + Insert: { + badgeID?: number + badgeName: string + badgeURI?: string | null + } + Update: { + badgeID?: number + badgeName?: string + badgeURI?: string | null + } + Relationships: [] + } certificate: { Row: { certificateID: number diff --git a/backend/src/controllers/accountsGamificationController.ts b/backend/src/controllers/accountsGamificationController.ts new file mode 100644 index 0000000..8e42e0e --- /dev/null +++ b/backend/src/controllers/accountsGamificationController.ts @@ -0,0 +1,54 @@ +import { Request, Response } from "express"; +import * as accountsGamificationService from "../services/accountsGamificationService"; +import { errorMapping } from "../errors/errorMappings"; +import handleError from "../errors/errorHandling"; + +/* READ */ + +export const getTop5Accounts = async (req: Request, res: Response) => { + try { + const accounts = await accountsGamificationService.getTop5Accounts( + req.params.userid + ); + res.status(200).json(accounts); + } catch (error: any) { + const errorResponse = handleError(error); + if(errorResponse){ + res.status(errorResponse.status).json(errorResponse); + } + } +}; + +export const getGamificationData = async (req: Request, res: Response) => { + try { + const gamificationData = await accountsGamificationService.getGamificationData( + req.params.userid + ); + res.status(200).json(gamificationData); + } catch (error: any) { + const errorResponse = handleError(error); + if(errorResponse){ + res.status(errorResponse.status).json(errorResponse); + } + } +} + +/* UPDATE */ + +export const updatePoints = async (req: Request, res: Response) => { + + const { userID, points } = req.body; + + try { + const updatedPoints = await accountsGamificationService.updatePoints(userID, points); + res.status(200).json({ + status: 200, + statusText: "Points Updated Successfully", + }); + } catch (error: any) { + const errorResponse = handleError(error); + if(errorResponse){ + res.status(errorResponse.status).json(errorResponse); + } + } +}; \ No newline at end of file diff --git a/backend/src/models/accountsGamificationModel.ts b/backend/src/models/accountsGamificationModel.ts new file mode 100644 index 0000000..ab4f7f8 --- /dev/null +++ b/backend/src/models/accountsGamificationModel.ts @@ -0,0 +1,31 @@ + +export class AccountsGamification { + userID: string; + points: number; + streaks: number; + lastUnitCompletionDate: Date | null; + + constructor( + userID: string, + points: number, + streaks: number, + lastUnitCompletionDate: Date | null + ) { + this.userID = userID; + this.points = points; + this.streaks = streaks; + this.lastUnitCompletionDate = lastUnitCompletionDate; + } + + getPoints(): number { + return this.points; + } + + getStreaks(): number { + return this.streaks; + } + + getLastUnitCompletionDate(): Date | null { + return this.lastUnitCompletionDate; + } +} diff --git a/backend/src/routes/accountsGamificationRouter.ts b/backend/src/routes/accountsGamificationRouter.ts new file mode 100644 index 0000000..156111d --- /dev/null +++ b/backend/src/routes/accountsGamificationRouter.ts @@ -0,0 +1,15 @@ +import * as accountsGamificationController from '../controllers/accountsGamificationController'; + +import { Router } from 'express'; +import verifyToken from '../middleware/authMiddleware'; + +const router = Router(); + +/* READ */ +router.get('/gamificationdata/:userid', accountsGamificationController.getGamificationData); +router.get('/leaderboard/:userid', accountsGamificationController.getTop5Accounts); + +/* UPDATE */ +router.patch('/updatepoints', accountsGamificationController.updatePoints); + +export default router; \ No newline at end of file diff --git a/backend/src/services/accountsGamificationService.ts b/backend/src/services/accountsGamificationService.ts new file mode 100644 index 0000000..bfe669b --- /dev/null +++ b/backend/src/services/accountsGamificationService.ts @@ -0,0 +1,93 @@ +import supabase from "../config/supabaseConfig"; +import { + AccountsGamification +} from "../models/accountsGamificationModel"; + +/* READ */ +export async function getTop5Accounts(userID: string) { + const { data, error } = await supabase + .from("accountsgamification") + .select("points, accounts!inner(userID, firstName, lastName)") + .order("points", { ascending: false }) + + if (error) { + console.error(error); + throw error; + } else { + + let rank = 1; + let previousPoints = data[0].points; + + const rankedData = data.map((record, index: number) => { + if (index > 0 && record.points < previousPoints) { + rank = rank + 1; + } + previousPoints = record.points; + + return { + rank: rank, + name: record.accounts.firstName + " " + record.accounts.lastName, + points: record.points, + userID: record.accounts.userID, + }; + }); + + // Filter accounts with rank <= 5 or userID = userID + const filteredData = rankedData + .filter((record) => record.rank <= 5 || record.userID === userID) + .map(({ userID, ...rest }) => rest); + + return filteredData; + } +} + +export async function getGamificationData(userID: string) { + const { data, error } = await supabase + .from("accountsgamification") + .select("*") + .eq("userID", userID) + .single(); + + if (error) { + console.error(error); + throw error; + } else { + + if (!data.lastUnitCompletionDate) { + return new AccountsGamification( + data.userID, + data.points, + data.streaks, + null + ); + } + + return new AccountsGamification( + data.userID, + data.points, + data.streaks, + new Date(data.lastUnitCompletionDate) + ); + } +} + +/* UPDATE */ +export async function updatePoints(userID: string, points: number) { + + const accountGamificationData = await getGamificationData(userID); + + const { status, statusText, error } = await supabase + .from("accountsgamification") + .update({ + points: accountGamificationData.getPoints() + points, + }) + .eq("userID", userID); + + if (error) { + console.error(error); + throw error; + } else { + return { status, statusText }; + } +} + From 920609711346776078df0f20ad6156e7ca383dc4 Mon Sep 17 00:00:00 2001 From: Germaine Date: Fri, 11 Oct 2024 20:43:09 +0800 Subject: [PATCH 2/3] SCRUM-137 add verifyToken to router --- backend/dist/routes/accountsGamificationRouter.js | 10 +++++++--- backend/src/routes/accountsGamificationRouter.ts | 6 +++--- 2 files changed, 10 insertions(+), 6 deletions(-) diff --git a/backend/dist/routes/accountsGamificationRouter.js b/backend/dist/routes/accountsGamificationRouter.js index a61e3a5..0314ada 100644 --- a/backend/dist/routes/accountsGamificationRouter.js +++ b/backend/dist/routes/accountsGamificationRouter.js @@ -22,13 +22,17 @@ var __importStar = (this && this.__importStar) || function (mod) { __setModuleDefault(result, mod); return result; }; +var __importDefault = (this && this.__importDefault) || function (mod) { + return (mod && mod.__esModule) ? mod : { "default": mod }; +}; Object.defineProperty(exports, "__esModule", { value: true }); const accountsGamificationController = __importStar(require("../controllers/accountsGamificationController")); const express_1 = require("express"); +const authMiddleware_1 = __importDefault(require("../middleware/authMiddleware")); const router = (0, express_1.Router)(); /* READ */ -router.get('/gamificationdata/:userid', accountsGamificationController.getGamificationData); -router.get('/leaderboard/:userid', accountsGamificationController.getTop5Accounts); +router.get('/gamificationdata/:userid', authMiddleware_1.default, accountsGamificationController.getGamificationData); +router.get("/leaderboard/:userid", authMiddleware_1.default, accountsGamificationController.getTop5Accounts); /* UPDATE */ -router.patch('/updatepoints', accountsGamificationController.updatePoints); +router.patch('/updatepoints', authMiddleware_1.default, accountsGamificationController.updatePoints); exports.default = router; diff --git a/backend/src/routes/accountsGamificationRouter.ts b/backend/src/routes/accountsGamificationRouter.ts index 156111d..1f45740 100644 --- a/backend/src/routes/accountsGamificationRouter.ts +++ b/backend/src/routes/accountsGamificationRouter.ts @@ -6,10 +6,10 @@ import verifyToken from '../middleware/authMiddleware'; const router = Router(); /* READ */ -router.get('/gamificationdata/:userid', accountsGamificationController.getGamificationData); -router.get('/leaderboard/:userid', accountsGamificationController.getTop5Accounts); +router.get('/gamificationdata/:userid', verifyToken, accountsGamificationController.getGamificationData); +router.get("/leaderboard/:userid", verifyToken, accountsGamificationController.getTop5Accounts); /* UPDATE */ -router.patch('/updatepoints', accountsGamificationController.updatePoints); +router.patch('/updatepoints', verifyToken, accountsGamificationController.updatePoints); export default router; \ No newline at end of file From adf6fe0924440a76f283d1b8852abaedb82c146f Mon Sep 17 00:00:00 2001 From: Germaine Date: Sat, 12 Oct 2024 01:22:31 +0800 Subject: [PATCH 3/3] SCRUM-137 add tc for updatePoints --- .../accountsGamificationService.test.js | 131 ++++++++++++++++-- backend/babel.config.json | 4 + backend/package.json | 3 + .../services/accountsGamificationService.ts | 3 +- 4 files changed, 124 insertions(+), 17 deletions(-) create mode 100644 backend/babel.config.json diff --git a/backend/__tests__/services/accountsGamificationService.test.js b/backend/__tests__/services/accountsGamificationService.test.js index 1f887ac..67d2690 100644 --- a/backend/__tests__/services/accountsGamificationService.test.js +++ b/backend/__tests__/services/accountsGamificationService.test.js @@ -1,6 +1,8 @@ -const accountsGamificationService = require("../../dist/services/accountsGamificationService"); const supabase = require("../../dist/config/supabaseConfig"); const accountsGamificationModel = require("../../dist/models/accountsGamificationModel"); +const accountsGamificationService = require("../../dist/services/accountsGamificationService"); +const __RewireAPI__ = require("../../dist/services/accountsGamificationService").__RewireAPI__; +const sinon = require("sinon"); jest.mock("../../dist/config/supabaseConfig", () => ({ from: jest.fn(), @@ -10,7 +12,7 @@ let consoleErrorSpy; beforeEach(() => { jest.resetAllMocks(); - consoleErrorSpy = jest.spyOn(console, "error").mockImplementation(() => { }); + consoleErrorSpy = jest.spyOn(console, "error").mockImplementation(() => {}); }); afterEach(() => { @@ -18,8 +20,7 @@ afterEach(() => { }); describe("getTop5Accounts", () => { - - userID = "2"; + const userID = "2"; const mockResult = [ { @@ -75,7 +76,7 @@ describe("getTop5Accounts", () => { accounts: { userID: "jd", lastName: "Doe", - firstName: "John" + firstName: "John", }, }, ]; @@ -109,14 +110,17 @@ describe("getTop5Accounts", () => { ]; it("returns accounts ranked top 5 & user rank", async () => { - - const mockOrder = jest.fn().mockReturnValue({ data: mockResult, error: null }); + const mockOrder = jest + .fn() + .mockReturnValue({ data: mockResult, error: null }); const mockSelect = jest.fn().mockReturnValue({ order: mockOrder }); supabase.from.mockReturnValue({ select: mockSelect }); - const accounts = await accountsGamificationService.getTop5Accounts(userID); + const accounts = await accountsGamificationService.getTop5Accounts( + userID + ); expect(accounts).toEqual(expectedResult); }); @@ -146,7 +150,6 @@ describe("getTop5Accounts", () => { }); it("returns more than 5 accounts if there is any same rank & user rank", async () => { - const mockResult2 = [ ...mockResult, { @@ -186,11 +189,11 @@ describe("getTop5Accounts", () => { it("handles error correctly and logs it to console.error", async () => { const errorMessage = "Failed to fetch all accounts"; - const mockOrder = jest.fn().mockResolvedValue({ data: null, error: new Error(errorMessage) }); - - const mockSelect = jest + const mockOrder = jest .fn() - .mockReturnValue({ order: mockOrder }); + .mockResolvedValue({ data: null, error: new Error(errorMessage) }); + + const mockSelect = jest.fn().mockReturnValue({ order: mockOrder }); supabase.from.mockReturnValue({ select: mockSelect }); @@ -221,12 +224,13 @@ describe("getGamificationData", () => { mockData.userID ); - expect(result).toBeInstanceOf(accountsGamificationModel.AccountsGamification); + expect(result).toBeInstanceOf( + accountsGamificationModel.AccountsGamification + ); expect(result).toEqual(mockData); }); it("should return an AccountsGamification object & lastUnitCompletionDate is null", async () => { - const mockData = { userID: "123", points: 10, @@ -269,3 +273,100 @@ describe("getGamificationData", () => { }); }); +describe("updatePoints", () => { + afterEach(() => { + // Reset the mocked dependency after each test + __RewireAPI__.__ResetDependency__("getGamificationData"); + }); + + const mockGamificationData = + new accountsGamificationModel.AccountsGamification("123", 10, 5, null); + + const expectedResult = { + status: 204, + statusText: "No Content", + }; + + it("should update the account successfully when valid fields are provided", async () => { + // Mock the getGamificationData response using sinon + const getGamificationDataSpy = sinon + .stub() + .returns(mockGamificationData); + __RewireAPI__.__Rewire__("getGamificationData", getGamificationDataSpy); + + // Mock the Supabase update query + const mockEq = jest.fn().mockResolvedValue({ + ...expectedResult, + error: null, + }); + + const mockUpdate = jest.fn().mockReturnValue({ eq: mockEq }); + supabase.from.mockReturnValue({ update: mockUpdate }); + + // Call the function to test + const result = await accountsGamificationService.updatePoints("123", 5); + + sinon.assert.calledWith(getGamificationDataSpy, "123"); + + // Validate that the update was called with the correct data + expect(mockUpdate).toHaveBeenCalledWith({ + points: mockGamificationData.getPoints() + 5, + }); + + // Validate that the result matches the expected output + expect(result).toEqual(expectedResult); + }); + + it("should throw an error and log the error when supabase function to update threw the error", async () => { + const errorMessage = "update error"; + + // Mock the getGamificationData response using sinon + const getGamificationDataSpy = sinon.stub().returns(mockGamificationData); + __RewireAPI__.__Rewire__("getGamificationData", getGamificationDataSpy); + + // Mock the Supabase update query + const mockEq = jest.fn().mockResolvedValue({ + ...expectedResult, + error: new Error(errorMessage), + }); + + const mockUpdate = jest.fn().mockReturnValue({ eq: mockEq }); + supabase.from.mockReturnValue({ update: mockUpdate }); + + await expect( + accountsGamificationService.updatePoints("123", 5) + ).rejects.toThrow(errorMessage); + + // Check if console.error was called with the expected message + expect(console.error).toHaveBeenCalledWith(new Error(errorMessage)); + }); + + it("should throw an error when getGamificationData throws the error, supabase function to update should not be called", async () => { + const errorMessage = "Failed to fetch account"; + + // Mock the getGamificationData response using sinon + const getGamificationDataSpy = sinon + .stub() + .throws(new Error(errorMessage)); + __RewireAPI__.__Rewire__("getGamificationData", getGamificationDataSpy); + + // Mock the Supabase update query + const mockEq = jest.fn().mockResolvedValue({ + ...expectedResult, + error: new Error("another"), + }); + + const mockUpdate = jest.fn().mockReturnValue({ eq: mockEq }); + supabase.from.mockReturnValue({ update: mockUpdate }); + + await expect( + accountsGamificationService.updatePoints("123", 5) + ).rejects.toThrow(errorMessage); + + // Verify that the Supabase update function was never called + expect(supabase.from).not.toHaveBeenCalled(); + expect(mockUpdate).not.toHaveBeenCalled(); + expect(mockEq).not.toHaveBeenCalled(); + + }); +}); diff --git a/backend/babel.config.json b/backend/babel.config.json new file mode 100644 index 0000000..d464f41 --- /dev/null +++ b/backend/babel.config.json @@ -0,0 +1,4 @@ +{ + "presets": ["@babel/preset-env"], + "plugins": ["rewire"] +} \ No newline at end of file diff --git a/backend/package.json b/backend/package.json index ae81b37..702cf03 100644 --- a/backend/package.json +++ b/backend/package.json @@ -13,12 +13,14 @@ "author": "", "license": "ISC", "devDependencies": { + "@babel/preset-env": "^7.25.8", "@types/amqplib": "^0.10.5", "@types/cookie-parser": "^1.4.7", "@types/cors": "^2.8.17", "@types/express": "^4.17.21", "@types/jsonwebtoken": "^9.0.6", "@types/uuid": "^10.0.0", + "babel-plugin-rewire": "^1.2.0", "jest": "^29.7.0", "typescript": "^5.5.3" }, @@ -34,6 +36,7 @@ "jwks-rsa": "^3.1.0", "log4js": "^6.9.1", "nodemon": "^3.1.4", + "sinon": "^19.0.2", "supabase": "^1.183.5", "supertest": "^7.0.0", "ts-node": "^10.9.2" diff --git a/backend/src/services/accountsGamificationService.ts b/backend/src/services/accountsGamificationService.ts index bfe669b..a86f3f1 100644 --- a/backend/src/services/accountsGamificationService.ts +++ b/backend/src/services/accountsGamificationService.ts @@ -89,5 +89,4 @@ export async function updatePoints(userID: string, points: number) { } else { return { status, statusText }; } -} - +} \ No newline at end of file