aboutsummaryrefslogtreecommitdiffhomepage
diff options
context:
space:
mode:
authornsfisis <nsfisis@gmail.com>2025-12-06 18:36:10 +0900
committernsfisis <nsfisis@gmail.com>2025-12-06 18:36:10 +0900
commita2569837aa07ef48f27884fc2869b5be47087a4e (patch)
tree5f2236441d751f6a3a7d0865c644e9a8c3259960
parent3923eb2f86c304bbd90c4eae9a338f7bc21c9e90 (diff)
downloadkioku-a2569837aa07ef48f27884fc2869b5be47087a4e.tar.gz
kioku-a2569837aa07ef48f27884fc2869b5be47087a4e.tar.zst
kioku-a2569837aa07ef48f27884fc2869b5be47087a4e.zip
feat(client): implement Register page with form validation
Add functional registration form with: - Username and password fields with confirm password - Client-side validation (password match, minimum length) - Error display for API failures - Redirect to home when already authenticated - Loading state during submission 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
-rw-r--r--package.json1
-rw-r--r--pnpm-lock.yaml13
-rw-r--r--src/client/App.test.tsx35
-rw-r--r--src/client/pages/RegisterPage.test.tsx198
-rw-r--r--src/client/pages/RegisterPage.tsx99
5 files changed, 343 insertions, 3 deletions
diff --git a/package.json b/package.json
index 3936102..b0f89a8 100644
--- a/package.json
+++ b/package.json
@@ -43,6 +43,7 @@
"@hono/cli": "^0.1.3",
"@testing-library/dom": "^10.4.1",
"@testing-library/react": "^16.3.0",
+ "@testing-library/user-event": "^14.6.1",
"@types/node": "^24.10.1",
"@types/pg": "^8.15.6",
"@types/react": "^19.2.7",
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index a58f707..44083c3 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -51,6 +51,9 @@ importers:
'@testing-library/react':
specifier: ^16.3.0
version: 16.3.0(@testing-library/dom@10.4.1)(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.2.1(react@19.2.1))(react@19.2.1)
+ '@testing-library/user-event':
+ specifier: ^14.6.1
+ version: 14.6.1(@testing-library/dom@10.4.1)
'@types/node':
specifier: ^24.10.1
version: 24.10.1
@@ -741,6 +744,12 @@ packages:
'@types/react-dom':
optional: true
+ '@testing-library/user-event@14.6.1':
+ resolution: {integrity: sha512-vq7fv0rnt+QTXgPxr5Hjc210p6YKq2kmdziLgnsZGgLJ9e6VAShx1pACLuRjd/AS/sr7phAR58OIIpf0LlmQNw==}
+ engines: {node: '>=12', npm: '>=6'}
+ peerDependencies:
+ '@testing-library/dom': '>=7.21.4'
+
'@types/aria-query@5.0.4':
resolution: {integrity: sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==}
@@ -1978,6 +1987,10 @@ snapshots:
'@types/react': 19.2.7
'@types/react-dom': 19.2.3(@types/react@19.2.7)
+ '@testing-library/user-event@14.6.1(@testing-library/dom@10.4.1)':
+ dependencies:
+ '@testing-library/dom': 10.4.1
+
'@types/aria-query@5.0.4': {}
'@types/babel__core@7.20.5':
diff --git a/src/client/App.test.tsx b/src/client/App.test.tsx
index cd448bf..4a7af14 100644
--- a/src/client/App.test.tsx
+++ b/src/client/App.test.tsx
@@ -2,22 +2,53 @@
* @vitest-environment jsdom
*/
import { cleanup, render, screen } from "@testing-library/react";
-import { afterEach, describe, expect, it } from "vitest";
+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 { App } from "./App";
+import { AuthProvider } from "./stores";
+
+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, static: true });
return render(
<Router hook={hook}>
- <App />
+ <AuthProvider>
+ <App />
+ </AuthProvider>
</Router>,
);
}
+beforeEach(() => {
+ vi.clearAllMocks();
+ vi.mocked(apiClient.getTokens).mockReturnValue(null);
+ vi.mocked(apiClient.isAuthenticated).mockReturnValue(false);
+});
+
afterEach(() => {
cleanup();
+ vi.restoreAllMocks();
});
describe("App routing", () => {
diff --git a/src/client/pages/RegisterPage.test.tsx b/src/client/pages/RegisterPage.test.tsx
new file mode 100644
index 0000000..adce8f0
--- /dev/null
+++ b/src/client/pages/RegisterPage.test.tsx
@@ -0,0 +1,198 @@
+/**
+ * @vitest-environment jsdom
+ */
+import { cleanup, render, screen, waitFor } from "@testing-library/react";
+import userEvent from "@testing-library/user-event";
+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 { RegisterPage } from "./RegisterPage";
+
+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 renderWithProviders(path = "/register") {
+ const { hook } = memoryLocation({ path });
+ return render(
+ <Router hook={hook}>
+ <AuthProvider>
+ <RegisterPage />
+ </AuthProvider>
+ </Router>,
+ );
+}
+
+describe("RegisterPage", () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ vi.mocked(apiClient.getTokens).mockReturnValue(null);
+ vi.mocked(apiClient.isAuthenticated).mockReturnValue(false);
+ });
+
+ afterEach(() => {
+ cleanup();
+ vi.restoreAllMocks();
+ });
+
+ it("renders register form", async () => {
+ renderWithProviders();
+
+ expect(screen.getByRole("heading", { name: "Register" })).toBeDefined();
+ expect(screen.getByLabelText("Username")).toBeDefined();
+ expect(screen.getByLabelText("Password")).toBeDefined();
+ expect(screen.getByLabelText("Confirm Password")).toBeDefined();
+ expect(screen.getByRole("button", { name: "Register" })).toBeDefined();
+ expect(screen.getByRole("link", { name: "Login" })).toBeDefined();
+ });
+
+ it("validates password match", async () => {
+ const user = userEvent.setup();
+ renderWithProviders();
+
+ await user.type(screen.getByLabelText("Username"), "testuser");
+ await user.type(screen.getByLabelText("Password"), "password123");
+ await user.type(screen.getByLabelText("Confirm Password"), "differentpass");
+ await user.click(screen.getByRole("button", { name: "Register" }));
+
+ expect(screen.getByRole("alert").textContent).toBe(
+ "Passwords do not match",
+ );
+ expect(apiClient.register).not.toHaveBeenCalled();
+ });
+
+ it("validates password length", async () => {
+ const user = userEvent.setup();
+ renderWithProviders();
+
+ await user.type(screen.getByLabelText("Username"), "testuser");
+ await user.type(screen.getByLabelText("Password"), "short");
+ await user.type(screen.getByLabelText("Confirm Password"), "short");
+ await user.click(screen.getByRole("button", { name: "Register" }));
+
+ expect(screen.getByRole("alert").textContent).toBe(
+ "Password must be at least 8 characters",
+ );
+ expect(apiClient.register).not.toHaveBeenCalled();
+ });
+
+ it("submits form and registers successfully", async () => {
+ const user = userEvent.setup();
+ const mockUser = { id: "user-1", username: "testuser" };
+ vi.mocked(apiClient.register).mockResolvedValue({ user: mockUser });
+ vi.mocked(apiClient.login).mockResolvedValue({
+ accessToken: "access-token",
+ refreshToken: "refresh-token",
+ user: mockUser,
+ });
+
+ renderWithProviders();
+
+ await user.type(screen.getByLabelText("Username"), "testuser");
+ await user.type(screen.getByLabelText("Password"), "password123");
+ await user.type(screen.getByLabelText("Confirm Password"), "password123");
+ await user.click(screen.getByRole("button", { name: "Register" }));
+
+ await waitFor(() => {
+ expect(apiClient.register).toHaveBeenCalledWith(
+ "testuser",
+ "password123",
+ );
+ });
+ expect(apiClient.login).toHaveBeenCalledWith("testuser", "password123");
+ });
+
+ it("displays error on registration failure", async () => {
+ const user = userEvent.setup();
+ const { ApiClientError } = await import("../api/client");
+ vi.mocked(apiClient.register).mockRejectedValue(
+ new ApiClientError("Username already taken", 409),
+ );
+
+ renderWithProviders();
+
+ await user.type(screen.getByLabelText("Username"), "existinguser");
+ await user.type(screen.getByLabelText("Password"), "password123");
+ await user.type(screen.getByLabelText("Confirm Password"), "password123");
+ await user.click(screen.getByRole("button", { name: "Register" }));
+
+ await waitFor(() => {
+ expect(screen.getByRole("alert").textContent).toBe(
+ "Username already taken",
+ );
+ });
+ });
+
+ it("disables form while submitting", async () => {
+ const user = userEvent.setup();
+ vi.mocked(apiClient.register).mockImplementation(
+ () => new Promise(() => {}), // Never resolves
+ );
+
+ renderWithProviders();
+
+ await user.type(screen.getByLabelText("Username"), "testuser");
+ await user.type(screen.getByLabelText("Password"), "password123");
+ await user.type(screen.getByLabelText("Confirm Password"), "password123");
+ await user.click(screen.getByRole("button", { name: "Register" }));
+
+ await waitFor(() => {
+ const button = screen.getByRole("button", { name: "Registering..." });
+ expect(button.hasAttribute("disabled")).toBe(true);
+ });
+ expect(
+ (screen.getByLabelText("Username") as HTMLInputElement).disabled,
+ ).toBe(true);
+ expect(
+ (screen.getByLabelText("Password") as HTMLInputElement).disabled,
+ ).toBe(true);
+ expect(
+ (screen.getByLabelText("Confirm Password") as HTMLInputElement).disabled,
+ ).toBe(true);
+ });
+
+ it("calls navigate when already authenticated", async () => {
+ vi.mocked(apiClient.isAuthenticated).mockReturnValue(true);
+ vi.mocked(apiClient.getTokens).mockReturnValue({
+ accessToken: "access-token",
+ refreshToken: "refresh-token",
+ });
+
+ const { hook } = memoryLocation({ path: "/register" });
+ const navigateSpy = vi.fn();
+ const hookWithSpy: typeof hook = () => {
+ const result = hook();
+ return [result[0], navigateSpy];
+ };
+
+ render(
+ <Router hook={hookWithSpy}>
+ <AuthProvider>
+ <RegisterPage />
+ </AuthProvider>
+ </Router>,
+ );
+
+ await waitFor(() => {
+ expect(navigateSpy).toHaveBeenCalledWith("/", { replace: true });
+ });
+ });
+});
diff --git a/src/client/pages/RegisterPage.tsx b/src/client/pages/RegisterPage.tsx
index a7fbb59..e6783bd 100644
--- a/src/client/pages/RegisterPage.tsx
+++ b/src/client/pages/RegisterPage.tsx
@@ -1,8 +1,105 @@
+import { type FormEvent, useEffect, useState } from "react";
+import { Link, useLocation } from "wouter";
+import { ApiClientError, useAuth } from "../stores";
+
export function RegisterPage() {
+ const [, navigate] = useLocation();
+ const { register, isAuthenticated } = useAuth();
+ const [username, setUsername] = useState("");
+ const [password, setPassword] = useState("");
+ const [confirmPassword, setConfirmPassword] = useState("");
+ const [error, setError] = useState<string | null>(null);
+ const [isSubmitting, setIsSubmitting] = useState(false);
+
+ // Redirect if already authenticated
+ useEffect(() => {
+ if (isAuthenticated) {
+ navigate("/", { replace: true });
+ }
+ }, [isAuthenticated, navigate]);
+
+ const handleSubmit = async (e: FormEvent) => {
+ e.preventDefault();
+ setError(null);
+
+ if (password !== confirmPassword) {
+ setError("Passwords do not match");
+ return;
+ }
+
+ if (password.length < 8) {
+ setError("Password must be at least 8 characters");
+ return;
+ }
+
+ setIsSubmitting(true);
+
+ try {
+ await register(username, password);
+ navigate("/", { replace: true });
+ } catch (err) {
+ if (err instanceof ApiClientError) {
+ setError(err.message);
+ } else {
+ setError("Registration failed. Please try again.");
+ }
+ } finally {
+ setIsSubmitting(false);
+ }
+ };
+
return (
<div>
<h1>Register</h1>
- <p>Registration page coming soon</p>
+ <form onSubmit={handleSubmit}>
+ {error && (
+ <div role="alert" style={{ color: "red" }}>
+ {error}
+ </div>
+ )}
+ <div>
+ <label htmlFor="username">Username</label>
+ <input
+ id="username"
+ type="text"
+ value={username}
+ onChange={(e) => setUsername(e.target.value)}
+ required
+ autoComplete="username"
+ disabled={isSubmitting}
+ />
+ </div>
+ <div>
+ <label htmlFor="password">Password</label>
+ <input
+ id="password"
+ type="password"
+ value={password}
+ onChange={(e) => setPassword(e.target.value)}
+ required
+ autoComplete="new-password"
+ disabled={isSubmitting}
+ />
+ </div>
+ <div>
+ <label htmlFor="confirmPassword">Confirm Password</label>
+ <input
+ id="confirmPassword"
+ type="password"
+ value={confirmPassword}
+ onChange={(e) => setConfirmPassword(e.target.value)}
+ required
+ autoComplete="new-password"
+ disabled={isSubmitting}
+ />
+ </div>
+ <button type="submit" disabled={isSubmitting}>
+ {isSubmitting ? "Registering..." : "Register"}
+ </button>
+ </form>
+ <p>
+ Already have an account? <Link href="/login">Login</Link>
+ </p>
</div>
);
}