diff --git a/client/src/app/features/user/apis/index.test.ts b/client/src/app/features/user/apis/index.test.ts index 090b28a..e000c52 100644 --- a/client/src/app/features/user/apis/index.test.ts +++ b/client/src/app/features/user/apis/index.test.ts @@ -216,6 +216,29 @@ describe("createUserApi", () => { }); }); + describe("deleteUser", () => { + it("sends a DELETE request to the users endpoint", async () => { + fetchSpy.mockResolvedValue({ ok: true, status: 204, json: async () => ({}) } as Response); + + await api.deleteUser(1); + + expect(fetchSpy).toHaveBeenCalledWith( + `${USERS_ENDPOINT}/1`, + expect.objectContaining({ + method: "DELETE", + headers: expect.objectContaining({ Accept: "application/json" }), + credentials: "omit", + }), + ); + }); + + it("throws on HTTP error", async () => { + fetchSpy.mockResolvedValue(makeNgResponse(404) as Response); + + await expect(api.deleteUser(1)).rejects.toThrow("HTTP 404"); + }); + }); + it("throws when apiBase is empty", () => { expect(() => createUserApi({ apiBase: "", fetchImpl: fetchSpy })).toThrow( "apiBase is required", diff --git a/client/src/app/features/user/apis/index.ts b/client/src/app/features/user/apis/index.ts index c776b27..830f5cf 100644 --- a/client/src/app/features/user/apis/index.ts +++ b/client/src/app/features/user/apis/index.ts @@ -11,3 +11,4 @@ const userApi = createUserApi({ apiBase }); export const createUser = userApi.createUser; export const getUser = userApi.getUser; export const updateUser = userApi.updateUser; +export const deleteUser = userApi.deleteUser; diff --git a/client/src/app/features/user/apis/user-api.ts b/client/src/app/features/user/apis/user-api.ts index 3c12d14..047080b 100644 --- a/client/src/app/features/user/apis/user-api.ts +++ b/client/src/app/features/user/apis/user-api.ts @@ -86,6 +86,17 @@ export const createUserApi = ({ apiBase, fetchImpl = fetch }: UserApiDeps) => { signal: init?.signal, }); + const withDeleteInit = (init?: RequestInit): RequestInit => ({ + ...init, + method: "DELETE", + headers: { + Accept: "application/json", + ...(init?.headers ?? {}), + }, + credentials: init?.credentials ?? "omit", + signal: init?.signal, + }); + return { async createUser(request: CreateUserRequest, init?: RequestInit): Promise { const response = await fetchImpl(createEndpoint, { @@ -141,5 +152,13 @@ export const createUserApi = ({ apiBase, fetchImpl = fetch }: UserApiDeps) => { return json.data; }, + + async deleteUser(id: number, init?: RequestInit): Promise { + const response = await fetchImpl(`${usersEndpoint}/${id}`, withDeleteInit(init)); + + if (!response.ok) { + throw new Error(`HTTP ${response.status}`); + } + }, }; }; diff --git a/client/src/app/features/user/components/UserProfileView.tsx b/client/src/app/features/user/components/UserProfileView.tsx index fde5c66..058c3ff 100644 --- a/client/src/app/features/user/components/UserProfileView.tsx +++ b/client/src/app/features/user/components/UserProfileView.tsx @@ -1,5 +1,9 @@ import { useEffect, useRef, useState } from "react"; import type { FormEvent } from "react"; +import Dialog from "@mui/material/Dialog"; +import DialogTitle from "@mui/material/DialogTitle"; +import DialogContent from "@mui/material/DialogContent"; +import DialogActions from "@mui/material/DialogActions"; import TextField from "@shared/uis/TextField.tsx"; import { Button } from "@shared/uis/Button.tsx"; import { ErrorPanel } from "@shared/uis/ErrorPanel.tsx"; @@ -12,6 +16,9 @@ type Props = { onUpdate: (values: UpdateUserFormValues) => void; isUpdating: boolean; updateError: unknown; + onDelete: () => void; + isDeleting: boolean; + deleteError: unknown; }; type EditableField = keyof UpdateUserFormValues; @@ -25,11 +32,21 @@ const toFormValues = (profile: UserProfile): UpdateUserFormValues => ({ email: profile.email, }); -export function UserProfileView({ profile, onUpdate, isUpdating, updateError }: Props) { +export function UserProfileView({ + profile, + onUpdate, + isUpdating, + updateError, + onDelete, + isDeleting, + deleteError, +}: Props) { const text = useText(); const [editingField, setEditingField] = useState(null); const [fieldValue, setFieldValue] = useState(""); + const [isConfirmingDelete, setIsConfirmingDelete] = useState(false); const wasUpdatingRef = useRef(false); + const wasDeletingRef = useRef(false); useEffect(() => { if (wasUpdatingRef.current && !isUpdating && updateError == null) { @@ -38,6 +55,13 @@ export function UserProfileView({ profile, onUpdate, isUpdating, updateError }: wasUpdatingRef.current = isUpdating; }, [isUpdating, updateError]); + useEffect(() => { + if (wasDeletingRef.current && !isDeleting && deleteError != null) { + setIsConfirmingDelete(false); + } + wasDeletingRef.current = isDeleting; + }, [isDeleting, deleteError]); + const startEditing = (field: EditableField) => { setFieldValue(profile[field]); setEditingField(field); @@ -53,6 +77,10 @@ export function UserProfileView({ profile, onUpdate, isUpdating, updateError }: onUpdate({ ...toFormValues(profile), [editingField]: fieldValue }); }; + const handleConfirmDelete = () => { + onDelete(); + }; + return (
@@ -118,6 +146,40 @@ export function UserProfileView({ profile, onUpdate, isUpdating, updateError }:
+ + {deleteError != null && } + + + + setIsConfirmingDelete(false)}> + {text.userDeleteConfirmTitle} + {text.userDeleteConfirmMessage} + + + + +
); } diff --git a/client/src/app/features/user/components/__tests__/UserProfileView.test.tsx b/client/src/app/features/user/components/__tests__/UserProfileView.test.tsx index fc1f7bb..573ef72 100644 --- a/client/src/app/features/user/components/__tests__/UserProfileView.test.tsx +++ b/client/src/app/features/user/components/__tests__/UserProfileView.test.tsx @@ -1,6 +1,6 @@ /** @jest-environment jsdom */ -import { render, screen, fireEvent } from "@testing-library/react"; +import { render, screen, fireEvent, waitFor } from "@testing-library/react"; import { MemoryRouter } from "react-router-dom"; import { LocaleProvider } from "@shared/locale/LocaleProvider.tsx"; import { UserProfileView } from "../UserProfileView"; @@ -22,6 +22,9 @@ const renderView = (props: Partial> onUpdate: jest.fn(), isUpdating: false, updateError: null, + onDelete: jest.fn(), + isDeleting: false, + deleteError: null, ...props, }; @@ -125,7 +128,14 @@ describe("UserProfileView", () => { rerender( - + , ); @@ -150,4 +160,45 @@ describe("UserProfileView", () => { expect(screen.queryByLabelText(/^First Name/)).not.toBeInTheDocument(); expect(screen.getByRole("button", { name: "Edit First Name" })).toBeInTheDocument(); }); + + it("opens a confirmation dialog when Delete User is clicked", () => { + renderView(); + + fireEvent.click(screen.getByRole("button", { name: "Delete User" })); + + expect( + screen.getByText("Are you sure you want to delete this user?", { exact: false }), + ).toBeInTheDocument(); + }); + + it("closes the dialog without calling onDelete when Cancel is clicked", async () => { + const onDelete = jest.fn(); + renderView({ onDelete }); + + fireEvent.click(screen.getByRole("button", { name: "Delete User" })); + fireEvent.click(screen.getByRole("button", { name: "Cancel" })); + + expect(onDelete).not.toHaveBeenCalled(); + await waitFor(() => { + expect( + screen.queryByText("Are you sure you want to delete this user?", { exact: false }), + ).not.toBeInTheDocument(); + }); + }); + + it("calls onDelete when the dialog's Delete User button is confirmed", () => { + const onDelete = jest.fn(); + renderView({ onDelete }); + + fireEvent.click(screen.getByRole("button", { name: "Delete User" })); + fireEvent.click(screen.getByRole("button", { name: "Delete User" })); + + expect(onDelete).toHaveBeenCalledTimes(1); + }); + + it("shows an error panel when deleteError is present", () => { + renderView({ deleteError: new Error("boom") }); + + expect(screen.getByText("Failed to delete user.")).toBeInTheDocument(); + }); }); diff --git a/client/src/app/features/user/containers/__tests__/user-get-container.test.tsx b/client/src/app/features/user/containers/__tests__/user-get-container.test.tsx index 3879687..09bc3b2 100644 --- a/client/src/app/features/user/containers/__tests__/user-get-container.test.tsx +++ b/client/src/app/features/user/containers/__tests__/user-get-container.test.tsx @@ -4,6 +4,7 @@ import type { UserProfile } from "../../types"; const useGetUserMock = jest.fn(); const useUpdateUserMock = jest.fn(); +const useDeleteUserMock = jest.fn(); jest.mock("../../hooks/use-get-user", () => ({ useGetUser: (id: number) => useGetUserMock(id), @@ -13,6 +14,10 @@ jest.mock("../../hooks/use-update-user", () => ({ useUpdateUser: () => useUpdateUserMock(), })); +jest.mock("../../hooks/use-delete-user", () => ({ + useDeleteUser: () => useDeleteUserMock(), +})); + import { render, screen, fireEvent } from "@testing-library/react"; import { MemoryRouter, Routes, Route } from "react-router-dom"; import { LocaleProvider } from "@shared/locale/LocaleProvider.tsx"; @@ -34,22 +39,30 @@ const renderContainer = (id = "1") => } /> + Heritages Home} /> , ); describe("UserGetContainer", () => { - const submitMock = jest.fn(); + const submitUpdateMock = jest.fn(); + const submitDeleteMock = jest.fn(); beforeEach(() => { jest.clearAllMocks(); useUpdateUserMock.mockReturnValue({ - submit: submitMock, + submit: submitUpdateMock, data: null, isLoading: false, error: null, }); + useDeleteUserMock.mockReturnValue({ + submit: submitDeleteMock, + done: false, + isLoading: false, + error: null, + }); }); it("shows a spinner while loading", () => { @@ -102,7 +115,7 @@ describe("UserGetContainer", () => { fireEvent.change(screen.getByLabelText(/^First Name/), { target: { value: "Jiro" } }); fireEvent.click(screen.getByRole("button", { name: "Save" })); - expect(submitMock).toHaveBeenCalledWith( + expect(submitUpdateMock).toHaveBeenCalledWith( 42, expect.objectContaining({ firstName: "Jiro", lastName: "Yamada", email: "taro@example.com" }), ); @@ -111,7 +124,7 @@ describe("UserGetContainer", () => { it("reflects the updated profile immediately once useUpdateUser returns data", () => { useGetUserMock.mockReturnValue({ data: profile, isLoading: false, error: null }); useUpdateUserMock.mockReturnValue({ - submit: submitMock, + submit: submitUpdateMock, data: { ...profile, firstName: "Jiro" }, isLoading: false, error: null, @@ -121,4 +134,29 @@ describe("UserGetContainer", () => { expect(screen.getByText("Jiro Yamada")).toBeInTheDocument(); }); + + it("calls useDeleteUser's submit with the numeric id after confirming deletion", () => { + useGetUserMock.mockReturnValue({ data: profile, isLoading: false, error: null }); + + renderContainer("42"); + + fireEvent.click(screen.getByRole("button", { name: "Delete User" })); + fireEvent.click(screen.getByRole("button", { name: "Delete User" })); + + expect(submitDeleteMock).toHaveBeenCalledWith(42); + }); + + it("navigates to /heritages once useDeleteUser reports done", () => { + useGetUserMock.mockReturnValue({ data: profile, isLoading: false, error: null }); + useDeleteUserMock.mockReturnValue({ + submit: submitDeleteMock, + done: true, + isLoading: false, + error: null, + }); + + renderContainer(); + + expect(screen.getByText("Heritages Home")).toBeInTheDocument(); + }); }); diff --git a/client/src/app/features/user/containers/user-get-container.tsx b/client/src/app/features/user/containers/user-get-container.tsx index 8f9c088..cbc7bf0 100644 --- a/client/src/app/features/user/containers/user-get-container.tsx +++ b/client/src/app/features/user/containers/user-get-container.tsx @@ -1,7 +1,8 @@ import { useEffect, useState } from "react"; -import { useParams } from "react-router-dom"; +import { useNavigate, useParams } from "react-router-dom"; import { useGetUser } from "../hooks/use-get-user"; import { useUpdateUser } from "../hooks/use-update-user"; +import { useDeleteUser } from "../hooks/use-delete-user"; import { UserProfileView } from "../components/UserProfileView"; import { ErrorPanel } from "@shared/uis/ErrorPanel.tsx"; import { Spinner } from "@shared/uis/Spinner.tsx"; @@ -11,6 +12,7 @@ import type { UserProfile } from "../types"; export function UserGetContainer() { const { id } = useParams<{ id: string }>(); const numericId = Number(id); + const navigate = useNavigate(); const { data, isLoading, error } = useGetUser(numericId); const { submit: submitUpdate, @@ -18,6 +20,12 @@ export function UserGetContainer() { isLoading: isUpdating, error: updateError, } = useUpdateUser(); + const { + submit: submitDelete, + done: isDeleted, + isLoading: isDeleting, + error: deleteError, + } = useDeleteUser(); const [profile, setProfile] = useState(null); const text = useText(); @@ -29,6 +37,10 @@ export function UserGetContainer() { if (updated) setProfile(updated); }, [updated]); + useEffect(() => { + if (isDeleted) navigate("/heritages"); + }, [isDeleted, navigate]); + if (isLoading) return ; if (error || !profile) return ; @@ -38,6 +50,9 @@ export function UserGetContainer() { onUpdate={(values) => submitUpdate(numericId, values)} isUpdating={isUpdating} updateError={updateError} + onDelete={() => submitDelete(numericId)} + isDeleting={isDeleting} + deleteError={deleteError} /> ); } diff --git a/client/src/app/features/user/hooks/__tests__/use-delete-user.test.ts b/client/src/app/features/user/hooks/__tests__/use-delete-user.test.ts new file mode 100644 index 0000000..f19b4c1 --- /dev/null +++ b/client/src/app/features/user/hooks/__tests__/use-delete-user.test.ts @@ -0,0 +1,168 @@ +/** @jest-environment jsdom */ + +import { jest } from "@jest/globals"; + +jest.mock("../../apis", () => ({ + deleteUser: jest.fn(), +})); + +import { renderHook, act, waitFor } from "@testing-library/react"; +import { describe, test, expect, beforeEach } from "@jest/globals"; +import { useDeleteUser } from "../use-delete-user"; +import { deleteUser } from "../../apis"; + +type DeleteFn = (id: number, opts?: { signal?: AbortSignal }) => Promise; +const deleteUserMock = deleteUser as unknown as jest.MockedFunction; + +type Deferred = { + promise: Promise; + resolve: (v: T) => void; + reject: (e: unknown) => void; +}; +const deferred = (): Deferred => { + let resolve!: (v: T) => void; + let reject!: (e: unknown) => void; + const promise = new Promise((res, rej) => { + resolve = res; + reject = rej; + }); + return { promise, resolve, reject }; +}; + +describe("useDeleteUser", () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + test("初期状態: done=false, isLoading=false, error=null", () => { + const { result } = renderHook(() => useDeleteUser()); + + expect(result.current.done).toBe(false); + expect(result.current.isLoading).toBe(false); + expect(result.current.error).toBeNull(); + }); + + test("成功パス: submit -> loading true -> done true -> loading false", async () => { + const d = deferred(); + deleteUserMock.mockImplementation(() => d.promise); + + const { result } = renderHook(() => useDeleteUser()); + + let submitPromise!: Promise; + act(() => { + submitPromise = result.current.submit(1); + }); + + await waitFor(() => { + expect(result.current.isLoading).toBe(true); + }); + + await act(async () => { + d.resolve(undefined); + await submitPromise; + }); + + expect(result.current.isLoading).toBe(false); + expect(result.current.done).toBe(true); + expect(result.current.error).toBeNull(); + expect(deleteUserMock).toHaveBeenCalledWith( + 1, + expect.objectContaining({ signal: expect.any(Object) }), + ); + }); + + test("通常エラー: error に反映され、done は false のまま", async () => { + const d = deferred(); + deleteUserMock.mockImplementation(() => d.promise); + + const { result } = renderHook(() => useDeleteUser()); + + const boom = new Error("boom"); + let submitPromise!: Promise; + act(() => { + submitPromise = result.current.submit(1); + }); + + await act(async () => { + d.reject(boom); + await submitPromise; + }); + + expect(result.current.isLoading).toBe(false); + expect(result.current.done).toBe(false); + expect(result.current.error).toBe(boom); + }); + + test("AbortError は無視される(エラー状態にしない)", async () => { + const d = deferred(); + deleteUserMock.mockImplementation(() => d.promise); + + const { result } = renderHook(() => useDeleteUser()); + + let submitPromise!: Promise; + act(() => { + submitPromise = result.current.submit(1); + }); + + await act(async () => { + d.reject(new DOMException("Aborted", "AbortError")); + await submitPromise; + }); + + expect(result.current.isLoading).toBe(false); + expect(result.current.error).toBeNull(); + expect(result.current.done).toBe(false); + }); + + test("再送信時に前のリクエストを abort する", async () => { + const first = deferred(); + const second = deferred(); + const signals: Array = []; + + deleteUserMock.mockImplementation((_id, opts) => { + signals.push(opts?.signal); + return signals.length === 1 ? first.promise : second.promise; + }); + + const { result } = renderHook(() => useDeleteUser()); + + act(() => { + void result.current.submit(1); + }); + + await waitFor(() => expect(signals).toHaveLength(1)); + + let secondSubmit!: Promise; + act(() => { + secondSubmit = result.current.submit(1); + }); + + await act(async () => { + second.resolve(undefined); + await secondSubmit; + }); + + expect(signals[0]?.aborted).toBe(true); + expect(result.current.done).toBe(true); + }); + + test("アンマウント時に現在のリクエストを abort する", () => { + const d = deferred(); + const signals: Array = []; + + deleteUserMock.mockImplementation((_id, opts) => { + signals.push(opts?.signal); + return d.promise; + }); + + const { result, unmount } = renderHook(() => useDeleteUser()); + + act(() => { + void result.current.submit(1); + }); + + unmount(); + + expect(signals[0]?.aborted).toBe(true); + }); +}); diff --git a/client/src/app/features/user/hooks/use-delete-user.ts b/client/src/app/features/user/hooks/use-delete-user.ts new file mode 100644 index 0000000..61029be --- /dev/null +++ b/client/src/app/features/user/hooks/use-delete-user.ts @@ -0,0 +1,37 @@ +import { useCallback, useEffect, useRef, useState } from "react"; +import { deleteUser } from "../apis"; + +const isAbortError = (element: unknown): boolean => { + return element instanceof DOMException && element.name === "AbortError"; +}; + +export function useDeleteUser() { + const [done, setDone] = useState(false); + const [isLoading, setLoading] = useState(false); + const [error, setError] = useState(null); + const abortRef = useRef(null); + + useEffect(() => { + return () => abortRef.current?.abort(); + }, []); + + const submit = useCallback(async (id: number) => { + abortRef.current?.abort(); + const abortController = new AbortController(); + abortRef.current = abortController; + + setLoading(true); + setError(null); + + try { + await deleteUser(id, { signal: abortController.signal }); + setDone(true); + } catch (error) { + if (!isAbortError(error)) setError(error); + } finally { + setLoading(false); + } + }, []); + + return { submit, done, isLoading, error }; +} diff --git a/client/src/locals/en/ui.json b/client/src/locals/en/ui.json index bbe2147..370f8d5 100644 --- a/client/src/locals/en/ui.json +++ b/client/src/locals/en/ui.json @@ -79,5 +79,9 @@ "userUpdateEdit": "Edit", "userUpdateSubmit": "Save", "userUpdateCancel": "Cancel", - "userUpdateError": "Failed to update user." + "userUpdateError": "Failed to update user.", + "userDeleteButton": "Delete User", + "userDeleteConfirmTitle": "Delete User", + "userDeleteConfirmMessage": "Are you sure you want to delete this user? This action cannot be undone.", + "userDeleteError": "Failed to delete user." } diff --git a/client/src/locals/ja/ui.json b/client/src/locals/ja/ui.json index be0e0bd..63da1ea 100644 --- a/client/src/locals/ja/ui.json +++ b/client/src/locals/ja/ui.json @@ -79,5 +79,9 @@ "userUpdateEdit": "編集", "userUpdateSubmit": "保存", "userUpdateCancel": "キャンセル", - "userUpdateError": "ユーザー情報の更新に失敗しました。" + "userUpdateError": "ユーザー情報の更新に失敗しました。", + "userDeleteButton": "ユーザーを削除", + "userDeleteConfirmTitle": "ユーザーの削除", + "userDeleteConfirmMessage": "このユーザーを削除してもよろしいですか?この操作は取り消せません。", + "userDeleteError": "ユーザーの削除に失敗しました。" }