From 42f1b4de384bcbbdab3b80d8e5cc53b36fcffd50 Mon Sep 17 00:00:00 2001 From: Adam Mathes Date: Thu, 12 Feb 2026 21:50:56 -0800 Subject: Implement frontend login logic with >90% coverage --- frontend/src/App.test.tsx | 30 ++++++++++-- frontend/src/App.tsx | 85 ++++++++++++++++++++++------------ frontend/src/components/Login.css | 63 +++++++++++++++++++++++++ frontend/src/components/Login.test.tsx | 76 ++++++++++++++++++++++++++++++ frontend/src/components/Login.tsx | 54 +++++++++++++++++++++ 5 files changed, 275 insertions(+), 33 deletions(-) create mode 100644 frontend/src/components/Login.css create mode 100644 frontend/src/components/Login.test.tsx create mode 100644 frontend/src/components/Login.tsx (limited to 'frontend/src') diff --git a/frontend/src/App.test.tsx b/frontend/src/App.test.tsx index f1572a7..8e3d805 100644 --- a/frontend/src/App.test.tsx +++ b/frontend/src/App.test.tsx @@ -1,10 +1,32 @@ -import { render, screen } from '@testing-library/react'; +import { render, screen, waitFor } from '@testing-library/react'; import App from './App'; -import { describe, it, expect } from 'vitest'; +import { describe, it, expect, vi, beforeEach } from 'vitest'; describe('App', () => { - it('renders heading', () => { + beforeEach(() => { + vi.resetAllMocks(); + global.fetch = vi.fn(); + }); + + it('renders login on initial load (unauthenticated)', async () => { + (global.fetch as any).mockResolvedValueOnce({ + ok: false, + }); + window.history.pushState({}, 'Test page', '/v2/login'); render(); - expect(screen.getByRole('heading', { level: 1 })).toBeInTheDocument(); // Adjust based on actual App content + expect(screen.getByRole('button', { name: /login/i })).toBeInTheDocument(); + }); + + it('renders dashboard when authenticated', async () => { + (global.fetch as any).mockResolvedValueOnce({ + ok: true, + }); + + window.history.pushState({}, 'Test page', '/v2/'); + render(); + + await waitFor(() => { + expect(screen.getByText(/dashboard/i)).toBeInTheDocument(); + }); }); }); diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 3d7ded3..b986198 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1,35 +1,62 @@ -import { useState } from 'react' -import reactLogo from './assets/react.svg' -import viteLogo from '/vite.svg' -import './App.css' +import React, { useEffect, useState } from 'react'; +import { BrowserRouter, Routes, Route, Navigate, useLocation } from 'react-router-dom'; +import Login from './components/Login'; +import './App.css'; -function App() { - const [count, setCount] = useState(0) +// Protected Route wrapper +function RequireAuth({ children }: { children: React.ReactElement }) { + const [auth, setAuth] = useState(null); + const location = useLocation(); + + useEffect(() => { + fetch('/api/auth') + .then((res) => { + if (res.ok) { + setAuth(true); + } else { + setAuth(false); + } + }) + .catch(() => setAuth(false)); + }, []); + + if (auth === null) { + return
Loading...
; + } + + if (!auth) { + return ; + } + + return children; +} +function Dashboard() { + // Placeholder for now return ( - <> - -

Vite + React

-
- -

- Edit src/App.tsx and save to test HMR -

-
-

- Click on the Vite and React logos to learn more -

- +
+

Dashboard

+

Welcome to the new Neko/v2 frontend.

+
) } -export default App +function App() { + return ( + + + } /> + + + + } + /> + + + ); +} + +export default App; diff --git a/frontend/src/components/Login.css b/frontend/src/components/Login.css new file mode 100644 index 0000000..f1ca976 --- /dev/null +++ b/frontend/src/components/Login.css @@ -0,0 +1,63 @@ +.login-container { + display: flex; + justify-content: center; + align-items: center; + height: 100vh; + background-color: #f5f5f5; +} + +.login-form { + background: white; + padding: 2rem; + border-radius: 8px; + box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1); + width: 100%; + max-width: 400px; +} + +.login-form h1 { + margin-bottom: 2rem; + text-align: center; + color: #333; +} + +.form-group { + margin-bottom: 1.5rem; +} + +.form-group label { + display: block; + margin-bottom: 0.5rem; + font-weight: bold; + color: #555; +} + +.form-group input { + width: 100%; + padding: 0.75rem; + border: 1px solid #ddd; + border-radius: 4px; + font-size: 1rem; +} + +.error-message { + color: #dc3545; + margin-bottom: 1rem; + text-align: center; +} + +button[type="submit"] { + width: 100%; + padding: 0.75rem; + background-color: #007bff; + color: white; + border: none; + border-radius: 4px; + font-size: 1rem; + cursor: pointer; + transition: background-color 0.2s; +} + +button[type="submit"]:hover { + background-color: #0056b3; +} diff --git a/frontend/src/components/Login.test.tsx b/frontend/src/components/Login.test.tsx new file mode 100644 index 0000000..44d1371 --- /dev/null +++ b/frontend/src/components/Login.test.tsx @@ -0,0 +1,76 @@ +import { render, screen, fireEvent, waitFor } from '@testing-library/react'; +import { BrowserRouter } from 'react-router-dom'; +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import Login from './Login'; + +// Mock fetch +global.fetch = vi.fn(); + +const renderLogin = () => { + render( + + + + ); +}; + +describe('Login Component', () => { + beforeEach(() => { + vi.resetAllMocks(); + }); + + it('renders login form', () => { + renderLogin(); + expect(screen.getByLabelText(/password/i)).toBeInTheDocument(); + expect(screen.getByRole('button', { name: /login/i })).toBeInTheDocument(); + }); + + it('handles successful login', async () => { + (global.fetch as any).mockResolvedValueOnce({ + ok: true, + }); + + renderLogin(); + + fireEvent.change(screen.getByLabelText(/password/i), { target: { value: 'secret' } }); + fireEvent.click(screen.getByRole('button', { name: /login/i })); + + await waitFor(() => { + expect(global.fetch).toHaveBeenCalledWith('/api/login', expect.objectContaining({ + method: 'POST', + })); + }); + // Navigation assertion is tricky without mocking useNavigate, + // but if no error is shown, we assume success path was taken + expect(screen.queryByText(/login failed/i)).not.toBeInTheDocument(); + }); + + it('handles failed login', async () => { + (global.fetch as any).mockResolvedValueOnce({ + ok: false, + json: async () => ({ message: 'Bad credentials' }), + }); + + renderLogin(); + + fireEvent.change(screen.getByLabelText(/password/i), { target: { value: 'wrong' } }); + fireEvent.click(screen.getByRole('button', { name: /login/i })); + + await waitFor(() => { + expect(screen.getByText(/bad credentials/i)).toBeInTheDocument(); + }); + }); + + it('handles network error', async () => { + (global.fetch as any).mockRejectedValueOnce(new Error('Network error')); + + renderLogin(); + + fireEvent.change(screen.getByLabelText(/password/i), { target: { value: 'secret' } }); + fireEvent.click(screen.getByRole('button', { name: /login/i })); + + await waitFor(() => { + expect(screen.getByText(/network error/i)).toBeInTheDocument(); + }); + }); +}); diff --git a/frontend/src/components/Login.tsx b/frontend/src/components/Login.tsx new file mode 100644 index 0000000..2e8bbf7 --- /dev/null +++ b/frontend/src/components/Login.tsx @@ -0,0 +1,54 @@ +import { useState, type FormEvent } from 'react'; +import { useNavigate } from 'react-router-dom'; +import './Login.css'; + +export default function Login() { + const [password, setPassword] = useState(''); + const [error, setError] = useState(''); + const navigate = useNavigate(); + + const handleSubmit = async (e: FormEvent) => { + e.preventDefault(); + setError(''); + + try { + // Use URLSearchParams to send as form-urlencoded, matching backend expectation + const params = new URLSearchParams(); + params.append('password', password); + + const res = await fetch('/api/login', { + method: 'POST', + body: params, + }); + + if (res.ok) { + navigate('/'); + } else { + const data = await res.json(); + setError(data.message || 'Login failed'); + } + } catch (err) { + setError('Network error'); + } + }; + + return ( +
+
+

neko rss mode

+
+ + setPassword(e.target.value)} + autoFocus + /> +
+ {error &&
{error}
} + +
+
+ ); +} -- cgit v1.2.3