diff --git a/api/apiFunctions.test.tsx b/api/apiFunctions.test.tsx index 30a7b3a5..7be6a988 100644 --- a/api/apiFunctions.test.tsx +++ b/api/apiFunctions.test.tsx @@ -1,12 +1,17 @@ +import { mock } from 'node:test'; import { recoverPassword, registerAccount, + resetPassword, resetRecoveredPassword, + updateUserEmail, } from './apiFunctions'; import { IUser } from './apiFunctions.interface'; -import { account, ID } from './config'; +import { account, databases, ID } from './config'; const apiFunctions = require('./apiFunctions'); import { getBaseURL } from '@/utils/getBaseUrl'; +import { Collection } from './apiFunctions.enum'; +import { Query } from 'appwrite'; jest.mock('./apiFunctions', () => { const actualModule = jest.requireActual('./apiFunctions'); @@ -30,8 +35,17 @@ jest.mock('./config', () => ({ account: { create: jest.fn(), createRecovery: jest.fn(), + updateEmail: jest.fn(), + updatePassword: jest.fn(), updateRecovery: jest.fn(), }, + appwriteConfig: { + databaseId: 'mock-database-id', + }, + databases: { + listDocuments: jest.fn(), + updateDocument: jest.fn(), + }, ID: { unique: jest.fn(), }, @@ -164,6 +178,110 @@ describe('apiFunctions', () => { ); }); }); + describe('updateUserEmail', () => { + const mockNewEmail = 'new@example.com'; + const mockPassword = 'password123'; + const mockUserId = '123'; + const mockDocumentId = '456'; + it("should successfully update the user's email", async () => { + (account.updateEmail as jest.Mock).mockResolvedValue({ + $id: mockUserId, + }); + + (databases.listDocuments as jest.Mock).mockResolvedValue({ + documents: [ + { + $id: mockDocumentId, + name: 'Test User', + email: 'old@example.com', + labels: '', + userId: mockUserId, + leagues: [], + }, + ], + }); + + (databases.updateDocument as jest.Mock).mockResolvedValue({}); + + await updateUserEmail({ + email: mockNewEmail, + password: mockPassword, + }); + + expect(account.updateEmail).toHaveBeenCalledWith( + mockNewEmail, + mockPassword, + ); + + expect(databases.listDocuments).toHaveBeenCalledWith( + 'mock-database-id', + Collection.USERS, + [Query.equal('userId', mockUserId)], + ); + + expect(databases.updateDocument).toHaveBeenCalledWith( + 'mock-database-id', + Collection.USERS, + mockDocumentId, + { + email: mockNewEmail, + name: 'Test User', + labels: '', + userId: mockUserId, + leagues: [], + }, + ); + }); + it('should throw an error if updating email fails', async () => { + (account.updateEmail as jest.Mock).mockRejectedValue(new Error()); + + await expect( + updateUserEmail({ + email: mockNewEmail, + password: mockPassword, + }), + ).rejects.toThrow(); + + expect(account.updateEmail).toHaveBeenCalledWith( + mockNewEmail, + mockPassword, + ); + + expect(databases.listDocuments).not.toHaveBeenCalled(); + expect(databases.updateDocument).not.toHaveBeenCalled(); + }); + }); + + describe('resetPassword', () => { + it('should successfully reset the password', async () => { + (account.updatePassword as jest.Mock).mockResolvedValue({}); + + await resetPassword({ + newPassword: 'newPassword123', + oldPassword: 'oldPassword123', + }); + + expect(account.updatePassword).toHaveBeenCalledWith( + 'newPassword123', + 'oldPassword123', + ); + }); + }); + it('should throw an error if resetting password fails', async () => { + (account.updatePassword as jest.Mock).mockRejectedValue(new Error()); + + await expect( + resetPassword({ + newPassword: 'newPassword123', + oldPassword: 'oldPassword123', + }), + ).rejects.toThrow(); + + expect(account.updatePassword).toHaveBeenCalledWith( + 'newPassword123', + 'oldPassword123', + ); + }); }); describe('Get Weekly Picks Mock function', () => { diff --git a/api/apiFunctions.ts b/api/apiFunctions.ts index 2fcc88d1..93041dd3 100644 --- a/api/apiFunctions.ts +++ b/api/apiFunctions.ts @@ -83,6 +83,67 @@ export async function resetRecoveredPassword({ } } +/** + * Resets a user's password from the settings page + * @param params - The params for the reset password function + * @param params.newPassword - The new password + * @param params.oldPassword - The old password + * @returns {Promise} + */ +export async function resetPassword({ + newPassword, + oldPassword, +}: { + newPassword: string; + oldPassword: string; +}): Promise { + try { + await account.updatePassword(newPassword, oldPassword); + } catch (error) { + throw error; + } +} + +/** + * Update the user email + * @param props - The props for the update email function + * @param props.email - The email + * @param props.password - The user's current password + * @returns {Promise} - The updated user + */ +export async function updateUserEmail({ + email, + password, +}: { + email: string; + password: string; +}): Promise { + try { + const result = await account.updateEmail(email, password); + + const userDocument = await databases.listDocuments( + appwriteConfig.databaseId, + Collection.USERS, + [Query.equal('userId', result.$id)], + ); + + await databases.updateDocument( + appwriteConfig.databaseId, + Collection.USERS, + userDocument.documents[0].$id, + { + email: email, + name: userDocument.documents[0].name, + labels: userDocument.documents[0].labels, + userId: userDocument.documents[0].userId, + leagues: userDocument.documents[0].leagues, + }, + ); + } catch (error) { + throw error; + } +} + /** * Get the current user * @param userId - The user ID diff --git a/app/(main)/account/settings/page.test.tsx b/app/(main)/account/settings/page.test.tsx new file mode 100644 index 00000000..75716f33 --- /dev/null +++ b/app/(main)/account/settings/page.test.tsx @@ -0,0 +1,45 @@ +import { render, screen, waitFor } from '@testing-library/react'; +import { useAuthContext } from '@/context/AuthContextProvider'; +import AccountSettings from './page'; + +jest.mock('@/context/AuthContextProvider'); + +describe('Account Settings Page', () => { + let mockUseAuthContext: jest.Mock; + + beforeEach(() => { + mockUseAuthContext = jest.fn(); + (useAuthContext as jest.Mock).mockImplementation(mockUseAuthContext); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + it('should display GlobalSpinner while loading data', async () => { + mockUseAuthContext.mockReturnValue({ + isSignedIn: false, + }); + render(); + + await waitFor(() => { + expect(screen.getByTestId('global-spinner')).toBeInTheDocument(); + }); + }); + + it('should not show GlobalSpinner and render the settings page', async () => { + mockUseAuthContext.mockReturnValue({ + isSignedIn: true, + }); + + render(); + + expect(screen.getByTestId('settings-page-header')).toBeInTheDocument(); + expect(screen.getByTestId('email')).toBeInTheDocument(); + expect(screen.getByTestId('current-password')).toBeInTheDocument(); + expect(screen.getByTestId('old-password')).toBeInTheDocument(); + expect(screen.getByTestId('new-password')).toBeInTheDocument(); + + expect(screen.queryByTestId('global-spinner')).not.toBeInTheDocument(); + }); +}); diff --git a/app/(main)/account/settings/page.tsx b/app/(main)/account/settings/page.tsx new file mode 100644 index 00000000..29777fe8 --- /dev/null +++ b/app/(main)/account/settings/page.tsx @@ -0,0 +1,66 @@ +// Copyright (c) Gridiron Survivor. +// Licensed under the MIT License. + +'use client'; +import GlobalSpinner from '@/components/GlobalSpinner/GlobalSpinner'; +import Heading from '@/components/Heading/Heading'; +import LinkCustom from '@/components/LinkCustom/LinkCustom'; +import ResetPasswordForm from '@/components/RestPasswordForm/ResetPasswordForm'; +import UpdateEmailForm from '@/components/UpdateEmailForm/UpdateEmailForm'; +import { useAuthContext } from '@/context/AuthContextProvider'; +import { ChevronLeft } from 'lucide-react'; +import { JSX, useEffect, useState } from 'react'; + +/** + * Display user preferences + * @returns {JSX.Element} The rendered user preferences component. + */ +const AccountSettings = (): JSX.Element => { + const [loadingData, setLoadingData] = useState(true); + const { isSignedIn } = useAuthContext(); + + useEffect(() => { + if (isSignedIn) { + setLoadingData(false); + } + }, [isSignedIn]); + + return ( + <> + {loadingData ? ( + + ) : ( +
+
+
+ + + Your Leagues + +
+ + Settings + +
+ +
+ + +
+
+ )} + + ); +}; + +export default AccountSettings; diff --git a/components/Nav/Nav.test.tsx b/components/Nav/Nav.test.tsx index b23ea7d9..74fc9655 100644 --- a/components/Nav/Nav.test.tsx +++ b/components/Nav/Nav.test.tsx @@ -1,8 +1,6 @@ import { render, screen, fireEvent, waitFor } from '@testing-library/react'; import Nav from './Nav'; import Login from '@/app/(main)/login/page'; -import { useDataStore } from '@/store/dataStore'; -import { getUserLeagues } from '@/utils/utils'; const mockPush = jest.fn(); const mockUsePathname = jest.fn(); @@ -34,10 +32,6 @@ jest.mock('../../context/AuthContextProvider', () => ({ }, })); -jest.mock('@/store/dataStore', () => ({ - useDataStore: jest.fn(() => ({ user: { id: '123', leagues: [] } })), -})); - Object.defineProperty(window, 'matchMedia', { writable: true, value: jest.fn().mockImplementation((query) => ({ @@ -53,8 +47,18 @@ Object.defineProperty(window, 'matchMedia', { }); describe('Nav', () => { - const mockUseDataStore = useDataStore as unknown as jest.Mock; - const mockGetUserLeagues = getUserLeagues as jest.Mock; + beforeAll(() => { + const originalCreateElement = document.createElement.bind(document); + jest + .spyOn(document, 'createElement') + .mockImplementation((tagName, options) => { + const element = originalCreateElement(tagName, options); + if (tagName.toLowerCase() === 'a') { + element.addEventListener('click', (e) => e.preventDefault()); + } + return element; + }); + }); beforeEach(() => { jest.clearAllMocks(); @@ -63,33 +67,45 @@ describe('Nav', () => { it('renders link to /league/all', async () => { render(