Skip to content

Commit

Permalink
#539 Chris/add user settings (#544)
Browse files Browse the repository at this point in the history
fixes #539

**NOTE:** This is a branch off my password reset branch which is why it
shows so many files changed.

- users can now navigate to settings using the navdrawer and update
their email and/or password.
- if the user forgot their OLD PASSWORD they can click on the recover
link in the form. When they click the link it will log them out and
route them to /recover-password.

## SCREENSHOT

![CleanShot_2024-09-18_at_17 08
32](https://github.com/user-attachments/assets/4ab11b7d-acad-4d1e-8fbe-e456ba165315)
  • Loading branch information
chris-nowicki authored Oct 17, 2024
1 parent 6bc5b39 commit 05661c5
Show file tree
Hide file tree
Showing 10 changed files with 927 additions and 42 deletions.
120 changes: 119 additions & 1 deletion api/apiFunctions.test.tsx
Original file line number Diff line number Diff line change
@@ -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');
Expand All @@ -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(),
},
Expand Down Expand Up @@ -164,6 +178,110 @@ describe('apiFunctions', () => {
);
});
});
describe('updateUserEmail', () => {
const mockNewEmail = '[email protected]';
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: '[email protected]',
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', () => {
Expand Down
61 changes: 61 additions & 0 deletions api/apiFunctions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<void>}
*/
export async function resetPassword({
newPassword,
oldPassword,
}: {
newPassword: string;
oldPassword: string;
}): Promise<void> {
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<void>} - The updated user
*/
export async function updateUserEmail({
email,
password,
}: {
email: string;
password: string;
}): Promise<void> {
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
Expand Down
45 changes: 45 additions & 0 deletions app/(main)/account/settings/page.test.tsx
Original file line number Diff line number Diff line change
@@ -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(<AccountSettings />);

await waitFor(() => {
expect(screen.getByTestId('global-spinner')).toBeInTheDocument();
});
});

it('should not show GlobalSpinner and render the settings page', async () => {
mockUseAuthContext.mockReturnValue({
isSignedIn: true,
});

render(<AccountSettings />);

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();
});
});
66 changes: 66 additions & 0 deletions app/(main)/account/settings/page.tsx
Original file line number Diff line number Diff line change
@@ -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<boolean>(true);
const { isSignedIn } = useAuthContext();

useEffect(() => {
if (isSignedIn) {
setLoadingData(false);
}
}, [isSignedIn]);

return (
<>
{loadingData ? (
<GlobalSpinner />
) : (
<section className="mx-auto max-w-5xl pt-10">
<header
className="flex flex-col gap-4"
data-testid="settings-page-header"
>
<div data-testid="link-to-all-leagues-page">
<LinkCustom
className="no-underline hover:underline text-primary flex gap-3 items-center font-semibold text-xl"
href={`/league/all`}
>
<ChevronLeft />
Your Leagues
</LinkCustom>
</div>
<Heading
as="h1"
className="text-4xl font-bold"
data-testid="entry-page-header-page-name"
>
Settings
</Heading>
</header>

<div className="flex flex-col w-full pt-10 gap-8">
<UpdateEmailForm />
<ResetPasswordForm />
</div>
</section>
)}
</>
);
};

export default AccountSettings;
Loading

0 comments on commit 05661c5

Please sign in to comment.