From f655585cc81ab4af5d27cebb1fa9390e93e0a4bf Mon Sep 17 00:00:00 2001
From: nsfisis <nsfisis@gmail.com>
Date: Sat, 6 Dec 2025 18:55:57 +0900
Subject: feat(client): add protected route handling with login redirect
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

Unauthenticated users accessing protected pages (like HomePage) are
now redirected to the login page. Includes ProtectedRoute component
with loading state support.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
---
 src/client/components/ProtectedRoute.test.tsx | 90 +++++++++++++++++++++++++++
 1 file changed, 90 insertions(+)
 create mode 100644 src/client/components/ProtectedRoute.test.tsx

(limited to 'src/client/components/ProtectedRoute.test.tsx')

diff --git a/src/client/components/ProtectedRoute.test.tsx b/src/client/components/ProtectedRoute.test.tsx
new file mode 100644
index 0000000..11de411
--- /dev/null
+++ b/src/client/components/ProtectedRoute.test.tsx
@@ -0,0 +1,90 @@
+/**
+ * @vitest-environment jsdom
+ */
+import { cleanup, render, screen } from "@testing-library/react";
+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
+import { Router } from "wouter";
+import { memoryLocation } from "wouter/memory-location";
+import { apiClient } from "../api/client";
+import { AuthProvider } from "../stores";
+import { ProtectedRoute } from "./ProtectedRoute";
+
+vi.mock("../api/client", () => ({
+	apiClient: {
+		login: vi.fn(),
+		register: vi.fn(),
+		logout: vi.fn(),
+		isAuthenticated: vi.fn(),
+		getTokens: vi.fn(),
+	},
+	ApiClientError: class ApiClientError extends Error {
+		constructor(
+			message: string,
+			public status: number,
+			public code?: string,
+		) {
+			super(message);
+			this.name = "ApiClientError";
+		}
+	},
+}));
+
+function renderWithRouter(path: string) {
+	const { hook } = memoryLocation({ path });
+
+	return render(
+		<Router hook={hook}>
+			<AuthProvider>
+				<ProtectedRoute>
+					<div data-testid="protected-content">Protected Content</div>
+				</ProtectedRoute>
+			</AuthProvider>
+		</Router>,
+	);
+}
+
+beforeEach(() => {
+	vi.clearAllMocks();
+});
+
+afterEach(() => {
+	cleanup();
+	vi.restoreAllMocks();
+});
+
+describe("ProtectedRoute", () => {
+	it("shows loading state while auth is loading", () => {
+		vi.mocked(apiClient.getTokens).mockReturnValue(null);
+		vi.mocked(apiClient.isAuthenticated).mockReturnValue(false);
+
+		// The AuthProvider initially sets isLoading to true, then false after checking tokens
+		// Since getTokens returns null, isLoading will quickly become false
+		renderWithRouter("/");
+
+		// After the initial check, the component should redirect since not authenticated
+		expect(screen.queryByTestId("protected-content")).toBeNull();
+	});
+
+	it("renders children when authenticated", () => {
+		vi.mocked(apiClient.getTokens).mockReturnValue({
+			accessToken: "access-token",
+			refreshToken: "refresh-token",
+		});
+		vi.mocked(apiClient.isAuthenticated).mockReturnValue(true);
+
+		renderWithRouter("/");
+
+		expect(screen.getByTestId("protected-content")).toBeDefined();
+		expect(screen.getByText("Protected Content")).toBeDefined();
+	});
+
+	it("redirects to login when not authenticated", () => {
+		vi.mocked(apiClient.getTokens).mockReturnValue(null);
+		vi.mocked(apiClient.isAuthenticated).mockReturnValue(false);
+
+		renderWithRouter("/");
+
+		// Should not show protected content
+		expect(screen.queryByTestId("protected-content")).toBeNull();
+	});
+});
-- 
cgit v1.2.3-70-g09d2

