From 11bdc7f5f5e22a651915b3bf8dd2f47ccfd6efe7 Mon Sep 17 00:00:00 2001 From: Application-drop-up Date: Sun, 16 Aug 2026 23:01:58 +0900 Subject: [PATCH 1/2] feat(favorites): add useRemoveFavorite hook --- .../favorites/hooks/use-remove-favorite.ts | 45 +++++++++++++++++++ 1 file changed, 45 insertions(+) create mode 100644 client/src/app/features/favorites/hooks/use-remove-favorite.ts diff --git a/client/src/app/features/favorites/hooks/use-remove-favorite.ts b/client/src/app/features/favorites/hooks/use-remove-favorite.ts new file mode 100644 index 0000000..f191e44 --- /dev/null +++ b/client/src/app/features/favorites/hooks/use-remove-favorite.ts @@ -0,0 +1,45 @@ +import { useCallback, useEffect, useRef, useState } from "react"; +import { removeFavorite } from "../apis"; +import { getStoredToken } from "@shared/auth/token-storage.ts"; + +const isAbortError = (element: unknown): boolean => { + return element instanceof DOMException && element.name === "AbortError"; +}; + +export function useRemoveFavorite() { + const [isRemoved, setIsRemoved] = 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 (worldHeritageId: number) => { + abortRef.current?.abort(); + const abortController = new AbortController(); + abortRef.current = abortController; + + setLoading(true); + setError(null); + + try { + const token = getStoredToken(); + if (!token) { + throw new Error("Not authenticated"); + } + + await removeFavorite(worldHeritageId, token, { + signal: abortController.signal, + }); + setIsRemoved(true); + } catch (element) { + if (!isAbortError(element)) setError(element); + } finally { + setLoading(false); + } + }, []); + + return { submit, isRemoved, isLoading, error }; +} From ab4fb18a8b43c25520cedce72d7e2ab3c244b4e8 Mon Sep 17 00:00:00 2001 From: Application-drop-up Date: Sun, 16 Aug 2026 23:02:11 +0900 Subject: [PATCH 2/2] test(favorites): add useRemoveFavorite hook tests --- .../__tests__/use-remove-favorite.test.ts | 194 ++++++++++++++++++ 1 file changed, 194 insertions(+) create mode 100644 client/src/app/features/favorites/hooks/__tests__/use-remove-favorite.test.ts diff --git a/client/src/app/features/favorites/hooks/__tests__/use-remove-favorite.test.ts b/client/src/app/features/favorites/hooks/__tests__/use-remove-favorite.test.ts new file mode 100644 index 0000000..a09729e --- /dev/null +++ b/client/src/app/features/favorites/hooks/__tests__/use-remove-favorite.test.ts @@ -0,0 +1,194 @@ +/** @jest-environment jsdom */ + +import { jest } from "@jest/globals"; + +jest.mock("../../apis", () => ({ + removeFavorite: jest.fn(), +})); + +jest.mock("@shared/auth/token-storage.ts", () => ({ + getStoredToken: jest.fn(), +})); + +import { renderHook, act, waitFor } from "@testing-library/react"; +import { describe, test, expect, beforeEach } from "@jest/globals"; +import { useRemoveFavorite } from "../use-remove-favorite"; +import { removeFavorite } from "../../apis"; +import { getStoredToken } from "@shared/auth/token-storage.ts"; + +type RemoveFn = ( + worldHeritageId: number, + token: string, + opts?: { signal?: AbortSignal }, +) => Promise; +const removeFavoriteMock = removeFavorite as unknown as jest.MockedFunction; +const getStoredTokenMock = getStoredToken as unknown as jest.MockedFunction<() => string | null>; + +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("useRemoveFavorite", () => { + beforeEach(() => { + jest.clearAllMocks(); + getStoredTokenMock.mockReturnValue("1|abcdef"); + }); + + test("初期状態: isRemoved=false, isLoading=false, error=null", () => { + const { result } = renderHook(() => useRemoveFavorite()); + + expect(result.current.isRemoved).toBe(false); + expect(result.current.isLoading).toBe(false); + expect(result.current.error).toBeNull(); + }); + + test("成功パス: submit -> loading true -> isRemoved true -> loading false", async () => { + const d = deferred(); + removeFavoriteMock.mockImplementation(() => d.promise); + + const { result } = renderHook(() => useRemoveFavorite()); + + let submitPromise!: Promise; + act(() => { + submitPromise = result.current.submit(42); + }); + + 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.isRemoved).toBe(true); + expect(result.current.error).toBeNull(); + expect(removeFavoriteMock).toHaveBeenCalledWith( + 42, + "1|abcdef", + expect.objectContaining({ signal: expect.any(Object) }), + ); + }); + + test("未認証: token が無い場合はエラーになり API を呼ばない", async () => { + getStoredTokenMock.mockReturnValue(null); + + const { result } = renderHook(() => useRemoveFavorite()); + + await act(async () => { + await result.current.submit(42); + }); + + expect(removeFavoriteMock).not.toHaveBeenCalled(); + expect(result.current.isRemoved).toBe(false); + expect(result.current.error).toBeInstanceOf(Error); + }); + + test("通常エラー: error に反映され、isRemoved は false のまま", async () => { + const d = deferred(); + removeFavoriteMock.mockImplementation(() => d.promise); + + const { result } = renderHook(() => useRemoveFavorite()); + + const boom = new Error("boom"); + let submitPromise!: Promise; + act(() => { + submitPromise = result.current.submit(42); + }); + + await act(async () => { + d.reject(boom); + await submitPromise; + }); + + expect(result.current.isLoading).toBe(false); + expect(result.current.isRemoved).toBe(false); + expect(result.current.error).toBe(boom); + }); + + test("AbortError は無視される(エラー状態にしない)", async () => { + const d = deferred(); + removeFavoriteMock.mockImplementation(() => d.promise); + + const { result } = renderHook(() => useRemoveFavorite()); + + let submitPromise!: Promise; + act(() => { + submitPromise = result.current.submit(42); + }); + + 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.isRemoved).toBe(false); + }); + + test("再送信時に前のリクエストを abort する", async () => { + const first = deferred(); + const second = deferred(); + const signals: Array = []; + + removeFavoriteMock.mockImplementation((_id, _token, opts) => { + signals.push(opts?.signal); + return signals.length === 1 ? first.promise : second.promise; + }); + + const { result } = renderHook(() => useRemoveFavorite()); + + act(() => { + void result.current.submit(42); + }); + + await waitFor(() => expect(signals).toHaveLength(1)); + + let secondSubmit!: Promise; + act(() => { + secondSubmit = result.current.submit(42); + }); + + await act(async () => { + second.resolve(undefined); + await secondSubmit; + }); + + expect(signals[0]?.aborted).toBe(true); + expect(result.current.isRemoved).toBe(true); + }); + + test("アンマウント時に現在のリクエストを abort する", () => { + const d = deferred(); + const signals: Array = []; + + removeFavoriteMock.mockImplementation((_id, _token, opts) => { + signals.push(opts?.signal); + return d.promise; + }); + + const { result, unmount } = renderHook(() => useRemoveFavorite()); + + act(() => { + void result.current.submit(42); + }); + + unmount(); + + expect(signals[0]?.aborted).toBe(true); + }); +});