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
33 changes: 33 additions & 0 deletions client/src/app/features/favorites/FavoritesLookupProvider.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
import React, { createContext, useMemo } from "react";
import type { ReactNode } from "react";
import { useAuth } from "@shared/auth/AuthHooks.ts";
import { useIsFavorited } from "./hooks/use-is-favorited";

type FavoritesLookupContextValue = {
isFavorited: (heritageId: number) => boolean;
isLoading: boolean;
error: unknown;
};

const FavoritesLookupContext = createContext<FavoritesLookupContextValue | undefined>(undefined);

function FavoritesLookupInner({ children }: { children: ReactNode }) {
const { isFavorited, isLoading, error } = useIsFavorited();

const value = useMemo(() => ({ isFavorited, isLoading, error }), [isFavorited, isLoading, error]);

return (
<FavoritesLookupContext.Provider value={value}>{children}</FavoritesLookupContext.Provider>
);
}

// GET /api/v1/favorites を一覧ページのカード枚数分バラバラに叩かないよう、
// 取得は最上位でこのProviderが1回だけ行い、各カードは共有のlookupを参照する。
export const FavoritesLookupProvider: React.FC<{ children: ReactNode }> = ({ children }) => {
const { user } = useAuth();

// keyでuser切り替え時にuseIsFavoritedを再マウントし、ログイン/ログアウトを反映する。
return <FavoritesLookupInner key={user?.id ?? "anonymous"}>{children}</FavoritesLookupInner>;
};

export default FavoritesLookupContext;
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
/** @jest-environment jsdom */

import { jest } from "@jest/globals";

const useAuthMock = jest.fn();

jest.mock("@shared/auth/AuthHooks.ts", () => ({
useAuth: () => useAuthMock(),
}));

const useIsFavoritedMock = jest.fn();

jest.mock("../hooks/use-is-favorited", () => ({
useIsFavorited: () => useIsFavoritedMock(),
}));

import { render, screen } from "@testing-library/react";
import { describe, test, expect, beforeEach } from "@jest/globals";
import { FavoritesLookupProvider } from "../FavoritesLookupProvider";
import { useFavoritesLookup } from "../hooks/use-favorites-lookup";

function Probe() {
const { isFavorited, isLoading } = useFavoritesLookup();
return (
<div data-testid="state">
{isLoading ? "loading" : isFavorited(1) ? "favorited" : "not-favorited"}
</div>
);
}

describe("FavoritesLookupProvider", () => {
beforeEach(() => {
jest.clearAllMocks();
});

test("useIsFavoritedの結果をcontext経由で公開する", () => {
useAuthMock.mockReturnValue({ user: { id: 1 } });
useIsFavoritedMock.mockReturnValue({
isFavorited: (id: number) => id === 1,
isLoading: false,
error: null,
});

render(
<FavoritesLookupProvider>
<Probe />
</FavoritesLookupProvider>,
);

expect(screen.getByTestId("state")).toHaveTextContent("favorited");
});

test("Provider外でuseFavoritesLookupを呼ぶとエラーになる", () => {
const spy = jest.spyOn(console, "error").mockImplementation(() => {});

expect(() => render(<Probe />)).toThrow(
"useFavoritesLookup must be used within FavoritesLookupProvider",
);

spy.mockRestore();
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -3,14 +3,11 @@
import "@testing-library/jest-dom/jest-globals";
import { jest } from "@jest/globals";

const submitAddMock = jest.fn();
const submitRemoveMock = jest.fn();
let useAddFavoriteReturn: { submit: typeof submitAddMock; isAdded: boolean; isLoading: boolean };
let useRemoveFavoriteReturn: {
submit: typeof submitRemoveMock;
isRemoved: boolean;
isLoading: boolean;
};
const submitAddMock = jest.fn<() => Promise<boolean>>();
const submitRemoveMock = jest.fn<() => Promise<boolean>>();
let useAddFavoriteReturn: { submit: typeof submitAddMock; isLoading: boolean };
let useRemoveFavoriteReturn: { submit: typeof submitRemoveMock; isLoading: boolean };
let useFavoritesLookupReturn: { isFavorited: (id: number) => boolean; isLoading: boolean };
let useAuthReturn: { user: { id: number } | null };

jest.mock("../../hooks/use-add-favorite", () => ({
Expand All @@ -21,19 +18,24 @@ jest.mock("../../hooks/use-remove-favorite", () => ({
useRemoveFavorite: () => useRemoveFavoriteReturn,
}));

jest.mock("../../hooks/use-favorites-lookup", () => ({
useFavoritesLookup: () => useFavoritesLookupReturn,
}));

jest.mock("@shared/auth/AuthHooks.ts", () => ({
useAuth: () => useAuthReturn,
}));

import { render, screen, fireEvent } from "@testing-library/react";
import { render, screen, fireEvent, waitFor } from "@testing-library/react";
import { describe, test, expect, beforeEach } from "@jest/globals";
import { FavoriteButtonContainer } from "../favorite-button-container";

describe("FavoriteButtonContainer", () => {
beforeEach(() => {
jest.clearAllMocks();
useAddFavoriteReturn = { submit: submitAddMock, isAdded: false, isLoading: false };
useRemoveFavoriteReturn = { submit: submitRemoveMock, isRemoved: false, isLoading: false };
useAddFavoriteReturn = { submit: submitAddMock, isLoading: false };
useRemoveFavoriteReturn = { submit: submitRemoveMock, isLoading: false };
useFavoritesLookupReturn = { isFavorited: () => false, isLoading: false };
useAuthReturn = { user: { id: 1 } };
});

Expand All @@ -45,7 +47,9 @@ describe("FavoriteButtonContainer", () => {
expect(container).toBeEmptyDOMElement();
});

test("未登録状態でクリックするとaddのsubmitが呼ばれる", () => {
test("lookupで未登録の場合は未登録表示になり、クリックでaddのsubmitが呼ばれる", () => {
submitAddMock.mockResolvedValue(true);

render(<FavoriteButtonContainer heritageId={42} />);

const button = screen.getByRole("button", { name: "Add to favorites" });
Expand All @@ -55,43 +59,58 @@ describe("FavoriteButtonContainer", () => {
expect(submitRemoveMock).not.toHaveBeenCalled();
});

test("isAdded=trueの場合は登録済み表示になり、クリックするとremoveのsubmitが呼ばれる", () => {
useAddFavoriteReturn = { submit: submitAddMock, isAdded: true, isLoading: false };
test("lookupで登録済みの場合は登録済み表示になり、クリックでremoveのsubmitが呼ばれる", () => {
useFavoritesLookupReturn = { isFavorited: (id) => id === 42, isLoading: false };
submitRemoveMock.mockResolvedValue(true);

render(<FavoriteButtonContainer heritageId={42} />);

const button = screen.getByRole("button", { name: "Favorited" });
expect(button).not.toBeDisabled();

fireEvent.click(button);

expect(submitRemoveMock).toHaveBeenCalledWith(42);
expect(submitAddMock).not.toHaveBeenCalled();
});

test("isAdded=true かつ isRemoved=true の場合は未登録表示に戻る", () => {
useAddFavoriteReturn = { submit: submitAddMock, isAdded: true, isLoading: false };
useRemoveFavoriteReturn = { submit: submitRemoveMock, isRemoved: true, isLoading: false };
test("add成功後は登録済み表示に切り替わる", async () => {
submitAddMock.mockResolvedValue(true);

render(<FavoriteButtonContainer heritageId={42} />);

expect(screen.getByRole("button", { name: "Add to favorites" })).toBeInTheDocument();
fireEvent.click(screen.getByRole("button", { name: "Add to favorites" }));

await waitFor(() => expect(screen.getByRole("button", { name: "Favorited" })).toBeVisible());
});

test("add処理中はボタンが無効化される", () => {
useAddFavoriteReturn = { submit: submitAddMock, isAdded: false, isLoading: true };
test("remove成功後は未登録表示に切り替わる", async () => {
useFavoritesLookupReturn = { isFavorited: (id) => id === 42, isLoading: false };
submitRemoveMock.mockResolvedValue(true);

render(<FavoriteButtonContainer heritageId={42} />);

expect(screen.getByRole("button", { name: "Add to favorites" })).toBeDisabled();
fireEvent.click(screen.getByRole("button", { name: "Favorited" }));

await waitFor(() =>
expect(screen.getByRole("button", { name: "Add to favorites" })).toBeVisible(),
);
});

test("remove処理中はボタンが無効化される", () => {
useAddFavoriteReturn = { submit: submitAddMock, isAdded: true, isLoading: false };
useRemoveFavoriteReturn = { submit: submitRemoveMock, isRemoved: false, isLoading: true };
test("add失敗時は未登録表示のまま", async () => {
submitAddMock.mockResolvedValue(false);

render(<FavoriteButtonContainer heritageId={42} />);

expect(screen.getByRole("button", { name: "Favorited" })).toBeDisabled();
fireEvent.click(screen.getByRole("button", { name: "Add to favorites" }));

await waitFor(() => expect(submitAddMock).toHaveBeenCalled());
expect(screen.getByRole("button", { name: "Add to favorites" })).toBeInTheDocument();
});

test("add/remove処理中はボタンが無効化される", () => {
useAddFavoriteReturn = { submit: submitAddMock, isLoading: true };

render(<FavoriteButtonContainer heritageId={42} />);

expect(screen.getByRole("button", { name: "Add to favorites" })).toBeDisabled();
});
});
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
import { useState } from "react";
import { useAddFavorite } from "../hooks/use-add-favorite";
import { useRemoveFavorite } from "../hooks/use-remove-favorite";
import { useFavoritesLookup } from "../hooks/use-favorites-lookup";
import { useAuth } from "@shared/auth/AuthHooks.ts";
import { FavoriteHeartButton } from "../components/FavoriteHeartButton";

Expand All @@ -11,18 +13,24 @@ export function FavoriteButtonContainer({
className?: string;
}) {
const { user } = useAuth();
const { submit: submitAdd, isAdded, isLoading: isAdding } = useAddFavorite();
const { submit: submitRemove, isRemoved, isLoading: isRemoving } = useRemoveFavorite();
const { isFavorited: isFavoritedInLookup } = useFavoritesLookup();
const { submit: submitAdd, isLoading: isAdding } = useAddFavorite();
const { submit: submitRemove, isLoading: isRemoving } = useRemoveFavorite();
const [override, setOverride] = useState<boolean | null>(null);

if (!user) return null;

const isFavorited = isAdded && !isRemoved;
const isFavorited = override ?? isFavoritedInLookup(heritageId);

const handleClick = () => {
if (isFavorited) {
void submitRemove(heritageId);
void submitRemove(heritageId).then((ok) => {
if (ok) setOverride(false);
});
} else {
void submitAdd(heritageId);
void submitAdd(heritageId).then((ok) => {
if (ok) setOverride(true);
});
}
};

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -59,7 +59,7 @@ describe("useAddFavorite", () => {

const { result } = renderHook(() => useAddFavorite());

let submitPromise!: Promise<void>;
let submitPromise!: Promise<boolean>;
act(() => {
submitPromise = result.current.submit(42);
});
Expand Down Expand Up @@ -104,7 +104,7 @@ describe("useAddFavorite", () => {
const { result } = renderHook(() => useAddFavorite());

const boom = new Error("boom");
let submitPromise!: Promise<void>;
let submitPromise!: Promise<boolean>;
act(() => {
submitPromise = result.current.submit(42);
});
Expand All @@ -125,7 +125,7 @@ describe("useAddFavorite", () => {

const { result } = renderHook(() => useAddFavorite());

let submitPromise!: Promise<void>;
let submitPromise!: Promise<boolean>;
act(() => {
submitPromise = result.current.submit(42);
});
Expand Down Expand Up @@ -158,7 +158,7 @@ describe("useAddFavorite", () => {

await waitFor(() => expect(signals).toHaveLength(1));

let secondSubmit!: Promise<void>;
let secondSubmit!: Promise<boolean>;
act(() => {
secondSubmit = result.current.submit(42);
});
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -59,7 +59,7 @@ describe("useRemoveFavorite", () => {

const { result } = renderHook(() => useRemoveFavorite());

let submitPromise!: Promise<void>;
let submitPromise!: Promise<boolean>;
act(() => {
submitPromise = result.current.submit(42);
});
Expand Down Expand Up @@ -104,7 +104,7 @@ describe("useRemoveFavorite", () => {
const { result } = renderHook(() => useRemoveFavorite());

const boom = new Error("boom");
let submitPromise!: Promise<void>;
let submitPromise!: Promise<boolean>;
act(() => {
submitPromise = result.current.submit(42);
});
Expand All @@ -125,7 +125,7 @@ describe("useRemoveFavorite", () => {

const { result } = renderHook(() => useRemoveFavorite());

let submitPromise!: Promise<void>;
let submitPromise!: Promise<boolean>;
act(() => {
submitPromise = result.current.submit(42);
});
Expand Down Expand Up @@ -158,7 +158,7 @@ describe("useRemoveFavorite", () => {

await waitFor(() => expect(signals).toHaveLength(1));

let secondSubmit!: Promise<void>;
let secondSubmit!: Promise<boolean>;
act(() => {
secondSubmit = result.current.submit(42);
});
Expand Down
4 changes: 3 additions & 1 deletion client/src/app/features/favorites/hooks/use-add-favorite.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ export function useAddFavorite() {
return () => abortRef.current?.abort();
}, []);

const submit = useCallback(async (worldHeritageId: number) => {
const submit = useCallback(async (worldHeritageId: number): Promise<boolean> => {
abortRef.current?.abort();
const abortController = new AbortController();
abortRef.current = abortController;
Expand All @@ -34,8 +34,10 @@ export function useAddFavorite() {
signal: abortController.signal,
});
setIsAdded(true);
return true;
} catch (element) {
if (!isAbortError(element)) setError(element);
return false;
} finally {
setLoading(false);
}
Expand Down
10 changes: 10 additions & 0 deletions client/src/app/features/favorites/hooks/use-favorites-lookup.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
import { useContext } from "react";
import FavoritesLookupContext from "../FavoritesLookupProvider.tsx";

export const useFavoritesLookup = () => {
const context = useContext(FavoritesLookupContext);
if (!context) {
throw new Error("useFavoritesLookup must be used within FavoritesLookupProvider");
}
return context;
};
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ export function useRemoveFavorite() {
return () => abortRef.current?.abort();
}, []);

const submit = useCallback(async (worldHeritageId: number) => {
const submit = useCallback(async (worldHeritageId: number): Promise<boolean> => {
abortRef.current?.abort();
const abortController = new AbortController();
abortRef.current = abortController;
Expand All @@ -34,8 +34,10 @@ export function useRemoveFavorite() {
signal: abortController.signal,
});
setIsRemoved(true);
return true;
} catch (element) {
if (!isAbortError(element)) setError(element);
return false;
} finally {
setLoading(false);
}
Expand Down
Loading
Loading