diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..8e4ad0a --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,40 @@ +name: CI/CD Pipeline + +on: + push: + branches: + - main + pull_request: + branches: + - main + +jobs: + build: + runs-on: ubuntu-latest + + strategy: + matrix: + node-version: [16.x, 18.x] + + steps: + - name: Checkout repository + uses: actions/checkout@v3 + + - name: Set up Node.js + uses: actions/setup-node@v3 + with: + node-version: ${{ matrix.node-version }} + + - name: Install dependencies + run: npm install + + - name: Run tests + run: npm run test + + - name: Build Next.js app + run: npm run build + + - name: Deploy to Vercel + run: vercel --prod + env: + VERCEL_TOKEN: ${{ secrets.VERCEL_TOKEN }} diff --git a/app/__tests__/Dashboard.test.tsx b/app/__tests__/Dashboard.test.tsx new file mode 100644 index 0000000..8a5a6d0 --- /dev/null +++ b/app/__tests__/Dashboard.test.tsx @@ -0,0 +1,90 @@ +// app/dashboard/Dashboard.test.tsx +import React from 'react'; +import { render, screen } from '@testing-library/react'; +import '@testing-library/jest-dom'; +import DashboardPage from '../dashboard/page'; +import DashboardClient from '../dashboard/DashboardClient'; +import { cookies } from 'next/headers'; +import { redirect } from 'next/navigation'; + +// Mock next/headers and next/navigation +jest.mock('next/headers', () => ({ + cookies: jest.fn(), +})); + +jest.mock('next/navigation', () => ({ + redirect: jest.fn(), +})); + +// Sample user data for the tests +const mockUserData = { + id: '1', + email: 'test@example.com', + fullName: 'Test User', + lastName: 'User', +}; + +describe('DashboardPage (Server Component)', () => { + it('should redirect if no user data cookie is found', () => { + // Mock cookies to return no data + (cookies as jest.Mock).mockReturnValue({ + get: jest.fn().mockReturnValue(undefined), + }); + + const { container } = render(); + + // Ensure redirect is called + expect(redirect).toHaveBeenCalledWith('/'); + // Ensure the component doesn't render anything (returns null) + expect(container.firstChild).toBeNull(); + }); + + it('should render DashboardClient when user data is found in cookies', () => { + // Mock cookies to return user data + (cookies as jest.Mock).mockReturnValue({ + get: jest.fn().mockReturnValue({ + value: JSON.stringify(mockUserData), + }), + }); + + render(); + + // Ensure DashboardClient is rendered + const greeting = screen.getByText(`Hello ${mockUserData.fullName}!`); + expect(greeting).toBeInTheDocument(); + }); +}); + +describe('DashboardClient (Client Component)', () => { + it('renders the user information and ExampleEquations component', () => { + render(); + + // Check if the user's name is displayed + const greeting = screen.getByText(`Hello ${mockUserData.fullName}!`); + expect(greeting).toBeInTheDocument(); + + // Instead of matching "ExampleEquations", look for the actual content it renders, like buttons or headers + const exampleEquationText = screen.getByText(/Select an Example/i); // Assuming ExampleEquations renders this text + expect(exampleEquationText).toBeInTheDocument(); + + // Alternatively, if ExampleEquations renders buttons, use getByRole: + const firstExampleButton = screen.getByRole('button', { name: /x \+ 1 = 2/i }); + expect(firstExampleButton).toBeInTheDocument(); + }); + + it('should render correctly when userData is provided', () => { + const mockUserData = { + id: '1', + email: 'test@example.com', + fullName: 'Test User', + lastName: 'User' + }; + + render(); + + const greeting = screen.getByText(/Hello Test User!/i); + expect(greeting).toBeInTheDocument(); + }); + + }); + \ No newline at end of file diff --git a/app/__tests__/calcbar.test.tsx b/app/__tests__/calcbar.test.tsx index ce6fc4f..7527a1a 100644 --- a/app/__tests__/calcbar.test.tsx +++ b/app/__tests__/calcbar.test.tsx @@ -1,84 +1,79 @@ -import React from 'react'; -import { render, fireEvent, screen } from '@testing-library/react'; +import { render, screen, fireEvent, waitFor } from '@testing-library/react'; import '@testing-library/jest-dom'; import Calcbar from '../components/Calcbar'; import mathsteps from 'mathsteps'; -jest.mock('mathsteps'); +// Type the mock function +jest.mock('mathsteps', () => ({ + solveEquation: jest.fn(), +})); -describe('Calcbar', () => { - const mockUserId = 'test-user-id'; +// Define the type for steps +interface Step { + newEquation: { ascii: () => string }; + changeType: string; +} +describe('Calcbar Component', () => { beforeEach(() => { jest.clearAllMocks(); }); - it('renders the Calcbar component', () => { - render(); + test('renders correctly with default props', () => { + render(); + expect(screen.getByLabelText(/Enter an algebraic equation/i)).toBeInTheDocument(); expect(screen.getByText(/Solve/i)).toBeInTheDocument(); }); - it('displays the solution when a valid equation is solved', () => { - const stepsMock = [ - { newEquation: { ascii: () => 'x = 1' }, changeType: 'SOLVE_EQUATION' }, + test('renders example input and solves equation on load', async () => { + const mockSteps: Step[] = [ + { newEquation: { ascii: () => 'x = 2' }, changeType: 'simplify' }, + { newEquation: { ascii: () => 'x = 3' }, changeType: 'simplify' }, ]; - (mathsteps.solveEquation as jest.Mock).mockReturnValue(stepsMock); - - render(); - - fireEvent.change(screen.getByLabelText(/Enter an algebraic equation/i), { target: { value: 'x + 1 = 2' } }); - fireEvent.click(screen.getByText(/Solve/i)); - - expect(screen.getByText(/Solution: x = 1/i)).toBeInTheDocument(); + (mathsteps.solveEquation as jest.Mock).mockReturnValue(mockSteps); + + render(); + + // Check that the input field has the example input value + expect(screen.getByLabelText(/Enter an algebraic equation/i)).toHaveValue('x + 2 = 4'); + + // Wait for the solution to appear + await waitFor(() => expect(screen.getByText(/Solution: x = 3/i)).toBeInTheDocument()); + + // Check steps + expect(screen.getByText(/Step 1/i)).toBeInTheDocument(); + expect(screen.getByText(/simplify/i)).toBeInTheDocument(); }); - it('displays "Invalid equation" when an invalid equation is entered', () => { - (mathsteps.solveEquation as jest.Mock).mockImplementation(() => { throw new Error('Invalid equation'); }); - - render(); + test('handles invalid equation', async () => { + (mathsteps.solveEquation as jest.Mock).mockImplementation(() => { throw new Error(); }); + render(); + fireEvent.change(screen.getByLabelText(/Enter an algebraic equation/i), { target: { value: 'invalid equation' } }); fireEvent.click(screen.getByText(/Solve/i)); - - expect(screen.getByText(/Solution: Invalid equation/i)).toBeInTheDocument(); + + // Wait for the solution to update + await waitFor(() => expect(screen.getByText(/Invalid equation/i)).toBeInTheDocument()); }); - it('displays steps when a valid equation is solved', () => { - const stepsMock = [ - { newEquation: { ascii: () => 'x + 1 = 2' }, changeType: 'ADD_CONSTANT' }, - { newEquation: { ascii: () => 'x = 1' }, changeType: 'SOLVE_EQUATION' }, - ]; - (mathsteps.solveEquation as jest.Mock).mockReturnValue(stepsMock); - - render(); + test('calls handleSolve on input change and button click', async () => { + const mockSteps: Step[] = [{ newEquation: { ascii: () => 'x = 2' }, changeType: 'simplify' }]; + (mathsteps.solveEquation as jest.Mock).mockReturnValue(mockSteps); - fireEvent.change(screen.getByLabelText(/Enter an algebraic equation/i), { target: { value: 'x + 1 = 2' } }); + render(); + + fireEvent.change(screen.getByLabelText(/Enter an algebraic equation/i), { target: { value: 'x + 2 = 4' } }); fireEvent.click(screen.getByText(/Solve/i)); - - expect(screen.getByText(/Steps/i)).toBeInTheDocument(); - expect(screen.getByText(/Step 1/i)).toBeInTheDocument(); - expect(screen.getAllByText(/x \+ 1 = 2/i)).toHaveLength(1); - expect(screen.getByText(/add constant/i)).toBeInTheDocument(); - expect(screen.getByText(/Step 2/i)).toBeInTheDocument(); - expect(screen.getAllByText(/x = 1/i)).toHaveLength(2); - expect(screen.getByText(/solve equation/i)).toBeInTheDocument(); + + await waitFor(() => expect(screen.getByText(/Solution: x = 2/i)).toBeInTheDocument()); }); - it('opens the Add to Study Guide modal when AddButton is clicked', () => { - const stepsMock = [ - { newEquation: { ascii: () => 'x = 1' }, changeType: 'SOLVE_EQUATION' }, - ]; - (mathsteps.solveEquation as jest.Mock).mockReturnValue(stepsMock); - - render(); - - fireEvent.change(screen.getByLabelText(/Enter an algebraic equation/i), { target: { value: 'x + 1 = 2' } }); - fireEvent.click(screen.getByText(/Solve/i)); - fireEvent.click(screen.getByText(/Add to Study Guide/i)); - - expect(screen.getByText(/Add to Study Guide/i)).toBeInTheDocument(); - expect(screen.getByLabelText(/New Title/i)).toBeInTheDocument(); - expect(screen.getByLabelText(/Existing Title/i)).toBeInTheDocument(); + test('does not render AddButton component', async () => { + render(); + + // Ensure AddButton is not rendered + expect(screen.queryByText(/AddButton Mock/i)).not.toBeInTheDocument(); }); }); diff --git a/app/api/logout/route.ts b/app/api/logout/route.ts index e8e3ab7..5c5ca11 100644 --- a/app/api/logout/route.ts +++ b/app/api/logout/route.ts @@ -1,20 +1,19 @@ -import { NextRequest, NextResponse } from 'next/server' -import { createClient } from '../../utils/supabase/server' -import { deleteCookie } from '../../utils/supabase/cookies' +import { NextRequest, NextResponse } from 'next/server'; +import { createClient } from '../../utils/supabase/server'; +import { deleteCookie } from '../../utils/supabase/cookies'; +export async function POST(req: NextRequest): Promise { + const supabase = createClient(); -export async function POST(req: NextRequest) { - const supabase = createClient(); + const { error } = await supabase.auth.signOut(); - const { error } = await supabase.auth.signOut() + if (error) { + console.error('Failed to logout', error); + return NextResponse.redirect(new URL('/error', req.url)); // Redirect to an error page if there's an error + } - if (error) { - return NextResponse.redirect(new URL('/error', req.url)) - } + const response = NextResponse.redirect(new URL('/', req.url)); // Redirect to the home page + deleteCookie(response, 'user-data'); // Delete cookies if needed - const response = NextResponse.redirect(new URL('/login', req.url)) - // Delete cookies if needed - deleteCookie(response, 'user-data') - - return response -} \ No newline at end of file + return response; +} diff --git a/app/auth/confirm/route.ts b/app/auth/confirm/route.ts deleted file mode 100644 index cd99da1..0000000 --- a/app/auth/confirm/route.ts +++ /dev/null @@ -1,19 +0,0 @@ -import { createClient } from "../../utils/supabase/server"; -import { NextResponse } from "next/server"; - -export async function GET(request: Request) { - // The `/auth/callback` route is required for the server-side auth flow implemented - // by the SSR package. It exchanges an auth code for the user's session. - // https://supabase.com/docs/guides/auth/server-side/nextjs - const requestUrl = new URL(request.url); - const code = requestUrl.searchParams.get("code"); - const origin = requestUrl.origin; - - if (code) { - const supabase = createClient(); - await supabase.auth.exchangeCodeForSession(code); - } - - // URL to redirect to after sign up process completes - return NextResponse.redirect(`${origin}/protected`); -} diff --git a/app/components/NavbarClient.tsx b/app/components/NavbarClient.tsx index 395f02c..33cf0b0 100644 --- a/app/components/NavbarClient.tsx +++ b/app/components/NavbarClient.tsx @@ -6,13 +6,10 @@ import { AppBar, Toolbar, Grid, Button, Box } from '@mui/material'; import Link from 'next/link'; import Image from 'next/image'; import logo from '../../assets/math_solver_black.png'; -import PersonIcon from '@mui/icons-material/Person'; +import AccountCircleRoundedIcon from '@mui/icons-material/AccountCircleRounded'; import { createClient } from '../utils/supabase/client'; -interface NavbarClientProps { - initialUserData: UserData | null; -} - +// Define the types for the user data interface UserData { id: string; email: string; @@ -20,6 +17,10 @@ interface UserData { lastName: string; } +interface NavbarClientProps { + initialUserData: UserData | null; +} + const home = { title: 'Home', path: '/', @@ -33,6 +34,12 @@ const study_guide = { describedBy: 'study-guide-link', }; +const dashboard = { + title: 'Dashboard', + path: '/dashboard', + describedBy: 'dashboard-link', +}; + const login = { title: 'Login', path: '/login', @@ -41,23 +48,28 @@ const login = { const NavbarClient: React.FC = ({ initialUserData }) => { const router = useRouter(); - const supabase = createClient(); - const [isLoggedIn, setIsLoggedIn] = useState(!!initialUserData); + const [isLoggedIn, setIsLoggedIn] = useState(!!initialUserData); useEffect(() => { setIsLoggedIn(!!initialUserData); }, [initialUserData]); - //need loading state. server component that creates a suspense boundary; look at next docs for suspense boundaries const handleLogout = async () => { - // startTransition(async () => { - await fetch('/api/logout', { + try { + const response = await fetch('/api/logout', { method: 'POST', - }) - router.push('/') // Redirect to the login page or any other page after logout - window.location.reload(); - // }) - } + }); + + if (response.ok) { + router.push('/'); // Redirect to the home page + window.location.reload(); // Reload to clear any client-side state + } else { + console.error('Logout failed'); + } + } catch (error) { + console.error('Error during logout:', error); + } + }; return ( @@ -72,20 +84,27 @@ const NavbarClient: React.FC = ({ initialUserData }) => { - {/* Right side: Study and Auth buttons */} + {/* Right side: Solve, Study, and Auth buttons */} {isLoggedIn && ( - - - + <> + + + + + + + )} {isLoggedIn ? (