aboutsummaryrefslogtreecommitdiffhomepage
path: root/src
diff options
context:
space:
mode:
Diffstat (limited to 'src')
-rw-r--r--src/client/App.test.tsx31
-rw-r--r--src/client/App.tsx7
-rw-r--r--src/client/components/ProtectedRoute.test.tsx90
-rw-r--r--src/client/components/ProtectedRoute.tsx21
-rw-r--r--src/client/components/index.ts1
5 files changed, 145 insertions, 5 deletions
diff --git a/src/client/App.test.tsx b/src/client/App.test.tsx
index f4e3541..321f073 100644
--- a/src/client/App.test.tsx
+++ b/src/client/App.test.tsx
@@ -52,10 +52,33 @@ afterEach(() => {
});
describe("App routing", () => {
- it("renders home page at /", () => {
- renderWithRouter("/");
- expect(screen.getByRole("heading", { name: "Kioku" })).toBeDefined();
- expect(screen.getByText("Spaced repetition learning app")).toBeDefined();
+ describe("when authenticated", () => {
+ beforeEach(() => {
+ vi.mocked(apiClient.getTokens).mockReturnValue({
+ accessToken: "access-token",
+ refreshToken: "refresh-token",
+ });
+ vi.mocked(apiClient.isAuthenticated).mockReturnValue(true);
+ });
+
+ it("renders home page at /", () => {
+ renderWithRouter("/");
+ expect(screen.getByRole("heading", { name: "Kioku" })).toBeDefined();
+ expect(screen.getByText("Spaced repetition learning app")).toBeDefined();
+ });
+ });
+
+ describe("when not authenticated", () => {
+ beforeEach(() => {
+ vi.mocked(apiClient.getTokens).mockReturnValue(null);
+ vi.mocked(apiClient.isAuthenticated).mockReturnValue(false);
+ });
+
+ it("redirects to login when accessing / without authentication", () => {
+ renderWithRouter("/");
+ // Should not render home page content
+ expect(screen.queryByRole("heading", { name: "Kioku" })).toBeNull();
+ });
});
it("renders login page at /login", () => {
diff --git a/src/client/App.tsx b/src/client/App.tsx
index 01d843a..098ded7 100644
--- a/src/client/App.tsx
+++ b/src/client/App.tsx
@@ -1,10 +1,15 @@
import { Route, Switch } from "wouter";
+import { ProtectedRoute } from "./components";
import { HomePage, LoginPage, NotFoundPage, RegisterPage } from "./pages";
export function App() {
return (
<Switch>
- <Route path="/" component={HomePage} />
+ <Route path="/">
+ <ProtectedRoute>
+ <HomePage />
+ </ProtectedRoute>
+ </Route>
<Route path="/login" component={LoginPage} />
<Route path="/register" component={RegisterPage} />
<Route component={NotFoundPage} />
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();
+ });
+});
diff --git a/src/client/components/ProtectedRoute.tsx b/src/client/components/ProtectedRoute.tsx
new file mode 100644
index 0000000..76b663c
--- /dev/null
+++ b/src/client/components/ProtectedRoute.tsx
@@ -0,0 +1,21 @@
+import type { ReactNode } from "react";
+import { Redirect } from "wouter";
+import { useAuth } from "../stores";
+
+export interface ProtectedRouteProps {
+ children: ReactNode;
+}
+
+export function ProtectedRoute({ children }: ProtectedRouteProps) {
+ const { isAuthenticated, isLoading } = useAuth();
+
+ if (isLoading) {
+ return <div>Loading...</div>;
+ }
+
+ if (!isAuthenticated) {
+ return <Redirect to="/login" replace />;
+ }
+
+ return <>{children}</>;
+}
diff --git a/src/client/components/index.ts b/src/client/components/index.ts
new file mode 100644
index 0000000..9b97620
--- /dev/null
+++ b/src/client/components/index.ts
@@ -0,0 +1 @@
+export { ProtectedRoute } from "./ProtectedRoute";