diff --git a/client/src/features/auth/context.ts b/client/src/features/auth/context.ts index a15f79d2..b8bda9c0 100644 --- a/client/src/features/auth/context.ts +++ b/client/src/features/auth/context.ts @@ -6,6 +6,7 @@ type LoadingState = { signIn?: never signOut?: never token?: never + userName?: never } type SignedInState = { @@ -14,6 +15,7 @@ type SignedInState = { signIn?: never signOut: () => void token: string + userName?: string } type SignedOutState = { @@ -22,6 +24,7 @@ type SignedOutState = { signIn: () => void signOut?: never token?: never + userName?: never } export type AuthContextInterface = LoadingState | SignedInState | SignedOutState @@ -37,11 +40,12 @@ const signedOutState = (signIn: () => void): AuthContextInterface => ({ signIn, }) -const signedInState = (token: string, signOut: () => void): AuthContextInterface => ({ +const signedInState = (token: string, userName: string | undefined, signOut: () => void): AuthContextInterface => ({ isLoading: false, isAuthenticated: true, signOut, token, + userName, }) export const AuthContext = createContext(loadingState) @@ -49,17 +53,18 @@ export const AuthContext = createContext(loadingState) type GetAuthStateParams = { isLoading: boolean token?: string + userName?: string signOut: () => void signIn: () => void } -export const getAuthState = ({ isLoading, token, signIn, signOut }: GetAuthStateParams) => { +export const getAuthState = ({ isLoading, token, userName, signIn, signOut }: GetAuthStateParams) => { if (isLoading) { return loadingState } if (token) { - return signedInState(token, signOut) + return signedInState(token, userName, signOut) } return signedOutState(signIn) diff --git a/client/src/features/auth/organisms/sign-in-button.tsx b/client/src/features/auth/organisms/sign-in-button.tsx index e9bca252..0e7907f0 100644 --- a/client/src/features/auth/organisms/sign-in-button.tsx +++ b/client/src/features/auth/organisms/sign-in-button.tsx @@ -1,14 +1,11 @@ import React from 'react' -import { useAuth0 } from '@auth0/auth0-react' import { Button, Spinner } from '@chakra-ui/react' import { AiOutlineLogout } from 'react-icons/ai' import { DefaultUserIcon } from '../../room/atoms/default-user-icon' +import { useAuth } from '..' export const SignInButton = () => { - const { user, isAuthenticated, isLoading, loginWithRedirect, logout } = useAuth0() - - const signIn = () => loginWithRedirect() - const signOut = () => logout({ returnTo: window.location.origin }) + const { isAuthenticated, isLoading, signIn, signOut, userName } = useAuth() if (isLoading) { return ( @@ -28,7 +25,7 @@ export const SignInButton = () => { return ( ) } diff --git a/client/src/features/auth/provider.tsx b/client/src/features/auth/provider.tsx index 3b3602b4..068c5cc6 100644 --- a/client/src/features/auth/provider.tsx +++ b/client/src/features/auth/provider.tsx @@ -8,7 +8,7 @@ import debug from 'debug' const log = debug('auth') export const AuthProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => { - const { isLoading: isAuth0Loading, loginWithRedirect, logout } = useAuth0() + const { isLoading: isAuth0Loading, loginWithRedirect, logout, user } = useAuth0() const { accessToken, isLoading: isTokenLoading } = useAccessToken() const [isLoading, setIsLoading] = useState(true) @@ -28,7 +28,7 @@ export const AuthProvider: React.FC<{ children: React.ReactNode }> = ({ children log('signOut called') setIsLoading(true) clearBearerToken() - logout() + logout({ returnTo: window.location.origin }) } const signIn = () => { @@ -40,6 +40,7 @@ export const AuthProvider: React.FC<{ children: React.ReactNode }> = ({ children const state = getAuthState({ isLoading, token: accessToken, + userName: user?.name, signIn, signOut, }) diff --git a/client/src/features/room/pages/room.spec.tsx b/client/src/features/room/pages/room.spec.tsx index 0b7d4386..81598bc6 100644 --- a/client/src/features/room/pages/room.spec.tsx +++ b/client/src/features/room/pages/room.spec.tsx @@ -6,8 +6,7 @@ import { render } from '../../../test/fixtures' describe('RoomPage', () => { it('loads messages for the room', async () => { render(, { - path: '/room/:roomId', - initialEntry: '/room/room:100-can-manage', + routes: { path: '/room/:roomId', initialEntry: '/room/room:100-can-manage' }, }) await waitFor(() => { @@ -17,8 +16,7 @@ describe('RoomPage', () => { it('shows a message if the user lacks permissions to read the room', async () => { render(, { - path: '/room/:roomId', - initialEntry: '/room/room:200-can-join', + routes: { path: '/room/:roomId', initialEntry: '/room/room:200-can-join' }, }) await waitFor(() => { @@ -28,8 +26,7 @@ describe('RoomPage', () => { it('shows an alert if the user can join the room', async () => { render(, { - path: '/room/:roomId', - initialEntry: '/room/room:200-can-join', + routes: { path: '/room/:roomId', initialEntry: '/room/room:200-can-join' }, }) await waitFor(() => { @@ -41,8 +38,7 @@ describe('RoomPage', () => { it('shows an alert if the user can request to join', async () => { render(, { - path: '/room/:roomId', - initialEntry: '/room/room:300-can-request-approval', + routes: { path: '/room/:roomId', initialEntry: '/room/room:300-can-request-approval' }, }) await waitFor(() => { @@ -54,8 +50,7 @@ describe('RoomPage', () => { it('shows an alert if the user requires an invite to join', async () => { render(, { - path: '/room/:roomId', - initialEntry: '/room/room:400-requires-invite', + routes: { path: '/room/:roomId', initialEntry: '/room/room:400-requires-invite' }, }) await waitFor(() => { diff --git a/client/src/mocks/handlers.ts b/client/src/mocks/handlers.ts index 4dacd478..5c317b1b 100644 --- a/client/src/mocks/handlers.ts +++ b/client/src/mocks/handlers.ts @@ -14,6 +14,12 @@ export const handlers = [ const response = loadResponse(`./messages/get/${roomId}.json`) return res(ctx.json(response)) }), + + rest.get('users/:userId', (req, res, ctx) => { + const userId = req.params['userId'] + const response = loadResponse(`./users/get/${userId}.json`) + return res(ctx.json(response)) + }), ] const loadResponse = (relativePath: string): JSON => { diff --git a/client/src/mocks/users/me.json b/client/src/mocks/users/me.json new file mode 100644 index 00000000..3f489d8c --- /dev/null +++ b/client/src/mocks/users/me.json @@ -0,0 +1,17 @@ +{ + "user": { + "id": "user:google-oauth2|123", + "name": "Test User", + "picture": "https://ui-avatars.com/api/?name=Joe+Bloggs", + "email": "test.user@example.com" + }, + "rooms": [ + { + "id": "room:100", + "name": "Test Room", + "ownerId": "user:google-oauth2|123", + "contentPolicy": "private", + "joinPolicy": "invite" + } + ] +} diff --git a/client/src/mocks/users/system.json b/client/src/mocks/users/system.json new file mode 100644 index 00000000..8b843881 --- /dev/null +++ b/client/src/mocks/users/system.json @@ -0,0 +1 @@ +{ "id": "system", "name": "System", "email": "system@example.com" } diff --git a/client/src/shared/navigation/organisms/header.spec.tsx b/client/src/shared/navigation/organisms/header.spec.tsx new file mode 100644 index 00000000..58887559 --- /dev/null +++ b/client/src/shared/navigation/organisms/header.spec.tsx @@ -0,0 +1,78 @@ +import React from 'react' +import { render } from '../../../test/fixtures' +import { waitFor, screen } from '@testing-library/react' +import { getAuthState } from '../../../features/auth/context' +import { Header } from './header' +import userEvent from '@testing-library/user-event' + +describe('Header', () => { + const signIn = vitest.fn() + const signOut = vitest.fn() + + const getMenuButton = () => screen.getByRole('button', { name: 'Open Menu' }) + const getMenu = () => screen.queryByRole('dialog') + const getSignInButton = () => screen.getByRole('button', { name: 'Sign In' }) + const getSignOutButton = () => screen.getByRole('button', { name: 'Sign Out Test User' }) + + const signedInState = () => + getAuthState({ token: 'a1b2c3', isLoading: false, userName: 'Test User', signOut, signIn }) + const signedOutState = () => getAuthState({ isLoading: false, signOut, signIn }) + + it('hides the navigation menu by default', () => { + render(
, { auth: signedOutState() }) + expect(getMenuButton()).toBeVisible() + expect(getMenu()).not.toBeInTheDocument() + }) + + it('shows the navigation menu when the menu button is clicked', async () => { + render(
, { auth: signedOutState() }) + await userEvent.click(getMenuButton()) + expect(getMenu()).toBeVisible() + }) + + describe('when signed out', () => { + it('has a sign in button', async () => { + // arrange + render(
, { + auth: signedOutState(), + }) + + // act + await userEvent.click(getMenuButton()) + + // assert + await waitFor(() => { + expect(getSignInButton()).toBeVisible() + }) + + await userEvent.click(getSignInButton()) + + expect(signIn).toHaveBeenCalledOnce() + }) + }) + + describe('when signed in', () => { + it('has a sign out button', async () => { + // arrange + render(
, { + auth: signedInState(), + }) + + // act + await waitFor(() => { + expect(getMenuButton()).toBeVisible() + }) + + // assert + userEvent.click(getMenuButton()) + + await waitFor(() => { + expect(getSignOutButton()).toBeVisible() + }) + + await userEvent.click(getSignOutButton()) + + expect(signOut).toHaveBeenCalledOnce() + }) + }) +}) diff --git a/client/src/test/fixtures.tsx b/client/src/test/fixtures.tsx index bb540ba9..57477ced 100644 --- a/client/src/test/fixtures.tsx +++ b/client/src/test/fixtures.tsx @@ -3,13 +3,17 @@ import { QueryClient, QueryClientProvider } from '@tanstack/react-query' import { render } from '@testing-library/react' import { ReactElement } from 'react' import { RouterProvider, createMemoryRouter } from 'react-router-dom' +import { AuthContext, AuthContextInterface } from '../features/auth/context' export type RenderOptions = { - path: string - initialEntry: string + routes?: { + path: string + initialEntry: string + } + auth?: AuthContextInterface } -const customRender = (ui: ReactElement, opts: RenderOptions | undefined) => { +const customRender = (ui: ReactElement, opts: RenderOptions = {}) => { const queryClient = new QueryClient({ defaultOptions: { queries: { @@ -17,13 +21,21 @@ const customRender = (ui: ReactElement, opts: RenderOptions | undefined) => { }, }, }) - const router = opts - ? createMemoryRouter([{ path: opts.path, element: ui }], { - initialEntries: [opts.initialEntry], - }) - : undefined + + const routes = opts.routes ?? { path: '/', initialEntry: '/' } + + const router = createMemoryRouter([{ path: routes.path, element: ui }], { + initialEntries: [routes.initialEntry], + }) + + const auth: AuthContextInterface = opts.auth ?? { isAuthenticated: false, isLoading: false, signIn: vitest.fn() } + render( - {router ? : ui}, + + + + + , ) }