summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--client/package.json10
-rw-r--r--client/src/AppThemeProvider.tsx (renamed from client/src/ThemeContextProvider.tsx)4
-rw-r--r--client/src/components/AdminLogin.test.tsx30
-rw-r--r--client/src/components/Dashboard.tsx1
-rw-r--r--client/src/components/Desktop.tsx2
-rw-r--r--client/src/components/GlobalSnackbar.test.tsx28
-rw-r--r--client/src/components/GuestLogin.test.tsx29
-rw-r--r--client/src/components/Mobile.tsx2
-rw-r--r--client/src/components/RsvpForm.test.tsx31
-rw-r--r--client/src/main.tsx67
-rw-r--r--client/src/mocks/handlers.ts57
-rw-r--r--client/src/mocks/server.ts4
-rw-r--r--client/src/mocks/worker.ts (renamed from client/src/mocks/browser.ts)0
-rw-r--r--client/src/models.ts2
-rw-r--r--client/src/renderWithProviders.tsx28
-rw-r--r--client/src/routes.tsx59
-rw-r--r--client/src/setup.ts6
-rw-r--r--client/src/slices/snackbarSlice.ts3
-rw-r--r--client/src/store.ts37
-rw-r--r--client/tsconfig.json2
-rw-r--r--client/vite.config.ts12
-rw-r--r--server/post.json10
22 files changed, 307 insertions, 117 deletions
diff --git a/client/package.json b/client/package.json
index 0d6bab2..0990aa3 100644
--- a/client/package.json
+++ b/client/package.json
@@ -7,7 +7,8 @@
"dev": "bunx --bun vite --host",
"build": "tsc && vite build",
"lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0",
- "preview": "vite preview"
+ "preview": "vite preview",
+ "test": "vitest"
},
"dependencies": {
"@emotion/react": "^11.13.3",
@@ -28,6 +29,9 @@
},
"devDependencies": {
"@hookform/devtools": "^4.3.1",
+ "@testing-library/jest-dom": "^6.5.0",
+ "@testing-library/react": "^16.0.1",
+ "@testing-library/user-event": "^14.5.2",
"@types/bun": "^1.1.0",
"@types/react": "^18.2.55",
"@types/react-dom": "^18.2.19",
@@ -37,8 +41,10 @@
"eslint": "^8.56.0",
"eslint-plugin-react-hooks": "^4.6.0",
"eslint-plugin-react-refresh": "^0.4.5",
+ "jsdom": "^25.0.0",
"msw": "^2.2.1",
"typescript": "^5.2.2",
- "vite": "^5.4.2"
+ "vite": "^5.4.2",
+ "vitest": "^2.1.1"
}
}
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'];
diff --git a/client/tsconfig.json b/client/tsconfig.json
index f5068c4..4c214ba 100644
--- a/client/tsconfig.json
+++ b/client/tsconfig.json
@@ -25,5 +25,5 @@
"noUnusedParameters": true,
"noPropertyAccessFromIndexSignature": true
},
- "include": ["vite.config.ts"]
+ "include": ["vite-env.d.ts", "vite.config.ts"]
}
diff --git a/client/vite.config.ts b/client/vite.config.ts
index 5a33944..2942029 100644
--- a/client/vite.config.ts
+++ b/client/vite.config.ts
@@ -1,7 +1,13 @@
-import { defineConfig } from 'vite'
-import react from '@vitejs/plugin-react'
+/// <reference types="vitest" />
+import { defineConfig } from 'vite';
+import react from '@vitejs/plugin-react';
// https://vitejs.dev/config/
export default defineConfig({
plugins: [react()],
-})
+ test: {
+ globals: true,
+ environment: 'jsdom',
+ setupFiles: './src/setup.ts',
+ },
+});
diff --git a/server/post.json b/server/post.json
deleted file mode 100644
index f12bd5a..0000000
--- a/server/post.json
+++ /dev/null
@@ -1,10 +0,0 @@
-{
- "id": 1,
- "firstName": "Michael",
- "lastName": "Hunteman",
- "attendance": "yes",
- "email": "mhunteman@cox.net",
- "message": "",
- "partySize": 1,
- "partyList": []
-}