diff --git a/package-lock.json b/package-lock.json index dee0ee6..197319e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "devnogi-front", - "version": "1.0.33", + "version": "1.0.34", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "devnogi-front", - "version": "1.0.33", + "version": "1.0.34", "dependencies": { "@hookform/resolvers": "^5.1.1", "@radix-ui/react-checkbox": "^1.3.3", diff --git a/package.json b/package.json index f6792b1..de79428 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "devnogi-front", - "version": "1.0.33", + "version": "1.0.34", "private": true, "scripts": { "dev": "next dev", diff --git a/scripts/test-production-workflow.mjs b/scripts/test-production-workflow.mjs index d37633c..ac5bdd9 100644 --- a/scripts/test-production-workflow.mjs +++ b/scripts/test-production-workflow.mjs @@ -64,7 +64,7 @@ test("production Compose requires the immutable image reference", () => { test("package and lockfile agree on the next release version", () => { const pkg = JSON.parse(read("package.json")); const lock = JSON.parse(read("package-lock.json")); - assert.equal(pkg.version, "1.0.33"); + assert.equal(pkg.version, "1.0.34"); assert.equal(lock.version, pkg.version); assert.equal(lock.packages[""].version, pkg.version); }); diff --git a/scripts/workflow-contract.test.mjs b/scripts/workflow-contract.test.mjs index 5ba72a6..015ea7b 100644 --- a/scripts/workflow-contract.test.mjs +++ b/scripts/workflow-contract.test.mjs @@ -68,7 +68,7 @@ test("release version and immutable image contract are fixed", () => { ); const pkg = JSON.parse(read("package.json")); const lock = JSON.parse(read("package-lock.json")); - assert.equal(pkg.version, "1.0.33"); + assert.equal(pkg.version, "1.0.34"); assert.equal(lock.version, pkg.version); assert.equal(lock.packages[""].version, pkg.version); assert.match( diff --git a/src/app/(main)/auction-history/AuctionHistoryClient.tsx b/src/app/(main)/auction-history/AuctionHistoryClient.tsx index 4e63a72..bf72c9b 100644 --- a/src/app/(main)/auction-history/AuctionHistoryClient.tsx +++ b/src/app/(main)/auction-history/AuctionHistoryClient.tsx @@ -11,6 +11,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import CategorySection from "@/components/commons/Category"; import CategoryDropdown from "@/components/commons/CategoryDropdown"; +import DataFetchError from "@/components/commons/DataFetchError"; import { MetalwareFilterItem } from "@/components/commons/MetalwareSearchFilter"; import AuctionItemSearch from "@/components/page/auction-common/AuctionItemSearch"; import AuctionHistoryList from "@/components/page/auction-history/AuctionHistoryList"; @@ -530,7 +531,9 @@ export default function Page({ header }: { header?: React.ReactNode }) { showMobileFilter, } = useAuctionHistoryLayout(); - const { data, isLoading, isFetching } = useAuctionHistory(searchParams); + const { data, isLoading, isFetching, isError, refetch } = + useAuctionHistory(searchParams); + const hasData = data !== undefined; const { isPreviewEnabled, setIsPreviewEnabled } = useAuctionItemPreviewPreference(); @@ -548,7 +551,7 @@ export default function Page({ header }: { header?: React.ReactNode }) { option.direction === searchParams.direction, ) || SORT_OPTIONS[0]; - const isResultCountLoading = isLoading || isFetching; + const isResultCountLoading = !hasData && isLoading; const resultCountNode = isResultCountLoading ? ( ) : ( @@ -1144,18 +1147,24 @@ export default function Page({ header }: { header?: React.ReactNode }) {
- {itemName ? ( - <> - - {itemName} - {" "} - 검색결과 - {resultCountNode}개 - + {isError && !hasData ? ( + "검색결과를 불러오지 못했습니다" ) : ( <> - 검색결과 - {resultCountNode}개 + {itemName ? ( + <> + + {itemName} + {" "} + 검색결과 + {resultCountNode}개 + + ) : ( + <> + 검색결과 + {resultCountNode}개 + + )} )}
@@ -1211,11 +1220,28 @@ export default function Page({ header }: { header?: React.ReactNode }) {
- + {isError && ( + { + if (!isFetching) void refetch(); + }} + className="mb-4" + /> + )} + {(!isError || hasData) && ( + + )} {isFetching && !isLoading && (
@@ -1223,13 +1249,15 @@ export default function Page({ header }: { header?: React.ReactNode }) {
)} - + {hasData && ( + + )} diff --git a/src/app/(main)/auction-history/page.test.tsx b/src/app/(main)/auction-history/page.test.tsx index 8d7d4ca..da40ed0 100644 --- a/src/app/(main)/auction-history/page.test.tsx +++ b/src/app/(main)/auction-history/page.test.tsx @@ -1,5 +1,6 @@ import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { + act, cleanup, fireEvent, render, @@ -365,16 +366,68 @@ describe("TradeLogPage", () => { ); }); - it("API 오류가 나면 로딩을 마치고 빈 목록을 렌더링한다", async () => { - fetchSpy.mockRejectedValue(new Error("API Error")); + it("최초 실패를 빈 결과와 구분하고 다시 시도로 복구한다", async () => { + fetchSpy + .mockResolvedValueOnce(Response.json({}, { status: 500 })) + .mockResolvedValue(response(["복구된 거래"])); + renderPage(); + await screen.findByText("거래내역을 불러오지 못했습니다."); + expect(screen.queryByTestId("trade-log-list")).not.toBeInTheDocument(); + expect(screen.queryByText("0")).not.toBeInTheDocument(); + expect( + screen.queryByRole("button", { name: "다음 페이지" }), + ).not.toBeInTheDocument(); + fireEvent.click(screen.getByRole("button", { name: "다시 시도" })); + expect(await screen.findByText("복구된 거래")).toBeInTheDocument(); + expect( + screen.queryByText("거래내역을 불러오지 못했습니다."), + ).not.toBeInTheDocument(); + expect(fetchSpy).toHaveBeenCalledTimes(2); + }); + + it("같은 조건의 재조회 실패는 기존 거래와 건수를 유지한다", async () => { + fetchSpy + .mockResolvedValueOnce(response(["기존 거래"])) + .mockRejectedValue(new Error("offline")); + renderPage(); + await screen.findByText("기존 거래"); + await act(async () => { + await queryClient.invalidateQueries({ queryKey: ["auction-history"] }); + }); + await screen.findByText( + "거래내역을 갱신하지 못했습니다. 이전 결과를 표시합니다.", + ); + expect(screen.getByText("기존 거래")).toBeInTheDocument(); + expect(screen.getByText("1")).toBeInTheDocument(); + fetchSpy.mockResolvedValue(response(["최신 거래"])); + fireEvent.click(screen.getByRole("button", { name: "다시 시도" })); + expect(await screen.findByText("최신 거래")).toBeInTheDocument(); + expect(screen.queryByText("기존 거래")).not.toBeInTheDocument(); + }); + + it("검색 조건 변경 후 실패하면 이전 조건의 결과를 표시하지 않는다", async () => { + fetchSpy + .mockResolvedValueOnce(response(["이전 조건 거래"])) + .mockRejectedValue(new Error("offline")); + renderPage(); + await screen.findByText("이전 조건 거래"); + fireEvent.click(screen.getByRole("button", { name: "Direct Search" })); + await screen.findByText("거래내역을 불러오지 못했습니다."); + expect(screen.queryByText("이전 조건 거래")).not.toBeInTheDocument(); + }); + + it("성공한 빈 응답은 오류 없이 0건으로 표시한다", async () => { + fetchSpy.mockResolvedValue(response([])); renderPage(); - await waitFor(() => expect(fetchSpy).toHaveBeenCalled()); await waitFor(() => expect(screen.getByTestId("trade-log-list")).toHaveAttribute( "aria-busy", "false", ), ); - expect(screen.queryAllByTestId("log-item")).toHaveLength(0); + expect(screen.getByText("0")).toBeInTheDocument(); + expect( + screen.queryByRole("button", { name: "다시 시도" }), + ).not.toBeInTheDocument(); }); }); diff --git a/src/app/(main)/auction-realtime/AuctionRealtimeClient.test.tsx b/src/app/(main)/auction-realtime/AuctionRealtimeClient.test.tsx new file mode 100644 index 0000000..e8a582b --- /dev/null +++ b/src/app/(main)/auction-realtime/AuctionRealtimeClient.test.tsx @@ -0,0 +1,199 @@ +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { + act, + fireEvent, + render, + screen, + waitFor, +} from "@testing-library/react"; + +import AuctionRealtimeClient from "./AuctionRealtimeClient"; + +const mockRouter = { replace: jest.fn() }; +let mockUrlParams = new URLSearchParams(); + +jest.mock("next/navigation", () => ({ + useRouter: () => mockRouter, + usePathname: () => "/auction-realtime", + useSearchParams: () => mockUrlParams, +})); +jest.mock("@/hooks/useItemCategories", () => { + const categories = [{ id: "all", name: "전체", children: [] }]; + return { useItemCategories: () => ({ data: categories, isLoading: false }) }; +}); +jest.mock("@/hooks/useAuctionHistoryLayout", () => ({ + useAuctionHistoryLayout: () => ({ + layoutMode: "desktop", + showCategorySidebar: false, + showFilterSidebar: false, + showMobileFilter: false, + }), +})); +jest.mock("@/hooks/useAuctionItemPreviewPreference", () => ({ + useAuctionItemPreviewPreference: () => ({ + isPreviewEnabled: false, + setIsPreviewEnabled: jest.fn(), + }), +})); +jest.mock("@/components/page/auction-common/AuctionItemSearch", () => { + return function Search({ onSearch }: { onSearch: (name: string) => void }) { + return ; + }; +}); + +function response(names = ["불타래"], page = 1, totalPages = 1) { + return Response.json({ + success: true, + data: { + items: names.map((name, index) => ({ + id: 123 + index, + itemName: name, + itemDisplayName: name, + itemCount: 1, + auctionPricePerUnit: 100, + dateRegister: "2026-11-14T10:00:00", + dateAuctionExpire: "2026-11-17T10:00:00", + itemTopCategory: "기타", + itemSubCategory: "불타래", + itemOptions: [], + })), + meta: { + currentPage: page, + pageSize: 20, + totalPages, + totalElements: names.length, + isFirst: page === 1, + isLast: page >= totalPages, + }, + }, + }); +} + +let fetchSpy: jest.SpyInstance; +const clients: QueryClient[] = []; + +beforeEach(() => { + mockUrlParams = new URLSearchParams("item_name=불타래"); + mockRouter.replace.mockClear(); + fetchSpy = jest.spyOn(global, "fetch"); +}); +afterEach(() => { + clients.forEach((client) => client.clear()); + clients.length = 0; + jest.restoreAllMocks(); +}); + +function renderPage() { + const client = new QueryClient({ + defaultOptions: { queries: { retry: false } }, + }); + clients.push(client); + return render( + + + , + ); +} + +it("shows an initial error instead of empty results and retries the current query", async () => { + fetchSpy.mockImplementation(async () => new Response(null, { status: 500 })); + renderPage(); + await screen.findByText("현재 매물을 불러오지 못했습니다."); + expect(screen.queryByText("검색 결과가 없습니다.")).not.toBeInTheDocument(); + expect( + screen.queryByRole("button", { name: "다음 페이지" }), + ).not.toBeInTheDocument(); + const previousCalls = fetchSpy.mock.calls.length; + const failedUrl = fetchSpy.mock.calls.at(-1)![0]; + fetchSpy.mockImplementation(async () => response()); + fireEvent.click(screen.getByRole("button", { name: "다시 시도" })); + await screen.findAllByRole("link", { name: /불타래/ }); + expect(fetchSpy).toHaveBeenCalledTimes(previousCalls + 1); + expect(fetchSpy.mock.calls.at(-1)![0]).toBe(failedUrl); + expect( + screen.queryByText("현재 매물을 불러오지 못했습니다."), + ).not.toBeInTheDocument(); +}); + +it("renders actual IDs and preserves results and last success time on refresh failure", async () => { + fetchSpy.mockImplementation(async () => response()); + renderPage(); + const links = await screen.findAllByRole("link", { name: /불타래/ }); + expect(links).toHaveLength(2); + links.forEach((link) => + expect(link.getAttribute("href")).toContain( + "/auction-realtime/123?return_to=", + ), + ); + const lastChecked = screen.getByText(/^마지막 조회/).textContent; + fetchSpy.mockImplementation(async () => new Response(null, { status: 500 })); + fireEvent.click(screen.getByRole("button", { name: "새로고침" })); + await screen.findByText("갱신하지 못했습니다. 이전 조회 결과를 표시합니다."); + expect(screen.getAllByRole("link", { name: /불타래/ })).toHaveLength(2); + expect(screen.getByText(/^마지막 조회/)).toHaveTextContent(lastChecked!); +}); + +it("only shows empty results for a successful empty response", async () => { + fetchSpy.mockImplementation(async () => response([])); + renderPage(); + expect(await screen.findByText("검색 결과가 없습니다.")).toBeInTheDocument(); + expect( + screen.queryByRole("button", { name: "다시 시도" }), + ).not.toBeInTheDocument(); +}); + +it("does not label results from previous search conditions as the new query", async () => { + fetchSpy.mockImplementation(async () => response()); + renderPage(); + await screen.findAllByRole("link", { name: /불타래/ }); + fetchSpy.mockImplementation(async () => new Response(null, { status: 500 })); + fireEvent.click(screen.getByRole("button", { name: "조건 변경" })); + await screen.findByText("현재 매물을 불러오지 못했습니다."); + expect( + screen.queryByRole("link", { name: /불타래/ }), + ).not.toBeInTheDocument(); +}); + +it("deduplicates manual refresh while keeping the selected page and sort", async () => { + mockUrlParams = new URLSearchParams( + "item_name=불타래&page=2&sort_by=auctionPricePerUnit&direction=asc", + ); + let resolveRefresh: (value: Response) => void = () => {}; + fetchSpy.mockImplementation(async () => response(["불타래"], 2, 3)); + renderPage(); + await screen.findAllByRole("link", { name: /불타래/ }); + const previousCalls = fetchSpy.mock.calls.length; + const previousUrl = fetchSpy.mock.calls.at(-1)![0]; + fetchSpy.mockImplementationOnce( + () => + new Promise((resolve) => { + resolveRefresh = resolve; + }), + ); + const button = screen.getByRole("button", { name: "새로고침" }); + fireEvent.click(button); + fireEvent.click(button); + await waitFor(() => expect(button).toBeDisabled()); + expect(fetchSpy).toHaveBeenCalledTimes(previousCalls + 1); + expect(fetchSpy.mock.calls.at(-1)![0]).toBe(previousUrl); + await act(async () => resolveRefresh(response(["불타래"], 2, 3))); + await waitFor(() => expect(button).toBeEnabled()); +}); + +it("clamps a removed page only after a successful response", async () => { + mockUrlParams = new URLSearchParams("page=3&item_name=불타래"); + fetchSpy.mockImplementation(async (input: string) => { + const page = Number( + new URL(input, "http://localhost").searchParams.get("page"), + ); + return page === 3 ? response([], 3, 2) : response(["남은 매물"], page, 2); + }); + renderPage(); + await screen.findAllByRole("link", { name: /남은 매물/ }); + await waitFor(() => + expect(String(fetchSpy.mock.calls.at(-1)![0])).toContain("page=2"), + ); + expect( + fetchSpy.mock.calls.filter(([input]) => String(input).includes("page=2")), + ).toHaveLength(1); +}); diff --git a/src/app/(main)/auction-realtime/AuctionRealtimeClient.tsx b/src/app/(main)/auction-realtime/AuctionRealtimeClient.tsx index eecb20a..bea6bcf 100644 --- a/src/app/(main)/auction-realtime/AuctionRealtimeClient.tsx +++ b/src/app/(main)/auction-realtime/AuctionRealtimeClient.tsx @@ -11,7 +11,9 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import CategorySection from "@/components/commons/Category"; import CategoryDropdown from "@/components/commons/CategoryDropdown"; +import DataFetchError from "@/components/commons/DataFetchError"; import { MetalwareFilterItem } from "@/components/commons/MetalwareSearchFilter"; +import QueryRefreshControls from "@/components/commons/QueryRefreshControls"; import AuctionItemSearch from "@/components/page/auction-common/AuctionItemSearch"; import AuctionRealtimeList from "@/components/page/auction-realtime/AuctionRealtimeList"; import MobileFilterChips from "@/components/page/auction-realtime/MobileFilterChips"; @@ -505,7 +507,8 @@ export default function Page({ header }: { header?: React.ReactNode }) { showMobileFilter, } = useAuctionHistoryLayout(); - const { data, isLoading, isFetching } = useAuctionRealtime(searchParams); + const { data, isLoading, isFetching, isError, refetch, dataUpdatedAt } = + useAuctionRealtime(searchParams); const { isPreviewEnabled, setIsPreviewEnabled } = useAuctionItemPreviewPreference(); @@ -515,6 +518,19 @@ export default function Page({ header }: { header?: React.ReactNode }) { const currentPage = meta?.currentPage ?? searchParams.page ?? 1; const totalPages = meta?.totalPages ?? 1; const pageSize = meta?.pageSize ?? searchParams.size ?? DEFAULT_PAGE_SIZE; + const hasData = data !== undefined; + const initialError = isError && !hasData; + const refresh = () => { + if (!isFetching) void refetch({ cancelRefetch: false }); + }; + + useEffect(() => { + if (!data || isError || isFetching) return; + const lastPage = Math.max(1, data.meta.totalPages); + if ((searchParams.page ?? 1) > lastPage) { + setSearchParams((previous) => ({ ...previous, page: lastPage })); + } + }, [data, isError, isFetching, searchParams.page]); const selectedSortOption = SORT_OPTIONS.find( @@ -523,7 +539,7 @@ export default function Page({ header }: { header?: React.ReactNode }) { option.direction === searchParams.direction, ) || SORT_OPTIONS[0]; - const isResultCountLoading = isLoading || isFetching; + const isResultCountLoading = !hasData && !isError; const resultCountNode = isResultCountLoading ? ( ) : ( @@ -1087,9 +1103,17 @@ export default function Page({ header }: { header?: React.ReactNode }) { )}
+
- {itemName ? ( + {initialError ? ( + "조회하지 못했습니다" + ) : itemName ? ( <> {itemName} @@ -1156,25 +1180,35 @@ export default function Page({ header }: { header?: React.ReactNode }) {
- - - {isFetching && !isLoading && ( -
-
로딩 중...
-
+ {isError && ( + + )} + {!initialError && ( + + )} + {hasData && ( + )} - -
diff --git a/src/app/(main)/auction-realtime/[auctionId]/page.test.tsx b/src/app/(main)/auction-realtime/[auctionId]/page.test.tsx index da2088e..ee291c8 100644 --- a/src/app/(main)/auction-realtime/[auctionId]/page.test.tsx +++ b/src/app/(main)/auction-realtime/[auctionId]/page.test.tsx @@ -32,11 +32,11 @@ it("uses a self canonical independent of return_to", async () => { record: { itemName: "검" }, } as never); const result = await generateMetadata({ - params: Promise.resolve({ auctionId: "record-1" }), + params: Promise.resolve({ auctionId: "123" }), searchParams: Promise.resolve({ return_to: "/auction-realtime?page=2" }), }); expect(result.alternates?.canonical).toBe( - "https://www.memonogi.com/auction-realtime/record-1", + "https://www.memonogi.com/auction-realtime/123", ); }); it("propagates missing and failed primary records", async () => { diff --git a/src/app/(main)/horn-bugle/HornBugleClient.test.tsx b/src/app/(main)/horn-bugle/HornBugleClient.test.tsx index fae02b5..7d1c64e 100644 --- a/src/app/(main)/horn-bugle/HornBugleClient.test.tsx +++ b/src/app/(main)/horn-bugle/HornBugleClient.test.tsx @@ -1,10 +1,11 @@ -import { fireEvent, render, screen } from "@testing-library/react"; +import { fireEvent, render, screen, waitFor } from "@testing-library/react"; import { useInfiniteHornBugle } from "@/hooks/useInfiniteHornBugle"; import HornBugleClient from "./HornBugleClient"; let query = ""; +let intersection: IntersectionObserverCallback; const replace = jest.fn((url: string) => { query = url.split("?")[1] ?? ""; }); @@ -20,6 +21,9 @@ jest.mock("@/hooks/useInfiniteHornBugle", () => ({ beforeAll(() => { class IntersectionObserverMock { + constructor(callback: IntersectionObserverCallback) { + intersection = callback; + } observe = jest.fn(); unobserve = jest.fn(); disconnect = jest.fn(); @@ -30,16 +34,106 @@ beforeAll(() => { }); }); -beforeEach(() => { - query = ""; - replace.mockClear(); - jest.mocked(useInfiniteHornBugle).mockReturnValue({ +const message = { + id: 1, + serverName: "류트", + characterName: "테스터", + message: "읽던 메시지", + dateSend: "2026-11-14T12:00:00Z", + dateRegister: "2026-11-14T12:00:00Z", +}; +function queryState(overrides: Record = {}) { + return { data: { pages: [{ items: [], meta: { isLast: true } }] }, isLoading: false, + isError: false, + isFetching: false, isFetchingNextPage: false, + isFetchNextPageError: false, hasNextPage: false, fetchNextPage: jest.fn(), - } as unknown as ReturnType); + refreshLatest: jest.fn().mockResolvedValue(true), + isRefreshing: false, + refreshError: null, + checkError: null, + isChecking: false, + hasNewMessages: false, + lastCheckedAt: 0, + ...overrides, + } as unknown as ReturnType; +} + +beforeEach(() => { + query = ""; + replace.mockClear(); + Element.prototype.scrollIntoView = jest.fn(); + jest.mocked(useInfiniteHornBugle).mockReturnValue(queryState()); +}); + +it("처음 조회 실패를 빈 목록과 구분하고 다시 시도한다", async () => { + const state = queryState({ data: undefined, isError: true }); + jest.mocked(useInfiniteHornBugle).mockReturnValue(state); + render(); + expect( + screen.getByText("뿔피리 정보를 불러오지 못했습니다."), + ).toBeInTheDocument(); + expect(screen.queryByText(/검색 결과가 없습니다/)).not.toBeInTheDocument(); + fireEvent.click(screen.getByRole("button", { name: "다시 시도" })); + await waitFor(() => expect(state.refreshLatest).toHaveBeenCalledTimes(1)); +}); + +it("새 메시지를 알려도 읽던 목록을 유지하고 명시적 적용 성공 후에만 스크롤한다", async () => { + const state = queryState({ + data: { pages: [{ items: [message], meta: { isLast: true } }] }, + hasNewMessages: true, + }); + jest.mocked(useInfiniteHornBugle).mockReturnValue(state); + render(); + expect(screen.getByText("새 메시지가 있습니다")).toBeInTheDocument(); + expect(screen.getByText("읽던 메시지")).toBeInTheDocument(); + expect(Element.prototype.scrollIntoView).not.toHaveBeenCalled(); + fireEvent.click(screen.getByRole("button", { name: "최신 목록 보기" })); + await waitFor(() => + expect(Element.prototype.scrollIntoView).toHaveBeenCalledTimes(1), + ); +}); + +it("갱신 실패 시 읽던 메시지를 유지하고 스크롤하지 않는다", async () => { + const refreshLatest = jest.fn().mockResolvedValue(false); + jest.mocked(useInfiniteHornBugle).mockReturnValue( + queryState({ + data: { pages: [{ items: [message], meta: { isLast: true } }] }, + refreshError: new Error("failed"), + refreshLatest, + }), + ); + render(); + expect(screen.getByText("읽던 메시지")).toBeInTheDocument(); + fireEvent.click(screen.getByRole("button", { name: "다시 시도" })); + await waitFor(() => expect(refreshLatest).toHaveBeenCalledTimes(1)); + expect(Element.prototype.scrollIntoView).not.toHaveBeenCalled(); +}); + +it("다음 페이지 실패 시 자동 요청을 멈추고 하단에서만 재시도한다", () => { + const fetchNextPage = jest.fn(); + jest.mocked(useInfiniteHornBugle).mockReturnValue( + queryState({ + data: { pages: [{ items: [message], meta: { isLast: false } }] }, + isError: true, + isFetchNextPageError: true, + hasNextPage: true, + fetchNextPage, + }), + ); + render(); + intersection( + [{ isIntersecting: true } as IntersectionObserverEntry], + {} as IntersectionObserver, + ); + expect(fetchNextPage).not.toHaveBeenCalled(); + expect(screen.getByText("읽던 메시지")).toBeInTheDocument(); + fireEvent.click(screen.getByRole("button", { name: "다시 시도" })); + expect(fetchNextPage).toHaveBeenCalledWith({ cancelRefetch: false }); }); it("통합 검색어를 URL에 기록하고 선택한 서버 탭을 유지한다", () => { diff --git a/src/app/(main)/horn-bugle/HornBugleClient.tsx b/src/app/(main)/horn-bugle/HornBugleClient.tsx index 8f0e719..50b805a 100644 --- a/src/app/(main)/horn-bugle/HornBugleClient.tsx +++ b/src/app/(main)/horn-bugle/HornBugleClient.tsx @@ -12,7 +12,10 @@ import { useState, } from "react"; +import DataFetchError from "@/components/commons/DataFetchError"; +import QueryRefreshControls from "@/components/commons/QueryRefreshControls"; import HornBugleList from "@/components/page/horn-bugle/HornBugleList"; +import { Button } from "@/components/ui/button"; import { useInfiniteHornBugle } from "@/hooks/useInfiniteHornBugle"; import { HORN_BUGLE_SERVERS } from "@/types/horn-bugle"; @@ -24,6 +27,7 @@ function HornBugleContent() { const pathname = usePathname(); const [selectedServer, setSelectedServer] = useState("전체"); const loadMoreRef = useRef(null); + const listStartRef = useRef(null); // URL에서 검색어 읽기 const urlSearchParams = useSearchParams(); @@ -56,8 +60,28 @@ function HornBugleContent() { [selectedServer, keyword], ); - const { data, isLoading, isFetchingNextPage, hasNextPage, fetchNextPage } = - useInfiniteHornBugle(fetchParams); + const { + data, + isLoading, + isError, + isFetching, + isFetchingNextPage, + isFetchNextPageError, + hasNextPage, + fetchNextPage, + refreshLatest, + isRefreshing, + refreshError, + checkError, + isChecking, + hasNewMessages, + lastCheckedAt, + } = useInfiniteHornBugle(fetchParams); + const busy = isFetching || isChecking || isRefreshing; + const refresh = async () => { + if (await refreshLatest()) + listStartRef.current?.scrollIntoView({ block: "start" }); + }; const allItems = useMemo( () => data?.pages.flatMap((page) => page.items) ?? [], @@ -72,8 +96,15 @@ function HornBugleContent() { useEffect(() => { const observer = new IntersectionObserver( (entries) => { - if (entries[0].isIntersecting && hasNextPage && !isFetchingNextPage) { - fetchNextPage(); + if ( + entries[0].isIntersecting && + hasNextPage && + !busy && + !isError && + !refreshError && + !checkError + ) { + void fetchNextPage({ cancelRefetch: false }); } }, { threshold: 0.1 }, @@ -85,7 +116,7 @@ function HornBugleContent() { return () => { if (currentRef) observer.unobserve(currentRef); }; - }, [hasNextPage, isFetchingNextPage, fetchNextPage]); + }, [hasNextPage, busy, isError, refreshError, checkError, fetchNextPage]); return (
@@ -151,6 +182,54 @@ function HornBugleContent() {
+
+ { + void refresh(); + }} + className="mb-3" + /> +

+ 1분마다 최신 정보를 확인합니다. 원본 수집 주기에 따라 내용이 같을 수 + 있습니다. +

+
+ {hasNewMessages && ( +
+

새 메시지가 있습니다

+ +
+ )} + {data !== undefined && + (refreshError || + checkError || + (isError && !isFetchNextPageError)) && ( + { + void refresh(); + }} + className="mb-4" + /> + )} + {/* 결과 카운트 */} {allItems.length > 0 && (
@@ -159,7 +238,29 @@ function HornBugleContent() { )} {/* 리스트 */} - + {data === undefined && (isError || refreshError) ? ( + { + void refresh(); + }} + /> + ) : ( + + )} + + {isFetchNextPageError && data !== undefined && ( + { + void fetchNextPage({ cancelRefetch: false }); + }} + className="mt-4" + /> + )} {/* 무한 스크롤 트리거 */}
({ + useRouter: () => mockRouter, + usePathname: () => "/statistics", + useSearchParams: () => mockSearchParams, +})); +jest.mock("@/hooks/useItemCategories", () => ({ + useItemCategories: () => ({ data: [] }), +})); +jest.mock("@/hooks/useItemInfos", () => ({ + useItemInfos: () => ({ data: [] }), +})); +jest.mock("@/components/page/statistics/StatisticsExamples", () => () => null); +jest.mock("@/components/page/statistics/PriceChart", () => () => null); +jest.mock("@/components/page/statistics/VolumeChart", () => () => null); +jest.mock("@/components/page/statistics/StatisticsTable", () => () => null); +jest.mock( + "@/components/page/statistics/StatisticsSummaryCards", + () => + function MockStatisticsSummary({ data }: { data: StatisticsBase[] }) { + return
평균 가격 {data[0]?.avgPrice}
; + }, +); + +const row = { + itemName: "나무장작", + date: "2026-11-14", + minPrice: 50, + maxPrice: 150, + avgPrice: 100, + totalQuantity: 3, + totalVolume: 300, +}; +let client: QueryClient; +let fetchSpy: jest.SpyInstance; + +function renderPage() { + client = new QueryClient({ + defaultOptions: { queries: { retry: false, gcTime: 0 } }, + }); + return render( + + + , + ); +} +const success = (rows: (typeof row)[]) => + Response.json({ success: true, data: rows }); + +beforeEach(() => { + localStorage.clear(); + fetchSpy = jest.spyOn(globalThis, "fetch"); +}); +afterEach(() => { + cleanup(); + client.clear(); + fetchSpy.mockRestore(); +}); + +it("최초 실패는 빈 통계로 보이지 않고 활성 기간만 재시도한다", async () => { + let dailyFails = true; + fetchSpy.mockImplementation(async (input: string) => + input.includes("/daily/") + ? dailyFails + ? Response.json({}, { status: 500 }) + : success([row]) + : success([]), + ); + renderPage(); + await screen.findByText("통계를 불러오지 못했습니다."); + expect(screen.queryByText("조회 결과가 없습니다")).not.toBeInTheDocument(); + const weeklyCalls = fetchSpy.mock.calls.filter(([url]) => + String(url).includes("/weekly/"), + ).length; + dailyFails = false; + fireEvent.click(screen.getByRole("button", { name: "다시 시도" })); + expect(await screen.findByText("평균 가격 100")).toBeInTheDocument(); + expect( + fetchSpy.mock.calls.filter(([url]) => String(url).includes("/weekly/")), + ).toHaveLength(weeklyCalls); +}); + +it("비활성 기간 실패를 숨기고 갱신 실패 시 기존 통계를 유지한다", async () => { + let dailyFails = false; + let weeklyFails = true; + fetchSpy.mockImplementation(async (input: string) => { + const fails = input.includes("/daily/") ? dailyFails : weeklyFails; + return fails + ? Response.json({ success: false, data: null }, { status: 500 }) + : success(input.includes("/daily/") ? [row] : []); + }); + renderPage(); + await screen.findByText("평균 가격 100"); + expect( + screen.queryByText("통계를 불러오지 못했습니다."), + ).not.toBeInTheDocument(); + dailyFails = true; + await act(async () => { + await client.invalidateQueries({ + queryKey: ["statistics", "daily", "items"], + }); + }); + await screen.findByText( + "통계를 갱신하지 못했습니다. 이전 결과를 표시합니다.", + ); + expect(screen.getByText("평균 가격 100")).toBeInTheDocument(); + fireEvent.click(screen.getByRole("button", { name: "주간" })); + await screen.findByText("통계를 불러오지 못했습니다."); + expect(screen.queryByText("평균 가격 100")).not.toBeInTheDocument(); + weeklyFails = false; + fireEvent.click(screen.getByRole("button", { name: "다시 시도" })); + expect(await screen.findByText("조회 결과가 없습니다")).toBeInTheDocument(); + expect( + screen.queryByText("통계를 불러오지 못했습니다."), + ).not.toBeInTheDocument(); +}); diff --git a/src/app/(main)/statistics/StatisticsClient.tsx b/src/app/(main)/statistics/StatisticsClient.tsx index dcbb54b..684cad6 100644 --- a/src/app/(main)/statistics/StatisticsClient.tsx +++ b/src/app/(main)/statistics/StatisticsClient.tsx @@ -10,6 +10,7 @@ import { useState, } from "react"; +import DataFetchError from "@/components/commons/DataFetchError"; import DateRangeSelector from "@/components/page/statistics/DateRangeSelector"; import PeriodToggle, { PeriodType, @@ -386,6 +387,19 @@ function StatisticsPageContent() {
{/* Content */} + {hasSearched && + currentQuery.isError && + currentQuery.data !== undefined && ( + { + if (!currentQuery.isFetching) void currentQuery.refetch(); + }} + className="mb-4" + /> + )} {!hasSearched ? (
@@ -412,6 +426,14 @@ function StatisticsPageContent() {
) : isLoading ? ( + ) : currentQuery.isError && currentQuery.data === undefined ? ( + { + if (!currentQuery.isFetching) void currentQuery.refetch(); + }} + /> ) : chartData.length === 0 ? (

diff --git a/src/app/api/auction-realtime/search/route.test.ts b/src/app/api/auction-realtime/search/route.test.ts new file mode 100644 index 0000000..f62f1a2 --- /dev/null +++ b/src/app/api/auction-realtime/search/route.test.ts @@ -0,0 +1,158 @@ +import { NextRequest } from "next/server"; + +import { GET } from "./route"; + +const item = { + id: 123, + itemName: "불타래", + itemDisplayName: "불타래", + itemCount: 6, + auctionPricePerUnit: 136, + dateRegister: "2026-11-14T00:00:00Z", + dateAuctionExpire: "2026-11-17T00:00:00Z", + itemSubCategory: "불타래", + itemTopCategory: "기타", + itemOptions: [], +}; +const data = { + items: [item], + meta: { + currentPage: 1, + pageSize: 20, + totalPages: 1, + totalElements: 1, + isFirst: true, + isLast: true, + }, +}; +const originalGateway = process.env.GATEWAY_URL; + +beforeEach(() => { + process.env.GATEWAY_URL = "https://gateway.test"; + jest.spyOn(global, "fetch"); + jest.spyOn(console, "error").mockImplementation(() => {}); +}); +afterEach(() => { + jest.restoreAllMocks(); + if (originalGateway === undefined) delete process.env.GATEWAY_URL; + else process.env.GATEWAY_URL = originalGateway; +}); + +function upstream(payload: unknown, status = 200) { + jest + .mocked(fetch) + .mockResolvedValueOnce(new Response(JSON.stringify(payload), { status })); +} + +it("preserves actual wire fields and metadata and disables both cache layers", async () => { + upstream({ success: true, data }); + const request = new NextRequest( + "https://app.test/api/auction-realtime/search?itemName=불타래&sortBy=dateAuctionRegister", + ); + const response = await GET(request); + expect(response.status).toBe(200); + expect(response.headers.get("Cache-Control")).toBe("no-store"); + expect((await response.json()).data).toEqual(data); + expect(fetch).toHaveBeenCalledWith( + expect.stringContaining("sortBy=dateRegister"), + { cache: "no-store", signal: request.signal }, + ); +}); + +it("preserves a successful empty result", async () => { + const empty = { + items: [], + meta: { ...data.meta, totalElements: 0, totalPages: 0 }, + }; + upstream({ success: true, data: empty }); + const response = await GET( + new NextRequest("https://app.test/api/auction-realtime/search"), + ); + expect(response.status).toBe(200); + expect((await response.json()).data).toEqual(empty); +}); + +it.each([ + { success: false, data }, + { success: true, data: null }, + { success: true, data: { ...data, items: [{ ...item, id: undefined }] } }, + { success: true, data: { ...data, items: [{ ...item, id: -1 }] } }, + { + success: true, + data: { ...data, items: [{ ...item, id: Number.MAX_SAFE_INTEGER + 1 }] }, + }, + { success: true, data: { ...data, meta: {} } }, +])( + "rejects malformed responses rather than returning an empty success", + async (payload) => { + upstream(payload); + const response = await GET( + new NextRequest("https://app.test/api/auction-realtime/search"), + ); + expect(response.status).toBe(500); + expect(response.headers.get("Cache-Control")).toBe("no-store"); + expect(await response.json()).toMatchObject({ success: false, data: null }); + }, +); + +it("does not cache upstream HTTP failures", async () => { + upstream({}, 503); + const response = await GET( + new NextRequest("https://app.test/api/auction-realtime/search"), + ); + expect(response.status).toBe(500); + expect(response.headers.get("Cache-Control")).toBe("no-store"); +}); + +it.each([ + null, + { optionType: 123, optionValue: "x" }, + { optionType: "인챈트", optionValue: {} }, + { + optionType: "인챈트", + optionValue: "x", + optionSubType: "접두", + optionDesc: 123, + }, + { optionType: "인챈트", optionValue: "x", optionSubType: {} }, + { optionType: "내구력", optionValue: "1", optionValue2: 10 }, +])("rejects unsafe nested option fields: %j", async (option) => { + upstream({ + success: true, + data: { ...data, items: [{ ...item, itemOptions: [option] }] }, + }); + const response = await GET( + new NextRequest("https://app.test/api/auction-realtime/search"), + ); + expect(response.status).toBe(500); + expect(await response.json()).toMatchObject({ success: false, data: null }); +}); + +it.each([undefined, null, "설명"])( + "accepts absent, null, or string optional option fields: %s", + async (field) => { + const validData = { + ...data, + items: [ + { + ...item, + itemOptions: [ + { + optionType: "인챈트", + optionValue: "x", + optionDesc: field, + optionSubType: field, + optionValue2: field, + }, + ], + }, + ], + }; + upstream({ success: true, data: validData }); + const response = await GET( + new NextRequest("https://app.test/api/auction-realtime/search"), + ); + expect(response.status).toBe(200); + expect((await response.json()).data).toEqual(validData); + }, +); diff --git a/src/app/api/auction-realtime/search/route.ts b/src/app/api/auction-realtime/search/route.ts index ba6a734..ae4295c 100644 --- a/src/app/api/auction-realtime/search/route.ts +++ b/src/app/api/auction-realtime/search/route.ts @@ -1,49 +1,8 @@ import { NextRequest, NextResponse } from "next/server"; -export const dynamic = "force-dynamic"; - -interface ItemOption { - id: string; - optionType: string; - optionSubType: string | null; - optionValue: string; - optionValue2: string | null; - optionDesc: string | null; -} - -interface AuctionRealtimeItem { - itemName: string; - itemDisplayName: string; - itemCount: number; - auctionPricePerUnit: number; - dateAuctionExpire: string; - dateAuctionRegister: string; - auctionId: string; - itemSubCategory: string; - itemTopCategory: string; - itemOptions: ItemOption[]; -} - -interface PageMeta { - currentPage: number; - pageSize: number; - totalPages: number; - totalElements: number; - isFirst: boolean; - isLast: boolean; -} -interface ApiResponse { - success: boolean; - code: string; - message: string; - data: T; - timestamp: string; -} +import { readAuctionRealtimeResponse } from "@/types/auction-realtime"; -interface AuctionRealtimeResponse { - items: AuctionRealtimeItem[]; - meta: PageMeta; -} +export const dynamic = "force-dynamic"; const MIN_PAGE_SIZE = 10; const MAX_PAGE_SIZE = 50; @@ -220,7 +179,8 @@ export async function GET(request: NextRequest) { const response = await fetch( `${gatewayUrl}/oab/auction-realtime/search?${queryString}`, { - next: { revalidate: 60 }, // Cache for 1 minute (실시간 데이터라 짧게) + cache: "no-store", + signal: request.signal, }, ); @@ -228,25 +188,20 @@ export async function GET(request: NextRequest) { throw new Error(`OAB API 호출 실패: ${response.status}`); } - const apiResponse: ApiResponse = - await response.json(); - - if (!apiResponse.success) { - throw new Error(apiResponse.message || "실시간 경매장 조회 실패"); - } + const data = readAuctionRealtimeResponse(await response.json()); return NextResponse.json( { success: true, code: "COMMON_SUCCESS", message: "요청이 성공적으로 처리되었습니다.", - data: apiResponse.data, + data, timestamp: new Date().toISOString(), }, { status: 200, headers: { - "Cache-Control": "public, s-maxage=60, stale-while-revalidate=120", + "Cache-Control": "no-store", }, }, ); @@ -261,7 +216,7 @@ export async function GET(request: NextRequest) { data: null, timestamp: new Date().toISOString(), }, - { status: 500 }, + { status: 500, headers: { "Cache-Control": "no-store" } }, ); } } diff --git a/src/app/api/horn-bugle/route.test.ts b/src/app/api/horn-bugle/route.test.ts new file mode 100644 index 0000000..251e43e --- /dev/null +++ b/src/app/api/horn-bugle/route.test.ts @@ -0,0 +1,70 @@ +import { NextRequest } from "next/server"; + +import { createServerAxios } from "@/lib/api/server"; + +import { GET } from "./route"; + +jest.mock("@/lib/api/server", () => ({ createServerAxios: jest.fn() })); +const get = jest.fn(); +beforeEach(() => { + get.mockReset(); + jest + .mocked(createServerAxios) + .mockReturnValue({ get } as unknown as ReturnType< + typeof createServerAxios + >); +}); +const payload = { + success: true, + data: { + items: [], + meta: { + currentPage: 1, + pageSize: 20, + totalPages: 0, + totalElements: 0, + isFirst: true, + isLast: true, + }, + }, +}; + +it("forwards the selected filters and signal and marks successful empty results no-store", async () => { + get.mockResolvedValue({ data: payload, status: 200 }); + const request = new NextRequest( + "http://localhost/api/horn-bugle?serverName=류트&keyword=검&page=1&size=20", + ); + const response = await GET(request); + expect(get).toHaveBeenCalledWith(expect.any(String), { + params: { serverName: "류트", keyword: "검", page: "1", size: "20" }, + signal: request.signal, + }); + expect(response.status).toBe(200); + expect(response.headers.get("Cache-Control")).toBe("no-store"); + expect(await response.json()).toEqual(payload); +}); + +it.each([ + { success: false, data: payload.data }, + { success: true, data: null }, + { success: true, data: { items: [] } }, +])("rejects invalid envelopes without caching: %j", async (data) => { + get.mockResolvedValue({ data, status: 200 }); + const response = await GET( + new NextRequest("http://localhost/api/horn-bugle"), + ); + expect(response.status).toBe(502); + expect(response.headers.get("Cache-Control")).toBe("no-store"); +}); + +it("preserves upstream failure status with no-store", async () => { + get.mockRejectedValue({ + isAxiosError: true, + response: { status: 503, data: { message: "temporarily unavailable" } }, + }); + const response = await GET( + new NextRequest("http://localhost/api/horn-bugle"), + ); + expect(response.status).toBe(503); + expect(response.headers.get("Cache-Control")).toBe("no-store"); +}); diff --git a/src/app/api/horn-bugle/route.ts b/src/app/api/horn-bugle/route.ts index 0f2adbc..154f965 100644 --- a/src/app/api/horn-bugle/route.ts +++ b/src/app/api/horn-bugle/route.ts @@ -3,10 +3,10 @@ import { NextRequest, NextResponse } from "next/server"; import { HORN_BUGLE_ENDPOINT } from "@/lib/api/constants"; import { createServerAxios } from "@/lib/api/server"; +import { parseHornBugleResponse } from "@/lib/horn-bugle"; -// 캐싱 5분 export const dynamic = "force-dynamic"; -export const revalidate = 300; +const headers = { "Cache-Control": "no-store" }; export async function GET(request: NextRequest) { const { searchParams } = new URL(request.url); @@ -18,6 +18,7 @@ export async function GET(request: NextRequest) { try { const axios = createServerAxios(request); const { data, status } = await axios.get(HORN_BUGLE_ENDPOINT, { + signal: request.signal, params: { serverName, keyword, @@ -25,7 +26,8 @@ export async function GET(request: NextRequest) { size, }, }); - return NextResponse.json(data, { status }); + parseHornBugleResponse(data); + return NextResponse.json(data, { status, headers }); } catch (error: unknown) { if ((error as AxiosError).isAxiosError) { const axiosError = error as AxiosError; @@ -33,10 +35,12 @@ export async function GET(request: NextRequest) { const payload = axiosError.response?.data ?? { message: axiosError.message, }; - return NextResponse.json(payload, { status }); + return NextResponse.json(payload, { status, headers }); } - const err = error instanceof Error ? error : new Error("Unknown error"); - return NextResponse.json({ message: err.message }, { status: 500 }); + return NextResponse.json( + { success: false, message: "뿔피리 정보를 불러오지 못했습니다." }, + { status: 502, headers }, + ); } } diff --git a/src/components/commons/DataFetchError.test.tsx b/src/components/commons/DataFetchError.test.tsx new file mode 100644 index 0000000..0871113 --- /dev/null +++ b/src/components/commons/DataFetchError.test.tsx @@ -0,0 +1,23 @@ +import { fireEvent, render, screen } from "@testing-library/react"; + +import DataFetchError from "./DataFetchError"; + +it("재시도를 실행하고 진행 중에는 중복 클릭을 차단한다", () => { + const onRetry = jest.fn(); + const { rerender } = render(); + fireEvent.click(screen.getByRole("button", { name: "다시 시도" })); + expect(onRetry).toHaveBeenCalledTimes(1); + rerender(); + const button = screen.getByRole("button", { name: "다시 조회 중..." }); + expect(button).toBeDisabled(); + fireEvent.click(button); + expect(onRetry).toHaveBeenCalledTimes(1); + expect(screen.getByRole("status")).toHaveTextContent( + "데이터를 받아오지 못 하였습니다", + ); +}); + +it("기존 showRetry=false 호출은 버튼을 표시하지 않는다", () => { + render(); + expect(screen.queryByRole("button")).not.toBeInTheDocument(); +}); diff --git a/src/components/commons/DataFetchError.tsx b/src/components/commons/DataFetchError.tsx index 07ac2ec..9c5d028 100644 --- a/src/components/commons/DataFetchError.tsx +++ b/src/components/commons/DataFetchError.tsx @@ -13,6 +13,8 @@ interface DataFetchErrorProps { showRetry?: boolean; /** 컨테이너 className */ className?: string; + isRetrying?: boolean; + compact?: boolean; } /** @@ -23,29 +25,43 @@ export default function DataFetchError({ onRetry, showRetry = true, className = "", + isRetrying = false, + compact = false, }: DataFetchErrorProps) { return (

-
-
- -
+
+ {!compact && ( +
+
+ )}
-

{message}

-

+

+ {message} +

+

잠시 후 다시 시도해주세요

{showRetry && onRetry && ( )}
diff --git a/src/components/commons/QueryRefreshControls.test.tsx b/src/components/commons/QueryRefreshControls.test.tsx new file mode 100644 index 0000000..32146b0 --- /dev/null +++ b/src/components/commons/QueryRefreshControls.test.tsx @@ -0,0 +1,25 @@ +import { fireEvent, render, screen } from "@testing-library/react"; + +import QueryRefreshControls from "./QueryRefreshControls"; + +it("labels successful retrieval time in Korean time and disables concurrent refresh", () => { + const refresh = jest.fn(); + const props = { + updatedAt: Date.parse("2026-11-14T01:02:03Z"), + onRefresh: refresh, + }; + const { rerender } = render( + , + ); + expect(screen.getByText("마지막 조회 10:02:03")).toBeInTheDocument(); + fireEvent.click(screen.getByRole("button", { name: "새로고침" })); + expect(refresh).toHaveBeenCalledTimes(1); + rerender(); + const button = screen.getByRole("button", { name: "새로고침" }); + expect(button).toBeDisabled(); + fireEvent.click(button); + expect(refresh).toHaveBeenCalledTimes(1); + expect(screen.getByText(/마지막 조회 10:02:03/)).toHaveTextContent( + "조회 중...", + ); +}); diff --git a/src/components/commons/QueryRefreshControls.tsx b/src/components/commons/QueryRefreshControls.tsx new file mode 100644 index 0000000..a7fc11d --- /dev/null +++ b/src/components/commons/QueryRefreshControls.tsx @@ -0,0 +1,56 @@ +"use client"; + +import { RefreshCw } from "lucide-react"; + +import { Button } from "@/components/ui/button"; + +interface QueryRefreshControlsProps { + updatedAt: number; + isFetching: boolean; + onRefresh: () => void; + className?: string; +} + +export default function QueryRefreshControls({ + updatedAt, + isFetching, + onRefresh, + className = "", +}: QueryRefreshControlsProps) { + const lastChecked = + updatedAt > 0 + ? new Intl.DateTimeFormat("ko-KR", { + timeZone: "Asia/Seoul", + hour: "2-digit", + minute: "2-digit", + second: "2-digit", + hour12: false, + }).format(new Date(updatedAt)) + : null; + + return ( +
+ + {lastChecked + ? `마지막 조회 ${lastChecked}` + : "아직 조회하지 않았습니다"} + {isFetching ? " · 조회 중..." : ""} + + +
+ ); +} diff --git a/src/components/page/auction-common/AuctionDetailView.test.tsx b/src/components/page/auction-common/AuctionDetailView.test.tsx index ce35d18..342637c 100644 --- a/src/components/page/auction-common/AuctionDetailView.test.tsx +++ b/src/components/page/auction-common/AuctionDetailView.test.tsx @@ -54,6 +54,40 @@ const baseResult: AuctionDetailResult = { describe("AuctionDetailView section states", () => { beforeEach(() => refresh.mockClear()); + it("renders the actual realtime dateRegister field and tolerates an invalid expiry", () => { + render( + , + ); + expect(screen.getByText("등록 일시").nextElementSibling).toHaveTextContent( + "2026", + ); + expect(screen.getByText("만료 일시").nextElementSibling).toHaveTextContent( + "일시 정보 없음", + ); + expect(screen.getByRole("link", { name: /목록/ })).toHaveAttribute( + "href", + "/auction-realtime?page=3", + ); + }); + it("shows retry controls for failed item info and the failed current period", () => { render( { expect(links).toHaveLength(2); expect(links[0]).toHaveAttribute( "href", - expect.stringContaining("/auction-realtime/realtime-1?return_to="), + expect.stringContaining("/auction-realtime/123?return_to="), ); + expect(links[1]).toHaveAttribute("href", links[0].getAttribute("href")); + expect( + new URL( + links[0].getAttribute("href")!, + "https://example.test", + ).searchParams.get("return_to"), + ).toBe("/auction-realtime?page=2&itemName=%EA%B2%80"); expect(screen.getByText("축복받은")).toBeInTheDocument(); expect(screen.getByText("#붉은색")).toBeInTheDocument(); fireEvent.click(links[0]); expect(screen.queryByText("가격 : 2,500 Gold")).not.toBeInTheDocument(); }); + + it("renders malformed expiry dates without crashing either layout", () => { + render( + , + ); + expect(screen.getAllByText(/일시 정보 없음/)).toHaveLength(2); + }); }); diff --git a/src/components/page/auction-realtime/AuctionRealtimeList.tsx b/src/components/page/auction-realtime/AuctionRealtimeList.tsx index 23e315e..577fad3 100644 --- a/src/components/page/auction-realtime/AuctionRealtimeList.tsx +++ b/src/components/page/auction-realtime/AuctionRealtimeList.tsx @@ -34,6 +34,7 @@ function formatPrice(price: number) { } function formatDateShort(value: string) { + if (!Number.isFinite(Date.parse(value))) return "일시 정보 없음"; return new Intl.DateTimeFormat("ko-KR", { month: "2-digit", day: "2-digit", @@ -130,8 +131,8 @@ export default function AuctionRealtimeList({ const pathname = usePathname(), searchParams = useSearchParams(); const returnTo = `${pathname}${searchParams.size ? `?${searchParams.toString()}` : ""}`; - const hrefFor = (id: string) => - `/auction-realtime/${encodeURIComponent(id)}?${new URLSearchParams({ return_to: returnTo })}`; + const hrefFor = (id: number) => + `/auction-realtime/${encodeURIComponent(String(id))}?${new URLSearchParams({ return_to: returnTo })}`; if (isLoading) return (
@@ -153,8 +154,8 @@ export default function AuctionRealtimeList({
{items.map((item) => (
@@ -199,9 +200,9 @@ export default function AuctionRealtimeList({
{items.map((item) => ( ))} diff --git a/src/components/page/home/HornBugleCard.test.tsx b/src/components/page/home/HornBugleCard.test.tsx index 8c93dc8..53c76be 100644 --- a/src/components/page/home/HornBugleCard.test.tsx +++ b/src/components/page/home/HornBugleCard.test.tsx @@ -1,5 +1,5 @@ import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; -import { render, screen, waitFor } from "@testing-library/react"; +import { fireEvent, render, screen, waitFor } from "@testing-library/react"; import HornBugleCard from "./HornBugleCard"; @@ -16,6 +16,23 @@ function renderCard() { afterEach(() => jest.restoreAllMocks()); +function envelope(items: unknown[]) { + return { + success: true, + data: { + items, + meta: { + currentPage: 1, + pageSize: 7, + totalPages: 1, + totalElements: items.length, + isFirst: true, + isLast: true, + }, + }, + }; +} + it("전체 서버의 최신 메시지를 기존 API 순서대로 최대 7건 표시한다", async () => { jest .spyOn(Date, "now") @@ -30,12 +47,15 @@ it("전체 서버의 최신 메시지를 기존 API 순서대로 최대 7건 표 })); const fetchMock = jest.spyOn(global, "fetch").mockResolvedValue({ ok: true, - json: async () => ({ data: { items } }), + json: async () => envelope(items), } as Response); renderCard(); await screen.findByText("메시지 1"); expect(fetchMock).toHaveBeenCalledTimes(1); - expect(fetchMock).toHaveBeenCalledWith("/api/horn-bugle?page=1&size=7"); + expect(fetchMock).toHaveBeenCalledWith("/api/horn-bugle?page=1&size=7", { + cache: "no-store", + signal: expect.any(AbortSignal), + }); expect(screen.getAllByText("류트")).toHaveLength(4); expect(screen.getByText("캐릭터 1")).toBeInTheDocument(); expect(screen.getByText("1분 전")).toBeInTheDocument(); @@ -51,13 +71,13 @@ it("전체 서버의 최신 메시지를 기존 API 순서대로 최대 7건 표 it("빈 응답을 표시한다", async () => { jest.spyOn(global, "fetch").mockResolvedValue({ ok: true, - json: async () => ({ data: { items: [] } }), + json: async () => envelope([]), } as Response); renderCard(); expect(await screen.findByText("뿔피리 내역이 없습니다")).toBeInTheDocument(); }); -it("실패 시 오류 상태를 표시하고 재시도하지 않는다", async () => { +it("첫 실패를 빈 결과와 구분하고 명시적 재시도로 복구한다", async () => { const fetchMock = jest .spyOn(global, "fetch") .mockResolvedValue({ ok: false, status: 500 } as Response); @@ -66,4 +86,37 @@ it("실패 시 오류 상태를 표시하고 재시도하지 않는다", async ( await screen.findByText("데이터를 불러올 수 없습니다"), ).toBeInTheDocument(); await waitFor(() => expect(fetchMock).toHaveBeenCalledTimes(1)); + expect(screen.queryByText("뿔피리 내역이 없습니다")).not.toBeInTheDocument(); + fetchMock.mockResolvedValue({ + ok: true, + json: async () => envelope([]), + } as Response); + fireEvent.click(screen.getByRole("button", { name: "다시 시도" })); + expect(await screen.findByText("뿔피리 내역이 없습니다")).toBeInTheDocument(); + expect(screen.getByText(/마지막 조회/)).toBeInTheDocument(); +}); + +it("수동 갱신 실패 시 기존 메시지와 마지막 성공 시각을 유지한다", async () => { + const item = { + id: 1, + serverName: "류트", + characterName: "테스터", + message: "유지할 메시지", + dateSend: "2026-11-14T12:00:00Z", + dateRegister: "2026-11-14T12:00:00Z", + }; + const fetchMock = jest.spyOn(global, "fetch").mockResolvedValue({ + ok: true, + json: async () => envelope([item]), + } as Response); + renderCard(); + await screen.findByText("유지할 메시지"); + const checkedAt = screen.getByText(/마지막 조회/).textContent; + fetchMock.mockResolvedValue({ ok: false, status: 503 } as Response); + fireEvent.click(screen.getByRole("button", { name: "새로고침" })); + await screen.findByText( + "최신 정보를 확인하지 못했습니다. 기존 메시지를 표시합니다.", + ); + expect(screen.getByText("유지할 메시지")).toBeInTheDocument(); + expect(screen.getByText(/마지막 조회/)).toHaveTextContent(checkedAt!); }); diff --git a/src/components/page/home/HornBugleCard.tsx b/src/components/page/home/HornBugleCard.tsx index 0e30b9b..95f01d3 100644 --- a/src/components/page/home/HornBugleCard.tsx +++ b/src/components/page/home/HornBugleCard.tsx @@ -3,6 +3,8 @@ import Link from "next/link"; import { useEffect, useState } from "react"; +import DataFetchError from "@/components/commons/DataFetchError"; +import QueryRefreshControls from "@/components/commons/QueryRefreshControls"; import { useLatestHornBugle } from "@/hooks/useLatestHornBugle"; import styles from "./HomeCards.module.css"; @@ -17,7 +19,11 @@ function formatTime(dateString: string, now: number) { } export default function HornBugleCard() { - const { data, isLoading, error } = useLatestHornBugle(); + const { data, isLoading, error, isFetching, dataUpdatedAt, refetch } = + useLatestHornBugle(); + const retry = () => { + void refetch({ cancelRefetch: false }); + }; const [now, setNow] = useState(Date.now); useEffect(() => { const timer = window.setInterval(() => setNow(Date.now()), 15_000); @@ -39,12 +45,32 @@ export default function HornBugleCard() { 더보기
+ {data !== undefined && ( + + )} + {data !== undefined && error && ( + + )} {isLoading ? ( - ) : error ? ( -
- 데이터를 불러올 수 없습니다 -
+ ) : error && data === undefined ? ( + ) : !data || data.length === 0 ? (
뿔피리 내역이 없습니다 diff --git a/src/hooks/useAuctionHistory.test.tsx b/src/hooks/useAuctionHistory.test.tsx new file mode 100644 index 0000000..4e19c88 --- /dev/null +++ b/src/hooks/useAuctionHistory.test.tsx @@ -0,0 +1,121 @@ +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { cleanup, renderHook, waitFor } from "@testing-library/react"; +import type { PropsWithChildren } from "react"; + +import { useAuctionHistory } from "./useAuctionHistory"; + +const emptyData = { + items: [], + meta: { + currentPage: 1, + pageSize: 20, + totalPages: 0, + totalElements: 0, + isFirst: true, + isLast: true, + }, +}; +let client: QueryClient; +let fetchSpy: jest.SpyInstance; +function wrapper({ children }: PropsWithChildren) { + return {children}; +} +beforeEach(() => { + client = new QueryClient({ + defaultOptions: { queries: { retry: false, gcTime: 0 } }, + }); + fetchSpy = jest.spyOn(globalThis, "fetch"); +}); +afterEach(() => { + cleanup(); + client.clear(); + fetchSpy.mockRestore(); +}); + +it.each([ + ["failed envelope", { success: false, data: emptyData }], + ["missing success", { data: emptyData }], + ["null data", { success: true, data: null }], + ["invalid items", { success: true, data: { ...emptyData, items: {} } }], + ["null row", { success: true, data: { ...emptyData, items: [null] } }], + ["missing pagination", { success: true, data: { items: [] } }], + [ + "invalid count", + { + success: true, + data: { ...emptyData, meta: { ...emptyData.meta, totalElements: "0" } }, + }, + ], + ["null envelope", null], +])("treats %s as a request failure", async (_label, payload) => { + fetchSpy.mockResolvedValue(Response.json(payload)); + const { result } = renderHook(() => useAuctionHistory({}), { wrapper }); + await waitFor(() => expect(result.current.isError).toBe(true)); + expect(result.current.data).toBeUndefined(); +}); + +it("accepts successful empty results with zero pages", async () => { + fetchSpy.mockResolvedValue(Response.json({ success: true, data: emptyData })); + const { result } = renderHook(() => useAuctionHistory({}), { wrapper }); + await waitFor(() => expect(result.current.isSuccess).toBe(true)); + expect(result.current.data).toEqual(emptyData); +}); + +const historyItem = { + auctionBuyId: "123", + itemName: "wood", + itemDisplayName: "wood", + itemTopCategory: "other", + itemSubCategory: "other", + dateAuctionBuy: "2026-11-14", + itemCount: 1, + auctionPricePerUnit: 100, +}; +it.each([ + null, + {}, + { optionType: {}, optionValue: "1" }, + { optionType: "attack", optionValue: {} }, + { optionType: "attack", optionValue: "1", optionDesc: {} }, + { optionType: "attack", optionValue: "1", optionSubType: {} }, + { optionType: "durability", optionValue: "1", optionValue2: {} }, +])("rejects malformed item option %j", async (option) => { + fetchSpy.mockResolvedValue( + Response.json({ + success: true, + data: { + ...emptyData, + items: [{ ...historyItem, itemOptions: [option] }], + }, + }), + ); + const { result } = renderHook(() => useAuctionHistory({}), { wrapper }); + await waitFor(() => expect(result.current.isError).toBe(true)); + expect(result.current.data).toBeUndefined(); +}); +it.each([ + { optionType: "attack", optionValue: "1" }, + { + optionType: "attack", + optionValue: "1", + optionDesc: null, + optionSubType: null, + optionValue2: null, + }, + { + optionType: "attack", + optionValue: "1", + optionDesc: "description", + optionSubType: "max", + optionValue2: "2", + }, +])("accepts optional option metadata %j", async (option) => { + const data = { + ...emptyData, + items: [{ ...historyItem, itemOptions: [option] }], + }; + fetchSpy.mockResolvedValue(Response.json({ success: true, data })); + const { result } = renderHook(() => useAuctionHistory({}), { wrapper }); + await waitFor(() => expect(result.current.isSuccess).toBe(true)); + expect(result.current.data).toEqual(data); +}); diff --git a/src/hooks/useAuctionHistory.ts b/src/hooks/useAuctionHistory.ts index dff0865..b09c817 100644 --- a/src/hooks/useAuctionHistory.ts +++ b/src/hooks/useAuctionHistory.ts @@ -104,14 +104,57 @@ async function fetchAuctionHistory( throw new Error(`Failed to fetch auction history: ${response.status}`); } - const apiResponse: ApiResponse = + const apiResponse: ApiResponse | null = await response.json(); - if (!apiResponse.success) { - throw new Error(apiResponse.message || "Failed to fetch auction history"); + if (apiResponse?.success !== true) { + throw new Error(apiResponse?.message || "Failed to fetch auction history"); } - return apiResponse.data || { items: [], meta: {} as PageMeta }; + const data = apiResponse.data; + if ( + !data || + !Array.isArray(data.items) || + !data.items.every( + (item) => + item !== null && + typeof item === "object" && + typeof item.auctionBuyId === "string" && + typeof item.itemName === "string" && + typeof item.itemDisplayName === "string" && + typeof item.itemTopCategory === "string" && + typeof item.itemSubCategory === "string" && + typeof item.dateAuctionBuy === "string" && + Number.isFinite(item.itemCount) && + Number.isFinite(item.auctionPricePerUnit) && + Array.isArray(item.itemOptions) && + item.itemOptions.every( + (option) => + option !== null && + typeof option === "object" && + typeof option.optionType === "string" && + typeof option.optionValue === "string" && + (option.optionDesc == null || + typeof option.optionDesc === "string") && + (option.optionSubType == null || + typeof option.optionSubType === "string") && + (option.optionValue2 == null || + typeof option.optionValue2 === "string"), + ), + ) || + !data.meta || + !Number.isInteger(data.meta.currentPage) || + data.meta.currentPage < 1 || + !Number.isInteger(data.meta.pageSize) || + data.meta.pageSize < 1 || + !Number.isInteger(data.meta.totalPages) || + data.meta.totalPages < 0 || + !Number.isInteger(data.meta.totalElements) || + data.meta.totalElements < 0 + ) { + throw new Error("Invalid auction history response"); + } + return data; } export function useAuctionHistory(params: AuctionHistorySearchParams) { diff --git a/src/hooks/useAuctionRealtime.test.tsx b/src/hooks/useAuctionRealtime.test.tsx new file mode 100644 index 0000000..f08025d --- /dev/null +++ b/src/hooks/useAuctionRealtime.test.tsx @@ -0,0 +1,152 @@ +import { + focusManager, + onlineManager, + QueryClient, + QueryClientProvider, +} from "@tanstack/react-query"; +import { act, renderHook } from "@testing-library/react"; +import type { ReactNode } from "react"; + +import { useAuctionRealtime } from "./useAuctionRealtime"; + +const item = { + id: 123, + itemName: "불타래", + itemDisplayName: "불타래", + itemCount: 6, + auctionPricePerUnit: 136, + dateRegister: "2026-11-14T00:00:00Z", + dateAuctionExpire: "2026-11-17T00:00:00Z", + itemSubCategory: "불타래", + itemTopCategory: "기타", + itemOptions: [], +}; +const data = { + items: [item], + meta: { + currentPage: 1, + pageSize: 20, + totalPages: 1, + totalElements: 1, + isFirst: true, + isLast: true, + }, +}; +let client: QueryClient; + +function renderQuery(params = { itemName: "불타래" }) { + return renderHook(() => useAuctionRealtime(params), { + wrapper: ({ children }: { children: ReactNode }) => ( + {children} + ), + }); +} + +async function advance(ms: number) { + await act(async () => { + await jest.advanceTimersByTimeAsync(ms); + }); +} + +beforeEach(() => { + jest.useFakeTimers(); + jest.setSystemTime(new Date("2026-11-14T00:00:00Z")); + focusManager.setFocused(true); + onlineManager.setOnline(true); + client = new QueryClient({ + defaultOptions: { queries: { retry: false, gcTime: Infinity } }, + }); + jest + .spyOn(global, "fetch") + .mockImplementation( + async () => new Response(JSON.stringify({ success: true, data })), + ); +}); + +afterEach(() => { + client.clear(); + focusManager.setFocused(undefined); + onlineManager.setOnline(true); + jest.restoreAllMocks(); + jest.useRealTimers(); +}); + +it("reads wire data with no-store and a cancellable request", async () => { + const { result, unmount } = renderQuery(); + await advance(1); + expect(result.current.data).toEqual(data); + expect(fetch).toHaveBeenCalledWith( + "/api/auction-realtime/search?itemName=%EB%B6%88%ED%83%80%EB%9E%98", + { cache: "no-store", signal: expect.any(AbortSignal) }, + ); + unmount(); +}); + +it("checks every 60 seconds in a visible tab, pauses hidden checks and refreshes stale data on return", async () => { + const { result } = renderQuery(); + await advance(1); + expect(result.current.isSuccess).toBe(true); + await advance(59_000); + expect(fetch).toHaveBeenCalledTimes(1); + await advance(1_000); + expect(fetch).toHaveBeenCalledTimes(2); + act(() => focusManager.setFocused(false)); + await advance(120_000); + expect(fetch).toHaveBeenCalledTimes(2); + act(() => focusManager.setFocused(true)); + await advance(1); + expect(fetch).toHaveBeenCalledTimes(3); +}); + +it("rechecks stale data on reconnect", async () => { + renderQuery(); + await advance(1); + act(() => { + focusManager.setFocused(false); + onlineManager.setOnline(false); + }); + await advance(60_000); + expect(fetch).toHaveBeenCalledTimes(1); + act(() => onlineManager.setOnline(true)); + await advance(1); + expect(fetch).toHaveBeenCalledTimes(2); +}); + +it.each([ + { success: false, data }, + { success: true, data: null }, + { success: true, data: { ...data, items: [{ ...item, id: undefined }] } }, +])("exposes malformed or unsuccessful responses as errors", async (payload) => { + jest + .mocked(fetch) + .mockImplementation(async () => new Response(JSON.stringify(payload))); + const { result } = renderQuery(); + await advance(1); + expect(result.current.isError).toBe(true); + expect(result.current.data).toBeUndefined(); +}); + +it("retains the last successful same-query data and timestamp when a refresh fails", async () => { + const { result } = renderQuery(); + await advance(1); + const successfulAt = result.current.dataUpdatedAt; + expect(result.current.isError).toBe(false); + jest + .mocked(fetch) + .mockImplementation( + async () => new Response("unavailable", { status: 503 }), + ); + await advance(60_010); + expect(result.current.isError).toBe(true); + expect(result.current.data).toEqual(data); + expect(result.current.dataUpdatedAt).toBe(successfulAt); +}); + +it("aborts an in-flight request when its query is unmounted", async () => { + jest.mocked(fetch).mockImplementation(() => new Promise(() => {})); + const { unmount } = renderQuery(); + const signal = jest.mocked(fetch).mock.calls[0][1]?.signal; + expect(signal?.aborted).toBe(false); + unmount(); + expect(signal?.aborted).toBe(true); +}); diff --git a/src/hooks/useAuctionRealtime.ts b/src/hooks/useAuctionRealtime.ts index 2e19338..66abe52 100644 --- a/src/hooks/useAuctionRealtime.ts +++ b/src/hooks/useAuctionRealtime.ts @@ -2,44 +2,14 @@ import { useQuery } from "@tanstack/react-query"; -import { AuctionRealtimeSearchParams } from "@/types/auction-realtime"; -import { ApiResponse } from "@/types/community"; +import { liveQueryOptions } from "@/lib/api/live-query"; +import { + AuctionRealtimeResponse, + AuctionRealtimeSearchParams, + readAuctionRealtimeResponse, +} from "@/types/auction-realtime"; -interface ItemOption { - id: string; - optionType: string; - optionSubType: string | null; - optionValue: string; - optionValue2: string | null; - optionDesc: string | null; -} - -export interface AuctionRealtimeItem { - itemName: string; - itemDisplayName: string; - itemCount: number; - auctionPricePerUnit: number; - dateAuctionExpire: string; - dateAuctionRegister: string; - auctionId: string; - itemSubCategory: string; - itemTopCategory: string; - itemOptions: ItemOption[]; -} - -interface PageMeta { - currentPage: number; - pageSize: number; - totalPages: number; - totalElements: number; - isFirst: boolean; - isLast: boolean; -} - -export interface AuctionRealtimeResponse { - items: AuctionRealtimeItem[]; - meta: PageMeta; -} +export type { AuctionRealtimeItem } from "@/types/auction-realtime"; function buildNestedQueryParams( obj: Record, @@ -85,6 +55,7 @@ function buildNestedQueryParams( async function fetchAuctionRealtime( params: AuctionRealtimeSearchParams, + signal: AbortSignal, ): Promise { const queryParams = buildNestedQueryParams( params as unknown as Record, @@ -92,28 +63,22 @@ async function fetchAuctionRealtime( const response = await fetch( `/api/auction-realtime/search?${queryParams.toString()}`, + { cache: "no-store", signal }, ); if (!response.ok) { throw new Error(`Failed to fetch auction realtime: ${response.status}`); } - const apiResponse: ApiResponse = - await response.json(); - - if (!apiResponse.success) { - throw new Error(apiResponse.message || "Failed to fetch auction realtime"); - } - - return apiResponse.data || { items: [], meta: {} as PageMeta }; + return readAuctionRealtimeResponse(await response.json()); } export function useAuctionRealtime(params: AuctionRealtimeSearchParams) { return useQuery({ queryKey: ["auction-realtime", params], - queryFn: () => fetchAuctionRealtime(params), + queryFn: ({ signal }) => fetchAuctionRealtime(params, signal), enabled: true, - staleTime: 1 * 60 * 1000, + ...liveQueryOptions, gcTime: 5 * 60 * 1000, }); } diff --git a/src/hooks/useInfiniteHornBugle.test.tsx b/src/hooks/useInfiniteHornBugle.test.tsx new file mode 100644 index 0000000..b019a12 --- /dev/null +++ b/src/hooks/useInfiniteHornBugle.test.tsx @@ -0,0 +1,264 @@ +import { + focusManager, + onlineManager, + QueryClient, + QueryClientProvider, +} from "@tanstack/react-query"; +import { act, renderHook, waitFor } from "@testing-library/react"; +import type { ReactNode } from "react"; + +import { useInfiniteHornBugle } from "./useInfiniteHornBugle"; + +function payload(page = 1, ids = [page * 10 + 1, page * 10 + 2]) { + return { + success: true, + data: { + items: ids.map((id) => ({ + id, + serverName: "류트", + characterName: `캐릭터 ${id}`, + message: `메시지 ${id}`, + dateSend: "2026-11-14T12:00:00Z", + dateRegister: "2026-11-14T12:00:00Z", + })), + meta: { + currentPage: page, + pageSize: 20, + totalPages: 3, + totalElements: 60, + isFirst: page === 1, + isLast: page === 3, + }, + }, + }; +} +function response(body: unknown, status = 200) { + return { ok: status === 200, status, json: async () => body } as Response; +} +function deferred() { + let resolve!: (value: T) => void; + const promise = new Promise((done) => { + resolve = done; + }); + return { promise, resolve }; +} +const clients: QueryClient[] = []; +function setup(serverName = "류트") { + const client = new QueryClient({ + defaultOptions: { queries: { retry: false, gcTime: Infinity } }, + }); + clients.push(client); + function Wrapper({ children }: { children: ReactNode }) { + return ( + {children} + ); + } + return renderHook( + ({ serverName: server }) => useInfiniteHornBugle({ serverName: server }), + { initialProps: { serverName }, wrapper: Wrapper }, + ); +} +let fetchMock: jest.SpyInstance; +beforeEach(() => { + fetchMock = jest.spyOn(global, "fetch").mockImplementation(async (input) => { + const page = Number( + new URL(String(input), "http://localhost").searchParams.get("page"), + ); + return response(payload(page)); + }); +}); +afterEach(() => { + clients.forEach((client) => client.clear()); + clients.length = 0; + focusManager.setFocused(true); + onlineManager.setOnline(true); + jest.useRealTimers(); + jest.restoreAllMocks(); +}); + +it("checks only the first page every minute without replacing three displayed pages", async () => { + jest.useFakeTimers(); + const { result } = setup(); + await act(async () => { + await jest.advanceTimersByTimeAsync(1); + }); + expect(result.current.data?.pages).toHaveLength(1); + expect(fetchMock).toHaveBeenCalledTimes(1); + await act(async () => { + await result.current.fetchNextPage(); + await jest.advanceTimersByTimeAsync(1); + }); + await act(async () => { + await result.current.fetchNextPage(); + await jest.advanceTimersByTimeAsync(1); + }); + const displayed = result.current.data; + fetchMock.mockResolvedValue(response(payload(1, [99, 98]))); + await act(async () => { + await jest.advanceTimersByTimeAsync(59_000); + }); + expect(fetchMock).toHaveBeenCalledTimes(3); + await act(async () => { + await jest.advanceTimersByTimeAsync(1_000); + }); + expect(fetchMock).toHaveBeenCalledTimes(4); + expect(String(fetchMock.mock.calls[3][0])).toContain("page=1&size=20"); + expect(result.current.data).toBe(displayed); + expect(result.current.hasNewMessages).toBe(true); +}); + +it("pauses checking while hidden and checks only the head on stale focus and reconnect", async () => { + jest.useFakeTimers(); + const { result } = setup(); + await act(async () => { + await jest.advanceTimersByTimeAsync(1); + }); + await act(async () => { + await result.current.fetchNextPage(); + }); + act(() => focusManager.setFocused(false)); + await act(async () => { + await jest.advanceTimersByTimeAsync(61_000); + }); + expect(fetchMock).toHaveBeenCalledTimes(2); + await act(async () => { + focusManager.setFocused(true); + await jest.advanceTimersByTimeAsync(1); + }); + expect(fetchMock).toHaveBeenCalledTimes(3); + act(() => onlineManager.setOnline(false)); + await act(async () => { + await jest.advanceTimersByTimeAsync(61_000); + }); + expect(fetchMock).toHaveBeenCalledTimes(3); + await act(async () => { + onlineManager.setOnline(true); + await jest.advanceTimersByTimeAsync(1); + }); + expect(fetchMock).toHaveBeenCalledTimes(4); + expect( + fetchMock.mock.calls + .slice(2) + .every(([url]) => String(url).includes("page=1&size=20")), + ).toBe(true); +}); + +it("deduplicates manual refresh, retains pages on failure and replaces them only after success", async () => { + const { result } = setup(); + await waitFor(() => expect(result.current.isSuccess).toBe(true)); + await act(async () => { + await result.current.fetchNextPage(); + }); + await waitFor(() => expect(result.current.data?.pages).toHaveLength(2)); + const displayed = result.current.data; + const checkedAt = result.current.lastCheckedAt; + const pending = deferred(); + fetchMock.mockReturnValueOnce(pending.promise); + let first!: Promise; + act(() => { + first = result.current.refreshLatest(); + expect(result.current.refreshLatest()).toBe(first); + }); + expect(result.current.data).toBe(displayed); + await act(async () => { + pending.resolve(response({}, 500)); + expect(await first).toBe(false); + }); + expect(result.current.data).toBe(displayed); + expect(result.current.lastCheckedAt).toBe(checkedAt); + expect(result.current.refreshError).toBeTruthy(); + fetchMock.mockResolvedValueOnce(response(payload(1, [100, 99]))); + await act(async () => { + expect(await result.current.refreshLatest()).toBe(true); + }); + expect(result.current.data?.pageParams).toEqual([1]); + expect(result.current.data?.pages[0].items[0].id).toBe(100); + expect(result.current.refreshError).toBeNull(); + await act(async () => { + await result.current.fetchNextPage(); + }); + expect(String(fetchMock.mock.calls.at(-1)?.[0])).toContain("page=2&size=20"); +}); + +it("cancels an in-flight next page before installing the successful new first page", async () => { + const { result } = setup(); + await waitFor(() => expect(result.current.isSuccess).toBe(true)); + const next = deferred(); + fetchMock.mockReturnValueOnce(next.promise); + act(() => { + void result.current.fetchNextPage(); + }); + const signal = fetchMock.mock.calls.at(-1)?.[1].signal as AbortSignal; + fetchMock.mockResolvedValueOnce(response(payload(1, [100, 99]))); + await act(async () => { + expect(await result.current.refreshLatest()).toBe(true); + }); + expect(signal.aborted).toBe(true); + await act(async () => { + next.resolve(response(payload(2))); + }); + expect(result.current.data?.pages).toHaveLength(1); + expect(result.current.data?.pages[0].items[0].id).toBe(100); +}); + +it("does not apply a refresh response to a different filter", async () => { + const { result, rerender } = setup(); + await waitFor(() => expect(result.current.isSuccess).toBe(true)); + const old = deferred(); + fetchMock.mockReturnValueOnce(old.promise); + let refresh!: Promise; + act(() => { + refresh = result.current.refreshLatest(); + }); + const signal = fetchMock.mock.calls.at(-1)?.[1].signal as AbortSignal; + rerender({ serverName: "하프" }); + await waitFor(() => expect(result.current.isSuccess).toBe(true)); + expect(signal.aborted).toBe(true); + await act(async () => { + old.resolve(response(payload(1, [999]))); + expect(await refresh).toBe(false); + }); + expect(result.current.data?.pages[0].items[0].id).toBe(11); + expect(result.current.refreshError).toBeNull(); + rerender({ serverName: "류트" }); + await waitFor(() => expect(result.current.isRefreshing).toBe(false)); + expect(result.current.data?.pages[0].items[0].id).toBe(11); + await act(async () => { + expect(await result.current.refreshLatest()).toBe(true); + }); +}); + +it("keeps prior pages after next-page failure and retries the same next page", async () => { + const { result } = setup(); + await waitFor(() => expect(result.current.isSuccess).toBe(true)); + const displayed = result.current.data; + fetchMock.mockResolvedValueOnce(response({}, 500)); + await act(async () => { + await result.current.fetchNextPage(); + }); + await waitFor(() => expect(result.current.isFetchNextPageError).toBe(true)); + expect(result.current.data).toBe(displayed); + await act(async () => { + await result.current.fetchNextPage(); + }); + await waitFor(() => expect(result.current.data?.pages).toHaveLength(2)); + expect(result.current.isFetchNextPageError).toBe(false); +}); + +it.each([ + { success: false, data: payload().data }, + { success: true, data: null }, + { success: true, data: { items: [] } }, +])( + "rejects malformed or unsuccessful envelopes and recovers via manual retry: %j", + async (body) => { + fetchMock.mockResolvedValueOnce(response(body)); + const { result } = setup(); + await waitFor(() => expect(result.current.isError).toBe(true)); + expect(result.current.data).toBeUndefined(); + await act(async () => { + expect(await result.current.refreshLatest()).toBe(true); + }); + expect(result.current.data?.pages[0].items).toHaveLength(2); + }, +); diff --git a/src/hooks/useInfiniteHornBugle.ts b/src/hooks/useInfiniteHornBugle.ts index 62d7b9c..9823178 100644 --- a/src/hooks/useInfiniteHornBugle.ts +++ b/src/hooks/useInfiniteHornBugle.ts @@ -1,33 +1,152 @@ "use client"; -import { useInfiniteQuery } from "@tanstack/react-query"; - -import { clientAxios } from "@/lib/api/clients"; -import { ApiResponse } from "@/types/community"; -import { HornBugleResponse, HornBugleSearchParams } from "@/types/horn-bugle"; - -async function fetchHornBugle( - params: HornBugleSearchParams, -): Promise { - const response = await clientAxios.get>( - "/horn-bugle", - { params }, - ); - return response.data.data; -} +import { + type InfiniteData, + useInfiniteQuery, + useQuery, + useQueryClient, +} from "@tanstack/react-query"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; + +import { liveQueryOptions } from "@/lib/api/live-query"; +import { fetchHornBugle } from "@/lib/horn-bugle"; +import type { + HornBugleResponse, + HornBugleSearchParams, +} from "@/types/horn-bugle"; export function useInfiniteHornBugle( params: Omit, ) { - return useInfiniteQuery({ - queryKey: ["horn-bugle", params], - queryFn: ({ pageParam = 1 }) => - fetchHornBugle({ ...params, page: pageParam, size: 20 }), + const queryClient = useQueryClient(); + const serverName = params.serverName; + const keyword = params.keyword; + const filters = useMemo( + () => ({ serverName, keyword }), + [serverName, keyword], + ); + const queryKey = useMemo(() => ["horn-bugle", filters] as const, [filters]); + const headKey = useMemo( + () => ["horn-bugle-head", filters] as const, + [filters], + ); + const scope = JSON.stringify(queryKey); + const activeScope = useRef(scope); + const generation = useRef(0); + const pendingRefresh = useRef<{ + scope: string; + token: symbol; + promise: Promise; + } | null>(null); + const [refreshState, setRefreshState] = useState<{ + scope: string; + pending: boolean; + error: Error | null; + }>({ scope, pending: false, error: null }); + const isRefreshing = refreshState.scope === scope && refreshState.pending; + + useEffect(() => { + generation.current += 1; + activeScope.current = scope; + setRefreshState({ scope, pending: false, error: null }); + return () => { + generation.current += 1; + activeScope.current = null; + pendingRefresh.current = null; + void queryClient.cancelQueries({ queryKey: headKey, exact: true }); + }; + }, [scope, headKey, queryClient]); + + const query = useInfiniteQuery({ + queryKey, + queryFn: async ({ pageParam = 1, signal }) => { + const page = await fetchHornBugle( + { ...filters, page: pageParam, size: 20 }, + signal, + ); + // Seed the head check instead of fetching page one twice on initial load. + if (pageParam === 1) queryClient.setQueryData(headKey, page); + return page; + }, initialPageParam: 1, getNextPageParam: (lastPage) => { if (lastPage.meta.isLast) return undefined; return lastPage.meta.currentPage + 1; }, - staleTime: 60 * 1000, // 1분 + staleTime: 60_000, + retry: false, + refetchOnMount: false, + refetchOnWindowFocus: false, + refetchOnReconnect: false, + }); + + const head = useQuery({ + queryKey: headKey, + queryFn: ({ signal }) => + fetchHornBugle({ ...filters, page: 1, size: 20 }, signal), + ...liveQueryOptions, + enabled: query.data !== undefined && !isRefreshing, + retry: false, }); + const { refetch: refetchHead } = head; + + const refreshLatest = useCallback((): Promise => { + if (pendingRefresh.current?.scope === scope) + return pendingRefresh.current.promise; + const startedGeneration = generation.current; + const token = Symbol(); + const isCurrent = () => + activeScope.current === scope && generation.current === startedGeneration; + setRefreshState({ scope, pending: true, error: null }); + const promise = (async () => { + try { + const result = await refetchHead({ cancelRefetch: false }); + if (!isCurrent()) return false; + if (result.isError || !result.data) + throw result.error ?? new Error("뿔피리 조회 실패"); + await queryClient.cancelQueries({ queryKey, exact: true }); + if (!isCurrent()) return false; + queryClient.setQueryData>( + queryKey, + { pages: [result.data], pageParams: [1] }, + { updatedAt: result.dataUpdatedAt }, + ); + setRefreshState({ scope, pending: false, error: null }); + return true; + } catch (error) { + if (isCurrent()) { + setRefreshState({ + scope, + pending: false, + error: + error instanceof Error ? error : new Error("뿔피리 조회 실패"), + }); + } + return false; + } finally { + if (pendingRefresh.current?.token === token) + pendingRefresh.current = null; + } + })(); + pendingRefresh.current = { scope, token, promise }; + return promise; + }, [scope, refetchHead, queryClient, queryKey]); + + const firstPage = query.data?.pages[0]; + const hasNewMessages = Boolean( + firstPage && + head.data && + JSON.stringify(firstPage.items) !== JSON.stringify(head.data.items), + ); + + return { + ...query, + refreshLatest, + isRefreshing, + refreshError: refreshState.scope === scope ? refreshState.error : null, + checkError: head.error, + isChecking: head.isFetching, + hasNewMessages, + lastCheckedAt: head.dataUpdatedAt || query.dataUpdatedAt, + }; } diff --git a/src/hooks/useLatestHornBugle.ts b/src/hooks/useLatestHornBugle.ts index c04226e..a34c252 100644 --- a/src/hooks/useLatestHornBugle.ts +++ b/src/hooks/useLatestHornBugle.ts @@ -2,21 +2,15 @@ import { useQuery } from "@tanstack/react-query"; -import { ApiResponse } from "@/types/community"; -import { HornBugleResponse } from "@/types/horn-bugle"; +import { liveQueryOptions } from "@/lib/api/live-query"; +import { fetchHornBugle } from "@/lib/horn-bugle"; export function useLatestHornBugle() { return useQuery({ queryKey: ["horn-bugle", "home", 7], - queryFn: async () => { - const response = await fetch("/api/horn-bugle?page=1&size=7"); - if (!response.ok) { - throw new Error(`Failed to fetch horn bugle: ${response.status}`); - } - const result: ApiResponse = await response.json(); - return result.data.items; - }, - staleTime: 60 * 1000, + queryFn: async ({ signal }) => + (await fetchHornBugle({ page: 1, size: 7 }, signal)).items, + ...liveQueryOptions, retry: false, }); } diff --git a/src/hooks/useStatistics.test.tsx b/src/hooks/useStatistics.test.tsx new file mode 100644 index 0000000..152b5e6 --- /dev/null +++ b/src/hooks/useStatistics.test.tsx @@ -0,0 +1,101 @@ +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { cleanup, renderHook, waitFor } from "@testing-library/react"; +import type { PropsWithChildren } from "react"; + +import { + useItemDailyStatistics, + useItemWeeklyStatistics, + useSubcategoryDailyStatistics, + useSubcategoryWeeklyStatistics, + useTopCategoryDailyStatistics, + useTopCategoryWeeklyStatistics, +} from "./useStatistics"; + +const hooks = [ + ["item daily", useItemDailyStatistics], + ["item weekly", useItemWeeklyStatistics], + ["subcategory daily", useSubcategoryDailyStatistics], + ["subcategory weekly", useSubcategoryWeeklyStatistics], + ["top category daily", useTopCategoryDailyStatistics], + ["top category weekly", useTopCategoryWeeklyStatistics], +] as const; +const params = { + itemName: "나무장작", + topCategory: "기타", + subCategory: "기타", +}; +let client: QueryClient; +let fetchSpy: jest.SpyInstance; + +function wrapper({ children }: PropsWithChildren) { + return {children}; +} +beforeEach(() => { + client = new QueryClient({ + defaultOptions: { queries: { retry: false, gcTime: 0 } }, + }); + fetchSpy = jest.spyOn(globalThis, "fetch"); +}); +afterEach(() => { + cleanup(); + client.clear(); + fetchSpy.mockRestore(); +}); + +describe.each(hooks)("%s response contract", (name, useStatistics) => { + const dates = name.includes("daily") + ? { date: "2026-11-14" } + : { weekStart: "2026-11-09", weekEnd: "2026-11-15" }; + const row = { + minPrice: 1, + maxPrice: 2, + avgPrice: 1.5, + totalVolume: 3, + totalQuantity: 2, + ...dates, + }; + it.each( + Object.keys(dates).flatMap((field) => [ + [field, undefined], + [field, null], + [field, {}], + ]), + )("rejects invalid %s date %j", async (field, value) => { + fetchSpy.mockResolvedValue( + Response.json({ + success: true, + data: [{ ...row, [field as string]: value }], + }), + ); + const { result } = renderHook(() => useStatistics(params), { wrapper }); + await waitFor(() => expect(result.current.isError).toBe(true)); + expect(result.current.data).toBeUndefined(); + }); + it("accepts the date fields for the selected period", async () => { + fetchSpy.mockResolvedValue(Response.json({ success: true, data: [row] })); + const { result } = renderHook(() => useStatistics(params), { wrapper }); + await waitFor(() => expect(result.current.isSuccess).toBe(true)); + expect(result.current.data).toEqual([row]); + }); + + it.each([ + ["failed envelope", { success: false, data: [] }], + ["null data", { success: true, data: null }], + ["non-array data", { success: true, data: {} }], + ["malformed row", { success: true, data: [null] }], + ["missing price fields", { success: true, data: [{ date: "2026-11-14" }] }], + ["null envelope", null], + ])("rejects %s instead of empty results", async (_label, payload) => { + fetchSpy.mockResolvedValue(Response.json(payload)); + const { result } = renderHook(() => useStatistics(params), { wrapper }); + await waitFor(() => expect(result.current.isError).toBe(true)); + expect(result.current.data).toBeUndefined(); + }); + + it("accepts a successful empty array", async () => { + fetchSpy.mockResolvedValue(Response.json({ success: true, data: [] })); + const { result } = renderHook(() => useStatistics(params), { wrapper }); + await waitFor(() => expect(result.current.isSuccess).toBe(true)); + expect(result.current.data).toEqual([]); + }); +}); diff --git a/src/hooks/useStatistics.ts b/src/hooks/useStatistics.ts index 5319ad5..da689cc 100644 --- a/src/hooks/useStatistics.ts +++ b/src/hooks/useStatistics.ts @@ -7,6 +7,7 @@ import { ItemStatisticsParams, ItemWeeklyStatistics, StatisticsApiResponse, + StatisticsBase, SubcategoryDailyStatistics, SubcategoryStatisticsParams, SubcategoryWeeklyStatistics, @@ -15,6 +16,33 @@ import { TopCategoryWeeklyStatistics, } from "@/types/statistics"; +function statisticsRows( + result: StatisticsApiResponse | null, + dateFields: readonly (keyof T)[], +): T[] { + const fields: (keyof StatisticsBase)[] = [ + "minPrice", + "maxPrice", + "avgPrice", + "totalVolume", + "totalQuantity", + ]; + if ( + result?.success !== true || + !Array.isArray(result.data) || + !result.data.every( + (row) => + row !== null && + typeof row === "object" && + fields.every((field) => Number.isFinite(row[field])) && + dateFields.every((field) => typeof row[field] === "string"), + ) + ) { + throw new Error("Invalid statistics response"); + } + return result.data; +} + // ===== 아이템 일간 통계 ===== async function fetchItemDailyStatistics( @@ -36,7 +64,7 @@ async function fetchItemDailyStatistics( } const result: StatisticsApiResponse = await response.json(); - return result.data ?? []; + return statisticsRows(result, ["date"]); } export function useItemDailyStatistics(params: ItemStatisticsParams) { @@ -70,7 +98,7 @@ async function fetchItemWeeklyStatistics( } const result: StatisticsApiResponse = await response.json(); - return result.data ?? []; + return statisticsRows(result, ["weekStart", "weekEnd"]); } export function useItemWeeklyStatistics(params: ItemStatisticsParams) { @@ -103,7 +131,7 @@ async function fetchSubcategoryDailyStatistics( } const result: StatisticsApiResponse = await response.json(); - return result.data ?? []; + return statisticsRows(result, ["date"]); } export function useSubcategoryDailyStatistics( @@ -138,7 +166,7 @@ async function fetchSubcategoryWeeklyStatistics( } const result: StatisticsApiResponse = await response.json(); - return result.data ?? []; + return statisticsRows(result, ["weekStart", "weekEnd"]); } export function useSubcategoryWeeklyStatistics( @@ -172,7 +200,7 @@ async function fetchTopCategoryDailyStatistics( } const result: StatisticsApiResponse = await response.json(); - return result.data ?? []; + return statisticsRows(result, ["date"]); } export function useTopCategoryDailyStatistics( @@ -206,7 +234,7 @@ async function fetchTopCategoryWeeklyStatistics( } const result: StatisticsApiResponse = await response.json(); - return result.data ?? []; + return statisticsRows(result, ["weekStart", "weekEnd"]); } export function useTopCategoryWeeklyStatistics( diff --git a/src/lib/api/live-query.ts b/src/lib/api/live-query.ts new file mode 100644 index 0000000..e522530 --- /dev/null +++ b/src/lib/api/live-query.ts @@ -0,0 +1,8 @@ +export const liveQueryOptions = { + staleTime: 60_000, + refetchInterval: 60_000, + refetchIntervalInBackground: false, + refetchOnMount: true, + refetchOnReconnect: true, + refetchOnWindowFocus: true, +} as const; diff --git a/src/lib/auction/detail.test.ts b/src/lib/auction/detail.test.ts index b8ffaf0..1e40bb4 100644 --- a/src/lib/auction/detail.test.ts +++ b/src/lib/auction/detail.test.ts @@ -160,6 +160,87 @@ describe("getAuctionDetail", () => { } }); + it("loads the realtime wire ID and registration date without alias fields", async () => { + const realtime = { + id: 123, + itemName: "롱소드", + itemDisplayName: "강한 롱소드", + itemCount: 2, + auctionPricePerUnit: 1200, + dateRegister: "2026-11-05T09:00:00Z", + dateAuctionExpire: "2026-11-08T09:00:00Z", + itemSubCategory: "검", + itemTopCategory: "근거리 장비", + itemOptions: [], + }; + jest + .mocked(fetch) + .mockImplementation(async (input) => + String(input).includes("/auction-realtime/") + ? jsonResponse({ success: true, data: realtime }) + : successFetch(input), + ); + const result = await getAuctionDetail("realtime", "123"); + expect(result.status).toBe("available"); + expect(result.record).toEqual(realtime); + expect(fetch).toHaveBeenCalledTimes(4); + }); + + it.each([ + "undefined", + "null", + "0", + "-1", + "1.5", + "abc", + "1e3", + " 12", + "9007199254740992", + ])("rejects invalid realtime URL ID %s before any request", async (id) => { + await expect(getAuctionDetail("realtime", id)).resolves.toMatchObject({ + status: "not-found", + }); + expect(fetch).not.toHaveBeenCalled(); + }); + + it("returns an error for a realtime record without its wire ID", async () => { + jest + .mocked(fetch) + .mockResolvedValueOnce( + jsonResponse({ ...record, dateRegister: "2026-11-05" }), + ); + await expect(getAuctionDetail("realtime", "123")).resolves.toMatchObject({ + status: "error", + }); + expect(fetch).toHaveBeenCalledTimes(1); + }); + + it.each(["optionDesc", "optionSubType", "optionValue2"])( + "rejects unsafe realtime %s before rendering or dependent requests", + async (field) => { + jest.mocked(fetch).mockResolvedValueOnce( + jsonResponse({ + ...record, + id: 123, + dateRegister: "2026-11-05", + dateAuctionExpire: "2026-11-08", + itemOptions: [ + { + optionType: "인챈트", + optionValue: "x", + optionSubType: "접두", + [field]: 123, + }, + ], + }), + ); + await expect(getAuctionDetail("realtime", "123")).resolves.toMatchObject({ + status: "error", + }); + expect(fetch).toHaveBeenCalledTimes(1); + }, + ); + it("distinguishes a missing primary record from a primary timeout", async () => { jest.mocked(fetch).mockResolvedValueOnce(jsonResponse({}, 404)); await expect( diff --git a/src/lib/auction/detail.ts b/src/lib/auction/detail.ts index c406329..5c0e909 100644 --- a/src/lib/auction/detail.ts +++ b/src/lib/auction/detail.ts @@ -3,6 +3,7 @@ import { cache } from "react"; import { AuctionHistoryItem } from "@/hooks/useAuctionHistory"; import { AuctionRealtimeItem } from "@/hooks/useAuctionRealtime"; import { getItemStatistics, getPublicItemJson } from "@/lib/items/data"; +import { isAuctionRealtimeItem } from "@/types/auction-realtime"; import { ItemInfoResponse, PageResponse } from "@/types/item-info"; import { ItemDailyStatistics, ItemWeeklyStatistics } from "@/types/statistics"; @@ -35,7 +36,18 @@ export const getAuctionDetail = cache( : `/oab/auction-realtime/${encodeURIComponent(id)}`; let record: AuctionRecord; try { + if ( + source === "realtime" && + (!/^[1-9]\d*$/.test(id) || !Number.isSafeInteger(Number(id))) + ) { + throw Object.assign(new Error("Invalid realtime auction ID"), { + status: 404, + }); + } record = await getPublicItemJson(recordPath); + if (source === "realtime" && !isAuctionRealtimeItem(record)) { + throw new Error("실시간 매물 응답 형식이 올바르지 않습니다."); + } } catch (error) { const status = typeof error === "object" && error !== null && "status" in error diff --git a/src/lib/horn-bugle.ts b/src/lib/horn-bugle.ts new file mode 100644 index 0000000..c9f4631 --- /dev/null +++ b/src/lib/horn-bugle.ts @@ -0,0 +1,64 @@ +import type { + HornBugleResponse, + HornBugleSearchParams, +} from "@/types/horn-bugle"; + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null; +} + +export function parseHornBugleResponse(payload: unknown): HornBugleResponse { + if ( + !isRecord(payload) || + payload.success !== true || + !isRecord(payload.data) + ) { + throw new Error("뿔피리 응답을 확인하지 못했습니다."); + } + const { items, meta } = payload.data; + if ( + !Array.isArray(items) || + !items.every( + (item) => + isRecord(item) && + Number.isSafeInteger(item.id) && + Number(item.id) > 0 && + [ + "serverName", + "characterName", + "message", + "dateSend", + "dateRegister", + ].every((field) => typeof item[field] === "string"), + ) || + !isRecord(meta) || + !["currentPage", "pageSize", "totalPages", "totalElements"].every( + (field) => Number.isSafeInteger(meta[field]) && Number(meta[field]) >= 0, + ) || + Number(meta.currentPage) < 1 || + Number(meta.pageSize) < 1 || + typeof meta.isFirst !== "boolean" || + typeof meta.isLast !== "boolean" + ) { + throw new Error("뿔피리 응답 형식이 올바르지 않습니다."); + } + return payload.data as unknown as HornBugleResponse; +} + +export async function fetchHornBugle( + params: HornBugleSearchParams, + signal?: AbortSignal, +): Promise { + const query = new URLSearchParams(); + Object.entries(params).forEach(([key, value]) => { + if (value !== undefined && value !== "") query.set(key, String(value)); + }); + const response = await fetch(`/api/horn-bugle?${query}`, { + signal, + cache: "no-store", + }); + if (!response.ok) throw new Error(`뿔피리 조회 실패: ${response.status}`); + const data = parseHornBugleResponse(await response.json()); + signal?.throwIfAborted(); + return data; +} diff --git a/src/types/auction-realtime.ts b/src/types/auction-realtime.ts index d6d0134..2c255e5 100644 --- a/src/types/auction-realtime.ts +++ b/src/types/auction-realtime.ts @@ -41,3 +41,103 @@ export interface AuctionRealtimeSearchParams sortBy?: string; // dateAuctionExpire, dateAuctionRegister, auctionPricePerUnit, itemName direction?: "asc" | "desc"; } + +interface ItemOption { + id: string; + optionType: string; + optionSubType: string | null; + optionValue: string; + optionValue2: string | null; + optionDesc: string | null; +} + +export interface AuctionRealtimeItem { + id: number; + itemName: string; + itemDisplayName: string; + itemCount: number; + auctionPricePerUnit: number; + dateAuctionExpire: string; + dateRegister: string; + itemSubCategory: string; + itemTopCategory: string; + itemOptions: ItemOption[]; +} + +export interface AuctionRealtimeResponse { + items: AuctionRealtimeItem[]; + meta: { + currentPage: number; + pageSize: number; + totalPages: number; + totalElements: number; + isFirst: boolean; + isLast: boolean; + }; +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +export function isAuctionRealtimeItem( + value: unknown, +): value is AuctionRealtimeItem { + return ( + isRecord(value) && + typeof value.id === "number" && + Number.isSafeInteger(value.id) && + value.id > 0 && + typeof value.itemName === "string" && + typeof value.itemDisplayName === "string" && + typeof value.itemCount === "number" && + Number.isFinite(value.itemCount) && + typeof value.auctionPricePerUnit === "number" && + Number.isFinite(value.auctionPricePerUnit) && + typeof value.dateAuctionExpire === "string" && + typeof value.dateRegister === "string" && + typeof value.itemSubCategory === "string" && + typeof value.itemTopCategory === "string" && + Array.isArray(value.itemOptions) && + value.itemOptions.every( + (option) => + isRecord(option) && + typeof option.optionType === "string" && + typeof option.optionValue === "string" && + [option.optionDesc, option.optionSubType, option.optionValue2].every( + (field) => field == null || typeof field === "string", + ), + ) + ); +} + +export function readAuctionRealtimeResponse( + payload: unknown, +): AuctionRealtimeResponse { + if ( + !isRecord(payload) || + payload.success !== true || + !isRecord(payload.data) + ) { + throw new Error("실시간 매물 응답을 확인할 수 없습니다."); + } + const { items, meta } = payload.data; + if ( + !Array.isArray(items) || + !items.every(isAuctionRealtimeItem) || + !isRecord(meta) || + !Number.isInteger(meta.currentPage) || + Number(meta.currentPage) < 1 || + !Number.isInteger(meta.pageSize) || + Number(meta.pageSize) < 1 || + !Number.isInteger(meta.totalPages) || + Number(meta.totalPages) < 0 || + !Number.isInteger(meta.totalElements) || + Number(meta.totalElements) < 0 || + typeof meta.isFirst !== "boolean" || + typeof meta.isLast !== "boolean" + ) { + throw new Error("실시간 매물 응답 형식이 올바르지 않습니다."); + } + return payload.data as unknown as AuctionRealtimeResponse; +}