From ac362e3137e3c4b2d1151edd4c29b222bcc01fc8 Mon Sep 17 00:00:00 2001 From: minbros Date: Wed, 2 Sep 2026 16:19:39 +0900 Subject: [PATCH 1/6] =?UTF-8?q?fix(places):=20=EA=B2=BD=EB=A1=9C=C2=B7?= =?UTF-8?q?=EC=82=AC=EC=A7=84=20=EB=B6=80=EB=B6=84=20=EC=8B=A4=ED=8C=A8=20?= =?UTF-8?q?=EC=B2=98=EB=A6=AC=20=EA=B0=9C=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 경로 일괄 조회 non-OK 응답은 단건 조회로 폴백 - 사진 FAILED 응답과 빈 결과의 재시도·TTL 처리 보강 --- src/hooks/usePlacePhotoUrl.test.ts | 327 +++++++++++++++++++ src/hooks/usePlacePhotoUrl.ts | 26 +- src/lib/api/places.test.ts | 26 ++ src/lib/api/places.ts | 8 +- src/lib/place-photo-query.ts | 53 ++- src/lib/places/place-batch-cache.ts | 24 +- src/lib/plan/schedule-bulk-hydration.test.ts | 79 ++++- src/lib/plan/schedule-bulk-hydration.ts | 6 +- src/types/place.ts | 2 - 9 files changed, 513 insertions(+), 38 deletions(-) create mode 100644 src/hooks/usePlacePhotoUrl.test.ts diff --git a/src/hooks/usePlacePhotoUrl.test.ts b/src/hooks/usePlacePhotoUrl.test.ts new file mode 100644 index 00000000..b67ccb26 --- /dev/null +++ b/src/hooks/usePlacePhotoUrl.test.ts @@ -0,0 +1,327 @@ +import { QueryClient, QueryObserver } from "@tanstack/react-query"; +import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; + +const useQueryMock = vi.hoisted(() => vi.fn((options) => options)); +const requestPlacePhotoUrlMock = vi.hoisted(() => vi.fn()); +const requestPlacePhotoUrlsBatchMock = vi.hoisted(() => vi.fn()); + +vi.hoisted(() => { + process.env.NEXT_PUBLIC_API_BASE_URL = "http://localhost:8080"; + process.env.NEXT_PUBLIC_GOOGLE_CLIENT_ID = "test-client"; + process.env.NEXT_PUBLIC_GOOGLE_REDIRECT_URI = "http://localhost/callback"; + process.env.NEXT_PUBLIC_GOOGLE_MAPS_API_KEY = "test-maps-key"; +}); + +vi.mock("@tanstack/react-query", async (importOriginal) => { + const actual = await importOriginal(); + return { ...actual, useQuery: useQueryMock }; +}); + +vi.mock("@/lib/api/places", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + requestPlacePhotoUrl: requestPlacePhotoUrlMock, + requestPlacePhotoUrlsBatch: requestPlacePhotoUrlsBatchMock, + }; +}); + +let fetchAndSeedPlacePhotoUrls: typeof import("@/lib/places/place-batch-cache").fetchAndSeedPlacePhotoUrls; +let ensurePlacePhotoUrlsCached: typeof import("@/lib/places/place-batch-cache").ensurePlacePhotoUrlsCached; +let placePhotoUrlQueryKey: typeof import("@/lib/place-photo-query").placePhotoUrlQueryKey; +let seededPlacePhotoUrlQueryOptions: typeof import("@/lib/place-photo-query").seededPlacePhotoUrlQueryOptions; +let registerQueryClient: typeof import("@/lib/query-client").registerQueryClient; +let usePlacePhotoUrlQuery: typeof import("@/hooks/usePlacePhotoUrl").usePlacePhotoUrlQuery; + +beforeAll(async () => { + ({ fetchAndSeedPlacePhotoUrls, ensurePlacePhotoUrlsCached } = await import( + "@/lib/places/place-batch-cache" + )); + ({ placePhotoUrlQueryKey, seededPlacePhotoUrlQueryOptions } = await import( + "@/lib/place-photo-query" + )); + ({ registerQueryClient } = await import("@/lib/query-client")); + ({ usePlacePhotoUrlQuery } = await import("@/hooks/usePlacePhotoUrl")); +}); + +beforeEach(() => { + useQueryMock.mockClear(); + requestPlacePhotoUrlMock.mockReset(); + requestPlacePhotoUrlsBatchMock.mockReset(); +}); + +describe("usePlacePhotoUrlQuery", () => { + it("authoritative empty 항목만 캐시하고 per-item FAILED는 cache miss로 남긴다", async () => { + const queryClient = new QueryClient(); + registerQueryClient(queryClient); + const emptyId = "ChIJ-authoritative-empty"; + const failedId = "ChIJ-per-item-failed"; + requestPlacePhotoUrlsBatchMock.mockResolvedValueOnce([ + { status: "OK", googlePlaceId: emptyId }, + { + status: "FAILED", + googlePlaceId: failedId, + errorCode: "UPSTREAM_TIMEOUT", + }, + ]); + + await fetchAndSeedPlacePhotoUrls([emptyId, failedId], queryClient); + + expect(queryClient.getQueryData(placePhotoUrlQueryKey(emptyId))).toBe(""); + expect( + queryClient.getQueryData(placePhotoUrlQueryKey(failedId)), + ).toBeUndefined(); + }); + + it("mixed batch의 FAILED 항목은 빈 URL로 고정하지 않고 단건 GET으로 fallback한다", async () => { + const queryClient = new QueryClient(); + registerQueryClient(queryClient); + const okId = "ChIJ-ok"; + const failedId = "ChIJ-failed"; + const failedItem = { + status: "FAILED" as const, + googlePlaceId: failedId, + errorCode: "UPSTREAM_TIMEOUT", + }; + requestPlacePhotoUrlsBatchMock + .mockResolvedValueOnce([ + { + status: "OK", + googlePlaceId: okId, + photoUrl: "https://cdn.example/ok.jpg", + }, + failedItem, + ]) + .mockResolvedValueOnce([failedItem]); + requestPlacePhotoUrlMock.mockResolvedValueOnce( + "https://cdn.example/fallback.jpg", + ); + + await fetchAndSeedPlacePhotoUrls([okId, failedId], queryClient); + + expect(queryClient.getQueryData(placePhotoUrlQueryKey(okId))).toBe( + "https://cdn.example/ok.jpg", + ); + expect(queryClient.getQueryData(placePhotoUrlQueryKey(failedId))).toBeUndefined(); + + usePlacePhotoUrlQuery(failedId); + const queryOptions = useQueryMock.mock.lastCall?.[0]; + const photoUrl = await queryClient.fetchQuery(queryOptions); + + expect(photoUrl).toBe("https://cdn.example/fallback.jpg"); + expect(requestPlacePhotoUrlMock).toHaveBeenCalledWith(failedId); + expect(queryClient.getQueryData(placePhotoUrlQueryKey(failedId))).toBe( + "https://cdn.example/fallback.jpg", + ); + }); + + it("FAILED 뒤 단건 GET 재시도에서는 batch 요청을 증폭하지 않는다", async () => { + const queryClient = new QueryClient(); + registerQueryClient(queryClient); + const failedId = "ChIJ-single-retry"; + requestPlacePhotoUrlsBatchMock.mockResolvedValueOnce([ + { + status: "FAILED", + googlePlaceId: failedId, + errorCode: "UPSTREAM_TIMEOUT", + }, + ]); + requestPlacePhotoUrlMock + .mockRejectedValueOnce(new Error("single request failed once")) + .mockRejectedValueOnce(new Error("single request failed twice")) + .mockResolvedValueOnce("https://cdn.example/retried.jpg"); + + usePlacePhotoUrlQuery(failedId); + const queryOptions = useQueryMock.mock.lastCall?.[0]; + + await expect(queryClient.fetchQuery(queryOptions)).resolves.toBe( + "https://cdn.example/retried.jpg", + ); + expect(requestPlacePhotoUrlsBatchMock).toHaveBeenCalledTimes(1); + expect(requestPlacePhotoUrlMock).toHaveBeenCalledTimes(3); + }); + + it("seeded multi-photo query도 FAILED 캐시 miss를 빈 URL 성공으로 바꾸지 않는다", async () => { + const queryClient = new QueryClient(); + registerQueryClient(queryClient); + const okId = "ChIJ-multi-ok"; + const failedId = "ChIJ-multi-failed"; + requestPlacePhotoUrlsBatchMock.mockResolvedValueOnce([ + { + status: "OK", + googlePlaceId: okId, + photoUrl: "https://cdn.example/multi-ok.jpg", + }, + { + status: "FAILED", + googlePlaceId: failedId, + errorCode: "UPSTREAM_TIMEOUT", + }, + ]); + requestPlacePhotoUrlMock.mockResolvedValueOnce( + "https://cdn.example/multi-fallback.jpg", + ); + + await fetchAndSeedPlacePhotoUrls([okId, failedId], queryClient); + const options = seededPlacePhotoUrlQueryOptions(failedId); + + await expect(queryClient.fetchQuery(options)).resolves.toBe( + "https://cdn.example/multi-fallback.jpg", + ); + expect(requestPlacePhotoUrlMock).toHaveBeenCalledWith(failedId); + expect(queryClient.getQueryData(placePhotoUrlQueryKey(failedId))).toBe( + "https://cdn.example/multi-fallback.jpg", + ); + }); + + it("seeded multi-photo의 no-photo 캐시도 5분 후 단건 GET을 허용한다", async () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date("2026-09-02T00:00:00.000Z")); + + try { + const queryClient = new QueryClient(); + registerQueryClient(queryClient); + const id = "ChIJ-seeded-no-photo"; + requestPlacePhotoUrlsBatchMock.mockResolvedValueOnce([ + { status: "OK", googlePlaceId: id, photoUrl: null }, + ]); + await fetchAndSeedPlacePhotoUrls([id], queryClient); + + const options = seededPlacePhotoUrlQueryOptions(id); + await expect(queryClient.fetchQuery(options)).resolves.toBe(""); + expect(requestPlacePhotoUrlMock).not.toHaveBeenCalled(); + + vi.advanceTimersByTime(5 * 60 * 1000 + 1); + requestPlacePhotoUrlMock.mockResolvedValueOnce( + "https://cdn.example/seeded-available-later.jpg", + ); + + const observer = new QueryObserver(queryClient, options); + const unsubscribe = observer.subscribe(() => undefined); + try { + await vi.waitFor(() => { + expect(requestPlacePhotoUrlMock).toHaveBeenCalledWith(id); + expect(queryClient.getQueryData(options.queryKey)).toBe( + "https://cdn.example/seeded-available-later.jpg", + ); + }); + } finally { + unsubscribe(); + } + } finally { + vi.useRealTimers(); + } + }); + + it("명시적 no-photo 결과는 5분만 캐시한 뒤 batch로 다시 조회한다", async () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date("2026-09-02T00:00:00.000Z")); + + try { + const queryClient = new QueryClient(); + registerQueryClient(queryClient); + const id = "ChIJ-no-photo"; + requestPlacePhotoUrlsBatchMock.mockResolvedValueOnce([ + { status: "OK", googlePlaceId: id, photoUrl: null }, + ]); + + usePlacePhotoUrlQuery(id); + const queryOptions = useQueryMock.mock.lastCall?.[0]; + + await expect(queryClient.fetchQuery(queryOptions)).resolves.toBe(""); + expect(requestPlacePhotoUrlMock).not.toHaveBeenCalled(); + expect(requestPlacePhotoUrlsBatchMock).toHaveBeenCalledTimes(1); + + vi.advanceTimersByTime(5 * 60 * 1000 + 1); + requestPlacePhotoUrlsBatchMock.mockResolvedValueOnce([ + { + status: "OK", + googlePlaceId: id, + photoUrl: "https://cdn.example/available-later.jpg", + }, + ]); + + await expect(queryClient.fetchQuery(queryOptions)).resolves.toBe( + "https://cdn.example/available-later.jpg", + ); + expect(requestPlacePhotoUrlsBatchMock).toHaveBeenCalledTimes(2); + expect(requestPlacePhotoUrlMock).not.toHaveBeenCalled(); + } finally { + vi.useRealTimers(); + } + }); + + it("만료된 no-photo 뒤 batch FAILED면 낡은 빈 값을 쓰지 않고 단건 fallback한다", async () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date("2026-09-02T00:00:00.000Z")); + + try { + const queryClient = new QueryClient(); + registerQueryClient(queryClient); + const id = "ChIJ-expired-then-failed"; + requestPlacePhotoUrlsBatchMock + .mockResolvedValueOnce([ + { status: "OK", googlePlaceId: id, photoUrl: null }, + ]) + .mockResolvedValueOnce([ + { + status: "FAILED", + googlePlaceId: id, + errorCode: "UPSTREAM_TIMEOUT", + }, + ]); + requestPlacePhotoUrlMock.mockResolvedValueOnce( + "https://cdn.example/single-fallback.jpg", + ); + + usePlacePhotoUrlQuery(id); + const queryOptions = useQueryMock.mock.lastCall?.[0]; + await expect(queryClient.fetchQuery(queryOptions)).resolves.toBe(""); + + vi.advanceTimersByTime(5 * 60 * 1000 + 1); + + await expect(queryClient.fetchQuery(queryOptions)).resolves.toBe( + "https://cdn.example/single-fallback.jpg", + ); + expect(requestPlacePhotoUrlsBatchMock).toHaveBeenCalledTimes(2); + expect(requestPlacePhotoUrlMock).toHaveBeenCalledWith(id); + } finally { + vi.useRealTimers(); + } + }); + + it("중간 ensure 호출이 no-photo의 최초 5분 TTL을 연장하지 않는다", async () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date("2026-09-02T00:00:00.000Z")); + + try { + const queryClient = new QueryClient(); + registerQueryClient(queryClient); + const id = "ChIJ-no-photo-ensure"; + requestPlacePhotoUrlsBatchMock + .mockResolvedValueOnce([ + { status: "OK", googlePlaceId: id, photoUrl: null }, + ]) + .mockResolvedValueOnce([ + { + status: "OK", + googlePlaceId: id, + photoUrl: "https://cdn.example/available-after-ttl.jpg", + }, + ]); + + await ensurePlacePhotoUrlsCached([id], queryClient); + vi.advanceTimersByTime(4 * 60 * 1000); + await ensurePlacePhotoUrlsCached([id], queryClient); + vi.advanceTimersByTime(60 * 1000 + 1); + await ensurePlacePhotoUrlsCached([id], queryClient); + + expect(requestPlacePhotoUrlsBatchMock).toHaveBeenCalledTimes(2); + expect(queryClient.getQueryData(placePhotoUrlQueryKey(id))).toBe( + "https://cdn.example/available-after-ttl.jpg", + ); + } finally { + vi.useRealTimers(); + } + }); +}); diff --git a/src/hooks/usePlacePhotoUrl.ts b/src/hooks/usePlacePhotoUrl.ts index a5bd5644..a78afae8 100644 --- a/src/hooks/usePlacePhotoUrl.ts +++ b/src/hooks/usePlacePhotoUrl.ts @@ -4,6 +4,7 @@ import { useQueries, useQuery } from "@tanstack/react-query"; import { requestPlacePhotoUrl } from "@/lib/api/places"; import { fetchAndSeedPlacePhotoUrls } from "@/lib/places/place-batch-cache"; import { + PLACE_PHOTO_EMPTY_STALE_MS, PLACE_PHOTO_QUERY_GC_MS, placePhotoUrlQueryDefaults, placePhotoUrlQueryKey, @@ -16,20 +17,35 @@ export function usePlacePhotoUrlQuery( options?: { enabled?: boolean }, ) { const id = typeof googlePlaceId === "string" ? googlePlaceId.trim() : ""; + const queryKey = placePhotoUrlQueryKey(id); return useQuery({ - queryKey: placePhotoUrlQueryKey(id), + queryKey, queryFn: async () => { const queryClient = getQueryClient(); if (!queryClient) { return requestPlacePhotoUrl(id); } - const cached = queryClient?.getQueryData(placePhotoUrlQueryKey(id)); + const cached = queryClient.getQueryData(queryKey); if (typeof cached === "string" && cached.trim().length > 0) { return cached.trim(); } - await fetchAndSeedPlacePhotoUrls([id], queryClient); - const seeded = queryClient?.getQueryData(placePhotoUrlQueryKey(id)); - return typeof seeded === "string" ? seeded.trim() : ""; + const stateBeforeFetch = queryClient.getQueryState(queryKey); + if ((stateBeforeFetch?.fetchFailureCount ?? 0) === 0) { + await fetchAndSeedPlacePhotoUrls([id], queryClient); + } + const seededState = queryClient.getQueryState(queryKey); + const seeded = seededState?.data; + if (typeof seeded === "string") { + const trimmed = seeded.trim(); + if ( + trimmed.length > 0 || + Date.now() - (seededState?.dataUpdatedAt ?? 0) < + PLACE_PHOTO_EMPTY_STALE_MS + ) { + return trimmed; + } + } + return requestPlacePhotoUrl(id); }, enabled: id.length > 0 && (options?.enabled ?? true), ...placePhotoUrlQueryDefaults, diff --git a/src/lib/api/places.test.ts b/src/lib/api/places.test.ts index e3f612f6..86f2c86e 100644 --- a/src/lib/api/places.test.ts +++ b/src/lib/api/places.test.ts @@ -122,4 +122,30 @@ describe("place photo API requests", () => { refresh: true, }); }); + + it("represents whole-batch 204 as explicit no-photo items instead of per-item failures", async () => { + const { requestPlacePhotoUrlsBatch } = await import("@/lib/api/places"); + apiFetch.mockResolvedValueOnce({ + status: 204, + ok: true, + json: async () => { + throw new Error("body should not be parsed"); + }, + }); + + await expect( + requestPlacePhotoUrlsBatch(["ChIJ-place-1", "ChIJ-place-2"]), + ).resolves.toEqual([ + { + status: "OK", + googlePlaceId: "ChIJ-place-1", + photoUrl: null, + }, + { + status: "OK", + googlePlaceId: "ChIJ-place-2", + photoUrl: null, + }, + ]); + }); }); diff --git a/src/lib/api/places.ts b/src/lib/api/places.ts index ea60d9f8..26c3231e 100644 --- a/src/lib/api/places.ts +++ b/src/lib/api/places.ts @@ -249,7 +249,13 @@ async function requestPlacePhotoUrlsBatchChunk( ...(options?.refresh === true ? { refresh: true } : {}), }), }); - if (res.status === 204) return []; + if (res.status === 204) { + return googlePlaceIds.map((googlePlaceId) => ({ + status: "OK", + googlePlaceId, + photoUrl: null, + })); + } if (!res.ok) { const body = await tryParseJson(res); const detail = readUserFacingMessageFromApiBody(body); diff --git a/src/lib/place-photo-query.ts b/src/lib/place-photo-query.ts index 1d02189a..299a0596 100644 --- a/src/lib/place-photo-query.ts +++ b/src/lib/place-photo-query.ts @@ -1,25 +1,43 @@ +import type { Query } from "@tanstack/react-query"; + +import { requestPlacePhotoUrl } from "@/lib/api/places"; import { getQueryClient } from "@/lib/query-client"; /** * `usePlacePhotoUrlQuery` — 같은 `googlePlaceId`에 대해 불필요한 `GET /places/photos` 재호출 방지. */ export const PLACE_PHOTO_QUERY_GC_MS = 7 * 24 * 60 * 60 * 1000; +export const PLACE_PHOTO_EMPTY_STALE_MS = 5 * 60 * 1000; + +export function placePhotoUrlQueryKey(googlePlaceId: string) { + const id = typeof googlePlaceId === "string" ? googlePlaceId.trim() : ""; + return ["places", "photoUrl", id] as const; +} + +function placePhotoUrlStaleTime( + query: Query< + string, + Error, + string, + ReturnType + >, +): number { + return typeof query.state.data === "string" && + query.state.data.trim().length === 0 + ? PLACE_PHOTO_EMPTY_STALE_MS + : Infinity; +} export const placePhotoUrlQueryDefaults = { - staleTime: Infinity, + staleTime: placePhotoUrlStaleTime, gcTime: PLACE_PHOTO_QUERY_GC_MS, - refetchOnMount: false, + refetchOnMount: true, refetchOnWindowFocus: false, refetchOnReconnect: false, retry: 2, } as const; -export function placePhotoUrlQueryKey(googlePlaceId: string) { - const id = typeof googlePlaceId === "string" ? googlePlaceId.trim() : ""; - return ["places", "photoUrl", id] as const; -} - -/** batch 시딩 후 캐시만 구독 — `GET /places/photos` fallback 없음 */ +/** batch 시딩 후 구독 — 항목별 실패로 캐시 miss이면 단건 GET fallback */ export function seededPlacePhotoUrlQueryOptions( googlePlaceId: string, options?: { enabled?: boolean }, @@ -28,12 +46,23 @@ export function seededPlacePhotoUrlQueryOptions( const enabled = (options?.enabled ?? true) && id.length > 0; return { queryKey: placePhotoUrlQueryKey(id), - queryFn: (): string => { - const cached = getQueryClient()?.getQueryData(placePhotoUrlQueryKey(id)); - return typeof cached === "string" ? cached.trim() : ""; + queryFn: (): string | Promise => { + const state = getQueryClient()?.getQueryState( + placePhotoUrlQueryKey(id), + ); + const cached = state?.data; + if (typeof cached === "string") { + const trimmed = cached.trim(); + if ( + trimmed.length > 0 || + Date.now() - (state?.dataUpdatedAt ?? 0) < PLACE_PHOTO_EMPTY_STALE_MS + ) { + return trimmed; + } + } + return requestPlacePhotoUrl(id); }, enabled, ...placePhotoUrlQueryDefaults, - retry: false, }; } diff --git a/src/lib/places/place-batch-cache.ts b/src/lib/places/place-batch-cache.ts index 3eeea091..768826d7 100644 --- a/src/lib/places/place-batch-cache.ts +++ b/src/lib/places/place-batch-cache.ts @@ -11,6 +11,7 @@ import { } from "@/lib/api/places"; import { isBatchItemError, isBatchItemOk } from "@/lib/api/batch-types"; import { + PLACE_PHOTO_EMPTY_STALE_MS, placePhotoUrlQueryDefaults, placePhotoUrlQueryKey, } from "@/lib/place-photo-query"; @@ -194,8 +195,15 @@ function uncachedPhotoPlaceIds( for (const raw of googlePlaceIds) { const id = typeof raw === "string" ? raw.trim() : ""; if (!id.length) continue; - const cached = queryClient.getQueryData(placePhotoUrlQueryKey(id)); - if (cached != null) continue; + const state = queryClient.getQueryState(placePhotoUrlQueryKey(id)); + const cached = state?.data; + if (typeof cached === "string" && cached.trim().length > 0) continue; + if ( + typeof cached === "string" && + Date.now() - (state?.dataUpdatedAt ?? 0) < PLACE_PHOTO_EMPTY_STALE_MS + ) { + continue; + } out.push(id); } return [...new Set(out)]; @@ -327,7 +335,7 @@ export async function fetchAndSeedPlacePhotoUrls( } } -/** batch 시딩 후 개별 query key에 데이터가 있도록 defaults와 함께 prefetch */ +/** 미캐시·만료된 장소 사진 URL을 batch로 시딩 */ export async function ensurePlacePhotoUrlsCached( googlePlaceIds: readonly string[], queryClient?: QueryClient | null, @@ -336,16 +344,6 @@ export async function ensurePlacePhotoUrlsCached( if (!qc) return; await fetchAndSeedPlacePhotoUrls(googlePlaceIds, qc); - - for (const raw of googlePlaceIds) { - const id = typeof raw === "string" ? raw.trim() : ""; - if (!id.length) continue; - const cached = qc.getQueryData(placePhotoUrlQueryKey(id)); - if (cached == null) continue; - qc.setQueryData(placePhotoUrlQueryKey(id), cached, { - updatedAt: Date.now(), - }); - } } export { diff --git a/src/lib/plan/schedule-bulk-hydration.test.ts b/src/lib/plan/schedule-bulk-hydration.test.ts index 8a4e50be..6a212077 100644 --- a/src/lib/plan/schedule-bulk-hydration.test.ts +++ b/src/lib/plan/schedule-bulk-hydration.test.ts @@ -8,13 +8,17 @@ vi.mock("@/lib/api/rooms/schedule-items", async (importOriginal) => { await importOriginal(); return { ...actual, + getScheduleItemRoute: vi.fn(), getScheduleItemRoutesBatch: vi.fn(), }; }); let hydrateScheduleRoutesBatch: typeof import("@/lib/plan/schedule-bulk-hydration").hydrateScheduleRoutesBatch; +let resolveScheduleSegmentRoute: typeof import("@/lib/plan/scheduleSegmentRoute").resolveScheduleSegmentRoute; let scheduleItemsQueryKey: typeof import("@/lib/query-keys").scheduleItemsQueryKey; let scheduleItemRouteQueryKey: typeof import("@/lib/query-keys").scheduleItemRouteQueryKey; +let registerQueryClient: typeof import("@/lib/query-client").registerQueryClient; +let getScheduleItemRouteMock: ReturnType; let getScheduleItemRoutesBatchMock: ReturnType; const ROOM_ID = "room-1"; @@ -38,14 +42,21 @@ beforeAll(async () => { ({ hydrateScheduleRoutesBatch } = await import( "@/lib/plan/schedule-bulk-hydration" )); + ({ resolveScheduleSegmentRoute } = await import( + "@/lib/plan/scheduleSegmentRoute" + )); ({ scheduleItemsQueryKey, scheduleItemRouteQueryKey } = await import( "@/lib/query-keys" )); - ({ getScheduleItemRoutesBatch: getScheduleItemRoutesBatchMock } = - vi.mocked(await import("@/lib/api/rooms/schedule-items"))); + ({ registerQueryClient } = await import("@/lib/query-client")); + ({ + getScheduleItemRoute: getScheduleItemRouteMock, + getScheduleItemRoutesBatch: getScheduleItemRoutesBatchMock, + } = vi.mocked(await import("@/lib/api/rooms/schedule-items"))); }); beforeEach(() => { + getScheduleItemRouteMock.mockReset(); getScheduleItemRoutesBatchMock.mockReset(); }); @@ -100,6 +111,70 @@ describe("hydrateScheduleRoutesBatch", () => { ).not.toHaveProperty("encodedPolyline"); }); + it("mixed batch의 비 OK 구간은 no-route로 시딩하지 않고 단건 GET으로 fallback한다", async () => { + const queryClient = new QueryClient(); + registerQueryClient(queryClient); + const mixedSnapshot = [ + place(1, "places/a"), + place(2, "places/b"), + place(3, "places/c"), + ]; + queryClient.setQueryData( + scheduleItemsQueryKey(ROOM_ID, SCHEDULE_ID), + mixedSnapshot, + ); + getScheduleItemRoutesBatchMock.mockResolvedValue([ + routeResult, + { + status: "ERROR", + itemId: 2, + travelMode: "WALKING", + errorCode: "UPSTREAM_TIMEOUT", + }, + ]); + const fallbackRoute = { + travelMode: "WALKING", + distanceMeters: 800, + durationSeconds: 600, + }; + getScheduleItemRouteMock + .mockRejectedValueOnce(new Error("temporary single route failure")) + .mockResolvedValueOnce(fallbackRoute); + + await hydrateScheduleRoutesBatch( + queryClient, + ROOM_ID, + SCHEDULE_ID, + mixedSnapshot, + ); + + const failedKey = scheduleItemRouteQueryKey( + ROOM_ID, + SCHEDULE_ID, + 2, + "WALKING", + ); + expect(queryClient.getQueryData(failedKey)).toBeUndefined(); + await expect( + resolveScheduleSegmentRoute({ + roomId: ROOM_ID, + scheduleId: SCHEDULE_ID, + segmentSourceItemId: 2, + travelMode: "WALKING", + }), + ).rejects.toThrow("temporary single route failure"); + expect(queryClient.getQueryData(failedKey)).toBeUndefined(); + await expect( + resolveScheduleSegmentRoute({ + roomId: ROOM_ID, + scheduleId: SCHEDULE_ID, + segmentSourceItemId: 2, + travelMode: "WALKING", + }), + ).resolves.toEqual(fallbackRoute); + expect(getScheduleItemRouteMock).toHaveBeenCalledTimes(2); + }); + it("응답 대기 중 일정이 바뀌면(지문 불일치) 늦게 도착한 결과를 버린다", async () => { const queryClient = new QueryClient(); queryClient.setQueryData( diff --git a/src/lib/plan/schedule-bulk-hydration.ts b/src/lib/plan/schedule-bulk-hydration.ts index edd63ca5..40507f8b 100644 --- a/src/lib/plan/schedule-bulk-hydration.ts +++ b/src/lib/plan/schedule-bulk-hydration.ts @@ -213,9 +213,8 @@ export async function hydrateScheduleItemsFromSchedulesWithItems( } function batchRouteItemToResponse( - item: ScheduleItemRouteBatchItem, -): import("@/lib/api/rooms/schedule-items").ScheduleItemRouteResponse | null { - if (!isBatchItemOk(item)) return null; + item: Extract, +): import("@/lib/api/rooms/schedule-items").ScheduleItemRouteResponse { return { distanceMeters: item.distanceMeters, durationSeconds: item.durationSeconds, @@ -318,6 +317,7 @@ export async function hydrateScheduleRoutesBatch( if (!snapshotFp.length || snapshotFp !== currentFp) return; for (const result of results) { + if (!isBatchItemOk(result)) continue; const route = batchRouteItemToResponse(result); const itemId = result.itemId; const mode = canonicalScheduleTravelMode(result.travelMode); diff --git a/src/types/place.ts b/src/types/place.ts index 4aae40be..087ced53 100644 --- a/src/types/place.ts +++ b/src/types/place.ts @@ -10,8 +10,6 @@ export type SearchResultCardProps = { isOpen?: boolean | null; /** Single preview image URL from /places/photos */ image?: string; - /** Google Places photo resource name; URL은 썸네일 노출 시점에 요청 */ - photoName?: string; address?: string; phone?: string; hours?: string; From 4b6aa932237027057d7a96b37f7afabc61c236b3 Mon Sep 17 00:00:00 2001 From: Minhyung Kim <127458006+minbros@users.noreply.github.com> Date: Sun, 6 Sep 2026 00:50:36 +0900 Subject: [PATCH 2/6] =?UTF-8?q?docs:=20=EB=B8=8C=EB=9E=9C=EC=B9=98=20?= =?UTF-8?q?=EB=B3=91=ED=95=A9=20=EC=A0=95=EC=B1=85=20=EB=AA=85=EB=AC=B8?= =?UTF-8?q?=ED=99=94=20(#142)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * docs(readme): 브랜치 병합 정책 명문화 - 병합 방향별 방식과 백머지 원칙 추가 - 금지 사항과 기존 이력 보존 원칙 명시 * docs(contributing): 브랜치 및 PR 정책 정본화 - 상세 브랜치·병합 정책을 CONTRIBUTING 문서로 분리 - README와 에이전트 문서에 정본 링크 추가 * docs(contributing): 기여 문서 정보 구조 정리 - README 온보딩을 간결하게 정리 - 상세 기여 정책을 CONTRIBUTING으로 통합 --- AGENTS.md | 4 ++++ CLAUDE.md | 2 ++ CONTRIBUTING.md | 35 +++++++++++++++++++++++++++++++++++ README.md | 30 ++++-------------------------- 4 files changed, 45 insertions(+), 26 deletions(-) create mode 100644 CONTRIBUTING.md diff --git a/AGENTS.md b/AGENTS.md index dfb766ef..dd93ee87 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -4,3 +4,7 @@ - Conventional Commits 형식을 사용한다. - 형식: `type(scope): 변경 요약` - 타이틀 하단 행에 "-" 을 이용하여 세부 변경 사항들을 핵심만 나열한다. 불필요하다고 판단되면 안넣어도 됨 + +## 브랜치 및 PR 규칙 + +브랜치 생성, PR 작성, 병합 작업은 반드시 [CONTRIBUTING.md](./CONTRIBUTING.md)를 따른다. diff --git a/CLAUDE.md b/CLAUDE.md index 33a96192..68bea562 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1,3 +1,5 @@ 공통 운영 규칙은 `AGENTS.md`를 정본으로 따른다. 아래에서 import한다. +브랜치 생성, PR 작성, 병합 작업은 반드시 [CONTRIBUTING.md](./CONTRIBUTING.md)를 따른다. + @AGENTS.md diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 00000000..496659bf --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,35 @@ +# 기여 가이드 + +이 문서는 커밋, 브랜치 생성, Pull Request(PR), 병합 정책의 상세 기준입니다. + +## 작업하고 PR 올리기 + +커밋 제목은 Conventional Commits 형식으로 한국어로 작성합니다. + +``` +feat: 일정 공유 기능 추가 +fix: 장소 검색 오류 수정 +docs: 개발자 온보딩 보완 +``` + +- `main`은 배포 기준 브랜치입니다. 직접 push하지 않고 모든 변경을 PR로 병합합니다. +- `dev`는 기능을 통합하고 릴리스 전에 함께 검증하는 브랜치입니다. +- 일반 작업은 최신 `dev`에서 `feature/` 브랜치를 만들고 `dev`를 대상으로 PR을 엽니다. 브랜치는 하나의 작업 단위로 짧게 유지하며, squash 병합이 끝난 feature 브랜치는 다시 사용하지 않습니다. +- 긴급 수정은 최신 `main`에서 `hotfix/` 브랜치를 만들고 `main`을 대상으로 PR을 엽니다. + +## 공통 브랜치 흐름 + +브랜치 병합에는 다음 공통 흐름을 적용합니다. + +| 병합 방향 | 방식 | +| :-- | :-- | +| `feature/*` → `dev` | Squash and merge | +| `dev` → `main` | Create a merge commit | +| `hotfix/*` → `main` | Create a merge commit | +| `main` → `dev` 백머지 | Create a merge commit | + +- hotfix나 revert가 `main`에 먼저 반영되면 최신 `main`의 정확한 HEAD를 merge commit으로 `dev`에 백머지합니다. cherry-pick, re-squash 또는 동일한 패치를 다시 적용하는 방식으로 전달하지 않습니다. +- 백머지 충돌을 해결하고 결과를 검증하기 전에는 다음 `dev` → `main` 릴리스를 병합하지 않습니다. +- Rebase merge는 사용하지 않습니다. +- 공유 중인 `main`과 `dev`의 이력을 재작성하거나 force-push하지 않습니다. +- 정책 도입 전의 기존 이력과 merge commit은 역사적 예외로 그대로 보존합니다. 토폴로지를 정리할 목적만으로 revert하거나 이력을 재작성하지 않습니다. diff --git a/README.md b/README.md index 14646eeb..0e49a84d 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,7 @@ 카카오톡으로 대화하고, 지도에서 장소를 찾고, 노션·엑셀에 일정을 따로 정리하던 여행 준비 과정을 하나의 워크스페이스로 합칩니다. -### 기술 스택 +## 기술 스택 | Category | Technology | | :------------ | ------------: | @@ -15,7 +15,7 @@ | **DataFetch** | Tanstack Query| -### 실행 +## 실행 npm install npm run dev @@ -24,28 +24,6 @@ npm test npm run build -## 작업하고 PR 올리기 +## 기여하기 -공통적으로 `main`에는 직접 push하지 않고, 커밋 제목은 Conventional Commits 형식으로 한국어로 작성합니다. - -``` -feat: 일정 공유 기능 추가 -fix: 장소 검색 오류 수정 -docs: 개발자 온보딩 보완 -``` - -### 모든 저장소의 공통 브랜치 흐름 - -Frontend, Backend, AI Server는 모두 같은 브랜치 전략을 사용합니다. - -``` -일반 작업: dev에서 feature/ 분기 → dev로 PR → dev에서 main으로 릴리스 PR -긴급 수정: main에서 hotfix/ 분기 → main으로 PR → dev로 백머지 -``` - -- `main` — 배포 기준 브랜치이며 직접 push하지 않습니다. -- `dev` — 기능을 통합하고 `main` 병합 전에 함께 검증하는 브랜치입니다. -- `feature/` — `dev`에서 분기하고 PR 대상도 `dev`로 지정합니다. -- `hotfix/` — `main`에서 분기하고 PR 대상은 `main`으로 지정합니다. 병합 후 같은 변경을 `dev`에 백머지합니다. - -브랜치는 하나의 작업 단위로 짧게 유지합니다. \ No newline at end of file +커밋, 브랜치 생성, PR 및 병합 절차는 [CONTRIBUTING.md](./CONTRIBUTING.md)를 따릅니다. From a7ba3682f25e6115258a19b77ad0de896680ae8e Mon Sep 17 00:00:00 2001 From: Minhyung Kim <127458006+minbros@users.noreply.github.com> Date: Sun, 6 Sep 2026 01:54:36 +0900 Subject: [PATCH 3/6] =?UTF-8?q?ci:=20PR=20Discord=20=EC=95=8C=EB=A6=BC=20?= =?UTF-8?q?=EC=9B=8C=ED=81=AC=ED=94=8C=EB=A1=9C=EC=9A=B0=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80=20(#143)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * ci: PR Discord 알림 워크플로우 추가 - Backend와 동일한 opened/reopened 알림 구성을 적용했다. * ci(deps): Discord 알림 액션을 불변 SHA로 고정 - v1 태그가 가리키는 검증된 커밋으로 액션 참조를 고정 --- .github/workflows/discord-pr-notify.yml | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) create mode 100644 .github/workflows/discord-pr-notify.yml diff --git a/.github/workflows/discord-pr-notify.yml b/.github/workflows/discord-pr-notify.yml new file mode 100644 index 00000000..eded78e5 --- /dev/null +++ b/.github/workflows/discord-pr-notify.yml @@ -0,0 +1,20 @@ +name: Discord PR 알림 + +on: + pull_request: + types: [opened, reopened] + +jobs: + notify: + runs-on: ubuntu-latest + steps: + - name: Discord 알림 전송 + uses: sarisia/actions-status-discord@eb045afee445dc055c18d3d90bd0f244fd062708 + with: + webhook: ${{ secrets.DISCORD_WEBHOOK_URL }} + title: "PR 올라왔어요!" + description: | + ${{ github.event.pull_request.title }} + ${{ github.event.pull_request.html_url }} + color: 0x5865F2 + username: GitHub From 3c8ae07c085cddcf5c104188d1778253f4439acb Mon Sep 17 00:00:00 2001 From: Minhyung Kim <127458006+minbros@users.noreply.github.com> Date: Sun, 6 Sep 2026 01:54:50 +0900 Subject: [PATCH 4/6] =?UTF-8?q?docs(contributing):=20hotfix=20=EB=B3=91?= =?UTF-8?q?=ED=95=A9=20=EC=A0=95=EC=B1=85=20=EC=A0=95=EC=A0=95=20(#144)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - hotfix 병합 방식을 Squash and merge로 일치시킴 --- CONTRIBUTING.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 496659bf..1c1542b8 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -25,7 +25,7 @@ docs: 개발자 온보딩 보완 | :-- | :-- | | `feature/*` → `dev` | Squash and merge | | `dev` → `main` | Create a merge commit | -| `hotfix/*` → `main` | Create a merge commit | +| `hotfix/*` → `main` | Squash and merge | | `main` → `dev` 백머지 | Create a merge commit | - hotfix나 revert가 `main`에 먼저 반영되면 최신 `main`의 정확한 HEAD를 merge commit으로 `dev`에 백머지합니다. cherry-pick, re-squash 또는 동일한 패치를 다시 적용하는 방식으로 전달하지 않습니다. From 81d6d0dc949d712230701fbd390b9c21436b5a69 Mon Sep 17 00:00:00 2001 From: KangShingyu <103213494+KangShinGyu98@users.noreply.github.com> Date: Sun, 6 Sep 2026 22:26:47 +0900 Subject: [PATCH 5/6] =?UTF-8?q?docs(github):=20Contribution=20=EB=AC=B8?= =?UTF-8?q?=EC=84=9C=EC=97=90=20PR=20=EA=B7=9C=EC=B9=99=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80=20(#146)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * docs(github): PR규칙 추가 * docs(pr): PR 템플릿의 체크리스트 섹션 제거 * docs(contributing): PR 자동 라벨 안내 삭제 --------- Co-authored-by: “KangShinGyu” <“rkdtlseb@naver.com”> Co-authored-by: minbros --- .github/pull_request_template.md | 10 ++++++++++ CONTRIBUTING.md | 6 ++++++ 2 files changed, 16 insertions(+) create mode 100644 .github/pull_request_template.md diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md new file mode 100644 index 00000000..4840c46a --- /dev/null +++ b/.github/pull_request_template.md @@ -0,0 +1,10 @@ +## 변경 내용 +- + +## 변경 이유 +- + +## 테스트 +- [ ] `./gradlew build` +- [ ] `/review-code-against-docs` 스킬로 검증 +- [ ] 그 외 수동 검증: diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 1c1542b8..c2136336 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -33,3 +33,9 @@ docs: 개발자 온보딩 보완 - Rebase merge는 사용하지 않습니다. - 공유 중인 `main`과 `dev`의 이력을 재작성하거나 force-push하지 않습니다. - 정책 도입 전의 기존 이력과 merge commit은 역사적 예외로 그대로 보존합니다. 토폴로지를 정리할 목적만으로 revert하거나 이력을 재작성하지 않습니다. + +## Pull Request Rules +- PR 제목은 커밋 컨벤션과 동일한 형식(feat: ...)을 따른다. +- main으로 직접 merge 전 최소 1명의 리뷰가 필요하다. +- PR 본문은 .github/pull_request_template.md 형식을 그대로 따른다. +- PR 본문에 변경 이유와 테스트 방법을 간략히 적는다. From 43e02cc21d2575b57ada6f6f8d9932a4867c337f Mon Sep 17 00:00:00 2001 From: Minhyung Kim <127458006+minbros@users.noreply.github.com> Date: Sun, 6 Sep 2026 22:58:38 +0900 Subject: [PATCH 6/6] =?UTF-8?q?docs(pr-template):=20=ED=94=84=EB=A1=A0?= =?UTF-8?q?=ED=8A=B8=EC=97=94=EB=93=9C=20=EA=B2=80=EC=A6=9D=20=EB=AA=85?= =?UTF-8?q?=EB=A0=B9=EC=9C=BC=EB=A1=9C=20=EC=B2=B4=ED=81=AC=EB=A6=AC?= =?UTF-8?q?=EC=8A=A4=ED=8A=B8=20=EC=88=98=EC=A0=95=20(#148)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/pull_request_template.md | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md index 4840c46a..af5b03dd 100644 --- a/.github/pull_request_template.md +++ b/.github/pull_request_template.md @@ -5,6 +5,7 @@ - ## 테스트 -- [ ] `./gradlew build` -- [ ] `/review-code-against-docs` 스킬로 검증 +- [ ] `npm run lint` +- [ ] `npm test` +- [ ] `npm run build` - [ ] 그 외 수동 검증: