diff options
author | Michael Hunteman <michael@huntm.net> | 2024-09-27 08:43:02 -0700 |
---|---|---|
committer | Michael Hunteman <michael@huntm.net> | 2024-09-27 08:43:02 -0700 |
commit | a88f613da7e5567dbfdebd7df94f94507c47c6b5 (patch) | |
tree | b10a6c1640c11672a940f8fa71cdf3d3485135d4 /client/src | |
parent | 7ccca5ca18200388d10fca33a1d7095a0abfcd36 (diff) |
Add vitests
Diffstat (limited to 'client/src')
-rw-r--r-- | client/src/AppThemeProvider.tsx (renamed from client/src/ThemeContextProvider.tsx) | 4 | ||||
-rw-r--r-- | client/src/components/AdminLogin.test.tsx | 30 | ||||
-rw-r--r-- | client/src/components/Dashboard.tsx | 1 | ||||
-rw-r--r-- | client/src/components/Desktop.tsx | 2 | ||||
-rw-r--r-- | client/src/components/GlobalSnackbar.test.tsx | 28 | ||||
-rw-r--r-- | client/src/components/GuestLogin.test.tsx | 29 | ||||
-rw-r--r-- | client/src/components/Mobile.tsx | 2 | ||||
-rw-r--r-- | client/src/components/RsvpForm.test.tsx | 31 | ||||
-rw-r--r-- | client/src/main.tsx | 67 | ||||
-rw-r--r-- | client/src/mocks/handlers.ts | 57 | ||||
-rw-r--r-- | client/src/mocks/server.ts | 4 | ||||
-rw-r--r-- | client/src/mocks/worker.ts (renamed from client/src/mocks/browser.ts) | 0 | ||||
-rw-r--r-- | client/src/models.ts | 2 | ||||
-rw-r--r-- | client/src/renderWithProviders.tsx | 28 | ||||
-rw-r--r-- | client/src/routes.tsx | 59 | ||||
-rw-r--r-- | client/src/setup.ts | 6 | ||||
-rw-r--r-- | client/src/slices/snackbarSlice.ts | 3 | ||||
-rw-r--r-- | client/src/store.ts | 37 |
18 files changed, 289 insertions, 101 deletions
diff --git a/client/src/ThemeContextProvider.tsx b/client/src/AppThemeProvider.tsx index 5e1e2e8..a88c328 100644 --- a/client/src/ThemeContextProvider.tsx +++ b/client/src/AppThemeProvider.tsx @@ -16,7 +16,7 @@ export const ThemeContext = createContext<ThemeContextType>({ toggleColorMode: () => {}, }); -function ThemeContextProvider({ children }: ThemeProviderProps) { +function AppThemeProvider({ children }: ThemeProviderProps) { const prefersDarkMode = useMediaQuery('(prefers-color-scheme: dark)'); const [mode, setMode] = useState<'light' | 'dark'>( prefersDarkMode ? 'dark' : 'light' @@ -91,4 +91,4 @@ function ThemeContextProvider({ children }: ThemeProviderProps) { ); } -export default ThemeContextProvider; +export default AppThemeProvider; diff --git a/client/src/components/AdminLogin.test.tsx b/client/src/components/AdminLogin.test.tsx new file mode 100644 index 0000000..feffadf --- /dev/null +++ b/client/src/components/AdminLogin.test.tsx @@ -0,0 +1,30 @@ +import '@testing-library/jest-dom'; +import React from 'react'; +import { fireEvent, screen } from '@testing-library/react'; +import { userEvent } from '@testing-library/user-event'; +import { describe, expect, it } from 'vitest'; +import { createMemoryRouter, RouterProvider } from 'react-router-dom'; +import { renderWithProviders } from '../renderWithProviders'; +import routes from '../routes'; + +describe('Admin Login', async () => { + const memoryRouter = createMemoryRouter(routes, { + initialEntries: ['/admin/login'], + }); + it('can log in', async () => { + const { getByLabelText, getByRole, findByText } = renderWithProviders( + <RouterProvider router={memoryRouter} /> + ); + const user = userEvent.setup(); + + await user.type(getByLabelText(/username/i), 'username'); + await user.type(getByLabelText(/password/i), 'password'); + fireEvent.click(getByRole('button', { name: 'Log in' })); + expect(await findByText(/first name/i)).toBeInTheDocument(); + expect(await findByText(/last name/i)).toBeInTheDocument(); + expect(await findByText(/attendance/i)).toBeInTheDocument(); + expect(await findByText(/email/i)).toBeInTheDocument(); + expect(await findByText(/message/i)).toBeInTheDocument(); + expect(await findByText(/party size/i)).toBeInTheDocument(); + }); +}); diff --git a/client/src/components/Dashboard.tsx b/client/src/components/Dashboard.tsx index 985e48a..56b50a2 100644 --- a/client/src/components/Dashboard.tsx +++ b/client/src/components/Dashboard.tsx @@ -48,6 +48,7 @@ function Dashboard() { columns, data: guests, muiPaginationProps: { + color: 'primary', shape: 'rounded', showRowsPerPage: false, variant: 'outlined', diff --git a/client/src/components/Desktop.tsx b/client/src/components/Desktop.tsx index 0aa4357..d7447f3 100644 --- a/client/src/components/Desktop.tsx +++ b/client/src/components/Desktop.tsx @@ -4,7 +4,7 @@ import { Link } from 'react-router-dom'; import { Button, IconButton, useTheme } from '@mui/material'; import DarkModeIcon from '@mui/icons-material/DarkMode'; import LightModeIcon from '@mui/icons-material/LightMode'; -import { ThemeContext } from '../ThemeContextProvider'; +import { ThemeContext } from '../AppThemeProvider'; import pages from '../pages'; function Desktop() { diff --git a/client/src/components/GlobalSnackbar.test.tsx b/client/src/components/GlobalSnackbar.test.tsx new file mode 100644 index 0000000..2643816 --- /dev/null +++ b/client/src/components/GlobalSnackbar.test.tsx @@ -0,0 +1,28 @@ +import '@testing-library/jest-dom'; +import React from 'react'; +import { describe, expect, it } from 'vitest'; +import { createMemoryRouter, RouterProvider } from 'react-router-dom'; +import { renderWithProviders } from '../renderWithProviders'; +import routes from '../routes'; +import { showSnackbar } from '../slices/snackbarSlice'; +import setupStore from '../store'; + +describe('Global Snackbar', async () => { + const memoryRouter = createMemoryRouter(routes, { + initialEntries: ['/'], + }); + it('displays message', async () => { + const store = setupStore(); + store.dispatch( + showSnackbar({ + message: 'message', + severity: 'success', + }) + ); + const { findByText } = renderWithProviders( + <RouterProvider router={memoryRouter} />, + { store } + ); + expect(await findByText(/message/i)).toBeInTheDocument(); + }); +}); diff --git a/client/src/components/GuestLogin.test.tsx b/client/src/components/GuestLogin.test.tsx new file mode 100644 index 0000000..b31a00a --- /dev/null +++ b/client/src/components/GuestLogin.test.tsx @@ -0,0 +1,29 @@ +import '@testing-library/jest-dom'; +import React from 'react'; +import { fireEvent } from '@testing-library/react'; +import { userEvent } from '@testing-library/user-event'; +import { describe, expect, it } from 'vitest'; +import { createMemoryRouter, RouterProvider } from 'react-router-dom'; +import { renderWithProviders } from '../renderWithProviders'; +import routes from '../routes'; + +describe('Guest Login', async () => { + const memoryRouter = createMemoryRouter(routes, { + initialEntries: ['/guests/login'], + }); + it('can log in', async () => { + const { getByLabelText, getByRole, findByLabelText } = renderWithProviders( + <RouterProvider router={memoryRouter} /> + ); + const user = userEvent.setup(); + + await user.type(getByLabelText(/first name/i), 'Michael'); + await user.type(getByLabelText(/last name/i), 'Hunteman'); + fireEvent.click(getByRole('button', { name: 'Log in' })); + expect(await findByLabelText(/accept/i)).not.toBeChecked(); + expect(await findByLabelText(/decline/i)).toBeChecked(); + expect(await findByLabelText(/email/i)).toHaveValue(''); + expect(await findByLabelText(/party size/i)).toHaveValue(1); + expect(await findByLabelText(/message to the couple/i)).toHaveValue(''); + }); +}); diff --git a/client/src/components/Mobile.tsx b/client/src/components/Mobile.tsx index 38aa20b..d8510c7 100644 --- a/client/src/components/Mobile.tsx +++ b/client/src/components/Mobile.tsx @@ -5,7 +5,7 @@ import { Button, IconButton, Menu, MenuItem } from '@mui/material'; import DarkModeIcon from '@mui/icons-material/DarkMode'; import LightModeIcon from '@mui/icons-material/LightMode'; import { useTheme } from '@mui/material/styles'; -import { ThemeContext } from '../ThemeContextProvider'; +import { ThemeContext } from '../AppThemeProvider'; import MenuIcon from '@mui/icons-material/Menu'; import pages from '../pages'; diff --git a/client/src/components/RsvpForm.test.tsx b/client/src/components/RsvpForm.test.tsx new file mode 100644 index 0000000..a871268 --- /dev/null +++ b/client/src/components/RsvpForm.test.tsx @@ -0,0 +1,31 @@ +import '@testing-library/jest-dom'; +import React from 'react'; +import { fireEvent } from '@testing-library/react'; +import { userEvent } from '@testing-library/user-event'; +import { describe, expect, it } from 'vitest'; +import { createMemoryRouter, RouterProvider } from 'react-router-dom'; +import { renderWithProviders } from '../renderWithProviders'; +import routes from '../routes'; +import setupStore from '../store'; +import { initialGuest } from '../mocks/handlers'; +import { setGuest } from '../slices/auth/guestSlice'; + +describe('RSVP form', async () => { + const memoryRouter = createMemoryRouter(routes, { + initialEntries: ['/rsvp'], + }); + it('can submit', async () => { + const store = setupStore(); + store.dispatch(setGuest(initialGuest)); + const { getByLabelText, getByRole, findByText } = renderWithProviders( + <RouterProvider router={memoryRouter} />, + { store } + ); + const user = userEvent.setup(); + + fireEvent.click(getByLabelText(/accept/i), { target: { clicked: true } }); + await user.type(getByLabelText(/email/i), 'mhunteman@cox.net'); + fireEvent.click(getByRole('button', { name: 'RSVP' })); + expect(await findByText(/RSVP updated/i)).toBeInTheDocument(); + }); +}); diff --git a/client/src/main.tsx b/client/src/main.tsx index 0e5a2f7..43e61c0 100644 --- a/client/src/main.tsx +++ b/client/src/main.tsx @@ -2,72 +2,19 @@ import React from 'react'; import ReactDOM from 'react-dom/client'; import { createBrowserRouter, RouterProvider } from 'react-router-dom'; import { Provider } from 'react-redux'; -import App from './App'; -import store from './store'; -import ThemeContextProvider from './ThemeContextProvider'; -import Schedule from './components/Schedule'; -import Registry from './components/Registry'; -import GuestLogin from './components/GuestLogin'; -import Rsvp from './components/Rsvp'; -import RsvpForm from './components/RsvpForm'; -import Home from './components/Home'; -import AdminLogin from './components/AdminLogin'; -import Admin from './components/Admin'; -import Dashboard from './components/Dashboard'; +import setupStore from './store'; +import AppThemeProvider from './AppThemeProvider'; +import routes from './routes'; import './main.css'; -const router = createBrowserRouter([ - { - element: <App />, - children: [ - { - path: '/', - element: <Home />, - }, - { - path: 'schedule', - element: <Schedule />, - }, - { - path: 'registry', - element: <Registry />, - }, - { - path: 'guests/login', - element: <GuestLogin />, - }, - { - path: 'admin/login', - element: <AdminLogin />, - }, - { - element: <Rsvp />, - children: [ - { - path: 'rsvp', - element: <RsvpForm />, - }, - ], - }, - { - element: <Admin />, - children: [ - { - path: 'dashboard', - element: <Dashboard />, - }, - ], - }, - ], - }, -]); +const router = createBrowserRouter(routes); ReactDOM.createRoot(document.getElementById('root')!).render( <React.StrictMode> - <Provider store={store}> - <ThemeContextProvider> + <Provider store={setupStore()}> + <AppThemeProvider> <RouterProvider router={router} /> - </ThemeContextProvider> + </AppThemeProvider> </Provider> </React.StrictMode> ); diff --git a/client/src/mocks/handlers.ts b/client/src/mocks/handlers.ts index 0e882ee..153a70c 100644 --- a/client/src/mocks/handlers.ts +++ b/client/src/mocks/handlers.ts @@ -3,28 +3,43 @@ import { nanoid } from '@reduxjs/toolkit'; const token = nanoid(); +export const initialGuest = { + guest: { + id: 1, + firstName: 'Michael', + lastName: 'Hunteman', + attendance: 'decline', + email: '', + message: '', + partySize: 1, + }, + token: token, +}; + +export const updatedGuest = { + id: 1, + firstName: 'Michael', + lastName: 'Hunteman', + attendance: 'accept', + email: 'mhunteman@cox.net', + message: '', + partySize: 1, + partyList: [], +}; + +export const guests = { + guests: [initialGuest], + token: token, +}; + export const handlers = [ - http.post('/guests/login', () => { - return HttpResponse.json({ - guest: { - id: 1, - firstName: 'Michael', - lastName: 'Hunteman', - attendance: 'false', - email: '', - message: '', - }, - token, - }); + http.post(`${import.meta.env.VITE_BASE_URL}guests/login`, () => { + return HttpResponse.json(initialGuest); + }), + http.put(`${import.meta.env.VITE_BASE_URL}guests/1`, () => { + return HttpResponse.json(updatedGuest); }), - http.patch('/guests/1', () => { - return HttpResponse.json({ - id: 1, - firstName: 'Michael', - lastName: 'Hunteman', - attendance: 'true', - email: '', - message: '', - }); + http.post(`${import.meta.env.VITE_BASE_URL}admin/login`, () => { + return HttpResponse.json(guests); }), ]; diff --git a/client/src/mocks/server.ts b/client/src/mocks/server.ts new file mode 100644 index 0000000..e52fee0 --- /dev/null +++ b/client/src/mocks/server.ts @@ -0,0 +1,4 @@ +import { setupServer } from 'msw/node'; +import { handlers } from './handlers'; + +export const server = setupServer(...handlers); diff --git a/client/src/mocks/browser.ts b/client/src/mocks/worker.ts index 0a56427..0a56427 100644 --- a/client/src/mocks/browser.ts +++ b/client/src/mocks/worker.ts diff --git a/client/src/models.ts b/client/src/models.ts index 6840a46..201a969 100644 --- a/client/src/models.ts +++ b/client/src/models.ts @@ -6,7 +6,7 @@ export interface Guest { email?: string; message?: string; partySize?: number; - partyList?: Array<Name>; + partyList?: Name[]; } export interface Name { diff --git a/client/src/renderWithProviders.tsx b/client/src/renderWithProviders.tsx new file mode 100644 index 0000000..476fdb1 --- /dev/null +++ b/client/src/renderWithProviders.tsx @@ -0,0 +1,28 @@ +import React, { PropsWithChildren, ReactElement } from 'react'; +import { render } from '@testing-library/react'; +import { Provider } from 'react-redux'; +import setupStore from './store'; +import type { AppStore, RootState } from './store'; +import type { RenderOptions } from '@testing-library/react'; + +interface ExtendedRenderOptions extends Omit<RenderOptions, 'queries'> { + preloadedState?: Partial<RootState>; + store?: AppStore; +} + +export function renderWithProviders( + ui: ReactElement, + extendedRenderOptions: ExtendedRenderOptions = {} +) { + const { + preloadedState = {}, + store = setupStore(preloadedState), + ...renderOptions + } = extendedRenderOptions; + + const Wrapper = ({ children }: PropsWithChildren) => ( + <Provider store={store}>{children}</Provider> + ); + + return { store, ...render(ui, { wrapper: Wrapper, ...renderOptions }) }; +} diff --git a/client/src/routes.tsx b/client/src/routes.tsx new file mode 100644 index 0000000..3fec783 --- /dev/null +++ b/client/src/routes.tsx @@ -0,0 +1,59 @@ +import React from 'react'; +import App from './App'; +import Schedule from './components/Schedule'; +import Registry from './components/Registry'; +import GuestLogin from './components/GuestLogin'; +import Rsvp from './components/Rsvp'; +import RsvpForm from './components/RsvpForm'; +import Home from './components/Home'; +import AdminLogin from './components/AdminLogin'; +import Admin from './components/Admin'; +import Dashboard from './components/Dashboard'; + +const routes = [ + { + element: <App />, + children: [ + { + path: '/', + element: <Home />, + }, + { + path: 'schedule', + element: <Schedule />, + }, + { + path: 'registry', + element: <Registry />, + }, + { + path: 'guests/login', + element: <GuestLogin />, + }, + { + path: 'admin/login', + element: <AdminLogin />, + }, + { + element: <Rsvp />, + children: [ + { + path: 'rsvp', + element: <RsvpForm />, + }, + ], + }, + { + element: <Admin />, + children: [ + { + path: 'dashboard', + element: <Dashboard />, + }, + ], + }, + ], + }, +]; + +export default routes; diff --git a/client/src/setup.ts b/client/src/setup.ts new file mode 100644 index 0000000..0abb5f3 --- /dev/null +++ b/client/src/setup.ts @@ -0,0 +1,6 @@ +import { afterAll, afterEach, beforeAll } from 'vitest'; +import { server } from './mocks/server'; + +beforeAll(() => server.listen({ onUnhandledRequest: 'error' })); +afterAll(() => server.close()); +afterEach(() => server.resetHandlers()); diff --git a/client/src/slices/snackbarSlice.ts b/client/src/slices/snackbarSlice.ts index f76b133..82532ec 100644 --- a/client/src/slices/snackbarSlice.ts +++ b/client/src/slices/snackbarSlice.ts @@ -1,10 +1,11 @@ import { createSlice } from '@reduxjs/toolkit'; +import type { AlertColor } from '@mui/material/Alert/Alert'; import type { RootState } from '../store'; export interface SnackbarState { open: boolean; message: string; - severity?: 'success' | 'error'; + severity?: AlertColor; } const initialState: SnackbarState = { diff --git a/client/src/store.ts b/client/src/store.ts index 4814868..e28bace 100644 --- a/client/src/store.ts +++ b/client/src/store.ts @@ -1,22 +1,31 @@ -import { configureStore } from '@reduxjs/toolkit'; +import { combineReducers, configureStore } from '@reduxjs/toolkit'; import guestReducer from './slices/auth/guestSlice'; import adminReducer from './slices/auth/adminSlice'; import snackbarReducer from './slices/snackbarSlice'; import { guestSlice } from './slices/api/guestSlice'; import { adminSlice } from './slices/api/adminSlice'; -const store = configureStore({ - reducer: { - [guestSlice.reducerPath]: guestSlice.reducer, - [adminSlice.reducerPath]: adminSlice.reducer, - guest: guestReducer, - admin: adminReducer, - snackbar: snackbarReducer, - }, - middleware: (getDefaultMiddleware) => - getDefaultMiddleware().concat(guestSlice.middleware, adminSlice.middleware), +const rootReducer = combineReducers({ + [guestSlice.reducerPath]: guestSlice.reducer, + [adminSlice.reducerPath]: adminSlice.reducer, + guest: guestReducer, + admin: adminReducer, + snackbar: snackbarReducer, }); -export default store; -export type RootState = ReturnType<typeof store.getState>; -export type AppDispatch = typeof store.dispatch; +const setupStore = (preloadedState?: Partial<RootState>) => { + return configureStore({ + reducer: rootReducer, + middleware: (getDefaultMiddleware) => + getDefaultMiddleware().concat( + guestSlice.middleware, + adminSlice.middleware + ), + preloadedState, + }); +}; + +export default setupStore; +export type RootState = ReturnType<typeof rootReducer>; +export type AppStore = ReturnType<typeof configureStore>; +export type AppDispatch = AppStore['dispatch']; |