diff --git a/frontend/src/__tests__/signinCallback.test.tsx b/frontend/src/__tests__/signinCallback.test.tsx new file mode 100644 index 0000000..c42da91 --- /dev/null +++ b/frontend/src/__tests__/signinCallback.test.tsx @@ -0,0 +1,123 @@ +/** + * Tests for app/signin/callback/page.tsx + * + * Covers the new error branches added to the OAuth callback handler: + * - Successful session → redirect to /dashboard + * - 403 from session API → redirect to /pending + * - Other failures from session API → inline error + * - Network errors → inline error + * - Missing user_id/name params → inline error + * - Missing is_approved param → inline error (not a /pending redirect) + * - Explicit is_approved=false → /pending redirect + */ +import React from 'react'; +import { render, screen, waitFor } from '@testing-library/react'; + +const replace = jest.fn(); +const setActiveUser = jest.fn(); +const confirmApproved = jest.fn(); +let searchParams = new URLSearchParams(); + +jest.mock('next/navigation', () => ({ + useRouter: () => ({ replace }), + useSearchParams: () => searchParams, +})); + +jest.mock('@/context/UserContext', () => ({ + useUser: () => ({ setActiveUser, confirmApproved }), +})); + +import CallbackPage from '@/app/signin/callback/page'; + +beforeEach(() => { + jest.clearAllMocks(); + searchParams = new URLSearchParams(); + global.fetch = jest.fn(); +}); + +afterEach(() => { + // @ts-expect-error allow cleanup + delete global.fetch; +}); + +function setParams(params: Record) { + searchParams = new URLSearchParams(params); +} + +describe('signin/callback page', () => { + it('redirects to /dashboard on a successful session', async () => { + setParams({ user_id: 'u1', name: 'Ada', is_approved: 'true' }); + (global.fetch as jest.Mock).mockResolvedValue({ ok: true, status: 200 }); + + render(); + + await waitFor(() => expect(replace).toHaveBeenCalledWith('/dashboard')); + expect(setActiveUser).toHaveBeenCalledWith('u1', 'Ada', ''); + expect(confirmApproved).toHaveBeenCalled(); + }); + + it('redirects to /pending when session API returns 403', async () => { + setParams({ user_id: 'u1', name: 'Ada', is_approved: 'true' }); + (global.fetch as jest.Mock).mockResolvedValue({ ok: false, status: 403 }); + + render(); + + await waitFor(() => expect(replace).toHaveBeenCalledWith('/pending')); + expect(confirmApproved).not.toHaveBeenCalled(); + }); + + it('shows an inline error when session API returns a non-403 failure', async () => { + setParams({ user_id: 'u1', name: 'Ada', is_approved: 'true' }); + (global.fetch as jest.Mock).mockResolvedValue({ ok: false, status: 500 }); + + render(); + + expect(await screen.findByText(/unable to complete sign-in/i)).toBeInTheDocument(); + expect(replace).not.toHaveBeenCalled(); + }); + + it('shows an inline error when the session fetch rejects', async () => { + setParams({ user_id: 'u1', name: 'Ada', is_approved: 'true' }); + (global.fetch as jest.Mock).mockRejectedValue(new Error('network down')); + + render(); + + expect(await screen.findByText(/unable to reach the server/i)).toBeInTheDocument(); + expect(replace).not.toHaveBeenCalled(); + }); + + it('shows an inline error when user_id is missing', async () => { + setParams({ name: 'Ada', is_approved: 'true' }); + + render(); + + expect(await screen.findByText(/sign-in failed/i)).toBeInTheDocument(); + expect(global.fetch).not.toHaveBeenCalled(); + expect(replace).not.toHaveBeenCalled(); + }); + + it('shows an inline error when is_approved is missing entirely', async () => { + setParams({ user_id: 'u1', name: 'Ada' }); + + render(); + + expect(await screen.findByText(/sign-in failed/i)).toBeInTheDocument(); + expect(replace).not.toHaveBeenCalled(); + }); + + it('redirects to /pending when is_approved is explicitly "false"', async () => { + setParams({ user_id: 'u1', name: 'Ada', is_approved: 'false' }); + + render(); + + await waitFor(() => expect(replace).toHaveBeenCalledWith('/pending')); + }); + + it('redirects to /pending when error=not_approved is set', async () => { + setParams({ error: 'not_approved' }); + + render(); + + await waitFor(() => expect(replace).toHaveBeenCalledWith('/pending')); + }); +}); diff --git a/frontend/src/app/signin/callback/page.tsx b/frontend/src/app/signin/callback/page.tsx index 7d83290..70f80be 100644 --- a/frontend/src/app/signin/callback/page.tsx +++ b/frontend/src/app/signin/callback/page.tsx @@ -1,6 +1,6 @@ 'use client'; -import { useEffect, Suspense } from 'react'; +import { useEffect, useState, Suspense } from 'react'; import { useSearchParams, useRouter } from 'next/navigation'; import { useUser } from '@/context/UserContext'; @@ -8,19 +8,25 @@ function CallbackInner() { const searchParams = useSearchParams(); const router = useRouter(); const { setActiveUser, confirmApproved } = useUser(); + const [errorMsg, setErrorMsg] = useState(null); useEffect(() => { const userId = searchParams.get('user_id'); const name = searchParams.get('name'); const avatar = searchParams.get('avatar'); - const isApproved = searchParams.get('is_approved') === 'true'; + const approvedParam = searchParams.get('is_approved'); const error = searchParams.get('error'); - if (error === 'not_approved' || !isApproved) { + if (error === 'not_approved' || approvedParam === 'false') { router.replace('/pending'); return; } + if (approvedParam !== 'true') { + setErrorMsg('Sign-in failed. Please try again.'); + return; + } + if (userId && name) { setActiveUser(userId, name, avatar || ''); fetch('/api/auth/session', { @@ -31,15 +37,50 @@ function CallbackInner() { if (res.ok) { confirmApproved(); router.replace('/dashboard'); + } else if (res.status === 403) { + router.replace('/pending'); } else { - router.replace('/signin'); + setErrorMsg('Unable to complete sign-in. Please try again.'); } + }).catch(() => { + setErrorMsg('Unable to reach the server. Please try again.'); }); } else { - router.replace('/signin'); + setErrorMsg('Sign-in failed. Please try again.'); } }, []); // eslint-disable-line react-hooks/exhaustive-deps + if (errorMsg) { + return ( +
+

{errorMsg}

+ + Try again + +
+ ); + } + return (