diff --git a/.eslintrc.json b/.eslintrc.json new file mode 100644 index 0000000..334efca --- /dev/null +++ b/.eslintrc.json @@ -0,0 +1,33 @@ +{ + "plugins": [ + "prettier", + "@typescript-eslint", + "react", + "eslint-plugin-react", + "@next/eslint-plugin-next" + ], + "extends": [ + "next/core-web-vitals", + "eslint:recommended", + "plugin:@typescript-eslint/eslint-recommended", + "plugin:@typescript-eslint/recommended", + "plugin:prettier/recommended", + "plugin:react/jsx-runtime", + "prettier" + ], + "parser": "@typescript-eslint/parser", + "parserOptions": { + "ecmaVersion": 2020, + "sourceType": "module", + "project": "**/tsconfig.json", + "tsconfigRootDir": "__dirname" + }, + "env": { + "es6": true, + "browser": true, + "node": true + }, + "rules": { + "@typescript-eslint/no-explicit-any": "error" + } +} diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..fd3dbb5 --- /dev/null +++ b/.gitignore @@ -0,0 +1,36 @@ +# See https://help.github.com/articles/ignoring-files/ for more about ignoring files. + +# dependencies +/node_modules +/.pnp +.pnp.js +.yarn/install-state.gz + +# testing +/coverage + +# next.js +/.next/ +/out/ + +# production +/build + +# misc +.DS_Store +*.pem + +# debug +npm-debug.log* +yarn-debug.log* +yarn-error.log* + +# local env files +.env*.local + +# vercel +.vercel + +# typescript +*.tsbuildinfo +next-env.d.ts diff --git a/.husky/pre-commit b/.husky/pre-commit new file mode 100644 index 0000000..75fac8e --- /dev/null +++ b/.husky/pre-commit @@ -0,0 +1,4 @@ +#!/usr/bin/env sh +. "$(dirname -- "$0")/_/husky.sh" + +npm run lint diff --git a/.husky/pre-push b/.husky/pre-push new file mode 100644 index 0000000..a155e38 --- /dev/null +++ b/.husky/pre-push @@ -0,0 +1,4 @@ +#!/usr/bin/env sh +. "$(dirname -- "$0")/_/husky.sh" + +npm run coverage diff --git a/.prettierrc b/.prettierrc new file mode 100644 index 0000000..5b48a7b --- /dev/null +++ b/.prettierrc @@ -0,0 +1,9 @@ +{ + "printWidth": 90, + "trailingComma": "es5", + "endOfLine": "auto", + "tabWidth": 2, + "singleQuote": true, + "semi": true, + "arrowParens": "always" +} diff --git a/README.md b/README.md index 61cecea..c403366 100644 --- a/README.md +++ b/README.md @@ -1 +1,36 @@ -# graphiql-app \ No newline at end of file +This is a [Next.js](https://nextjs.org/) project bootstrapped with [`create-next-app`](https://github.com/vercel/next.js/tree/canary/packages/create-next-app). + +## Getting Started + +First, run the development server: + +```bash +npm run dev +# or +yarn dev +# or +pnpm dev +# or +bun dev +``` + +Open [http://localhost:3000](http://localhost:3000) with your browser to see the result. + +You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file. + +This project uses [`next/font`](https://nextjs.org/docs/basic-features/font-optimization) to automatically optimize and load Inter, a custom Google Font. + +## Learn More + +To learn more about Next.js, take a look at the following resources: + +- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API. +- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial. + +You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js/) - your feedback and contributions are welcome! + +## Deploy on Vercel + +The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js. + +Check out our [Next.js deployment documentation](https://nextjs.org/docs/deployment) for more details. diff --git a/app/[locale]/[...rest]/page.test.tsx b/app/[locale]/[...rest]/page.test.tsx new file mode 100644 index 0000000..87fd554 --- /dev/null +++ b/app/[locale]/[...rest]/page.test.tsx @@ -0,0 +1,16 @@ +import { render } from '@testing-library/react'; +import { vi } from 'vitest'; +import CatchAllPage from './page'; +import { notFound } from 'next/navigation'; + +vi.mock('next/navigation', () => ({ + notFound: vi.fn(), +})); + +describe('CatchAllPage', () => { + it('should call notFound on render', () => { + render(); + + expect(notFound).toHaveBeenCalled(); + }); +}); diff --git a/app/[locale]/[...rest]/page.tsx b/app/[locale]/[...rest]/page.tsx new file mode 100644 index 0000000..e718587 --- /dev/null +++ b/app/[locale]/[...rest]/page.tsx @@ -0,0 +1,6 @@ +import { notFound } from 'next/navigation'; + +export default function CatchAllPage() { + notFound(); + return null; +} diff --git a/app/[locale]/globals.css b/app/[locale]/globals.css new file mode 100644 index 0000000..3bddaa0 --- /dev/null +++ b/app/[locale]/globals.css @@ -0,0 +1,53 @@ +@tailwind base; +@tailwind components; +@tailwind utilities; + +@import '~react-toastify/ReactToastify.css'; + +:root { + --foreground-rgb: 0, 0, 0; + --background-start-rgb: 214, 219, 220; + --background-end-rgb: 255, 255, 255; +} + +@media (prefers-color-scheme: dark) { + :root { + --foreground-rgb: 255, 255, 255; + --background-start-rgb: 0, 0, 0; + --background-end-rgb: 0, 0, 0; + } +} + +body { + color: rgb(var(--foreground-rgb)); + min-height: 100vh; +} + +@layer utilities { + .text-balance { + text-wrap: balance; + } + + .scrollbar::-webkit-scrollbar { + width: 12px; + } + + .scrollbar::-webkit-scrollbar-thumb { + background-color: #6bc8ed; + border-radius: 9999px; + border: 3px solid #ffffff; + } + + .scrollbar::-webkit-scrollbar-track { + background-color: #e0e0e0; + border-radius: 9999px; + } + + .scrollbar::-webkit-scrollbar-thumb:hover { + background-color: #38a9f5; + } + + .scrollbar::-webkit-scrollbar-thumb:active { + background-color: #faeb65; + } +} diff --git a/app/[locale]/graph-ql/page.tsx b/app/[locale]/graph-ql/page.tsx new file mode 100644 index 0000000..5878f54 --- /dev/null +++ b/app/[locale]/graph-ql/page.tsx @@ -0,0 +1,9 @@ +export default function GraphQlPage() { + return ( +
+

+ GraphQl +

+
+ ); +} diff --git a/app/[locale]/history/page.tsx b/app/[locale]/history/page.tsx new file mode 100644 index 0000000..2d8134d --- /dev/null +++ b/app/[locale]/history/page.tsx @@ -0,0 +1,9 @@ +export default function ApiHistoryPage() { + return ( +
+

+ History +

+
+ ); +} diff --git a/app/[locale]/icon.ico b/app/[locale]/icon.ico new file mode 100644 index 0000000..751d327 Binary files /dev/null and b/app/[locale]/icon.ico differ diff --git a/app/[locale]/layout.tsx b/app/[locale]/layout.tsx new file mode 100644 index 0000000..adbaa74 --- /dev/null +++ b/app/[locale]/layout.tsx @@ -0,0 +1,48 @@ +import type { Metadata } from 'next'; +import { Inter } from 'next/font/google'; +import './globals.css'; +import ErrorBoundary from '@/components/errorBoundary/ErrorBoundary'; +import { ToastContainer } from 'react-toastify'; +import 'react-toastify/ReactToastify.css'; + +import { NextIntlClientProvider } from 'next-intl'; +import { getMessages } from 'next-intl/server'; +import { Header } from '@/components/header/Header'; +import { Footer } from '@/components/footer/Footer'; +import { AuthContextProvider } from '@/context/AuthContext'; + +const inter = Inter({ subsets: ['latin'] }); + +export const metadata: Metadata = { + title: 'GraphiQl APP', + description: 'Graph + REST', +}; + +export default async function RootLayout({ + children, + params: { locale }, +}: { + children: React.ReactNode; + params: { locale: string }; +}) { + const messages = await getMessages(); + + return ( + + + + + +
+
+ {children} +
+ +
+
+
+
+ + + ); +} diff --git a/app/[locale]/login/page.test.tsx b/app/[locale]/login/page.test.tsx new file mode 100644 index 0000000..1e0966a --- /dev/null +++ b/app/[locale]/login/page.test.tsx @@ -0,0 +1,44 @@ +import { render, screen } from '@testing-library/react'; +import SignInPage from './page'; +import { vi } from 'vitest'; +import { useAuth } from '@/context/AuthContext'; +import { useTranslations } from 'next-intl'; +import { useRouter } from 'next/navigation'; + +// Mock necessary hooks +vi.mock('next/navigation', () => ({ + useRouter: vi.fn(), +})); + +vi.mock('@/context/AuthContext', () => ({ + useAuth: vi.fn(), +})); + +vi.mock('next-intl', () => ({ + useTranslations: vi.fn(), +})); + +describe('SignInPage', () => { + const mockPush = vi.fn(); + const mockLogIn = vi.fn(); + const mockUseRouter = useRouter as jest.Mock; + const mockUseAuth = useAuth as jest.Mock; + const mockUseTranslations = useTranslations as jest.Mock; + + beforeEach(() => { + mockUseRouter.mockReturnValue({ push: mockPush }); + mockUseAuth.mockReturnValue({ authUser: null, logIn: mockLogIn }); + mockUseTranslations.mockReturnValue((key: string) => key); + }); + + afterEach(() => { + vi.clearAllMocks(); + }); + + test('renders LoginForm component', () => { + render(); + + const mainElement = screen.getByRole('main'); + expect(mainElement).toHaveClass('flex-1 flex items-center justify-center'); + }); +}); diff --git a/app/[locale]/login/page.tsx b/app/[locale]/login/page.tsx new file mode 100644 index 0000000..0e9ce2a --- /dev/null +++ b/app/[locale]/login/page.tsx @@ -0,0 +1,9 @@ +import LoginForm from '@/components/auth/LoginForm'; + +export default function SignInPage() { + return ( +
+ +
+ ); +} diff --git a/app/[locale]/not-found.test.tsx b/app/[locale]/not-found.test.tsx new file mode 100644 index 0000000..e602dd2 --- /dev/null +++ b/app/[locale]/not-found.test.tsx @@ -0,0 +1,69 @@ +import { render, screen } from '@testing-library/react'; +import { expect, test, vi } from 'vitest'; +import NotFound from './not-found'; +import { toast } from 'react-toastify'; +import mockRouter from 'next-router-mock'; +import * as nextNavigation from 'next/navigation'; +import { AuthContextProvider } from '@/context/AuthContext'; + +vi.mock('next/navigation', async () => { + const originalModule = await vi.importActual('next/navigation'); + return { + ...originalModule, + useRouter: () => ({ + push: vi.fn(), + prefetch: vi.fn(), + }), + usePathname: () => '/en', + }; +}); + +vi.mock('next-intl', () => ({ + useTranslations: () => (key: string) => + key === 'sorry' ? 'Sorry, the page not found' : '', +})); + +vi.mock('next/link', () => ({ + __esModule: true, + default: ({ children }: { children: React.ReactNode }) => {children}, +})); + +vi.mock('firebase/app', () => ({ + initializeApp: vi.fn(), + getApps: vi.fn(() => []), +})); + +vi.mock('firebase/auth', () => ({ + getAuth: vi.fn(() => ({ + currentUser: null, + signInWithEmailAndPassword: vi.fn(), + signOut: vi.fn(), + })), + onAuthStateChanged: vi.fn((auth, callback) => { + const unsubscribe = vi.fn(); + callback(null); + return unsubscribe; + }), +})); + +test('NotFound page renders correctly', () => { + mockRouter.setCurrentUrl('/en'); + + const toastErrorSpy = vi.spyOn(toast, 'error'); + + render( + + + + ); + + expect( + screen.getByRole('heading', { level: 2, name: 'Sorry, the page not found' }) + ).toBeInTheDocument(); + + expect(screen.getByRole('link')).toBeInTheDocument(); + + expect(toastErrorSpy).toHaveBeenCalledWith('404 - Page Not Found'); + + toastErrorSpy.mockRestore(); +}); diff --git a/app/[locale]/not-found.tsx b/app/[locale]/not-found.tsx new file mode 100644 index 0000000..41fc2fd --- /dev/null +++ b/app/[locale]/not-found.tsx @@ -0,0 +1,26 @@ +'use client'; + +import Error404Icon from '@/assets/Error404Icon'; +import Link from 'next/link'; +import { useEffect } from 'react'; +import { toast } from 'react-toastify'; +import { useTranslations } from 'next-intl'; + +export default function NotFound() { + const t = useTranslations('NotFoundPage'); + useEffect(() => { + toast.error('404 - Page Not Found'); + }, []); + + return ( +
+
+

{t('sorry')}

+ + + +

{t('click')}

+
+
+ ); +} diff --git a/app/[locale]/page.tsx b/app/[locale]/page.tsx new file mode 100644 index 0000000..10d8eaf --- /dev/null +++ b/app/[locale]/page.tsx @@ -0,0 +1,49 @@ +'use client'; + +import { AppRoutes } from '@/constants/routes.enum'; +import { useAuth } from '@/context/AuthContext'; +import { useTranslations } from 'next-intl'; +import Link from 'next/link'; + +export default function WelcomePage() { + const t = useTranslations('HomePage'); + const { authUser } = useAuth(); + + return ( +
+
+ {authUser ? ( + <> + + Rest API + + + GraphQL + + + {t('history')} + + + ) : ( + <> + + {t('signUp')} + + + {t('signIn')} + + + )} +
+
+
+

+ {t('welcome')},{' '} + {authUser && authUser.email ? authUser.email.split('@')[0] : 'my dear'} +

+

GraphQL/REST Api’s {t('playground')}

+
+
+
+ ); +} diff --git a/app/[locale]/register/page.tsx b/app/[locale]/register/page.tsx new file mode 100644 index 0000000..d6bddc6 --- /dev/null +++ b/app/[locale]/register/page.tsx @@ -0,0 +1,9 @@ +import RegisterForm from '@/components/auth/RegisterForm'; + +export default function SignUpPage() { + return ( +
+ +
+ ); +} diff --git a/app/[locale]/rest-api/page.tsx b/app/[locale]/rest-api/page.tsx new file mode 100644 index 0000000..b318094 --- /dev/null +++ b/app/[locale]/rest-api/page.tsx @@ -0,0 +1,9 @@ +export default function RestApi() { + return ( +
+

+ Rest Api +

+
+ ); +} diff --git a/assets/Error404Icon.tsx b/assets/Error404Icon.tsx new file mode 100644 index 0000000..6ad819f --- /dev/null +++ b/assets/Error404Icon.tsx @@ -0,0 +1,28 @@ +export default function Error404Icon() { + return ( + + + + + + + ); +} diff --git a/components/auth/LoginForm.test.tsx b/components/auth/LoginForm.test.tsx new file mode 100644 index 0000000..6f84bab --- /dev/null +++ b/components/auth/LoginForm.test.tsx @@ -0,0 +1,79 @@ +import { render, screen, fireEvent, waitFor } from '@testing-library/react'; +import { useAuth } from '@/context/AuthContext'; +import { useRouter } from 'next/navigation'; +import { useTranslations } from 'next-intl'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import LoginForm from './LoginForm'; + +// Mock necessary hooks +vi.mock('next/navigation', () => ({ + useRouter: vi.fn(), +})); + +vi.mock('@/context/AuthContext', () => ({ + useAuth: vi.fn(), +})); + +vi.mock('next-intl', () => ({ + useTranslations: vi.fn(), +})); + +describe('LoginForm', () => { + const mockPush = vi.fn(); + const mockLogIn = vi.fn(); + const mockUseRouter = useRouter as jest.Mock; + const mockUseAuth = useAuth as jest.Mock; + const mockUseTranslations = useTranslations as jest.Mock; + + beforeEach(() => { + mockUseRouter.mockReturnValue({ push: mockPush }); + mockUseAuth.mockReturnValue({ authUser: null, logIn: mockLogIn }); + mockUseTranslations.mockReturnValue((key: string) => key); + }); + + afterEach(() => { + vi.clearAllMocks(); + }); + + it('renders the form and handles login', async () => { + render(); + + // Verify form is rendered + expect(screen.getByLabelText(/email/i)).toBeInTheDocument(); + expect(screen.getByLabelText(/pswd/i)).toBeInTheDocument(); + expect(screen.getByText(/loginbtn/i)).toBeInTheDocument(); + + // Fill out and submit the form + fireEvent.change(screen.getByLabelText(/email/i), { + target: { value: 'test@example.com' }, + }); + fireEvent.change(screen.getByLabelText(/pswd/i), { + target: { value: 'Password123!' }, + }); + fireEvent.click(screen.getByText(/loginbtn/i)); + + // Verify logIn is called and router pushes to MAIN_PAGE + await waitFor(() => { + expect(mockLogIn).toHaveBeenCalledWith('test@example.com', 'Password123!'); + expect(mockPush).toHaveBeenCalledWith('/'); + }); + }); + + it('displays an error message on failed login', async () => { + mockLogIn.mockRejectedValueOnce(new Error('Invalid credentials')); + + render(); + + fireEvent.change(screen.getByLabelText(/email/i), { + target: { value: 'test@example.com' }, + }); + fireEvent.change(screen.getByLabelText(/pswd/i), { + target: { value: 'Password123!' }, + }); + fireEvent.click(screen.getByText(/loginbtn/i)); + + await waitFor(() => { + expect(screen.getByText(/responseError/i)).toBeInTheDocument(); + }); + }); +}); diff --git a/components/auth/LoginForm.tsx b/components/auth/LoginForm.tsx new file mode 100644 index 0000000..b466d5e --- /dev/null +++ b/components/auth/LoginForm.tsx @@ -0,0 +1,106 @@ +'use client'; +import { AppRoutes } from '@/constants/routes.enum'; +import { useAuth } from '@/context/AuthContext'; +import { AuthType } from '@/types/authTypes'; +import { useTranslations } from 'next-intl'; +import { useRouter } from 'next/navigation'; +import { FormEvent, useEffect, useState } from 'react'; + +const LoginForm = () => { + const t = useTranslations('Form'); + const { authUser, logIn } = useAuth(); + const router = useRouter(); + const [data, setData] = useState({ + email: '', + password: '', + }); + const [error, setError] = useState(''); + + useEffect(() => { + if (authUser) { + router.push(AppRoutes.MAIN_PAGE); + } + }); + + const handleSubmit = async (e: FormEvent) => { + e.preventDefault(); + try { + await logIn(data.email, data.password); + setData({ email: '', password: '' }); + router.push(AppRoutes.MAIN_PAGE); + } catch (error) { + setError(t('responseError')); + } + }; + + return ( +
+
+
+
+ {t('signIn')} +
+
+ + { + setData({ + ...data, + email: e.target.value, + }); + }} + /> + {t('emailError')} +
+
+ + { + setData({ + ...data, + password: e.target.value, + }); + }} + /> + {t('pswdError')} +
+ + + {error ?

{error}

: ''} +
+
+
+ ); +}; + +export default LoginForm; diff --git a/components/auth/RegisterForm.test.tsx b/components/auth/RegisterForm.test.tsx new file mode 100644 index 0000000..d614f0d --- /dev/null +++ b/components/auth/RegisterForm.test.tsx @@ -0,0 +1,93 @@ +import { render, screen, fireEvent, waitFor, act } from '@testing-library/react'; +import '@testing-library/jest-dom'; +import { useAuth } from '@/context/AuthContext'; +import { useRouter } from 'next/navigation'; +import { useTranslations } from 'next-intl'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import RegisterForm from './RegisterForm'; + +// Mock necessary hooks +vi.mock('next/navigation', () => ({ + useRouter: vi.fn(), +})); + +vi.mock('@/context/AuthContext', () => ({ + useAuth: vi.fn(), +})); + +vi.mock('next-intl', () => ({ + useTranslations: vi.fn(), +})); + +describe('RegisterForm', () => { + const mockPush = vi.fn(); + const mockSignUp = vi.fn(); + const mockUseRouter = useRouter as jest.Mock; + const mockUseAuth = useAuth as jest.Mock; + const mockUseTranslations = useTranslations as jest.Mock; + + beforeEach(() => { + mockUseRouter.mockReturnValue({ push: mockPush }); + mockUseAuth.mockReturnValue({ authUser: null, signUp: mockSignUp }); + mockUseTranslations.mockReturnValue((key: string) => key); + }); + + afterEach(() => { + vi.clearAllMocks(); + }); + + it('renders the form and handles registration', async () => { + render(); + + // Verify form is rendered + expect(screen.getByLabelText(/email/i)).toBeInTheDocument(); + expect(screen.getByLabelText(/pswd/i)).toBeInTheDocument(); + expect(screen.getByLabelText(/pswdRepeat/i)).toBeInTheDocument(); + expect(screen.getByText(/logupbtn/i)).toBeInTheDocument(); + + // Fill out and submit the form + fireEvent.change(screen.getByLabelText(/email/i), { + target: { value: 'test@example.com' }, + }); + fireEvent.change(screen.getByLabelText(/pswd/i), { + target: { value: 'Password123!' }, + }); + fireEvent.change(screen.getByLabelText(/pswdRepeat/i), { + target: { value: 'Password123!' }, + }); + + await act(async () => { + fireEvent.submit(screen.getByRole('button', { name: 'logupbtn' })); + }); + + // Verify signUp is called and router pushes to MAIN_PAGE + await waitFor(() => { + expect(mockSignUp).toHaveBeenCalledWith('test@example.com', 'Password123!'); + expect(mockPush).toHaveBeenCalledWith('/'); + }); + }); + + it('displays an error message on failed registration', async () => { + mockSignUp.mockRejectedValueOnce(new Error('Registration failed')); + + render(); + + fireEvent.change(screen.getByLabelText(/email/i), { + target: { value: 'test@example.com' }, + }); + fireEvent.change(screen.getByLabelText(/pswd/i), { + target: { value: 'Password123!' }, + }); + fireEvent.change(screen.getByLabelText(/pswdRepeat/i), { + target: { value: 'Password123!' }, + }); + + await act(async () => { + fireEvent.submit(screen.getByRole('button', { name: 'logupbtn' })); + }); + + await waitFor(() => { + expect(screen.getByText(/responseError/i)).toBeInTheDocument(); + }); + }); +}); diff --git a/components/auth/RegisterForm.tsx b/components/auth/RegisterForm.tsx new file mode 100644 index 0000000..a406125 --- /dev/null +++ b/components/auth/RegisterForm.tsx @@ -0,0 +1,125 @@ +'use client'; +import { AppRoutes } from '@/constants/routes.enum'; +import { useAuth } from '@/context/AuthContext'; +import { AuthType } from '@/types/authTypes'; +import { useTranslations } from 'next-intl'; +import { useRouter } from 'next/navigation'; +import { FormEvent, useEffect, useState } from 'react'; + +const RegisterForm = () => { + const t = useTranslations('Form'); + const { authUser, signUp } = useAuth(); + const router = useRouter(); + const [data, setData] = useState({ + email: '', + password: '', + }); + const [error, setError] = useState(''); + + useEffect(() => { + if (authUser) { + router.push('/'); + } + }); + + const handleSubmit = async (e: FormEvent) => { + e.preventDefault(); + try { + await signUp(data.email, data.password); + router.push(AppRoutes.MAIN_PAGE); + } catch (error) { + setError(t('responseError')); + } + }; + + return ( +
+
+
+
+ {t('signUp')} +
+
+ + { + setData({ + ...data, + email: e.target.value, + }); + }} + /> + {t('emailError')} +
+
+ + { + setData({ + ...data, + password: e.target.value, + }); + }} + /> + {t('pswdError')} +
+
+ + + + {t('pswdRepeatError')}{' '} + +
+ + + {error ?

{error}

: ''} +
+
+
+ ); +}; + +export default RegisterForm; diff --git a/components/errorBoundary/ErrorBoundary.test.tsx b/components/errorBoundary/ErrorBoundary.test.tsx new file mode 100644 index 0000000..918fedd --- /dev/null +++ b/components/errorBoundary/ErrorBoundary.test.tsx @@ -0,0 +1,39 @@ +import { render, screen } from '@testing-library/react'; +import '@testing-library/jest-dom'; +import { vi } from 'vitest'; +import ErrorBoundary from './ErrorBoundary'; +import { toast } from 'react-toastify'; + +vi.mock('react-toastify', () => ({ + toast: { + error: vi.fn(), + }, +})); + +const ProblematicComponent = () => { + throw new Error('Test error'); +}; + +describe('ErrorBoundary', () => { + it('should catch errors and display an error message', () => { + render( + + + + ); + + expect(screen.getByText('Something went wrong.')).toBeInTheDocument(); + + expect(toast.error).toHaveBeenCalledWith('An unexpected error occurred: Test error'); + }); + + it('should render children when there is no error', () => { + render( + +
All good!
+
+ ); + + expect(screen.getByText('All good!')).toBeInTheDocument(); + }); +}); diff --git a/components/errorBoundary/ErrorBoundary.tsx b/components/errorBoundary/ErrorBoundary.tsx new file mode 100644 index 0000000..2500442 --- /dev/null +++ b/components/errorBoundary/ErrorBoundary.tsx @@ -0,0 +1,38 @@ +'use client'; + +import { Component, ErrorInfo, ReactNode } from 'react'; +import { toast } from 'react-toastify'; + +interface ErrorBoundaryProps { + children: ReactNode; +} + +interface ErrorBoundaryState { + hasError: boolean; +} + +class ErrorBoundary extends Component { + constructor(props: ErrorBoundaryProps) { + super(props); + this.state = { hasError: false }; + } + + static getDerivedStateFromError(): ErrorBoundaryState { + return { hasError: true }; + } + + componentDidCatch(error: Error, info: ErrorInfo) { + toast.error(`An unexpected error occurred: ${error.message}`); + console.error('ErrorBoundary caught an error:', error, info); + } + + render() { + if (this.state.hasError) { + return

Something went wrong.

; + } + + return this.props.children; + } +} + +export default ErrorBoundary; diff --git a/components/footer/Footer.test.tsx b/components/footer/Footer.test.tsx new file mode 100644 index 0000000..3557a66 --- /dev/null +++ b/components/footer/Footer.test.tsx @@ -0,0 +1,42 @@ +import { render, screen, fireEvent } from '@testing-library/react'; +import '@testing-library/jest-dom'; +import { Footer } from './Footer'; +import { vi } from 'vitest'; + +vi.mock('next/link', () => ({ + __esModule: true, + default: ({ children }: { children: React.ReactNode }) => ( + {children} + ), +})); + +vi.mock('./PopupAuthors', () => ({ + PopupAuthors: vi.fn(() =>
Mocked PopupAuthors
), +})); + +describe('Footer', () => { + it('renders without crashing', () => { + render(