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
Original file line number Diff line number Diff line change
@@ -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<ApiWorldHeritageDto[]>;
const getFavoritesMock = getFavorites as unknown as jest.MockedFunction<GetFn>;

type MapFn = (dtos: ApiWorldHeritageDto[]) => Record<number, true>;
const toFavoriteIdLookupMock = toFavoriteIdLookup as unknown as jest.MockedFunction<MapFn>;

const getStoredTokenMock = getStoredToken as unknown as jest.MockedFunction<() => string | null>;

const dto = { id: 1 } as unknown as ApiWorldHeritageDto;

type Deferred<T> = {
promise: Promise<T>;
resolve: (v: T) => void;
reject: (e: unknown) => void;
};
const deferred = <T>(): Deferred<T> => {
let resolve!: (v: T) => void;
let reject!: (e: unknown) => void;
const promise = new Promise<T>((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<ApiWorldHeritageDto[]>();
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<ApiWorldHeritageDto[]>();
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<ApiWorldHeritageDto[]>();
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<ApiWorldHeritageDto[]>();
const signals: Array<AbortSignal | undefined> = [];

getFavoritesMock.mockImplementation((_token, opts) => {
signals.push(opts?.signal);
return d.promise;
});

const { unmount } = renderHook(() => useIsFavorited());

unmount();

expect(signals[0]?.aborted).toBe(true);
});
});
43 changes: 43 additions & 0 deletions client/src/app/features/favorites/hooks/use-is-favorited.ts
Original file line number Diff line number Diff line change
@@ -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<FavoriteIdLookup>({});
const [isLoading, setLoading] = useState(true);
const [error, setError] = useState<unknown>(null);
const abortRef = useRef<AbortController | null>(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 };
}
Original file line number Diff line number Diff line change
@@ -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> = {}): 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({});
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
import type { ApiWorldHeritageDto } from "../../../../domain/types.ts";

export type FavoriteIdLookup = Record<number, true>;

export const toFavoriteIdLookup = (dtos: ApiWorldHeritageDto[]): FavoriteIdLookup =>
Object.fromEntries(dtos.map((dto) => [dto.id, true]));
Loading