diff --git a/coderabbit.yaml b/.coderabbit.yaml similarity index 78% rename from coderabbit.yaml rename to .coderabbit.yaml index f4edd24f..47c67592 100644 --- a/coderabbit.yaml +++ b/.coderabbit.yaml @@ -1,7 +1,6 @@ language: "en-US" -early_access: true reviews: - profile: "chill" + profile: assertive # Profile defaults to assertive, which is good for diagrams request_changes_workflow: false high_level_summary: true sequence_diagrams: true diff --git a/apps/web/src/layouts/AdminLayout.spec.tsx b/apps/web/src/layouts/AdminLayout.spec.tsx new file mode 100644 index 00000000..5a1a22cd --- /dev/null +++ b/apps/web/src/layouts/AdminLayout.spec.tsx @@ -0,0 +1,107 @@ +import { describe, it, expect, vi, beforeEach, type Mock } from 'vitest'; +import { render, screen, waitFor } from '@testing-library/react'; +import { AdminLayout } from './AdminLayout'; +import { useAuth } from '../lib/auth/context'; +import { useNavigate, useLocation } from 'react-router-dom'; + +// Mock Dependencies +vi.mock('../lib/auth/context', () => ({ + useAuth: vi.fn(), +})); + +vi.mock('react-router-dom', () => ({ + useNavigate: vi.fn(), + useLocation: vi.fn(), + Outlet: () =>
Child Content
, // Mock Outlet + Link: ({ to, children }: { to: string; children: React.ReactNode }) => {children}, +})); + +// Mock UI components that might cause issues or are heavy +vi.mock('@/components/ui/sheet', () => ({ + Sheet: ({ children }: { children: React.ReactNode }) =>
{children}
, + SheetTrigger: ({ children }: { children: React.ReactNode }) =>
{children}
, + SheetContent: ({ children }: { children: React.ReactNode }) =>
{children}
, +})); + +vi.mock('@/components/ui/dropdown-menu', () => ({ + DropdownMenu: ({ children }: { children: React.ReactNode }) =>
{children}
, + DropdownMenuTrigger: ({ children }: { children: React.ReactNode }) =>
{children}
, + DropdownMenuContent: ({ children }: { children: React.ReactNode }) =>
{children}
, + DropdownMenuItem: ({ children, onClick }: { children: React.ReactNode, onClick?: () => void }) =>
{children}
, + DropdownMenuLabel: ({ children }: { children: React.ReactNode }) =>
{children}
, + DropdownMenuSeparator: () =>
, +})); + + +describe('AdminLayout', () => { + const mockNavigate = vi.fn(); + const mockLogout = vi.fn(); + + beforeEach(() => { + vi.clearAllMocks(); + (useNavigate as unknown as Mock).mockReturnValue(mockNavigate); + (useLocation as unknown as Mock).mockReturnValue({ pathname: '/admin' }); + }); + + it('renders loading state', () => { + (useAuth as unknown as Mock).mockReturnValue({ + isLoading: true, + isAuthenticated: false, + user: null, + logout: mockLogout + }); + + render(); + expect(screen.getByText('Loading Admin Panel...')).toBeInTheDocument(); + }); + + it('redirects to login if not authenticated', async () => { + (useAuth as unknown as Mock).mockReturnValue({ + isLoading: false, + isAuthenticated: false, // Not auth + user: null, + logout: mockLogout + }); + + render(); + + // Wait for useEffect + await waitFor(() => { + expect(mockNavigate).toHaveBeenCalledWith('/login'); + }); + }); + + it('redirects to dashboard if user is not platform_admin', async () => { + (useAuth as unknown as Mock).mockReturnValue({ + isLoading: false, + isAuthenticated: true, + user: { name: 'User', systemRole: 'user' }, // Wrong role + logout: mockLogout + }); + + render(); + + await waitFor(() => { + expect(mockNavigate).toHaveBeenCalledWith('/dashboard'); + }); + + // Access denied message might behave briefly + expect(screen.getByText('Access denied.')).toBeInTheDocument(); + }); + + it('renders content when user is platform_admin', () => { + (useAuth as unknown as Mock).mockReturnValue({ + isLoading: false, + isAuthenticated: true, + user: { name: 'Admin', systemRole: 'platform_admin' }, + logout: mockLogout + }); + + render(); + + expect(screen.getByTestId('outlet')).toBeInTheDocument(); + // It renders twice (Desktop + Mobile sidebars) + expect(screen.getAllByText('Admin Console')).toHaveLength(2); + expect(screen.getAllByText('Admin')).toHaveLength(3); // Desktop + Mobile + Breadcrumb + }); +}); diff --git a/apps/web/src/layouts/TenantLayout.spec.tsx b/apps/web/src/layouts/TenantLayout.spec.tsx new file mode 100644 index 00000000..bebb82a4 --- /dev/null +++ b/apps/web/src/layouts/TenantLayout.spec.tsx @@ -0,0 +1,125 @@ +import { describe, it, expect, vi, beforeEach, type Mock } from 'vitest'; +import { render, screen, waitFor } from '@testing-library/react'; +import { TenantLayout } from './TenantLayout'; +import { useAuth } from '@/lib/auth/context'; +import { useNavigate, useLocation } from 'react-router-dom'; + +// Mock Dependencies +vi.mock('@/lib/auth/context', () => ({ + useAuth: vi.fn(), +})); + +vi.mock('react-router-dom', () => ({ + useNavigate: vi.fn(), + useLocation: vi.fn(), + Outlet: () =>
Tenant Content
, + Link: ({ to, children }: { to: string; children: React.ReactNode }) => {children}, + useParams: () => ({ tenantId: 'org-123' }), +})); + +// Mock UI components +vi.mock('@/components/ui/sheet', () => ({ + Sheet: ({ children }: { children: React.ReactNode }) =>
{children}
, + SheetTrigger: ({ children }: { children: React.ReactNode }) =>
{children}
, + SheetContent: ({ children }: { children: React.ReactNode }) =>
{children}
, +})); + +vi.mock('@/components/ui/dropdown-menu', () => ({ + DropdownMenu: ({ children }: { children: React.ReactNode }) =>
{children}
, + DropdownMenuTrigger: ({ children }: { children: React.ReactNode }) =>
{children}
, + DropdownMenuContent: ({ children }: { children: React.ReactNode }) =>
{children}
, + DropdownMenuItem: ({ children, onClick }: { children: React.ReactNode, onClick?: () => void }) =>
{children}
, + DropdownMenuLabel: ({ children }: { children: React.ReactNode }) =>
{children}
, + DropdownMenuSeparator: () =>
, +})); + +vi.mock('@/components/ui/avatar', () => ({ + Avatar: ({ children }: { children: React.ReactNode }) =>
{children}
, + AvatarImage: () => avatar, + AvatarFallback: () => A, +})); + + +describe('TenantLayout', () => { + const mockNavigate = vi.fn(); + const mockLogout = vi.fn(); + + // Sample nav groups for testing + const mockNavGroups = [ + { + title: 'Main', + items: [ + { label: 'Dashboard', href: '/org-123/dashboard', icon: () => }, + { label: 'Settings', href: '/org-123/settings', icon: () => } + ] + } + ]; + + beforeEach(() => { + vi.clearAllMocks(); + (useNavigate as unknown as Mock).mockReturnValue(mockNavigate); + (useLocation as unknown as Mock).mockReturnValue({ pathname: '/org-123/dashboard' }); + }); + + it('renders loading state', () => { + (useAuth as unknown as Mock).mockReturnValue({ + isLoading: true, + isAuthenticated: false, + user: null, + logout: mockLogout + }); + + render(); + expect(screen.getByText('Loading...')).toBeInTheDocument(); + }); + + it('redirects to login if not authenticated', async () => { + (useAuth as unknown as Mock).mockReturnValue({ + isLoading: false, + isAuthenticated: false, + user: null, + logout: mockLogout + }); + + render(); + + await waitFor(() => { + expect(mockNavigate).toHaveBeenCalledWith('/login'); + }); + }); + + it('renders correctly for authenticated user', async () => { + (useAuth as unknown as Mock).mockReturnValue({ + isLoading: false, + isAuthenticated: true, + user: { name: 'Tenant User', email: 'user@org.com', organizationName: 'Acme Corp' }, + logout: mockLogout + }); + + render(); + + expect(screen.getByTestId('outlet')).toBeInTheDocument(); + expect(screen.getAllByText('Dashboard')).toHaveLength(3); // Sidebar x2 + Breadcrumb + }); + + it('displays user profile info', () => { + (useAuth as unknown as Mock).mockReturnValue({ + isLoading: false, + isAuthenticated: true, + user: { name: 'John Doe', email: 'john@example.com', organizationName: 'Acme Corp' }, + logout: mockLogout + }); + + render(); + + // It renders twice (Desktop + Mobile sidebars) + // Wait, sidebar has user name? NO, sidebar shows Org Name. Dropdown shows User Name. + // Let's re-read TenantLayout.tsx carefully. + // SidebarContent: line 56 shows user.organizationName. + // Topbar Dropdown: Line 193 shows user.name. + + expect(screen.getByText('John Doe')).toBeInTheDocument(); + expect(screen.getByText('john@example.com')).toBeInTheDocument(); + expect(screen.getAllByText('Acme Corp')).toHaveLength(2); // Desktop sidebar + Mobile sidebar + }); +}); diff --git a/apps/web/src/pages/DashboardPage.spec.tsx b/apps/web/src/pages/DashboardPage.spec.tsx new file mode 100644 index 00000000..5f194038 --- /dev/null +++ b/apps/web/src/pages/DashboardPage.spec.tsx @@ -0,0 +1,13 @@ +import { describe, it, expect } from 'vitest'; +import { render, screen } from '@testing-library/react'; +import { DashboardPage } from './DashboardPage'; + +describe('DashboardPage', () => { + it('renders dashboard stats', () => { + render(); + expect(screen.getByText('Total Members')).toBeInTheDocument(); + expect(screen.getByText('Active Sessions')).toBeInTheDocument(); + expect(screen.getByText('Security Score')).toBeInTheDocument(); + expect(screen.getByText('Activity Feed')).toBeInTheDocument(); + }); +}); diff --git a/apps/web/src/pages/LandingPage.spec.tsx b/apps/web/src/pages/LandingPage.spec.tsx new file mode 100644 index 00000000..893463a8 --- /dev/null +++ b/apps/web/src/pages/LandingPage.spec.tsx @@ -0,0 +1,55 @@ +import { describe, it, expect, vi, beforeEach, type Mock } from 'vitest'; +import { render, screen, fireEvent } from '@testing-library/react'; +import { LandingPage } from './LandingPage'; +import { useAuth } from '../hooks/useAuth'; +import { useNavigate } from 'react-router-dom'; + +// Mock Dependencies +vi.mock('../hooks/useAuth', () => ({ + useAuth: vi.fn(), +})); + +vi.mock('react-router-dom', () => ({ + useNavigate: vi.fn(), +})); + +describe('LandingPage', () => { + const mockNavigate = vi.fn(); + const mockLogin = vi.fn(); + const mockSignup = vi.fn(); + + beforeEach(() => { + vi.clearAllMocks(); + (useNavigate as unknown as Mock).mockReturnValue(mockNavigate); + (useAuth as unknown as Mock).mockReturnValue({ + isAuthenticated: false, + login: mockLogin, + signup: mockSignup, + }); + }); + + it('renders landing page content', () => { + render(); + expect(screen.getByRole('heading', { name: 'Nexiom' })).toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'Login' })).toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'Sign Up' })).toBeInTheDocument(); + }); + + it('navigates to dashboard if authenticated', () => { + (useAuth as unknown as Mock).mockReturnValue({ + isAuthenticated: true, + }); + render(); + expect(mockNavigate).toHaveBeenCalledWith('/dashboard', { replace: true }); + expect(screen.getByText('Redirecting to Dashboard...')).toBeInTheDocument(); + }); + + it('triggers login and signup', () => { + render(); + fireEvent.click(screen.getByRole('button', { name: 'Login' })); + expect(mockLogin).toHaveBeenCalled(); + + fireEvent.click(screen.getByRole('button', { name: 'Sign Up' })); + expect(mockSignup).toHaveBeenCalled(); + }); +}); diff --git a/apps/web/src/pages/LoginPage.spec.tsx b/apps/web/src/pages/LoginPage.spec.tsx new file mode 100644 index 00000000..3db93d30 --- /dev/null +++ b/apps/web/src/pages/LoginPage.spec.tsx @@ -0,0 +1,146 @@ +import { describe, it, expect, vi, beforeEach, afterEach, type Mock } from 'vitest'; +import { render, screen, fireEvent, waitFor } from '@testing-library/react'; +import { LoginPage } from './LoginPage'; +import { useAuth } from '../hooks/useAuth'; +import { useNavigate } from 'react-router-dom'; +import { authClient } from '../lib/auth-client'; + +// Mock Dependencies +vi.mock('../hooks/useAuth', () => ({ + useAuth: vi.fn(), +})); + +vi.mock('react-router-dom', () => ({ + useNavigate: vi.fn(), +})); + +vi.mock('../lib/auth-client', () => ({ + authClient: { + signIn: { + social: vi.fn(), + }, + }, +})); + +// Setup global fetch mock +global.fetch = vi.fn(); + +describe('LoginPage', () => { + const mockNavigate = vi.fn(); + const mockSetAuthState = vi.fn(); + let originalLocation: Location; + + beforeEach(() => { + vi.clearAllMocks(); + + // Stub VITE_API_URL + vi.stubEnv('VITE_API_URL', 'http://test-api.com'); + + (useNavigate as unknown as Mock).mockReturnValue(mockNavigate); + (useAuth as unknown as Mock).mockReturnValue({ + user: null, + isLoading: false, + setAuthState: mockSetAuthState, + }); + // Default fetch mock to success + (global.fetch as Mock).mockResolvedValue({ + ok: true, + json: async () => ({ user: { id: '1', email: 'test@example.com', systemRole: 'user' }, session: {} }), + }); + + // Mock window.location + originalLocation = window.location; + Object.defineProperty(window, 'location', { + value: { origin: 'http://localhost:3000', search: '' }, + writable: true, + configurable: true + }); + }); + + afterEach(() => { + vi.unstubAllEnvs(); + Object.defineProperty(window, 'location', { + value: originalLocation, + writable: true, + configurable: true + }); + }); + + it('renders login form correctly', () => { + render(); + expect(screen.getByPlaceholderText('Email')).toBeInTheDocument(); + expect(screen.getByPlaceholderText('Password')).toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'Login' })).toBeInTheDocument(); + }); + + it('redirects if user is already authenticated', () => { + (useAuth as unknown as Mock).mockReturnValue({ + user: { id: '1', systemRole: 'platform_admin' }, + isLoading: false, + setAuthState: mockSetAuthState, + }); + + render(); + expect(mockNavigate).toHaveBeenCalledWith('/admin'); + }); + + it('handles successful login', async () => { + render(); + + fireEvent.change(screen.getByPlaceholderText('Email'), { target: { value: 'test@example.com' } }); + fireEvent.change(screen.getByPlaceholderText('Password'), { target: { value: 'password123' } }); + fireEvent.click(screen.getByRole('button', { name: 'Login' })); + + await waitFor(() => { + expect(global.fetch).toHaveBeenCalledWith( + expect.stringContaining('/auth/login'), + expect.any(Object) + ); + }); + + // Strict check on body + const fetchCall = (global.fetch as Mock).mock.calls.find(call => call[0].includes('/auth/login')); + if (!fetchCall) throw new Error("Fetch not called"); + const body = JSON.parse(fetchCall[1].body); + expect(body).toEqual({ email: 'test@example.com', password: 'password123' }); + + await waitFor(() => { + expect(mockSetAuthState).toHaveBeenCalled(); + expect(mockNavigate).toHaveBeenCalledWith('/dashboard'); // Default fallback + }); + }); + + it('handles login failure', async () => { + (global.fetch as Mock).mockResolvedValueOnce({ + ok: false, + json: async () => ({ message: 'Invalid credentials' }), + }); + + render(); + + fireEvent.change(screen.getByPlaceholderText('Email'), { target: { value: 'fail@example.com' } }); + fireEvent.change(screen.getByPlaceholderText('Password'), { target: { value: 'wrong' } }); + fireEvent.click(screen.getByRole('button', { name: 'Login' })); + + // Wait for error + await waitFor(() => { + expect(screen.getByText('Invalid credentials')).toBeInTheDocument(); + }); + expect(mockSetAuthState).not.toHaveBeenCalled(); + }); + + it('initiates social login', async () => { + render(); + + const socialLoginMock = authClient.signIn.social as unknown as Mock; + // Mock already set in mockReturnValue, but we can override return value if needed + socialLoginMock.mockResolvedValueOnce({}); + + fireEvent.click(screen.getByText('Sign in with Google')); + + expect(socialLoginMock).toHaveBeenCalledWith(expect.objectContaining({ + provider: 'google', + callbackURL: 'http://localhost:3000/dashboard', + })); + }); +}); diff --git a/apps/web/src/pages/SignupPage.spec.tsx b/apps/web/src/pages/SignupPage.spec.tsx new file mode 100644 index 00000000..676f9d1b --- /dev/null +++ b/apps/web/src/pages/SignupPage.spec.tsx @@ -0,0 +1,139 @@ +import { describe, it, expect, vi, beforeEach, type Mock } from 'vitest'; +import { render, screen, fireEvent, waitFor } from '@testing-library/react'; +import { SignupPage } from './SignupPage'; +import { useAuth } from '../hooks/useAuth'; +import { useNavigate, useSearchParams } from 'react-router-dom'; + +// Mock Dependencies +vi.mock('../hooks/useAuth', () => ({ + useAuth: vi.fn(), +})); + +vi.mock('react-router-dom', () => ({ + useNavigate: vi.fn(), + useSearchParams: vi.fn(), +})); + +// Setup global fetch mock +global.fetch = vi.fn(); + +describe('SignupPage', () => { + const mockNavigate = vi.fn(); + const mockSetAuthState = vi.fn(); + + beforeEach(() => { + vi.clearAllMocks(); + + // Stub VITE_API_URL + vi.stubGlobal('import.meta', { + env: { VITE_API_URL: 'http://test-api.com' } + }); + + (useNavigate as unknown as Mock).mockReturnValue(mockNavigate); + (useAuth as unknown as Mock).mockReturnValue({ + setAuthState: mockSetAuthState, + }); + // Default standard flow (no params) + (useSearchParams as unknown as Mock).mockReturnValue([new URLSearchParams('')]); + + // Default fetch success + (global.fetch as Mock).mockResolvedValue({ + ok: true, + json: async () => ({}), + }); + }); + + it('renders standard signup form', () => { + render(); + expect(screen.getByPlaceholderText('First Name')).toBeInTheDocument(); + expect(screen.getByPlaceholderText('Company Name')).toBeInTheDocument(); + // Use getByRole to avoid ambiguity with heading vs button + expect(screen.getByRole('button', { name: 'Sign Up' })).toBeInTheDocument(); + }); + + it('renders invite flow correctly', () => { + // Mock params for invite flow + const params = new URLSearchParams(); + params.set('to', '/invite/accept?id=123'); + params.set('email', 'invitee@example.com'); + (useSearchParams as unknown as Mock).mockReturnValue([params]); + + render(); + + expect(screen.getByText('Join Organization')).toBeInTheDocument(); + expect(screen.getByDisplayValue('invitee@example.com')).toBeDisabled(); + expect(screen.queryByPlaceholderText('Company Name')).not.toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'Join & Accept' })).toBeInTheDocument(); + }); + + it('handles standard signup submission', async () => { + // Mock alert to prevent JSDOM issues or unhandled output + const alertMock = vi.spyOn(window, 'alert').mockImplementation(() => { }); + + // Standard flow + render(); + + fireEvent.change(screen.getByPlaceholderText('First Name'), { target: { value: 'John' } }); + fireEvent.change(screen.getByPlaceholderText('Last Name'), { target: { value: 'Doe' } }); + fireEvent.change(screen.getByPlaceholderText('Company Name'), { target: { value: 'Acme Inc' } }); + fireEvent.change(screen.getByPlaceholderText('Work Email'), { target: { value: 'john@acme.com' } }); + fireEvent.change(screen.getByPlaceholderText('Password (min 8 chars)'), { target: { value: 'password123' } }); + + fireEvent.click(screen.getByRole('button', { name: 'Sign Up' })); + + await waitFor(() => { + expect(global.fetch).toHaveBeenCalledWith( + expect.stringContaining('/auth/signup'), + expect.objectContaining({ + method: 'POST', + body: expect.stringContaining('"companyName":"Acme Inc"'), + }) + ); + }); + + // Wrap navigation check in waitFor to handle async microtask queue + await waitFor(() => { + expect(mockNavigate).toHaveBeenCalledWith('/login'); + }); + + alertMock.mockRestore(); + }); + + it('handles invite flow submission (auto-login)', async () => { + // Invite flow + const params = new URLSearchParams(); + params.set('to', '/invite/accept?id=123'); + params.set('email', 'invitee@example.com'); + (useSearchParams as unknown as Mock).mockReturnValue([params]); + + (global.fetch as Mock).mockResolvedValue({ + ok: true, + json: async () => ({ session: { token: 'abc' }, user: { id: '2' } }), + }); + + render(); + + fireEvent.change(screen.getByPlaceholderText('First Name'), { target: { value: 'Jane' } }); + fireEvent.change(screen.getByPlaceholderText('Password (min 8 chars)'), { target: { value: 'securepass' } }); + + fireEvent.click(screen.getByRole('button', { name: 'Join & Accept' })); + + await waitFor(() => { + expect(global.fetch).toHaveBeenCalledWith( + expect.stringContaining('/auth/complete-invite'), + expect.objectContaining({ + method: 'POST', + body: expect.stringContaining('"invitationId":"123"'), + }) + ); + }); + + await waitFor(() => { + expect(mockSetAuthState).toHaveBeenCalledWith(expect.objectContaining({ + user: { id: '2' }, + accessToken: 'abc' + })); + expect(mockNavigate).toHaveBeenCalledWith('/dashboard'); + }); + }); +}); diff --git a/apps/web/src/pages/UsersPage.spec.tsx b/apps/web/src/pages/UsersPage.spec.tsx new file mode 100644 index 00000000..bfa5f1cd --- /dev/null +++ b/apps/web/src/pages/UsersPage.spec.tsx @@ -0,0 +1,89 @@ +import { describe, it, expect, vi, beforeEach, type Mock } from 'vitest'; +import { render, screen, fireEvent, waitFor } from '@testing-library/react'; +import { UsersPage } from './UsersPage'; +import { useAuth } from '../hooks/useAuth'; +import { authorizedFetch } from '../lib/api'; + +// Mock Dependencies +vi.mock('../hooks/useAuth', () => ({ + useAuth: vi.fn(), +})); + +vi.mock('../lib/api', () => ({ + authorizedFetch: vi.fn(), +})); + +describe('UsersPage', () => { + beforeEach(() => { + vi.clearAllMocks(); + (useAuth as unknown as Mock).mockReturnValue({ token: 'test-token' }); + (authorizedFetch as Mock).mockResolvedValue([]); + }); + + it('renders user list (empty state)', async () => { + render(); + expect(screen.getByText('User Management')).toBeInTheDocument(); + + await waitFor(() => { + expect(screen.getByText('No users found (or loading...)')).toBeInTheDocument(); + }); + expect(authorizedFetch).toHaveBeenCalledWith('test-token', '/users'); + }); + + it('renders user list with data', async () => { + const users = [ + { id: '1', email: 'user1@example.com', role: 'admin' }, + { id: '2', email: 'user2@example.com', roleId: 'viewer' }, + ]; + (authorizedFetch as Mock).mockResolvedValueOnce(users); + + render(); + + await waitFor(() => { + expect(screen.getByText('user1@example.com')).toBeInTheDocument(); + expect(screen.getByText('user2@example.com')).toBeInTheDocument(); + }); + }); + + it('handles adding a user', async () => { + render(); + + fireEvent.change(screen.getByPlaceholderText('user@example.com'), { target: { value: 'new@example.com' } }); + fireEvent.change(screen.getByRole('combobox'), { target: { value: 'editor' } }); // Select role + + (authorizedFetch as Mock).mockResolvedValueOnce({}); // Success for POST + + fireEvent.click(screen.getByRole('button', { name: 'Add User' })); + + await waitFor(() => { + expect(authorizedFetch).toHaveBeenCalledWith('test-token', '/users', expect.objectContaining({ + method: 'POST', + body: expect.stringContaining('new@example.com') + })); + expect(screen.getByText('User added successfully!')).toBeInTheDocument(); + }); + }); + + it('handles add user failure', async () => { + // Prevent console error from polluting output (since the component logs it) + const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => { }); + + render(); + + (authorizedFetch as Mock).mockImplementation((_token, _url, options) => { + if (options && options.method === 'POST') { + return Promise.reject(new Error('Fetch Failed')); + } + return Promise.resolve([]); // Default GET response + }); + + fireEvent.change(screen.getByPlaceholderText('user@example.com'), { target: { value: 'fail@example.com' } }); + fireEvent.click(screen.getByRole('button', { name: 'Add User' })); + + await waitFor(() => { + expect(screen.getByText('Error: Fetch Failed')).toBeInTheDocument(); + }); + + consoleSpy.mockRestore(); + }); +}); diff --git a/apps/web/src/pages/public/AcceptInvitePage.spec.tsx b/apps/web/src/pages/public/AcceptInvitePage.spec.tsx new file mode 100644 index 00000000..1603c1b6 --- /dev/null +++ b/apps/web/src/pages/public/AcceptInvitePage.spec.tsx @@ -0,0 +1,142 @@ +import { describe, it, expect, vi, beforeEach, type Mock } from 'vitest'; +import { render, screen, waitFor } from '@testing-library/react'; +import { AcceptInvitePage } from './AcceptInvitePage'; +import { useAuth } from '@/hooks/useAuth'; +import { useNavigate, useSearchParams } from 'react-router-dom'; + +// Mock Dependencies +vi.mock('@/hooks/useAuth', () => ({ + useAuth: vi.fn(), +})); + +vi.mock('react-router-dom', () => ({ + useNavigate: vi.fn(), + useSearchParams: vi.fn(), +})); + +// Mock UI components (shallow render mostly, but since we use happy-dom, full render is fine) +// Card etc are just divs usually. + +// Setup global fetch mock +global.fetch = vi.fn(); + +describe('AcceptInvitePage', () => { + const mockNavigate = vi.fn(); + + beforeEach(() => { + vi.clearAllMocks(); + (useNavigate as unknown as Mock).mockReturnValue(mockNavigate); + (useAuth as unknown as Mock).mockReturnValue({ + user: null, + isLoading: false, + token: null + }); + // Default valid params + (useSearchParams as unknown as Mock).mockReturnValue([new URLSearchParams('id=123')]); + + // Default fetch success + (global.fetch as Mock).mockResolvedValue({ + ok: true, + json: async () => ({}), + }); + }); + + it('renders error state when invite ID is missing', () => { + (useSearchParams as unknown as Mock).mockReturnValue([new URLSearchParams('')]); + + render(); + expect(screen.getByText('Invalid Link')).toBeInTheDocument(); + expect(screen.getByText(/missing required parameters/)).toBeInTheDocument(); + }); + + it('redirects to signup when user is not logged in', () => { + // User null, isLoading false. ID is present. + render(); + + // Should check navigate call + const expectedTarget = `/signup?to=${encodeURIComponent('/invite/accept?id=123')}&email=`; + expect(mockNavigate).toHaveBeenCalledWith(expectedTarget, { replace: true }); + + // Should verify loader is present + // Note: The component renders loader while effect triggers redirect + // Ideally we check if text is NOT invalid link + expect(screen.queryByText('Invalid Link')).not.toBeInTheDocument(); + }); + + it('redirects to signup with email param if provided', () => { + const params = new URLSearchParams(); + params.set('id', '456'); + params.set('email', 'test@example.com'); + (useSearchParams as unknown as Mock).mockReturnValue([params]); + + render(); + + const expectedTarget = `/signup?to=${encodeURIComponent('/invite/accept?id=456')}&email=${encodeURIComponent('test@example.com')}`; + expect(mockNavigate).toHaveBeenCalledWith(expectedTarget, { replace: true }); + }); + + it('performs silent accept when user is logged in', async () => { + (useAuth as unknown as Mock).mockReturnValue({ + user: { id: 'u1' }, + isLoading: false, + token: 'valid-token' + }); + + render(); + + // Should call fetch + await waitFor(() => { + expect(global.fetch).toHaveBeenCalledWith( + expect.stringContaining('/invitations/accept'), + expect.objectContaining({ + method: 'POST', + headers: expect.objectContaining({ + Authorization: 'Bearer valid-token', + }), + body: JSON.stringify({ invitationId: '123' }), + }) + ); + }); + + // Should redirect to dashboard + await waitFor(() => { + expect(mockNavigate).toHaveBeenCalledWith('/dashboard', { replace: true }); + }); + }); + + it('proceeds to dashboard even if silent accept fails (idempotency)', async () => { + (useAuth as unknown as Mock).mockReturnValue({ + user: { id: 'u1' }, + isLoading: false, + token: 'valid-token' + }); + + (global.fetch as Mock).mockRejectedValueOnce(new Error('Network Error')); + + render(); + + await waitFor(() => { + expect(global.fetch).toHaveBeenCalled(); + }); + + // Still navigates + await waitFor(() => { + expect(mockNavigate).toHaveBeenCalledWith('/dashboard', { replace: true }); + }); + }); + + it('waits for loading state before acting', () => { + (useAuth as unknown as Mock).mockReturnValue({ + user: null, // or whatever + isLoading: true, + token: null + }); + + render(); + + // Should NOT navigate yet + expect(mockNavigate).not.toHaveBeenCalled(); + // Should NOT fetch + expect(global.fetch).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/web/src/test/setup.ts b/apps/web/src/test/setup.ts index 7b0828bf..b1833655 100644 --- a/apps/web/src/test/setup.ts +++ b/apps/web/src/test/setup.ts @@ -1 +1,15 @@ import '@testing-library/jest-dom'; + + +// Cleanup +import { cleanup } from '@testing-library/react'; +import { afterEach, vi } from 'vitest'; + +vi.stubEnv('VITE_API_URL', 'http://localhost:3000/api'); +vi.stubEnv('DEV', true); +vi.stubEnv('MODE', 'test'); +vi.stubEnv('SSR', false); + +afterEach(() => { + cleanup(); +}); diff --git a/apps/web/vitest.config.ts b/apps/web/vitest.config.ts index 0a1f9757..822ff0ae 100644 --- a/apps/web/vitest.config.ts +++ b/apps/web/vitest.config.ts @@ -17,18 +17,31 @@ export default defineConfig({ environment: 'happy-dom', globals: true, setupFiles: ['./src/test/setup.ts'], + env: { + VITE_API_URL: 'http://localhost:3000/api', + }, coverage: { provider: 'v8', reporter: ['text', 'json', 'html'], include: [ 'src/providers/auth-provider.ts', - 'src/lib/auth-client.ts' + 'src/lib/auth-client.ts', + 'src/pages/**/*.tsx', + 'src/components/**/*.tsx', + 'src/layouts/**/*.tsx' + ], + exclude: [ + 'src/components/ui/**', + 'src/main.tsx', + 'src/App.tsx', + 'src/vite-env.d.ts', + 'src/pages/admin/**', ], thresholds: { - lines: 60, - functions: 60, - branches: 60, - statements: 60 + lines: 80, + functions: 80, + branches: 80, + statements: 80 }, }, },