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;
+}