Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[#19] [UI] As a user, when I am authenticated I can see the company logo and my profile image at the top of pages [#18] [#22] As a user, I can logout #39

Open
wants to merge 9 commits into
base: feature/25-login-page-when-unauthenticated
Choose a base branch
from
Open
7 changes: 5 additions & 2 deletions cypress/integration/Authentication/login.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ describe('User Authentication', () => {
});

context('login with email and password', () => {
it('given correct credentials, redirects to the home page', () => {
it('given correct credentials, redirects to the home page, shows user header', () => {
cy.intercept('POST', 'api/v1/oauth/token', {
statusCode: 200,
fixture: 'Authentication/valid-credentials.json',
Expand All @@ -27,9 +27,11 @@ describe('User Authentication', () => {
});

cy.findByTestId('app-main-heading').should('be.visible');

cy.findByTestId('header-avatar').should('have.attr', 'src', 'valid_avatar_url');
});

it('given INCORRECT credentials, shows login error', () => {
it('given INCORRECT credentials, shows login error, does NOT show user header', () => {
cy.intercept('POST', 'api/v1/oauth/token', {
statusCode: 400,
fixture: 'Authentication/invalid-credentials.json',
Expand All @@ -52,6 +54,7 @@ describe('User Authentication', () => {
});

cy.findByTestId('app-main-heading').should('not.exist');
cy.findByTestId('header-avatar').should('not.exist');
});

it('given NO credentials entered, shows field validation errors', () => {
Expand Down
47 changes: 47 additions & 0 deletions cypress/integration/Surveys/home.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
import { setItem } from '../../../src/helpers/localStorage';
/* eslint-disable camelcase */
const mockTokenData = {
access_token: 'test_access_token',
refresh_token: 'test_refresh_token',
token_type: 'Bearer',
expires_in: 7200,
created_at: 1677045997,
};

const mockUserProfileData = {
email: '[email protected]',
name: 'TestName',
avatar_url: 'https://secure.gravatar.com/avatar/6733d09432e89459dba795de8312ac2d',
};

// TODO: Add test for expired token for surveys are shown on home page (a further authenticated request required)
describe('Home', () => {
context('Authentication token', () => {
it('with user tokens, displays home page and user header', () => {
setItem('UserProfile', { auth: mockTokenData, user: mockUserProfileData });

cy.visit('/');

cy.location().should((location) => {
expect(location.pathname).to.eq('/');
});

cy.findByTestId('app-main-heading').should('be.visible');

cy.findByText('Home Screen').should('exist');
cy.findByText('Home Screen').should('be.visible');
});

it('WITHOUT user tokens, redirects to the login page', () => {
cy.visit('/');

cy.location().should((location) => {
expect(location.pathname).to.eq('/login');
});

cy.findByTestId('app-main-heading').should('not.exist');

cy.findByText('Home Screen').should('not.exist');
});
});
});
2 changes: 1 addition & 1 deletion package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

18 changes: 18 additions & 0 deletions src/adapters/authAdapter.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -81,4 +81,22 @@ describe('AuthAdapter', () => {
expect(scope.isDone()).toBe(true);
});
});

describe('logout', () => {
test('The logout endpoint is called', async () => {
const token = 'access_token';

const scope = nock(`${process.env.REACT_APP_API_ENDPOINT}`)
.defaultReplyHeaders({
'access-control-allow-origin': '*',
'access-control-allow-credentials': 'true',
})
.post('/oauth/revoke')
.reply(200);

expect(scope.isDone()).toBe(false);
expect(await AuthAdapter.logout(token));
expect(scope.isDone()).toBe(true);
});
});
});
11 changes: 11 additions & 0 deletions src/adapters/authAdapter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,17 @@ class AuthAdapter extends BaseAdapter {
return this.prototype.postRequest('oauth/token', { data: requestParams });
}

static logout(accessToken: string) {
/* eslint-disable camelcase */
const requestParams = {
...OauthParams,
token: accessToken,
};
/* eslint-enable camelcase */

return this.prototype.postRequest('oauth/revoke', { data: requestParams });
}

static getUser() {
return this.prototype.getRequest('me', {});
}
Expand Down
41 changes: 41 additions & 0 deletions src/components/Header/index.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
/* eslint-disable camelcase */
import { BrowserRouter } from 'react-router-dom';

import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';

import { User } from 'types/User';

import Header from '.';

const mockUserProfileData = {
email: '[email protected]',
name: 'TestName',
avatar_url: 'https://secure.gravatar.com/avatar/6733d09432e89459dba795de8312ac2d',
};

describe('Header', () => {
const user: User = { name: 'Test User', email: '[email protected]', avatarUrl: mockUserProfileData.avatar_url };

test('renders a header on the page with sidebar interaction', async () => {
render(<Header user={user} />, { wrapper: BrowserRouter });

const profileImage = screen.getByTestId('header-avatar') as HTMLImageElement;
expect(profileImage).toBeInTheDocument();
expect(profileImage.src).toContain(mockUserProfileData.avatar_url);

expect(screen.queryByTestId('sidebar-avatar')).not.toBeInTheDocument();
expect(screen.queryByTestId('username')).not.toBeInTheDocument();
expect(screen.queryByRole('button', { name: 'Logout' })).not.toBeInTheDocument();

const sidebarButton = screen.getByTestId('open-sidebar');

await userEvent.click(sidebarButton);

expect(screen.getByTestId('sidebar-avatar')).toBeInTheDocument();
expect(screen.getByTestId('username')).toBeInTheDocument();
expect(screen.getByRole('button', { name: 'Logout' })).toBeInTheDocument();

expect(screen.queryByTestId('header-avatar')).not.toBeInTheDocument();
});
});
39 changes: 39 additions & 0 deletions src/components/Header/index.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
import { useState } from 'react';

import logo from 'assets/images/logo.svg';
import Sidebar from 'components/Sidebar';
import { User } from 'types/User';

type HeaderProps = {
user: User;
};

function Header({ user }: HeaderProps) {
const [sidebarVisible, setSidebarVisible] = useState(false);

return (
<header className="fixed top-5 flex w-11/12 flex-row items-center justify-between p-0">
<div>
<img src={logo} alt="logo"></img>
</div>
<div data-test-id="open-sidebar" onClick={() => setSidebarVisible(!sidebarVisible)} role="presentation">
{sidebarVisible ? (
<Sidebar user={user} />
) : (
<div>
<img
data-test-id="header-avatar"
className="cursor-pointer rounded-full"
height={36}
width={36}
src={user.avatarUrl}
alt="profile"
></img>
</div>
)}
</div>
</header>
);
}

export default Header;
9 changes: 6 additions & 3 deletions src/components/PrivateRoute/index.test.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
/* eslint-disable camelcase */
import { MemoryRouter } from 'react-router-dom';

import { render, screen, waitForElementToBeRemoved } from '@testing-library/react';
import { render, screen } from '@testing-library/react';

import PrivateRoute from '.';
import { setItem, clearItem } from '../../helpers/localStorage';
Expand Down Expand Up @@ -31,14 +31,17 @@ describe('PrivateRoute', () => {
render(<PrivateRoute />, { wrapper: MemoryRouter });

expect(localStorage.getItem).toBeCalledWith('UserProfile');

// Only the header is rendered and not the outlet (home page)
// expect(screen.getByTestId('app-main-heading')).toBeVisible();
});

test.skip('renders a PrivateRoute', async () => {
// Infinite loop
render(<PrivateRoute />, { wrapper: MemoryRouter });

await waitForElementToBeRemoved(() => screen.queryAllByTestId('loading'));
expect(screen.getByTestId('loading')).toHaveTextContent('Loading');

expect(screen.getByTestId('loading'));
// expect(screen.getByTestId('loading')).not.toBeVisible();
});
});
22 changes: 12 additions & 10 deletions src/components/PrivateRoute/index.tsx
Original file line number Diff line number Diff line change
@@ -1,11 +1,10 @@
import { useState, useEffect } from 'react';
import { Navigate, Outlet, useOutletContext } from 'react-router-dom';
import { Navigate, Outlet } from 'react-router-dom';

import Header from 'components/Header';
import { getItem } from 'helpers/localStorage';
import type { User } from 'types/User';

type ContextType = User;

import { LOGIN_URL } from '../../constants';

function PrivateRoute() {
Expand All @@ -16,7 +15,7 @@ function PrivateRoute() {
const fetchCurrentUser = async () => {
const userProfile = getItem('UserProfile');
if (userProfile?.user) {
setUser({ ...userProfile.user });
setUser({ name: userProfile.user.name, email: userProfile.user.email, avatarUrl: userProfile.user.avatar_url });
}

setLoading(false);
Expand All @@ -25,14 +24,17 @@ function PrivateRoute() {
}, []);

if (loading) {
return <h3>Loading...</h3>;
return <h3 data-test-id="loading">Loading...</h3>;
}

return user ? <Outlet context={user} /> : <Navigate to={LOGIN_URL} />;
return user ? (
<>
<Header user={user} />
<Outlet />
</>
) : (
<Navigate to={LOGIN_URL} />
);
}

export default PrivateRoute;

export function useUser() {
return useOutletContext<ContextType>();
}
5 changes: 5 additions & 0 deletions src/components/Sidebar/Sidebar.module.scss
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
.sidebar {
border: 5px bla solid;

background: rgba(30, 30, 30, 0.9);
}
65 changes: 65 additions & 0 deletions src/components/Sidebar/index.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
/* eslint-disable camelcase */
import { BrowserRouter } from 'react-router-dom';

import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';

import AuthAdapter from 'adapters/authAdapter';
import { User } from 'types/User';

import Sidebar from '.';
import * as myStorage from '../../helpers/localStorage';

const mockUserProfileData = {
email: '[email protected]',
name: 'TestName',
avatar_url: 'https://secure.gravatar.com/avatar/6733d09432e89459dba795de8312ac2d',
};

const mockTokenData = {
access_token: 'test_access_token',
refresh_token: 'test_refresh_token',
token_type: 'Bearer',
expires_in: 7200,
created_at: 1677045997,
};

describe('Sidebar', () => {
const user: User = { name: 'Test User', email: '[email protected]', avatarUrl: mockUserProfileData.avatar_url };

test("renders a sidebar on the page with the user's name and avatar image", () => {
render(<Sidebar user={user} />, { wrapper: BrowserRouter });

expect(screen.getByTestId('username')).toHaveTextContent(user.name);

const profileImage = screen.getByTestId('sidebar-avatar') as HTMLImageElement;
expect(profileImage.src).toContain(mockUserProfileData.avatar_url);
});

test('renders a sidebar on the page with a logout button that when clicked, calls Logout adapter and removes storage', async () => {
const mockLogout = jest.spyOn(AuthAdapter, 'logout');

// const mockClearToken = jest.spyOn(myStorage, 'clearItem');

const storageMock = jest.spyOn(myStorage, 'getItem').mockImplementationOnce(() => {
return { auth: mockTokenData, user: mockUserProfileData };
});

expect(myStorage.getItem('UserProfile')).not.toBeNull();

render(<Sidebar user={user} />, { wrapper: BrowserRouter });

const submitButton = screen.getByRole('button', { name: 'Logout' });

await userEvent.click(submitButton);

expect(mockLogout).toBeCalledTimes(1);

expect(myStorage.getItem('UserProfile')).toBeNull();

// navigates to LOGIN URL
// useNavigate is not called because redirect is handled in axios interceptor? Mock window.location.href instead?

storageMock.mockRestore();
});
});
Loading