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: () => ,
+ 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
},
},
},