Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions client/.gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -41,3 +41,6 @@ wrangler.toml.lock
# typescript
*.tsbuildinfo
next-env.d.ts

# swc plugin cache
/.swc/
2 changes: 2 additions & 0 deletions client/next.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@ import type { NextConfig } from "next";
const nextConfig: NextConfig = {
/* config options here */
reactCompiler: true,
// Pin the workspace root — a stray lockfile in $HOME otherwise wins root inference.
turbopack: { root: __dirname },
};

export default nextConfig;
41 changes: 0 additions & 41 deletions client/src/__tests__/components/auth/GoogleAuthButton.test.tsx

This file was deleted.

70 changes: 70 additions & 0 deletions client/src/__tests__/components/auth/GoogleAuthPanel.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
import { render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { GoogleAuthPanel } from '@/components/auth/GoogleAuthPanel';
import { useAuth } from '@/contexts/AuthContext';
import { isFirebaseConfigured } from '@/lib/firebase';

const push = jest.fn();
jest.mock('next/navigation', () => ({ useRouter: () => ({ push }) }));
jest.mock('@/contexts/AuthContext', () => ({ useAuth: jest.fn() }));
jest.mock('@/lib/firebase', () => ({ isFirebaseConfigured: jest.fn() }));
jest.mock('react-hot-toast', () => ({ __esModule: true, default: { success: jest.fn(), error: jest.fn() } }));

const mockUseAuth = useAuth as jest.MockedFunction<typeof useAuth>;
const mockConfigured = isFirebaseConfigured as jest.MockedFunction<typeof isFirebaseConfigured>;

const authState = (over: Partial<ReturnType<typeof useAuth>> = {}) => ({
user: null,
loading: false,
loginWithGoogle: jest.fn().mockResolvedValue(undefined),
logout: jest.fn(),
refreshUser: jest.fn(),
...over,
});

beforeEach(() => {
push.mockClear();
mockConfigured.mockReturnValue(true);
mockUseAuth.mockReturnValue(authState());
});

describe('GoogleAuthPanel', () => {
it('offers Google as the only credential in signin mode', () => {
render(<GoogleAuthPanel mode="signin" />);
expect(screen.getByRole('button', { name: /Continue with Google/i })).toBeInTheDocument();
expect(document.querySelectorAll('input')).toHaveLength(0);
});

it('offers Google as the only credential in signup mode', () => {
render(<GoogleAuthPanel mode="signup" />);
expect(screen.getByRole('button', { name: /Sign up with Google/i })).toBeInTheDocument();
expect(document.querySelectorAll('input')).toHaveLength(0);
});

it('signs in and redirects to the dashboard', async () => {
const loginWithGoogle = jest.fn().mockResolvedValue(undefined);
mockUseAuth.mockReturnValue(authState({ loginWithGoogle }));

render(<GoogleAuthPanel mode="signin" />);
await userEvent.click(screen.getByRole('button', { name: /Continue with Google/i }));

await waitFor(() => expect(loginWithGoogle).toHaveBeenCalledTimes(1));
expect(push).toHaveBeenCalledWith('/dashboard');
});

// Without a password fallback, an unconfigured Firebase is a dead end — the
// page has to say so rather than render an empty card.
it('explains the dead end when Firebase is not configured', () => {
mockConfigured.mockReturnValue(false);
render(<GoogleAuthPanel mode="signin" />);

expect(screen.queryByRole('button', { name: /Google/i })).not.toBeInTheDocument();
expect(screen.getByText(/isn't configured on this deployment/i)).toBeInTheDocument();
});

it('redirects an already-authenticated visitor away', async () => {
mockUseAuth.mockReturnValue(authState({ user: { id: '1', name: 'Ada', email: 'ada@example.com' } as any }));
render(<GoogleAuthPanel mode="signin" />);
await waitFor(() => expect(push).toHaveBeenCalledWith('/dashboard'));
});
});
55 changes: 0 additions & 55 deletions client/src/__tests__/contexts/AuthContext.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,9 +5,7 @@ import { api } from '@/lib/api';
jest.mock('@/lib/api', () => ({
api: {
getCurrentUser: jest.fn(),
login: jest.fn(),
logout: jest.fn(),
register: jest.fn(),
googleAuth: jest.fn(),
},
}));
Expand Down Expand Up @@ -64,34 +62,6 @@ describe('AuthContext', () => {
await waitFor(() => expect(screen.getByText('User: Alice')).toBeInTheDocument());
});

it('login() sets user on success', async () => {
mockApi.getCurrentUser.mockRejectedValueOnce(new Error('Not authenticated'));
mockApi.login.mockResolvedValueOnce({
success: true,
data: { user: fakeUser },
message: 'ok',
});

function LoginTester() {
const { user, loading, login } = useAuth();
if (loading) return <div>Loading</div>;
return (
<div>
{user ? <span>User: {user.name}</span> : <button onClick={() => login('a@b.com', 'pass')}>Login</button>}
</div>
);
}

render(<AuthProvider><LoginTester /></AuthProvider>);
await waitFor(() => screen.getByRole('button', { name: 'Login' }));

await act(async () => {
screen.getByRole('button', { name: 'Login' }).click();
});

await waitFor(() => expect(screen.getByText('User: Alice')).toBeInTheDocument());
});

it('logout() clears user', async () => {
mockApi.getCurrentUser.mockResolvedValueOnce({
success: true,
Expand Down Expand Up @@ -122,29 +92,4 @@ describe('AuthContext', () => {
await waitFor(() => expect(screen.getByText('Logged out')).toBeInTheDocument());
expect(mockApi.logout).toHaveBeenCalledTimes(1);
});

it('register() calls api.register', async () => {
mockApi.getCurrentUser.mockRejectedValueOnce(new Error('Not authenticated'));
mockApi.register.mockResolvedValueOnce({ success: true, message: 'ok' });

function RegisterTester() {
const { loading, register } = useAuth();
if (loading) return <div>Loading</div>;
return <button onClick={() => register('Alice', 'a@b.com', 'pass', 'pass')}>Register</button>;
}

render(<AuthProvider><RegisterTester /></AuthProvider>);
await waitFor(() => screen.getByRole('button', { name: 'Register' }));

await act(async () => {
screen.getByRole('button', { name: 'Register' }).click();
});

expect(mockApi.register).toHaveBeenCalledWith({
name: 'Alice',
email: 'a@b.com',
password: 'pass',
confirmPassword: 'pass',
});
});
});
92 changes: 0 additions & 92 deletions client/src/__tests__/unit/useTheme.test.ts

This file was deleted.

14 changes: 10 additions & 4 deletions client/src/app/dashboard/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -235,7 +235,11 @@ export default function DashboardPage() {
{ l: 'Active balancers', v: loadBalancers.filter(b => b.status === 'active').length, sub: `of ${loadBalancers.length} total` },
{ l: 'Origins total', v: loadBalancers.reduce((a, b) => a + (b.originCount || 0), 0), sub: 'all checks passing', color: 'var(--green)' },
].map((s, i) => (
<div key={i} className="card" style={{ padding: 'clamp(16px, 2vw, 20px)' }}>
<div
key={i}
className="feature-card animate-slide-up"
style={{ padding: 'clamp(16px, 2vw, 20px)', animationDelay: `${i * 0.08}s` }}
>
<div className="kicker" style={{ fontSize: 'clamp(9px, 2vw, 11px)' }}>{s.l}</div>
<div className="mono" style={{ fontSize: 'clamp(20px, 3vw, 24px)', marginTop: 8, letterSpacing: '-0.02em', color: s.color || 'var(--text)' }}>
{s.v}
Expand Down Expand Up @@ -269,9 +273,11 @@ export default function DashboardPage() {
onClick={() => setStatusFilter(value)}
className="btn btn-sm"
style={{
background: isActive ? 'var(--bg-2)' : 'transparent',
color: isActive ? 'var(--text)' : 'var(--text-3)',
border: '1px solid var(--line)',
background: isActive ? 'var(--accent-dim)' : '#ffffff0d',
color: isActive ? 'var(--accent)' : 'var(--text-3)',
border: `1px solid ${isActive ? 'var(--accent)' : 'var(--line)'}`,
borderRadius: 999,
fontWeight: isActive ? 600 : 500,
fontSize: 'clamp(12px, 2vw, 13px)',
padding: 'clamp(6px, 1vw, 8px) clamp(10px, 2vw, 12px)',
}}
Expand Down
Loading
Loading