From 124d5587d669bf5126052f6e4e315f1f85ed9180 Mon Sep 17 00:00:00 2001 From: Application-drop-up Date: Tue, 18 Aug 2026 09:38:11 +0900 Subject: [PATCH 1/2] feat(favorites): add to-favorite-id-lookup mapper --- .../__tests__/to-favorite-id-lookup.test.ts | 41 +++++++++++++++++++ .../mappers/to-favorite-id-lookup.ts | 6 +++ 2 files changed, 47 insertions(+) create mode 100644 client/src/app/features/favorites/mappers/__tests__/to-favorite-id-lookup.test.ts create mode 100644 client/src/app/features/favorites/mappers/to-favorite-id-lookup.ts diff --git a/client/src/app/features/favorites/mappers/__tests__/to-favorite-id-lookup.test.ts b/client/src/app/features/favorites/mappers/__tests__/to-favorite-id-lookup.test.ts new file mode 100644 index 0000000..2298d56 --- /dev/null +++ b/client/src/app/features/favorites/mappers/__tests__/to-favorite-id-lookup.test.ts @@ -0,0 +1,41 @@ +import { describe, it, expect } from "@jest/globals"; +import { toFavoriteIdLookup } from "../to-favorite-id-lookup"; +import type { ApiWorldHeritageDto } from "../../../../../domain/types.ts"; + +const makeHeritageDto = (overrides: Partial = {}): ApiWorldHeritageDto => ({ + id: 1, + official_name: "Official Name", + name: "Name", + heritage_name_jp: "名前", + country: "Country", + country_name_jp: "国名", + region: "Europe", + category: "Cultural", + year_inscribed: 2000, + latitude: 0, + longitude: 0, + is_endangered: false, + criteria: ["i"], + area_hectares: null, + buffer_zone_hectares: null, + short_description: "desc", + short_description_jp: "説明", + unesco_site_url: null, + state_party: null, + state_party_codes: [], + state_parties_meta: {}, + thumbnail_url: null, + ...overrides, +}); + +describe("toFavoriteIdLookup", () => { + it("converts a DTO list into an id lookup object", () => { + const dtos = [makeHeritageDto({ id: 1 }), makeHeritageDto({ id: 5 })]; + + expect(toFavoriteIdLookup(dtos)).toEqual({ 1: true, 5: true }); + }); + + it("returns an empty object for an empty list", () => { + expect(toFavoriteIdLookup([])).toEqual({}); + }); +}); diff --git a/client/src/app/features/favorites/mappers/to-favorite-id-lookup.ts b/client/src/app/features/favorites/mappers/to-favorite-id-lookup.ts new file mode 100644 index 0000000..9b55719 --- /dev/null +++ b/client/src/app/features/favorites/mappers/to-favorite-id-lookup.ts @@ -0,0 +1,6 @@ +import type { ApiWorldHeritageDto } from "../../../../domain/types.ts"; + +export type FavoriteIdLookup = Record; + +export const toFavoriteIdLookup = (dtos: ApiWorldHeritageDto[]): FavoriteIdLookup => + Object.fromEntries(dtos.map((dto) => [dto.id, true])); From 1e31710ddb860724451267c8b62429f0f93803cf Mon Sep 17 00:00:00 2001 From: Application-drop-up Date: Tue, 18 Aug 2026 09:40:46 +0900 Subject: [PATCH 2/2] feat(favorites): add useIsFavorited hook --- .../hooks/__tests__/use-is-favorited.test.ts | 150 ++++++++++++++++++ .../favorites/hooks/use-is-favorited.ts | 43 +++++ 2 files changed, 193 insertions(+) create mode 100644 client/src/app/features/favorites/hooks/__tests__/use-is-favorited.test.ts create mode 100644 client/src/app/features/favorites/hooks/use-is-favorited.ts diff --git a/client/src/app/features/favorites/hooks/__tests__/use-is-favorited.test.ts b/client/src/app/features/favorites/hooks/__tests__/use-is-favorited.test.ts new file mode 100644 index 0000000..29bda6d --- /dev/null +++ b/client/src/app/features/favorites/hooks/__tests__/use-is-favorited.test.ts @@ -0,0 +1,150 @@ +/** @jest-environment jsdom */ + +import { jest } from "@jest/globals"; + +jest.mock("../../apis", () => ({ + getFavorites: jest.fn(), +})); + +jest.mock("../../mappers/to-favorite-id-lookup", () => ({ + toFavoriteIdLookup: 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 { useIsFavorited } from "../use-is-favorited"; +import { getFavorites } from "../../apis"; +import { toFavoriteIdLookup } from "../../mappers/to-favorite-id-lookup"; +import { getStoredToken } from "@shared/auth/token-storage.ts"; +import type { ApiWorldHeritageDto } from "../../../../../domain/types.ts"; + +type GetFn = (token: string, opts?: { signal?: AbortSignal }) => Promise; +const getFavoritesMock = getFavorites as unknown as jest.MockedFunction; + +type MapFn = (dtos: ApiWorldHeritageDto[]) => Record; +const toFavoriteIdLookupMock = toFavoriteIdLookup as unknown as jest.MockedFunction; + +const getStoredTokenMock = getStoredToken as unknown as jest.MockedFunction<() => string | null>; + +const dto = { id: 1 } as unknown as ApiWorldHeritageDto; + +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("useIsFavorited", () => { + beforeEach(() => { + jest.clearAllMocks(); + getStoredTokenMock.mockReturnValue("1|abcdef"); + toFavoriteIdLookupMock.mockReturnValue({ 1: true }); + }); + + test("初期状態: isLoading=true, error=null, isFavoritedは常にfalse", () => { + getFavoritesMock.mockImplementation(() => new Promise(() => {})); + + const { result } = renderHook(() => useIsFavorited()); + + expect(result.current.isLoading).toBe(true); + expect(result.current.error).toBeNull(); + expect(result.current.isFavorited(1)).toBe(false); + }); + + test("成功パス: lookupに含まれるidはtrue、含まれないidはfalseを返す", async () => { + const d = deferred(); + getFavoritesMock.mockImplementation(() => d.promise); + + const { result } = renderHook(() => useIsFavorited()); + + await act(async () => { + d.resolve([dto]); + await d.promise; + }); + + await waitFor(() => expect(result.current.isLoading).toBe(false)); + + expect(result.current.isFavorited(1)).toBe(true); + expect(result.current.isFavorited(999)).toBe(false); + expect(getFavoritesMock).toHaveBeenCalledWith( + "1|abcdef", + expect.objectContaining({ signal: expect.any(Object) }), + ); + expect(toFavoriteIdLookupMock).toHaveBeenCalledWith([dto]); + }); + + test("未認証: tokenが無い場合はAPIを呼ばずisFavoritedは常にfalse", async () => { + getStoredTokenMock.mockReturnValue(null); + + const { result } = renderHook(() => useIsFavorited()); + + await waitFor(() => expect(result.current.isLoading).toBe(false)); + + expect(getFavoritesMock).not.toHaveBeenCalled(); + expect(result.current.isFavorited(1)).toBe(false); + expect(result.current.error).toBeNull(); + }); + + test("エラー: error に反映され、isFavoritedはfalseのまま", async () => { + const boom = new Error("boom"); + const d = deferred(); + getFavoritesMock.mockImplementation(() => d.promise); + + const { result } = renderHook(() => useIsFavorited()); + + await act(async () => { + d.reject(boom); + await d.promise.catch(() => {}); + }); + + await waitFor(() => expect(result.current.isLoading).toBe(false)); + + expect(result.current.error).toBe(boom); + expect(result.current.isFavorited(1)).toBe(false); + }); + + test("AbortError は無視される(エラー状態にしない)", async () => { + const d = deferred(); + getFavoritesMock.mockImplementation(() => d.promise); + + const { result } = renderHook(() => useIsFavorited()); + + await act(async () => { + d.reject(new DOMException("Aborted", "AbortError")); + await d.promise.catch(() => {}); + }); + + await waitFor(() => expect(result.current.isLoading).toBe(false)); + + expect(result.current.error).toBeNull(); + }); + + test("アンマウント時に現在のリクエストを abort する", () => { + const d = deferred(); + const signals: Array = []; + + getFavoritesMock.mockImplementation((_token, opts) => { + signals.push(opts?.signal); + return d.promise; + }); + + const { unmount } = renderHook(() => useIsFavorited()); + + unmount(); + + expect(signals[0]?.aborted).toBe(true); + }); +}); diff --git a/client/src/app/features/favorites/hooks/use-is-favorited.ts b/client/src/app/features/favorites/hooks/use-is-favorited.ts new file mode 100644 index 0000000..82f3412 --- /dev/null +++ b/client/src/app/features/favorites/hooks/use-is-favorited.ts @@ -0,0 +1,43 @@ +import { useCallback, useEffect, useRef, useState } from "react"; +import { getFavorites } from "../apis"; +import { toFavoriteIdLookup, type FavoriteIdLookup } from "../mappers/to-favorite-id-lookup"; +import { getStoredToken } from "@shared/auth/token-storage.ts"; + +const isAbortError = (element: unknown): boolean => { + return element instanceof DOMException && element.name === "AbortError"; +}; + +export function useIsFavorited() { + const [lookup, setLookup] = useState({}); + const [isLoading, setLoading] = useState(true); + const [error, setError] = useState(null); + const abortRef = useRef(null); + + useEffect(() => { + const abortController = new AbortController(); + abortRef.current = abortController; + + setLoading(true); + setError(null); + + const token = getStoredToken(); + if (!token) { + setLookup({}); + setLoading(false); + return () => abortController.abort(); + } + + getFavorites(token, { signal: abortController.signal }) + .then((dtos) => setLookup(toFavoriteIdLookup(dtos))) + .catch((element) => { + if (!isAbortError(element)) setError(element); + }) + .finally(() => setLoading(false)); + + return () => abortController.abort(); + }, []); + + const isFavorited = useCallback((heritageId: number) => Boolean(lookup[heritageId]), [lookup]); + + return { isFavorited, isLoading, error }; +}