mirror of
https://github.com/khoaliber/LetterFeed.git
synced 2026-03-02 21:19:13 +00:00
fix: auth disabled routing and UI
This commit is contained in:
@@ -3,27 +3,38 @@ import { render, screen, fireEvent, waitFor } from "@testing-library/react"
|
|||||||
import "@testing-library/jest-dom"
|
import "@testing-library/jest-dom"
|
||||||
import LoginPage from "@/app/login/page"
|
import LoginPage from "@/app/login/page"
|
||||||
import { useAuth } from "@/hooks/useAuth"
|
import { useAuth } from "@/hooks/useAuth"
|
||||||
|
import { useRouter } from "next/navigation"
|
||||||
import { toast } from "sonner"
|
import { toast } from "sonner"
|
||||||
|
|
||||||
|
// Mock the necessary hooks and modules
|
||||||
jest.mock("@/hooks/useAuth")
|
jest.mock("@/hooks/useAuth")
|
||||||
jest.mock("sonner", () => ({
|
jest.mock("sonner", () => ({
|
||||||
toast: {
|
toast: {
|
||||||
error: jest.fn(),
|
error: jest.fn(),
|
||||||
},
|
},
|
||||||
}))
|
}))
|
||||||
|
jest.mock("next/navigation", () => ({
|
||||||
|
useRouter: jest.fn(),
|
||||||
|
}))
|
||||||
|
|
||||||
const mockedUseAuth = useAuth as jest.Mock
|
const mockedUseAuth = useAuth as jest.Mock
|
||||||
const mockedToast = toast as jest.Mocked<typeof toast>
|
const mockedToast = toast as jest.Mocked<typeof toast>
|
||||||
|
const mockedUseRouter = useRouter as jest.Mock
|
||||||
|
|
||||||
describe("LoginPage", () => {
|
describe("LoginPage", () => {
|
||||||
const login = jest.fn()
|
const login = jest.fn()
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
jest.clearAllMocks()
|
jest.clearAllMocks()
|
||||||
mockedUseAuth.mockReturnValue({ login })
|
// Default mock for useAuth
|
||||||
|
mockedUseAuth.mockReturnValue({
|
||||||
|
login,
|
||||||
|
isAuthenticated: false,
|
||||||
|
isLoading: false,
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
it("renders the login page", () => {
|
it("renders the login page when not authenticated", () => {
|
||||||
render(<LoginPage />)
|
render(<LoginPage />)
|
||||||
expect(screen.getByText("LetterFeed")).toBeInTheDocument()
|
expect(screen.getByText("LetterFeed")).toBeInTheDocument()
|
||||||
expect(screen.getByLabelText("Username")).toBeInTheDocument()
|
expect(screen.getByLabelText("Username")).toBeInTheDocument()
|
||||||
@@ -31,6 +42,28 @@ describe("LoginPage", () => {
|
|||||||
expect(screen.getByRole("button", { name: "Sign In" })).toBeInTheDocument()
|
expect(screen.getByRole("button", { name: "Sign In" })).toBeInTheDocument()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it("shows loading spinner when isLoading is true", () => {
|
||||||
|
mockedUseAuth.mockReturnValue({
|
||||||
|
login,
|
||||||
|
isAuthenticated: false,
|
||||||
|
isLoading: true,
|
||||||
|
})
|
||||||
|
render(<LoginPage />)
|
||||||
|
expect(screen.getByTestId("loading-spinner")).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it("redirects when authenticated", () => {
|
||||||
|
const push = jest.fn()
|
||||||
|
mockedUseRouter.mockReturnValue({ push })
|
||||||
|
mockedUseAuth.mockReturnValue({
|
||||||
|
login,
|
||||||
|
isAuthenticated: true,
|
||||||
|
isLoading: false,
|
||||||
|
})
|
||||||
|
render(<LoginPage />)
|
||||||
|
expect(push).toHaveBeenCalledWith("/")
|
||||||
|
})
|
||||||
|
|
||||||
it("allows typing in the username and password fields", () => {
|
it("allows typing in the username and password fields", () => {
|
||||||
render(<LoginPage />)
|
render(<LoginPage />)
|
||||||
const usernameInput = screen.getByLabelText("Username")
|
const usernameInput = screen.getByLabelText("Username")
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
"use client"
|
"use client"
|
||||||
|
|
||||||
import type React from "react"
|
import type React from "react"
|
||||||
import { useState } from "react"
|
import { useEffect, useState } from "react"
|
||||||
import { Button } from "@/components/ui/button"
|
import { Button } from "@/components/ui/button"
|
||||||
import { Card, CardContent } from "@/components/ui/card"
|
import { Card, CardContent } from "@/components/ui/card"
|
||||||
import { Input } from "@/components/ui/input"
|
import { Input } from "@/components/ui/input"
|
||||||
@@ -9,11 +9,20 @@ import { Label } from "@/components/ui/label"
|
|||||||
import Image from "next/image"
|
import Image from "next/image"
|
||||||
import { useAuth } from "@/hooks/useAuth"
|
import { useAuth } from "@/hooks/useAuth"
|
||||||
import { toast } from "sonner"
|
import { toast } from "sonner"
|
||||||
|
import { useRouter } from "next/navigation"
|
||||||
|
import { LoadingSpinner } from "@/components/letterfeed/LoadingSpinner"
|
||||||
|
|
||||||
export default function LoginPage() {
|
export default function LoginPage() {
|
||||||
const [username, setUsername] = useState("")
|
const [username, setUsername] = useState("")
|
||||||
const [password, setPassword] = useState("")
|
const [password, setPassword] = useState("")
|
||||||
const { login } = useAuth()
|
const { login, isAuthenticated, isLoading } = useAuth()
|
||||||
|
const router = useRouter()
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isLoading && isAuthenticated) {
|
||||||
|
router.push("/")
|
||||||
|
}
|
||||||
|
}, [isLoading, isAuthenticated, router])
|
||||||
|
|
||||||
const handleLogin = async (e: React.FormEvent) => {
|
const handleLogin = async (e: React.FormEvent) => {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
@@ -25,11 +34,15 @@ export default function LoginPage() {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
await login(username, password)
|
await login(username, password)
|
||||||
} catch {
|
} catch {
|
||||||
// The error is already toasted by the API layer,
|
// The error is already toasted by the API layer,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (isLoading || (!isLoading && isAuthenticated)) {
|
||||||
|
return <LoadingSpinner />
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-background flex items-center justify-center">
|
<div className="min-h-screen bg-background flex items-center justify-center">
|
||||||
<div className="max-w-md w-full space-y-8 p-8">
|
<div className="max-w-md w-full space-y-8 p-8">
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ interface HeaderProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function Header({ onOpenAddNewsletter, onOpenSettings }: HeaderProps) {
|
export function Header({ onOpenAddNewsletter, onOpenSettings }: HeaderProps) {
|
||||||
const { logout } = useAuth()
|
const { logout, isAuthEnabled } = useAuth()
|
||||||
const handleProcessEmails = async () => {
|
const handleProcessEmails = async () => {
|
||||||
try {
|
try {
|
||||||
await processEmails()
|
await processEmails()
|
||||||
@@ -62,10 +62,12 @@ export function Header({ onOpenAddNewsletter, onOpenSettings }: HeaderProps) {
|
|||||||
Settings
|
Settings
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
<Button variant="outline" onClick={logout}>
|
{isAuthEnabled && (
|
||||||
<LogOut className="w-4 h-4 mr-2" />
|
<Button variant="outline" onClick={logout}>
|
||||||
Logout
|
<LogOut className="w-4 h-4 mr-2" />
|
||||||
</Button>
|
Logout
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { Toaster } from "@/components/ui/sonner"
|
|||||||
import { toast } from "sonner"
|
import { toast } from "sonner"
|
||||||
import * as api from "@/lib/api"
|
import * as api from "@/lib/api"
|
||||||
import { AuthProvider } from "@/contexts/AuthContext"
|
import { AuthProvider } from "@/contexts/AuthContext"
|
||||||
|
import { useAuth } from "@/hooks/useAuth"
|
||||||
|
|
||||||
jest.mock("@/lib/api")
|
jest.mock("@/lib/api")
|
||||||
jest.mock("next/navigation", () => ({
|
jest.mock("next/navigation", () => ({
|
||||||
@@ -11,7 +12,10 @@ jest.mock("next/navigation", () => ({
|
|||||||
push: jest.fn(),
|
push: jest.fn(),
|
||||||
}),
|
}),
|
||||||
}))
|
}))
|
||||||
|
jest.mock("@/hooks/useAuth")
|
||||||
|
|
||||||
const mockedApi = api as jest.Mocked<typeof api>
|
const mockedApi = api as jest.Mocked<typeof api>
|
||||||
|
const mockedUseAuth = useAuth as jest.Mock
|
||||||
|
|
||||||
// Mock the toast functions
|
// Mock the toast functions
|
||||||
jest.mock("sonner", () => {
|
jest.mock("sonner", () => {
|
||||||
@@ -28,6 +32,7 @@ jest.mock("sonner", () => {
|
|||||||
describe("Header", () => {
|
describe("Header", () => {
|
||||||
const onOpenAddNewsletter = jest.fn()
|
const onOpenAddNewsletter = jest.fn()
|
||||||
const onOpenSettings = jest.fn()
|
const onOpenSettings = jest.fn()
|
||||||
|
const logout = jest.fn()
|
||||||
const consoleError = jest.spyOn(console, "error").mockImplementation(() => {})
|
const consoleError = jest.spyOn(console, "error").mockImplementation(() => {})
|
||||||
|
|
||||||
const renderWithAuthProvider = (component: React.ReactElement) => {
|
const renderWithAuthProvider = (component: React.ReactElement) => {
|
||||||
@@ -37,14 +42,18 @@ describe("Header", () => {
|
|||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
jest.clearAllMocks()
|
jest.clearAllMocks()
|
||||||
consoleError.mockClear()
|
consoleError.mockClear()
|
||||||
|
mockedUseAuth.mockReturnValue({
|
||||||
|
logout,
|
||||||
|
isAuthEnabled: true, // Default to auth being enabled for most tests
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
afterAll(() => {
|
afterAll(() => {
|
||||||
consoleError.mockRestore()
|
consoleError.mockRestore()
|
||||||
})
|
})
|
||||||
|
|
||||||
it("renders the header with title and buttons", () => {
|
it("renders the header with title and buttons including logout", () => {
|
||||||
renderWithAuthProvider(
|
render(
|
||||||
<Header
|
<Header
|
||||||
onOpenAddNewsletter={onOpenAddNewsletter}
|
onOpenAddNewsletter={onOpenAddNewsletter}
|
||||||
onOpenSettings={onOpenSettings}
|
onOpenSettings={onOpenSettings}
|
||||||
@@ -54,10 +63,25 @@ describe("Header", () => {
|
|||||||
expect(screen.getByText("Add Newsletter")).toBeInTheDocument()
|
expect(screen.getByText("Add Newsletter")).toBeInTheDocument()
|
||||||
expect(screen.getByText("Settings")).toBeInTheDocument()
|
expect(screen.getByText("Settings")).toBeInTheDocument()
|
||||||
expect(screen.getByText("Process Now")).toBeInTheDocument()
|
expect(screen.getByText("Process Now")).toBeInTheDocument()
|
||||||
|
expect(screen.getByText("Logout")).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it("does not render the logout button if auth is disabled", () => {
|
||||||
|
mockedUseAuth.mockReturnValue({
|
||||||
|
logout,
|
||||||
|
isAuthEnabled: false,
|
||||||
|
})
|
||||||
|
render(
|
||||||
|
<Header
|
||||||
|
onOpenAddNewsletter={onOpenAddNewsletter}
|
||||||
|
onOpenSettings={onOpenSettings}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
expect(screen.queryByText("Logout")).not.toBeInTheDocument()
|
||||||
})
|
})
|
||||||
|
|
||||||
it('calls onOpenAddNewsletter when "Add Newsletter" button is clicked', () => {
|
it('calls onOpenAddNewsletter when "Add Newsletter" button is clicked', () => {
|
||||||
renderWithAuthProvider(
|
render(
|
||||||
<Header
|
<Header
|
||||||
onOpenAddNewsletter={onOpenAddNewsletter}
|
onOpenAddNewsletter={onOpenAddNewsletter}
|
||||||
onOpenSettings={onOpenSettings}
|
onOpenSettings={onOpenSettings}
|
||||||
@@ -68,7 +92,7 @@ describe("Header", () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
it('calls onOpenSettings when "Settings" button is clicked', () => {
|
it('calls onOpenSettings when "Settings" button is clicked', () => {
|
||||||
renderWithAuthProvider(
|
render(
|
||||||
<Header
|
<Header
|
||||||
onOpenAddNewsletter={onOpenAddNewsletter}
|
onOpenAddNewsletter={onOpenAddNewsletter}
|
||||||
onOpenSettings={onOpenSettings}
|
onOpenSettings={onOpenSettings}
|
||||||
@@ -81,7 +105,7 @@ describe("Header", () => {
|
|||||||
it('calls the process emails API when "Process Now" button is clicked and shows success toast', async () => {
|
it('calls the process emails API when "Process Now" button is clicked and shows success toast', async () => {
|
||||||
mockedApi.processEmails.mockResolvedValue({ message: "Success" })
|
mockedApi.processEmails.mockResolvedValue({ message: "Success" })
|
||||||
|
|
||||||
renderWithAuthProvider(
|
render(
|
||||||
<>
|
<>
|
||||||
<Header
|
<Header
|
||||||
onOpenAddNewsletter={onOpenAddNewsletter}
|
onOpenAddNewsletter={onOpenAddNewsletter}
|
||||||
@@ -107,7 +131,7 @@ describe("Header", () => {
|
|||||||
it("shows an error toast if the process emails API call fails", async () => {
|
it("shows an error toast if the process emails API call fails", async () => {
|
||||||
mockedApi.processEmails.mockRejectedValue(new Error("Failed to process"))
|
mockedApi.processEmails.mockRejectedValue(new Error("Failed to process"))
|
||||||
|
|
||||||
renderWithAuthProvider(
|
render(
|
||||||
<>
|
<>
|
||||||
<Header
|
<Header
|
||||||
onOpenAddNewsletter={onOpenAddNewsletter}
|
onOpenAddNewsletter={onOpenAddNewsletter}
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { useRouter } from "next/navigation"
|
|||||||
|
|
||||||
interface AuthContextType {
|
interface AuthContextType {
|
||||||
isAuthenticated: boolean
|
isAuthenticated: boolean
|
||||||
|
isAuthEnabled: boolean
|
||||||
login: (username: string, password: string) => Promise<void>
|
login: (username: string, password: string) => Promise<void>
|
||||||
logout: () => void
|
logout: () => void
|
||||||
isLoading: boolean
|
isLoading: boolean
|
||||||
@@ -15,6 +16,7 @@ export const AuthContext = createContext<AuthContextType | undefined>(undefined)
|
|||||||
|
|
||||||
export const AuthProvider = ({ children }: { children: ReactNode }) => {
|
export const AuthProvider = ({ children }: { children: ReactNode }) => {
|
||||||
const [isAuthenticated, setIsAuthenticated] = useState(false)
|
const [isAuthenticated, setIsAuthenticated] = useState(false)
|
||||||
|
const [isAuthEnabled, setIsAuthEnabled] = useState(false)
|
||||||
const [isLoading, setIsLoading] = useState(true)
|
const [isLoading, setIsLoading] = useState(true)
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
|
|
||||||
@@ -23,6 +25,7 @@ export const AuthProvider = ({ children }: { children: ReactNode }) => {
|
|||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
try {
|
try {
|
||||||
const { auth_enabled } = await getAuthStatus();
|
const { auth_enabled } = await getAuthStatus();
|
||||||
|
setIsAuthEnabled(auth_enabled);
|
||||||
if (!auth_enabled) {
|
if (!auth_enabled) {
|
||||||
setIsAuthenticated(true);
|
setIsAuthenticated(true);
|
||||||
} else {
|
} else {
|
||||||
@@ -67,7 +70,7 @@ export const AuthProvider = ({ children }: { children: ReactNode }) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<AuthContext.Provider value={{ isAuthenticated, login, logout, isLoading }}>
|
<AuthContext.Provider value={{ isAuthenticated, isAuthEnabled, login, logout, isLoading }}>
|
||||||
{children}
|
{children}
|
||||||
</AuthContext.Provider>
|
</AuthContext.Provider>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -28,21 +28,27 @@ describe("AuthContext", () => {
|
|||||||
consoleError.mockRestore()
|
consoleError.mockRestore()
|
||||||
})
|
})
|
||||||
|
|
||||||
it("authenticates if auth is not enabled", async () => {
|
it("authenticates and sets auth enabled to false if auth is not enabled on server", async () => {
|
||||||
mockedApi.getAuthStatus.mockResolvedValue({ auth_enabled: false })
|
mockedApi.getAuthStatus.mockResolvedValue({ auth_enabled: false })
|
||||||
render(
|
render(
|
||||||
<AuthProvider>
|
<AuthProvider>
|
||||||
<AuthContext.Consumer>
|
<AuthContext.Consumer>
|
||||||
{(value) => (
|
{(value) => (
|
||||||
<span>
|
<div>
|
||||||
Is Authenticated: {value?.isAuthenticated.toString()}
|
<span>
|
||||||
</span>
|
Is Authenticated: {value?.isAuthenticated.toString()}
|
||||||
|
</span>
|
||||||
|
<span>
|
||||||
|
Is Auth Enabled: {value?.isAuthEnabled.toString()}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
</AuthContext.Consumer>
|
</AuthContext.Consumer>
|
||||||
</AuthProvider>
|
</AuthProvider>
|
||||||
)
|
)
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(screen.getByText("Is Authenticated: true")).toBeInTheDocument()
|
expect(screen.getByText("Is Authenticated: true")).toBeInTheDocument()
|
||||||
|
expect(screen.getByText("Is Auth Enabled: false")).toBeInTheDocument()
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -54,15 +60,21 @@ describe("AuthContext", () => {
|
|||||||
<AuthProvider>
|
<AuthProvider>
|
||||||
<AuthContext.Consumer>
|
<AuthContext.Consumer>
|
||||||
{(value) => (
|
{(value) => (
|
||||||
<span>
|
<div>
|
||||||
Is Authenticated: {value?.isAuthenticated.toString()}
|
<span>
|
||||||
</span>
|
Is Authenticated: {value?.isAuthenticated.toString()}
|
||||||
|
</span>
|
||||||
|
<span>
|
||||||
|
Is Auth Enabled: {value?.isAuthEnabled.toString()}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
</AuthContext.Consumer>
|
</AuthContext.Consumer>
|
||||||
</AuthProvider>
|
</AuthProvider>
|
||||||
)
|
)
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(screen.getByText("Is Authenticated: true")).toBeInTheDocument()
|
expect(screen.getByText("Is Authenticated: true")).toBeInTheDocument()
|
||||||
|
expect(screen.getByText("Is Auth Enabled: true")).toBeInTheDocument()
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user