Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 8 additions & 5 deletions client/src/app/features/auth/apis/auth-api.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,10 @@ describe("createAuthApi", () => {
describe("login", () => {
it("posts credentials and returns the bearer token from the envelope", async () => {
fetchSpy.mockResolvedValue(
makeOkResponse({ status: "success", data: { token: TOKEN, token_type: "Bearer" } }) as Response,
makeOkResponse({
status: "success",
data: { token: TOKEN, token_type: "Bearer" },
}) as Response,
);

const result = await api.login({ email: "taro@example.com", password: "password123" });
Expand All @@ -64,9 +67,9 @@ describe("createAuthApi", () => {
it("throws on HTTP error", async () => {
fetchSpy.mockResolvedValue(makeNgResponse(422) as Response);

await expect(
api.login({ email: "taro@example.com", password: "wrong" }),
).rejects.toThrow("HTTP 422");
await expect(api.login({ email: "taro@example.com", password: "wrong" })).rejects.toThrow(
"HTTP 422",
);
});
});

Expand Down Expand Up @@ -117,4 +120,4 @@ describe("createAuthApi", () => {
await expect(api.getCurrentUser(TOKEN)).rejects.toThrow("HTTP 500");
});
});
});
});
54 changes: 54 additions & 0 deletions client/src/app/features/auth/components/LoginForm.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
import type { FormEvent } from "react";
import TextField from "@shared/uis/TextField.tsx";
import { Button } from "@shared/uis/Button.tsx";
import { ErrorPanel } from "@shared/uis/ErrorPanel.tsx";
import { useText } from "@shared/locale/ui-text.ts";
import type { LoginFormValues } from "../types";

type Props = {
value: LoginFormValues;
onChange: (next: LoginFormValues) => void;
onSubmit: () => void;
isLoading: boolean;
error: unknown;
};

export function LoginForm({ value, onChange, onSubmit, isLoading, error }: Props) {
const text = useText();

const handleField = <K extends keyof LoginFormValues>(key: K, next: LoginFormValues[K]) => {
onChange({ ...value, [key]: next });
};

const handleSubmit = (e: FormEvent) => {
e.preventDefault();
onSubmit();
};

return (
<form onSubmit={handleSubmit} className="mx-auto flex max-w-xl flex-col gap-4 px-4 py-8">
<h1 className="text-xl font-bold">{text.login}</h1>

{error != null && <ErrorPanel message={text.loginError} />}

<TextField
label={text.userEmail}
type="email"
value={value.email}
onChange={(e) => handleField("email", e.target.value)}
required
/>
<TextField
label={text.userPassword}
type="password"
value={value.password}
onChange={(e) => handleField("password", e.target.value)}
required
/>

<Button type="submit" variant="primary" isLoading={isLoading}>
{text.login}
</Button>
</form>
);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
/** @jest-environment jsdom */

import { render, screen, fireEvent } from "@testing-library/react";
import { MemoryRouter } from "react-router-dom";
import { LocaleProvider } from "@shared/locale/LocaleProvider.tsx";
import { LoginForm } from "../LoginForm";
import type { LoginFormValues } from "../../types";

const renderForm = (props: Partial<React.ComponentProps<typeof LoginForm>> = {}) => {
const value: LoginFormValues = { email: "", password: "" };

const defaultProps: React.ComponentProps<typeof LoginForm> = {
value,
onChange: jest.fn(),
onSubmit: jest.fn(),
isLoading: false,
error: null,
...props,
};

return render(
<MemoryRouter>
<LocaleProvider>
<LoginForm {...defaultProps} />
</LocaleProvider>
</MemoryRouter>,
);
};

describe("LoginForm", () => {
it("renders email and password fields", () => {
renderForm();

expect(screen.getByLabelText(/^Email/)).toBeInTheDocument();
expect(screen.getByLabelText(/^Password/)).toBeInTheDocument();
});

it("calls onChange with the updated field when typing", () => {
const onChange = jest.fn();
renderForm({ onChange });

fireEvent.change(screen.getByLabelText(/^Email/), {
target: { value: "taro@example.com" },
});

expect(onChange).toHaveBeenCalledWith(
expect.objectContaining({ email: "taro@example.com", password: "" }),
);
});

it("calls onSubmit when the form is submitted", () => {
const onSubmit = jest.fn();
const value: LoginFormValues = { email: "taro@example.com", password: "password123" };
renderForm({ onSubmit, value });

fireEvent.click(screen.getByRole("button", { name: "Login" }));

expect(onSubmit).toHaveBeenCalledTimes(1);
});

it("shows an error panel when error is present", () => {
renderForm({ error: new Error("boom") });

expect(screen.getByText("Failed to log in.")).toBeInTheDocument();
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
/** @jest-environment jsdom */

import { jest } from "@jest/globals";
import type { LoginFormValues } from "../../types";

const submitMock = jest.fn<(values: LoginFormValues) => Promise<boolean>>();
const useLoginMock = jest.fn();
const navigateMock = jest.fn();

jest.mock("react-router-dom", () => {
const actual = jest.requireActual("react-router-dom") as typeof import("react-router-dom");
return { ...actual, useNavigate: () => navigateMock };
});

jest.mock("../../hooks/use-login", () => ({
useLogin: () => useLoginMock(),
}));

jest.mock("../../components/LoginForm", () => ({
__esModule: true,
LoginForm: function MockLoginForm(props: {
value: { email: string; password: string };
onChange: (next: { email: string; password: string }) => void;
onSubmit: () => void;
isLoading: boolean;
error: unknown;
}) {
return (
<div>
<span data-testid="email">{props.value.email}</span>
<span data-testid="is-loading">{String(props.isLoading)}</span>
<span data-testid="error">{props.error ? "has-error" : "no-error"}</span>
<button
type="button"
onClick={() => props.onChange({ ...props.value, email: "taro@example.com" })}
>
change
</button>
<button type="button" onClick={props.onSubmit}>
submit
</button>
</div>
);
},
}));

import { render, screen, fireEvent, waitFor } from "@testing-library/react";
import { describe, it, expect, beforeEach } from "@jest/globals";
import { MemoryRouter } from "react-router-dom";
import { LoginContainer } from "../login-container";

describe("LoginContainer", () => {
beforeEach(() => {
jest.clearAllMocks();
useLoginMock.mockReturnValue({ submit: submitMock, isLoading: false, error: null });
});

const renderContainer = () =>
render(
<MemoryRouter>
<LoginContainer />
</MemoryRouter>,
);

it("renders the form with default draft values", () => {
renderContainer();

expect(screen.getByTestId("email").textContent).toBe("");
expect(screen.getByTestId("is-loading").textContent).toBe("false");
expect(screen.getByTestId("error").textContent).toBe("no-error");
});

it("updates the draft when the form calls onChange", () => {
renderContainer();

fireEvent.click(screen.getByRole("button", { name: "change" }));

expect(screen.getByTestId("email").textContent).toBe("taro@example.com");
});

it("calls submit with the current draft and navigates to /mypage on success", async () => {
submitMock.mockResolvedValue(true);
renderContainer();

fireEvent.click(screen.getByRole("button", { name: "change" }));
fireEvent.click(screen.getByRole("button", { name: "submit" }));

expect(submitMock).toHaveBeenCalledWith(
expect.objectContaining({ email: "taro@example.com", password: "" }),
);
await waitFor(() => expect(navigateMock).toHaveBeenCalledWith("/mypage"));
});

it("does not navigate when submit fails", async () => {
submitMock.mockResolvedValue(false);
renderContainer();

fireEvent.click(screen.getByRole("button", { name: "submit" }));

await waitFor(() => expect(submitMock).toHaveBeenCalled());
expect(navigateMock).not.toHaveBeenCalled();
});

it("passes through isLoading and error from the hook", () => {
useLoginMock.mockReturnValue({
submit: submitMock,
isLoading: true,
error: new Error("boom"),
});

renderContainer();

expect(screen.getByTestId("is-loading").textContent).toBe("true");
expect(screen.getByTestId("error").textContent).toBe("has-error");
});
});
33 changes: 33 additions & 0 deletions client/src/app/features/auth/containers/login-container.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
import { useCallback, useState } from "react";
import { useNavigate } from "react-router-dom";
import { useLogin } from "../hooks/use-login";
import type { LoginFormValues } from "../types";
import { LoginForm } from "../components/LoginForm";

const DEFAULT_LOGIN_FORM_VALUES: LoginFormValues = { email: "", password: "" };

export function LoginContainer() {
const [draft, setDraft] = useState<LoginFormValues>(DEFAULT_LOGIN_FORM_VALUES);
const { submit, isLoading, error } = useLogin();
const navigate = useNavigate();

const handleChange = useCallback((next: LoginFormValues) => {
setDraft(next);
}, []);

const handleSubmit = useCallback(() => {
void submit(draft).then((ok) => {
if (ok) navigate("/mypage");
});
}, [draft, submit, navigate]);

return (
<LoginForm
value={draft}
onChange={handleChange}
onSubmit={handleSubmit}
isLoading={isLoading}
error={error}
/>
);
}
Original file line number Diff line number Diff line change
Expand Up @@ -143,4 +143,4 @@ describe("useLogin", () => {
expect(result.current.isLoading).toBe(false);
expect(result.current.error).toBeNull();
});
});
});
2 changes: 1 addition & 1 deletion client/src/app/features/auth/hooks/use-logout.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,4 +42,4 @@ export function useLogout() {
}, [setUser]);

return { submit, isLoading, error };
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -29,4 +29,4 @@ describe("toLoginRequest", () => {

expect(request.password).toBe(" spaced out ");
});
});
});
2 changes: 1 addition & 1 deletion client/src/app/features/auth/mapper/to-login-request.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,4 +4,4 @@ import type { LoginRequest } from "../apis/auth-api";
export const toLoginRequest = (values: LoginFormValues): LoginRequest => ({
email: values.email.trim(),
password: values.password,
});
});
6 changes: 5 additions & 1 deletion client/src/locals/en/ui.json
Original file line number Diff line number Diff line change
Expand Up @@ -63,5 +63,9 @@
"viewAllResults": "View all results",
"unescoCriteriaSource": "UNESCO official criteria page",
"worldHeritageBasics": "World Heritage Basics",
"worldHeritageBasicsDescription": "Every site is inscribed under one or more of these 10 selection criteria. Explore what each one means."
"worldHeritageBasicsDescription": "Every site is inscribed under one or more of these 10 selection criteria. Explore what each one means.",
"userEmail": "Email",
"userPassword": "Password",
"login": "Login",
"loginError": "Failed to log in."
}
6 changes: 5 additions & 1 deletion client/src/locals/ja/ui.json
Original file line number Diff line number Diff line change
Expand Up @@ -63,5 +63,9 @@
"viewAllResults": "すべての結果を見る",
"unescoCriteriaSource": "UNESCO 公式の登録基準ページ",
"worldHeritageBasics": "世界遺産の基礎知識",
"worldHeritageBasicsDescription": "すべての世界遺産は、以下の10個の登録基準のいずれか(または複数)を満たして登録されています。各基準の意味を見てみましょう。"
"worldHeritageBasicsDescription": "すべての世界遺産は、以下の10個の登録基準のいずれか(または複数)を満たして登録されています。各基準の意味を見てみましょう。",
"userEmail": "メールアドレス",
"userPassword": "パスワード",
"login": "ログイン",
"loginError": "ログインに失敗しました。"
}
Loading