Wire up the Dashboard page with /api/today data: - Fetch today's decision, biometrics, and nutrition on mount - Display DecisionCard with status, icon, and reason - Show DataPanel with HRV, Body Battery, intensity minutes - Show NutritionPanel with seed cycling and carb guidance - Integrate OverrideToggles with POST/DELETE /api/overrides - Handle loading states, error states, and setup prompts - Display cycle day and phase information Add 23 unit tests for the Dashboard component covering: - Data fetching from /api/today and /api/user - Component rendering (DecisionCard, DataPanel, NutritionPanel) - Override toggle functionality (POST/DELETE API calls) - Error handling and loading states - Cycle information display Also fixed TypeScript error in login page tests (resolveAuth initialization). 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
262 lines
8.5 KiB
TypeScript
262 lines
8.5 KiB
TypeScript
// ABOUTME: Unit tests for the Login page component.
|
|
// ABOUTME: Tests form rendering, validation, auth flow, and error handling.
|
|
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
|
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
|
|
|
// Mock next/navigation
|
|
const mockPush = vi.fn();
|
|
vi.mock("next/navigation", () => ({
|
|
useRouter: () => ({
|
|
push: mockPush,
|
|
}),
|
|
}));
|
|
|
|
// Mock PocketBase
|
|
const mockAuthWithPassword = vi.fn();
|
|
vi.mock("@/lib/pocketbase", () => ({
|
|
pb: {
|
|
collection: () => ({
|
|
authWithPassword: mockAuthWithPassword,
|
|
}),
|
|
},
|
|
}));
|
|
|
|
import LoginPage from "./page";
|
|
|
|
describe("LoginPage", () => {
|
|
beforeEach(() => {
|
|
vi.clearAllMocks();
|
|
});
|
|
|
|
describe("rendering", () => {
|
|
it("renders the login form with email and password inputs", () => {
|
|
render(<LoginPage />);
|
|
|
|
expect(screen.getByLabelText(/email/i)).toBeInTheDocument();
|
|
expect(screen.getByLabelText(/password/i)).toBeInTheDocument();
|
|
});
|
|
|
|
it("renders a sign in button", () => {
|
|
render(<LoginPage />);
|
|
|
|
expect(
|
|
screen.getByRole("button", { name: /sign in/i }),
|
|
).toBeInTheDocument();
|
|
});
|
|
|
|
it("renders the PhaseFlow branding", () => {
|
|
render(<LoginPage />);
|
|
|
|
expect(screen.getByText(/phaseflow/i)).toBeInTheDocument();
|
|
});
|
|
|
|
it("has email input with type email", () => {
|
|
render(<LoginPage />);
|
|
|
|
const emailInput = screen.getByLabelText(/email/i);
|
|
expect(emailInput).toHaveAttribute("type", "email");
|
|
});
|
|
|
|
it("has password input with type password", () => {
|
|
render(<LoginPage />);
|
|
|
|
const passwordInput = screen.getByLabelText(/password/i);
|
|
expect(passwordInput).toHaveAttribute("type", "password");
|
|
});
|
|
});
|
|
|
|
describe("form submission", () => {
|
|
it("calls PocketBase auth with email and password on submit", async () => {
|
|
mockAuthWithPassword.mockResolvedValueOnce({ token: "test-token" });
|
|
render(<LoginPage />);
|
|
|
|
const emailInput = screen.getByLabelText(/email/i);
|
|
const passwordInput = screen.getByLabelText(/password/i);
|
|
const submitButton = screen.getByRole("button", { name: /sign in/i });
|
|
|
|
fireEvent.change(emailInput, { target: { value: "test@example.com" } });
|
|
fireEvent.change(passwordInput, { target: { value: "password123" } });
|
|
fireEvent.click(submitButton);
|
|
|
|
await waitFor(() => {
|
|
expect(mockAuthWithPassword).toHaveBeenCalledWith(
|
|
"test@example.com",
|
|
"password123",
|
|
);
|
|
});
|
|
});
|
|
|
|
it("redirects to dashboard on successful login", async () => {
|
|
mockAuthWithPassword.mockResolvedValueOnce({ token: "test-token" });
|
|
render(<LoginPage />);
|
|
|
|
const emailInput = screen.getByLabelText(/email/i);
|
|
const passwordInput = screen.getByLabelText(/password/i);
|
|
const submitButton = screen.getByRole("button", { name: /sign in/i });
|
|
|
|
fireEvent.change(emailInput, { target: { value: "test@example.com" } });
|
|
fireEvent.change(passwordInput, { target: { value: "password123" } });
|
|
fireEvent.click(submitButton);
|
|
|
|
await waitFor(() => {
|
|
expect(mockPush).toHaveBeenCalledWith("/");
|
|
});
|
|
});
|
|
|
|
it("shows loading state while authenticating", async () => {
|
|
// Create a promise that we can control
|
|
let resolveAuth: (value: unknown) => void = () => {};
|
|
const authPromise = new Promise((resolve) => {
|
|
resolveAuth = resolve;
|
|
});
|
|
mockAuthWithPassword.mockReturnValue(authPromise);
|
|
|
|
render(<LoginPage />);
|
|
|
|
const emailInput = screen.getByLabelText(/email/i);
|
|
const passwordInput = screen.getByLabelText(/password/i);
|
|
const submitButton = screen.getByRole("button", { name: /sign in/i });
|
|
|
|
fireEvent.change(emailInput, { target: { value: "test@example.com" } });
|
|
fireEvent.change(passwordInput, { target: { value: "password123" } });
|
|
fireEvent.click(submitButton);
|
|
|
|
// Button should show loading state
|
|
await waitFor(() => {
|
|
expect(
|
|
screen.getByRole("button", { name: /signing in/i }),
|
|
).toBeInTheDocument();
|
|
});
|
|
|
|
// Resolve the auth
|
|
resolveAuth({ token: "test-token" });
|
|
});
|
|
|
|
it("disables form inputs while loading", async () => {
|
|
let resolveAuth: (value: unknown) => void = () => {};
|
|
const authPromise = new Promise((resolve) => {
|
|
resolveAuth = resolve;
|
|
});
|
|
mockAuthWithPassword.mockReturnValue(authPromise);
|
|
|
|
render(<LoginPage />);
|
|
|
|
const emailInput = screen.getByLabelText(/email/i);
|
|
const passwordInput = screen.getByLabelText(/password/i);
|
|
const submitButton = screen.getByRole("button", { name: /sign in/i });
|
|
|
|
fireEvent.change(emailInput, { target: { value: "test@example.com" } });
|
|
fireEvent.change(passwordInput, { target: { value: "password123" } });
|
|
fireEvent.click(submitButton);
|
|
|
|
await waitFor(() => {
|
|
expect(emailInput).toBeDisabled();
|
|
expect(passwordInput).toBeDisabled();
|
|
expect(screen.getByRole("button")).toBeDisabled();
|
|
});
|
|
|
|
resolveAuth({ token: "test-token" });
|
|
});
|
|
});
|
|
|
|
describe("error handling", () => {
|
|
it("shows error message on failed login", async () => {
|
|
mockAuthWithPassword.mockRejectedValueOnce(
|
|
new Error("Invalid credentials"),
|
|
);
|
|
render(<LoginPage />);
|
|
|
|
const emailInput = screen.getByLabelText(/email/i);
|
|
const passwordInput = screen.getByLabelText(/password/i);
|
|
const submitButton = screen.getByRole("button", { name: /sign in/i });
|
|
|
|
fireEvent.change(emailInput, { target: { value: "test@example.com" } });
|
|
fireEvent.change(passwordInput, { target: { value: "wrongpassword" } });
|
|
fireEvent.click(submitButton);
|
|
|
|
await waitFor(() => {
|
|
expect(screen.getByRole("alert")).toBeInTheDocument();
|
|
expect(screen.getByText(/invalid credentials/i)).toBeInTheDocument();
|
|
});
|
|
});
|
|
|
|
it("clears error when user starts typing again", async () => {
|
|
mockAuthWithPassword.mockRejectedValueOnce(
|
|
new Error("Invalid credentials"),
|
|
);
|
|
render(<LoginPage />);
|
|
|
|
const emailInput = screen.getByLabelText(/email/i);
|
|
const passwordInput = screen.getByLabelText(/password/i);
|
|
const submitButton = screen.getByRole("button", { name: /sign in/i });
|
|
|
|
fireEvent.change(emailInput, { target: { value: "test@example.com" } });
|
|
fireEvent.change(passwordInput, { target: { value: "wrongpassword" } });
|
|
fireEvent.click(submitButton);
|
|
|
|
await waitFor(() => {
|
|
expect(screen.getByRole("alert")).toBeInTheDocument();
|
|
});
|
|
|
|
// Start typing again
|
|
fireEvent.change(emailInput, { target: { value: "new@example.com" } });
|
|
|
|
expect(screen.queryByRole("alert")).not.toBeInTheDocument();
|
|
});
|
|
|
|
it("re-enables form after error", async () => {
|
|
mockAuthWithPassword.mockRejectedValueOnce(
|
|
new Error("Invalid credentials"),
|
|
);
|
|
render(<LoginPage />);
|
|
|
|
const emailInput = screen.getByLabelText(/email/i);
|
|
const passwordInput = screen.getByLabelText(/password/i);
|
|
const submitButton = screen.getByRole("button", { name: /sign in/i });
|
|
|
|
fireEvent.change(emailInput, { target: { value: "test@example.com" } });
|
|
fireEvent.change(passwordInput, { target: { value: "wrongpassword" } });
|
|
fireEvent.click(submitButton);
|
|
|
|
await waitFor(() => {
|
|
expect(screen.getByRole("alert")).toBeInTheDocument();
|
|
});
|
|
|
|
// Form should be re-enabled
|
|
expect(emailInput).not.toBeDisabled();
|
|
expect(passwordInput).not.toBeDisabled();
|
|
expect(
|
|
screen.getByRole("button", { name: /sign in/i }),
|
|
).not.toBeDisabled();
|
|
});
|
|
});
|
|
|
|
describe("validation", () => {
|
|
it("does not submit with empty email", async () => {
|
|
render(<LoginPage />);
|
|
|
|
const passwordInput = screen.getByLabelText(/password/i);
|
|
const submitButton = screen.getByRole("button", { name: /sign in/i });
|
|
|
|
fireEvent.change(passwordInput, { target: { value: "password123" } });
|
|
fireEvent.click(submitButton);
|
|
|
|
// Should not call auth
|
|
expect(mockAuthWithPassword).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("does not submit with empty password", async () => {
|
|
render(<LoginPage />);
|
|
|
|
const emailInput = screen.getByLabelText(/email/i);
|
|
const submitButton = screen.getByRole("button", { name: /sign in/i });
|
|
|
|
fireEvent.change(emailInput, { target: { value: "test@example.com" } });
|
|
fireEvent.click(submitButton);
|
|
|
|
// Should not call auth
|
|
expect(mockAuthWithPassword).not.toHaveBeenCalled();
|
|
});
|
|
});
|
|
});
|