diff --git a/src/lib/api/client.test.ts b/src/lib/api/client.test.ts new file mode 100644 index 00000000..52f2f852 --- /dev/null +++ b/src/lib/api/client.test.ts @@ -0,0 +1,82 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const mocks = vi.hoisted(() => ({ + authenticatedFetch: vi.fn(), + getTabId: vi.fn<() => string | null>(), +})); + +vi.mock("@/lib/api/authenticated-fetch", () => ({ + authenticatedFetch: mocks.authenticatedFetch, +})); +vi.mock("@/lib/auth-session-client", () => ({ + expireClientSessionAndRedirect: vi.fn(), +})); +vi.mock("@/lib/auth", () => ({ fetchSessionUserRaw: vi.fn() })); +vi.mock("@/lib/query-client", () => ({ getQueryClient: () => null })); +vi.mock("@/lib/tab-id", () => ({ getTabId: mocks.getTabId })); + +import { apiFetch } from "./client"; +import { jsonBody } from "./http"; + +const TAB_ID = "3f2b8c1e-7d4a-4e9b-9c2f-1a2b3c4d5e6f"; +const REQUEST_URL = "http://localhost:8080/rooms/room-1/schedules/10/items"; + +/** apiFetch가 실제로 보낸 요청의 탭 ID 헤더 */ +function sentTabId(): string | null { + const init: RequestInit | undefined = mocks.authenticatedFetch.mock.calls[0]?.[1]; + return new Headers(init?.headers).get("X-Client-Tab-Id"); +} + +describe("apiFetch tab ID header", () => { + beforeEach(() => { + vi.clearAllMocks(); + mocks.authenticatedFetch.mockResolvedValue({ + response: new Response(null, { status: 204 }), + didRefresh: false, + }); + mocks.getTabId.mockReturnValue(TAB_ID); + }); + + it.each([ + ["JSON POST", { method: "POST", ...jsonBody({ placeId: "p" }) }], + ["PATCH", { method: "PATCH", ...jsonBody({ memo: "m" }) }], + ["PUT", { method: "PUT", ...jsonBody({ content: "c" }) }], + ["DELETE", { method: "DELETE" }], + ] satisfies [string, RequestInit][])("adds the header to a %s that is already preflighted", async (_, init) => { + await apiFetch(REQUEST_URL, init); + + expect(sentTabId()).toBe(TAB_ID); + }); + + it.each([ + ["GET without init", undefined], + ["GET", { method: "GET" }], + ["POST without body", { method: "POST" }], + ] satisfies [string, RequestInit | undefined][])("leaves a %s without the header", async (_, init) => { + await apiFetch(REQUEST_URL, init); + + expect(sentTabId()).toBeNull(); + }); + + it("does not add the header when the tab has no ID", async () => { + mocks.getTabId.mockReturnValue(null); + + await apiFetch(REQUEST_URL, { method: "DELETE" }); + + expect(sentTabId()).toBeNull(); + }); + + it.each([ + ["object", { "Content-Type": "application/json", "X-Extra": "1" }], + ["Headers", new Headers({ "Content-Type": "application/json", "X-Extra": "1" })], + ["array", [["Content-Type", "application/json"], ["X-Extra", "1"]]], + ] satisfies [string, HeadersInit][])("keeps existing %s headers", async (_, headers) => { + await apiFetch(REQUEST_URL, { method: "POST", headers, body: "{}" }); + + const init: RequestInit | undefined = mocks.authenticatedFetch.mock.calls[0]?.[1]; + const sent = new Headers(init?.headers); + expect(sent.get("Content-Type")).toBe("application/json"); + expect(sent.get("X-Extra")).toBe("1"); + expect(sent.get("X-Client-Tab-Id")).toBe(TAB_ID); + }); +}); diff --git a/src/lib/api/client.ts b/src/lib/api/client.ts index a71ca10c..1578a9af 100644 --- a/src/lib/api/client.ts +++ b/src/lib/api/client.ts @@ -6,6 +6,32 @@ import { readSessionUserCache, setSessionUserCache, } from "@/lib/session-user-cache"; +import { getTabId } from "@/lib/tab-id"; + +const CLIENT_TAB_ID_HEADER = "X-Client-Tab-Id"; + +/** + * 일정·북마크 STOMP 이벤트의 `originTabId`로 돌아와, 이 탭이 보낸 변경의 반향을 가려낸다. + * 이미 CORS preflight를 하는 요청(PATCH·PUT·DELETE, JSON POST)에만 싣는다 — GET·body 없는 POST에 + * 커스텀 헤더를 붙이면 preflight가 새로 생기고, 백엔드 CORS에 maxAge가 없어 Chrome은 그 결과를 약 5초만 기억한다. + */ +function withClientTabIdHeader(init: RequestInit | undefined): RequestInit | undefined { + const tabId = getTabId(); + if (!tabId || !init) return init; + + const method = (init.method ?? "GET").toUpperCase(); + const headers = new Headers(init.headers); + const isPreflighted = + method === "PATCH" || + method === "PUT" || + method === "DELETE" || + (method === "POST" && + (headers.get("Content-Type") ?? "").toLowerCase().startsWith("application/json")); + if (!isPreflighted) return init; + + headers.set(CLIENT_TAB_ID_HEADER, tabId); + return { ...init, headers }; +} /** * Drop-in replacement for fetch that automatically retries once after @@ -15,7 +41,10 @@ export async function apiFetch( input: RequestInfo | URL, init?: RequestInit, ): Promise { - const { response: res, didRefresh } = await authenticatedFetch(input, init); + const { response: res, didRefresh } = await authenticatedFetch( + input, + withClientTabIdHeader(init), + ); if (res.status === 401) { expireClientSessionAndRedirect({ diff --git a/src/lib/plan/scheduleItemPlaces.ts b/src/lib/plan/scheduleItemPlaces.ts index 94172c14..b087ce82 100644 --- a/src/lib/plan/scheduleItemPlaces.ts +++ b/src/lib/plan/scheduleItemPlaces.ts @@ -178,25 +178,20 @@ export function removeScheduleItemFromPlanPlacesCache( * STOMP·뮤테이션 동기화: 그 일차 장소 목록을 서버에서 다시 받게 합니다. 보는 화면이 있으면 바로 받고, * 없으면 다시 받을 목록으로 표시해 두었다가 그 화면에 들어갈 때 받습니다. * 같은 일차를 받는 중이면 그 조회는 이 변경 전에 보낸 요청이라 취소하고 새로 받습니다(늦게 온 옛 응답은 버려짐). - * 본인 저장의 반향처럼 받는 중인 조회가 이미 그 변경 뒤에 보낸 것이면 `joinInFlight`로 합류해 요청을 아낍니다. * 다시 받지 못하면 throw합니다. */ export async function invalidateSchedulePlanPlaces( queryClient: QueryClient, roomId: string, scheduleId: number, - { joinInFlight = false }: { joinInFlight?: boolean } = {}, ): Promise { const queryKey = scheduleItemsQueryKey(roomId.trim(), scheduleId); // 목록이 아직 없으면 TanStack은 진행 중인 첫 조회를 취소하지 않고 합류만 한다 const state = queryClient.getQueryState(queryKey); - if (!joinInFlight && state?.data === undefined && state?.fetchStatus === "fetching") { + if (state?.data === undefined && state?.fetchStatus === "fetching") { await queryClient.cancelQueries({ queryKey, exact: true }); } - await queryClient.invalidateQueries( - { queryKey, exact: true }, - { throwOnError: true, cancelRefetch: !joinInFlight }, - ); + await queryClient.invalidateQueries({ queryKey, exact: true }, { throwOnError: true }); } /** diff --git a/src/lib/stomp/bookmarks-dispatch.test.ts b/src/lib/stomp/bookmarks-dispatch.test.ts new file mode 100644 index 00000000..746d9166 --- /dev/null +++ b/src/lib/stomp/bookmarks-dispatch.test.ts @@ -0,0 +1,75 @@ +import { QueryClient } from "@tanstack/react-query"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import { sessionUserQueryKey } from "@/lib/query-keys"; +import type { RoomBookmarkChangedEvent } from "@/types/roomBookmarkStomp"; + +const mocks = vi.hoisted(() => ({ + invalidateRoomBookmarkQueries: vi.fn(), +})); + +vi.mock("@/lib/bookmarks-invalidation", () => ({ + invalidateRoomBookmarkQueries: mocks.invalidateRoomBookmarkQueries, +})); +vi.mock("@/lib/tab-id", () => ({ getTabId: () => "tab-a" })); + +import { applyRoomBookmarkStompMessage } from "@/lib/stomp/bookmarks-dispatch"; + +const ME = 7; + +/** 이 계정(ME)이 폴더를 추가한 이벤트 */ +function categoryCreated(originTabId: string | null | undefined): string { + const event: RoomBookmarkChangedEvent = { + roomId: "room-1", + actorUserId: ME, + type: "CATEGORY_CREATED", + bookmarkId: 0, + categoryId: 10, + originTabId, + }; + return JSON.stringify(event); +} + +describe("applyRoomBookmarkStompMessage — echo of this tab", () => { + let queryClient: QueryClient; + + beforeEach(() => { + vi.clearAllMocks(); + mocks.invalidateRoomBookmarkQueries.mockResolvedValue(undefined); + queryClient = new QueryClient(); + queryClient.setQueryData(sessionUserQueryKey, { id: ME }); + }); + + afterEach(() => { + queryClient.clear(); + }); + + it("skips a change sent from this tab", async () => { + await applyRoomBookmarkStompMessage(queryClient, categoryCreated("tab-a")); + + expect(mocks.invalidateRoomBookmarkQueries).not.toHaveBeenCalled(); + }); + + it("applies a change whose tab ID matches this tab but comes from another account", async () => { + queryClient.setQueryData(sessionUserQueryKey, { id: ME + 1 }); + + await applyRoomBookmarkStompMessage(queryClient, categoryCreated("tab-a")); + + expect(mocks.invalidateRoomBookmarkQueries).toHaveBeenCalledOnce(); + }); + + it("applies a change sent from another tab of the same account", async () => { + await applyRoomBookmarkStompMessage(queryClient, categoryCreated("tab-b")); + + expect(mocks.invalidateRoomBookmarkQueries).toHaveBeenCalledOnce(); + }); + + it.each([null, undefined])( + "applies a change of the same account without a tab ID (%s) — it may come from another tab", + async (originTabId) => { + await applyRoomBookmarkStompMessage(queryClient, categoryCreated(originTabId)); + + expect(mocks.invalidateRoomBookmarkQueries).toHaveBeenCalledOnce(); + }, + ); +}); diff --git a/src/lib/stomp/bookmarks-dispatch.tsx b/src/lib/stomp/bookmarks-dispatch.tsx index 5fe9f2f9..d3510493 100644 --- a/src/lib/stomp/bookmarks-dispatch.tsx +++ b/src/lib/stomp/bookmarks-dispatch.tsx @@ -3,7 +3,7 @@ import type { QueryClient } from "@tanstack/react-query"; import { invalidateRoomBookmarkQueries } from "@/lib/bookmarks-invalidation"; -import { readSessionUserId } from "@/lib/session-user-cache"; +import { isOwnEcho } from "@/lib/stomp/own-echo"; import type { RoomBookmarkChangedEvent } from "@/types/roomBookmarkStomp"; /** bookmarks 토픽 STOMP — React Query 무효화만 (토스트·선 fetch 없음) */ @@ -15,13 +15,8 @@ export async function applyRoomBookmarkStompMessage( const rid = String(event.roomId ?? "").trim(); if (!rid) return; - const me = readSessionUserId(queryClient); - const actorIsMe = - typeof me === "number" && - Number.isFinite(me) && - me === event.actorUserId; - /** 본인 mutation은 `onSuccess`에서 캐시 패치 */ - if (actorIsMe) { + /** 본인 탭의 변경은 그 탭의 mutation이 캐시를 맞춘다 */ + if (isOwnEcho(queryClient, event)) { return; } diff --git a/src/lib/stomp/own-echo.test.ts b/src/lib/stomp/own-echo.test.ts new file mode 100644 index 00000000..ec90a607 --- /dev/null +++ b/src/lib/stomp/own-echo.test.ts @@ -0,0 +1,48 @@ +import { QueryClient } from "@tanstack/react-query"; +import { afterEach, describe, expect, it, vi } from "vitest"; + +import { sessionUserQueryKey } from "@/lib/query-keys"; + +const tab = vi.hoisted(() => ({ id: "tab-a" as string | null })); + +vi.mock("@/lib/tab-id", () => ({ getTabId: () => tab.id })); + +import { isOwnEcho } from "@/lib/stomp/own-echo"; + +const ME = 7; +const OTHER = 8; + +describe("isOwnEcho", () => { + const queryClient = new QueryClient(); + + afterEach(() => { + queryClient.clear(); + tab.id = "tab-a"; + }); + + it.each([ + { name: "sent from this tab by me", originTabId: "tab-a", actorUserId: ME, expected: true }, + { name: "sent from another tab of my account", originTabId: "tab-b", actorUserId: ME, expected: false }, + // 탭 ID는 방 멤버 모두에게 보여 흉내 낼 수 있다 — 다른 계정이면 반향이 아니다 + { name: "my tab ID forged by another account", originTabId: "tab-a", actorUserId: OTHER, expected: false }, + // 탭 ID가 없으면 어느 탭 것인지 몰라 놓치지 않도록 받는다 + { name: "no tab ID", originTabId: null, actorUserId: ME, expected: false }, + { name: "tab ID field missing", originTabId: undefined, actorUserId: ME, expected: false }, + { name: "empty tab ID", originTabId: "", actorUserId: ME, expected: false }, + ])("$name → $expected", ({ originTabId, actorUserId, expected }) => { + queryClient.setQueryData(sessionUserQueryKey, { id: ME }); + + expect(isOwnEcho(queryClient, { actorUserId, originTabId })).toBe(expected); + }); + + it("does not match a tab-less event in a tab that could not make an ID", () => { + tab.id = null; + queryClient.setQueryData(sessionUserQueryKey, { id: ME }); + + expect(isOwnEcho(queryClient, { actorUserId: ME, originTabId: null })).toBe(false); + }); + + it("receives this tab's change while the session user is not loaded yet", () => { + expect(isOwnEcho(queryClient, { actorUserId: ME, originTabId: "tab-a" })).toBe(false); + }); +}); diff --git a/src/lib/stomp/own-echo.ts b/src/lib/stomp/own-echo.ts new file mode 100644 index 00000000..0194f017 --- /dev/null +++ b/src/lib/stomp/own-echo.ts @@ -0,0 +1,21 @@ +import type { QueryClient } from "@tanstack/react-query"; + +import { readSessionUserId } from "@/lib/session-user-cache"; +import { getTabId } from "@/lib/tab-id"; + +type ChangeOrigin = { + actorUserId: number; + /** 변경 요청의 `X-Client-Tab-Id`. 헤더를 싣기 전 백엔드거나 탭 ID를 못 만든 탭의 변경이면 없다 */ + originTabId?: string | null; +}; + +/** + * 이 탭이 보낸 변경의 반향인지 판정한다 — 반향이면 그 탭의 mutation이 이미 캐시를 맞춘다. + * 탭 ID가 없는 이벤트는 어느 탭의 변경인지 알 수 없어, 놓치지 않도록 다른 탭의 변경으로 받는다. + * 탭 ID는 방 멤버 모두에게 보이고 헤더로 흉내 낼 수 있어, 서버가 인증으로 정한 `actorUserId`도 나여야 반향으로 본다. + */ +export function isOwnEcho(queryClient: QueryClient, event: ChangeOrigin): boolean { + if (!event.originTabId || event.originTabId !== getTabId()) return false; + const me = readSessionUserId(queryClient); + return typeof me === "number" && me === event.actorUserId; +} diff --git a/src/lib/stomp/schedule-events.ts b/src/lib/stomp/schedule-events.ts index b4cf8bbb..7b7e0b84 100644 --- a/src/lib/stomp/schedule-events.ts +++ b/src/lib/stomp/schedule-events.ts @@ -16,6 +16,8 @@ export type RoomScheduleChangedEvent = { itemId: number | null; affectedRouteItemIds: number[] | null; scheduleIds: number[] | null; + /** 변경 요청을 보낸 탭 ID — 같으면 이 탭의 반향(`isOwnEcho`) */ + originTabId?: string | null; }; const KNOWN_TYPES = new Set([ diff --git a/src/lib/stomp/schedules-dispatch.order.test.ts b/src/lib/stomp/schedules-dispatch.order.test.ts index 91f15bfe..9b7df890 100644 --- a/src/lib/stomp/schedules-dispatch.order.test.ts +++ b/src/lib/stomp/schedules-dispatch.order.test.ts @@ -3,7 +3,11 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import type { RoomScheduleItem } from "@/lib/api/rooms/schedule-items"; import type { PlanPlace } from "@/lib/plan/types"; -import { roomSchedulesQueryKey, scheduleItemsQueryKey } from "@/lib/query-keys"; +import { + roomSchedulesQueryKey, + scheduleItemsQueryKey, + sessionUserQueryKey, +} from "@/lib/query-keys"; import type { RoomScheduleChangedEvent } from "@/lib/stomp/schedule-events"; const fixtures = vi.hoisted(() => ({ @@ -42,6 +46,7 @@ vi.mock("@/lib/rooms", () => ({ vi.mock("@/lib/query-client", () => ({ getQueryClient: () => null, })); +vi.mock("@/lib/tab-id", () => ({ getTabId: () => "tab-a" })); import { removeScheduleItemFromPlanPlacesCache, @@ -236,6 +241,23 @@ describe("schedule day list refetch order", () => { expect(fixtures.getScheduleItems).toHaveBeenCalledOnce(); }); + it("shows a reorder made in another tab of the same account", async () => { + // remoteEvent의 actorUserId(2)가 이 계정 — 예전에는 계정이 같아 버렸다 + client.setQueryData(sessionUserQueryKey, { id: 2 }); + client.setQueryData(DAY_KEY, [100, 101, 102].map(fixtures.place)); + watchDayList(); + fixtures.getScheduleItems.mockResolvedValue(items(102, 100, 101)); + + await dispatchRoomScheduleEvent(client, { + ...remoteEvent("SCHEDULE_ITEMS_REORDERED", 102), + originTabId: "tab-b", + }); + await settle(); + + expect(fixtures.getScheduleItems).toHaveBeenCalledOnce(); + expect(dayItemIds()).toEqual([102, 100, 101]); + }); + it("fetches from the server when the day list is invalidated", async () => { client.setQueryData(DAY_KEY, [100, 102].map(fixtures.place)); watchDayList(); diff --git a/src/lib/stomp/schedules-dispatch.test.ts b/src/lib/stomp/schedules-dispatch.test.ts index 4ca587d9..89bf39d4 100644 --- a/src/lib/stomp/schedules-dispatch.test.ts +++ b/src/lib/stomp/schedules-dispatch.test.ts @@ -8,6 +8,7 @@ import type { RoomScheduleChangedEvent } from "@/lib/stomp/schedule-events"; const mocks = vi.hoisted(() => ({ invalidateSchedulePlanPlaces: vi.fn(), syncAfterCrossScheduleItemMove: vi.fn(), + syncRoomDetailFromServer: vi.fn(), })); vi.mock("@/lib/plan/scheduleItemPlaces", () => ({ @@ -16,9 +17,12 @@ vi.mock("@/lib/plan/scheduleItemPlaces", () => ({ })); vi.mock("@/lib/rooms", () => ({ - syncRoomDetailFromServer: vi.fn(), + syncRoomDetailFromServer: mocks.syncRoomDetailFromServer, })); +vi.mock("@/lib/tab-id", () => ({ getTabId: () => "tab-a" })); +const MY_TAB_ID = "tab-a"; + import { dispatchRoomScheduleEvent } from "@/lib/stomp/schedules-dispatch"; function itemEvent( @@ -54,17 +58,14 @@ describe("dispatchRoomScheduleEvent", () => { queryClient.clear(); }); - it.each([false, true])("refetches ID-only time updates, including own-user tabs (%s)", async (ownUser) => { + it("refetches consecutive ID-only time updates once", async () => { vi.useFakeTimers(); - if (ownUser) queryClient.setQueryData(sessionUserQueryKey, { id: 99 }); const event = itemEvent("SCHEDULE_ITEM_UPDATED", []); await dispatchRoomScheduleEvent(queryClient, event); await dispatchRoomScheduleEvent(queryClient, event); await vi.runAllTimersAsync(); - // 본인 반향만 모였으면 본인 저장이 끝나며 다시 받는 조회에 합류한다 - expect(mocks.invalidateSchedulePlanPlaces).toHaveBeenCalledExactlyOnceWith(queryClient, "room-1", 10, { - joinInFlight: ownUser, - }); + + expect(mocks.invalidateSchedulePlanPlaces).toHaveBeenCalledExactlyOnceWith(queryClient, "room-1", 10); expect(queryClient.getQueryState(routeKey)?.isInvalidated).toBe(false); }); @@ -103,9 +104,7 @@ describe("dispatchRoomScheduleEvent", () => { ); await vi.runAllTimersAsync(); - expect(mocks.invalidateSchedulePlanPlaces).toHaveBeenCalledExactlyOnceWith(queryClient, "room-1", 10, { - joinInFlight: false, - }); + expect(mocks.invalidateSchedulePlanPlaces).toHaveBeenCalledExactlyOnceWith(queryClient, "room-1", 10); expect(queryClient.getQueryState(routeKey)?.isInvalidated).toBe(false); }); }); @@ -120,3 +119,86 @@ describe("expense synchronization on existing schedule events",()=>{ client.clear(); }); }); + +describe("echo of this tab vs. other tabs of the same account", () => { + const SKIPPABLE_TYPES = [ + "ROOM_SCHEDULES_RESYNCED", + "SCHEDULE_ITEM_MOVED", + "SCHEDULE_ITEM_DELETED", + "SCHEDULE_ITEM_CREATED", + "SCHEDULE_ITEMS_REORDERED", + "SCHEDULE_ITEM_TRAVEL_MODE_UPDATED", + ] as const; + let queryClient: QueryClient; + + /** itemEvent의 actorUserId(99)가 이 계정이다 */ + function ownAccountEvent( + type: RoomScheduleChangedEvent["type"], + originTabId: string | null, + ): RoomScheduleChangedEvent { + return { ...itemEvent(type, [20]), scheduleIds: [10, 11], originTabId }; + } + + /** 일차 목록·일정 전체·방 상세 중 어느 것이든 다시 받은 횟수 */ + const refetchCount = () => + mocks.invalidateSchedulePlanPlaces.mock.calls.length + + mocks.syncAfterCrossScheduleItemMove.mock.calls.length + + mocks.syncRoomDetailFromServer.mock.calls.length; + + beforeEach(() => { + vi.useFakeTimers(); + vi.clearAllMocks(); + mocks.invalidateSchedulePlanPlaces.mockResolvedValue(undefined); + mocks.syncAfterCrossScheduleItemMove.mockResolvedValue(undefined); + queryClient = new QueryClient(); + queryClient.setQueryData(sessionUserQueryKey, { id: 99 }); + }); + + afterEach(() => { + vi.useRealTimers(); + queryClient.clear(); + }); + + it.each(SKIPPABLE_TYPES)("skips %s sent from this tab", async (type) => { + await dispatchRoomScheduleEvent(queryClient, ownAccountEvent(type, MY_TAB_ID)); + await vi.runAllTimersAsync(); + + expect(refetchCount()).toBe(0); + }); + + it.each(SKIPPABLE_TYPES)("applies %s sent from another tab of the same account", async (type) => { + await dispatchRoomScheduleEvent(queryClient, ownAccountEvent(type, "tab-b")); + await vi.runAllTimersAsync(); + + expect(refetchCount()).toBe(1); + }); + + it.each(SKIPPABLE_TYPES)("applies %s of the same account without a tab ID — it may come from another tab", async (type) => { + await dispatchRoomScheduleEvent(queryClient, ownAccountEvent(type, null)); + await vi.runAllTimersAsync(); + + expect(refetchCount()).toBe(1); + }); + + it("applies a change whose tab ID matches this tab but comes from another account", async () => { + queryClient.setQueryData(sessionUserQueryKey, { id: 100 }); + + await dispatchRoomScheduleEvent(queryClient, ownAccountEvent("SCHEDULE_ITEM_DELETED", MY_TAB_ID)); + + expect(refetchCount()).toBe(1); + }); + + it("skips a time update sent from this tab — its own settled refetch already has it", async () => { + await dispatchRoomScheduleEvent(queryClient, ownAccountEvent("SCHEDULE_ITEM_UPDATED", MY_TAB_ID)); + await vi.runAllTimersAsync(); + + expect(mocks.invalidateSchedulePlanPlaces).not.toHaveBeenCalled(); + }); + + it.each(["tab-b", null])("refetches a time update of the same account from another tab (%s)", async (originTabId) => { + await dispatchRoomScheduleEvent(queryClient, ownAccountEvent("SCHEDULE_ITEM_UPDATED", originTabId)); + await vi.runAllTimersAsync(); + + expect(mocks.invalidateSchedulePlanPlaces).toHaveBeenCalledExactlyOnceWith(queryClient, "room-1", 10); + }); +}); diff --git a/src/lib/stomp/schedules-dispatch.ts b/src/lib/stomp/schedules-dispatch.ts index 1e218a4d..3a10e7ea 100644 --- a/src/lib/stomp/schedules-dispatch.ts +++ b/src/lib/stomp/schedules-dispatch.ts @@ -7,40 +7,31 @@ import { } from "@/lib/plan/scheduleItemPlaces"; import { roomSchedulesQueryKey } from "@/lib/query-keys"; import { syncRoomDetailFromServer } from "@/lib/rooms"; +import { isOwnEcho } from "@/lib/stomp/own-echo"; import type { RoomScheduleChangedEvent } from "@/lib/stomp/schedule-events"; -import { readSessionUserId } from "@/lib/session-user-cache"; /** 연속 일정 항목 동기화(GET items)를 한 번으로 묶음 — `SCHEDULE_ITEM_UPDATED` 전용 */ const syncSchedulePlacesFromItemsDebouncers = new Map< string, ReturnType >(); -/** 모으는 동안 다른 사람의 변경이 하나라도 들어온 일차 */ -const syncSchedulePlacesRemoteChangePending = new Set(); const DEBOUNCE_ITEMS_GET_MS = 90; function enqueueDebouncedHydratePlacesFromScheduleItemsApi( queryClient: QueryClient, rid: string, sid: number, - ownEcho: boolean, ): void { const mapKey = `${rid}:${sid}`; - if (!ownEcho) syncSchedulePlacesRemoteChangePending.add(mapKey); const pending = syncSchedulePlacesFromItemsDebouncers.get(mapKey); if (pending !== undefined) clearTimeout(pending); syncSchedulePlacesFromItemsDebouncers.set( mapKey, setTimeout(() => { syncSchedulePlacesFromItemsDebouncers.delete(mapKey); - // 모은 이벤트가 모두 본인 반향이면, 본인 저장이 끝나며 보낸 조회가 이미 그 변경을 담고 있어 합류한다 - const joinInFlight = !syncSchedulePlacesRemoteChangePending.has(mapKey); - syncSchedulePlacesRemoteChangePending.delete(mapKey); void (async () => { try { - await invalidateSchedulePlanPlaces(queryClient, rid, sid, { - joinInFlight, - }); + await invalidateSchedulePlanPlaces(queryClient, rid, sid); } catch { // } @@ -51,11 +42,13 @@ function enqueueDebouncedHydratePlacesFromScheduleItemsApi( /** * Plan 화면 등이 쓰는 캐시만, 스키마의 `type`별로 갱신합니다. - * — `ROOM_SCHEDULES_RESYNCED`: `room-schedules` 전체 재조회 + 방 상세 동기화 (원격 탭; 액터는 mutation `onSuccess`) + * — 이 탭이 보낸 변경의 반향(`isOwnEcho`)은 건너뛴다. 그 탭의 mutation이 캐시를 맞춘다. + * 같은 계정의 다른 탭과 탭 ID가 없는 변경은 다른 사람의 변경처럼 받는다 + * — `ROOM_SCHEDULES_RESYNCED`: `room-schedules` 전체 재조회 + 방 상세 동기화 * — `schedule-items`: 그 일차 장소 목록을 무효화해 다시 받는다(보는 화면이 없으면 그 화면에 들어갈 때) * — `schedule-item-route`: 무효화하지 않는다 — key에 도착 항목·이동수단이 있어, 순서·수단이 바뀐 구간은 * 새 목록을 그릴 때 새 key로 받고 되돌리면 받아 둔 값을 쓴다 - * — `SCHEDULE_ITEM_UPDATED`: 일정 목록만 동기화할 때 사용(본인 이벤트 포함) + * — `SCHEDULE_ITEM_UPDATED`·`SCHEDULE_ITEM_TRAVEL_MODE_UPDATED`: 연속 이벤트를 모아 그 일차를 한 번만 다시 받는다 */ export async function dispatchRoomScheduleEvent( queryClient: QueryClient, @@ -70,19 +63,10 @@ export async function dispatchRoomScheduleEvent( ) { await invalidateExpenses(queryClient, rid); } + if (isOwnEcho(queryClient, event)) return; switch (event.type) { case "ROOM_SCHEDULES_RESYNCED": { - const me = readSessionUserId(queryClient); - const actorIsMe = - typeof me === "number" && - Number.isFinite(me) && - me === event.actorUserId; - /** 본인 mutation은 `onSuccess`에서 schedules·room-detail 캐시 갱신 */ - if (actorIsMe) { - return; - } - // 일정 전체 재조회는 room-schedules 조회 하나로 한다 — 진행 중인 옛 조회가 취소돼 새 일정을 덮지 않는다 await queryClient.invalidateQueries({ queryKey: roomSchedulesQueryKey(rid) }); await syncRoomDetailFromServer(queryClient, rid); @@ -96,15 +80,6 @@ export async function dispatchRoomScheduleEvent( const sourceScheduleId = scheduleIds[0]!; const targetScheduleId = scheduleIds[1]!; - const me = readSessionUserId(queryClient); - const actorIsMe = - typeof me === "number" && - Number.isFinite(me) && - me === event.actorUserId; - if (actorIsMe) { - return; - } - await syncAfterCrossScheduleItemMove( queryClient, rid, @@ -119,16 +94,6 @@ export async function dispatchRoomScheduleEvent( const itemId = event.itemId; if (sid == null || itemId == null) return; - const me = readSessionUserId(queryClient); - const actorIsMe = - typeof me === "number" && - Number.isFinite(me) && - me === event.actorUserId; - /** 본인 삭제는 mutation이 응답으로 캐시를 고치고 그 일차를 다시 받는다 */ - if (actorIsMe) { - return; - } - await invalidateSchedulePlanPlaces(queryClient, rid, sid); return; } @@ -138,16 +103,6 @@ export async function dispatchRoomScheduleEvent( const itemId = event.itemId; if (sid == null || itemId == null) return; - const me = readSessionUserId(queryClient); - const actorIsMe = - typeof me === "number" && - Number.isFinite(me) && - me === event.actorUserId; - /** 본인 추가는 mutation이 응답으로 캐시를 고치고 그 일차를 다시 받는다 */ - if (actorIsMe) { - return; - } - await invalidateSchedulePlanPlaces(queryClient, rid, sid); return; } @@ -157,16 +112,6 @@ export async function dispatchRoomScheduleEvent( const itemId = event.itemId; if (sid == null || itemId == null) return; - const me = readSessionUserId(queryClient); - const actorIsMe = - typeof me === "number" && - Number.isFinite(me) && - me === event.actorUserId; - /** 본인 순서 변경은 mutation이 응답으로 캐시를 고치고 그 일차를 다시 받는다 */ - if (actorIsMe) { - return; - } - await invalidateSchedulePlanPlaces(queryClient, rid, sid); return; } @@ -176,18 +121,7 @@ export async function dispatchRoomScheduleEvent( const itemId = event.itemId; if (sid == null || itemId == null) return; - const me = readSessionUserId(queryClient); - const actorIsMe = - typeof me === "number" && - Number.isFinite(me) && - event.actorUserId === me; - // 같은 사용자의 다른 탭도 이 이벤트를 받으므로 HTTP 응답과 무관하게 목록을 동기화합니다. - enqueueDebouncedHydratePlacesFromScheduleItemsApi( - queryClient, - rid, - sid, - actorIsMe, - ); + enqueueDebouncedHydratePlacesFromScheduleItemsApi(queryClient, rid, sid); return; } @@ -196,22 +130,7 @@ export async function dispatchRoomScheduleEvent( const itemId = event.itemId; if (sid == null || itemId == null) return; - const me = readSessionUserId(queryClient); - const actorIsMe = - typeof me === "number" && - Number.isFinite(me) && - event.actorUserId === me; - /** 본인 PATCH는 mutation `onSuccess`에서 `schedule-items` 캐시 갱신 */ - if (actorIsMe) { - return; - } - - enqueueDebouncedHydratePlacesFromScheduleItemsApi( - queryClient, - rid, - sid, - false, - ); + enqueueDebouncedHydratePlacesFromScheduleItemsApi(queryClient, rid, sid); return; } } diff --git a/src/lib/tab-id.test.ts b/src/lib/tab-id.test.ts new file mode 100644 index 00000000..3724141c --- /dev/null +++ b/src/lib/tab-id.test.ts @@ -0,0 +1,54 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +const UUID_V4 = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/; + +/** 탭 ID는 모듈 변수에 한 번 만들어 두므로 경우마다 모듈을 새로 읽는다 */ +async function loadGetTabId() { + vi.resetModules(); + return (await import("@/lib/tab-id")).getTabId; +} + +describe("getTabId", () => { + beforeEach(() => { + vi.stubGlobal("window", {}); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it("returns null during server render", async () => { + vi.unstubAllGlobals(); + const getTabId = await loadGetTabId(); + + expect(getTabId()).toBeNull(); + }); + + it("creates the ID once with randomUUID and keeps it for the tab", async () => { + const randomUUID = vi.fn(() => "3f2b8c1e-7d4a-4e9b-9c2f-1a2b3c4d5e6f"); + vi.stubGlobal("crypto", { randomUUID, getRandomValues: vi.fn() }); + const getTabId = await loadGetTabId(); + + expect(getTabId()).toBe("3f2b8c1e-7d4a-4e9b-9c2f-1a2b3c4d5e6f"); + expect(getTabId()).toBe("3f2b8c1e-7d4a-4e9b-9c2f-1a2b3c4d5e6f"); + expect(randomUUID).toHaveBeenCalledOnce(); + }); + + it("builds a v4 UUID from getRandomValues when randomUUID is missing (non-secure context)", async () => { + vi.stubGlobal("crypto", { + getRandomValues: (bytes: Uint8Array) => bytes.fill(0xff), + }); + const getTabId = await loadGetTabId(); + + const id = getTabId(); + expect(id).toMatch(UUID_V4); + expect(id).toBe("ffffffff-ffff-4fff-bfff-ffffffffffff"); + }); + + it("returns null when the browser cannot make random values", async () => { + vi.stubGlobal("crypto", {}); + const getTabId = await loadGetTabId(); + + expect(getTabId()).toBeNull(); + }); +}); diff --git a/src/lib/tab-id.ts b/src/lib/tab-id.ts new file mode 100644 index 00000000..95050886 --- /dev/null +++ b/src/lib/tab-id.ts @@ -0,0 +1,29 @@ +/** + * 이 탭이 보낸 변경 요청과 그 STOMP 반향(`originTabId`)을 잇는 ID. + * + * 모듈 변수에만 둔다. sessionStorage는 탭을 복제하면 값이 복사돼 두 탭이 같은 ID가 되고, + * localStorage는 모든 탭이 같은 값을 쓴다. 새로고침하면 새 ID가 된다. + */ +let tabId: string | null | undefined; + +/** 서버 렌더에서는 null. UUID를 만들 수 없는 브라우저도 null이고, 그때는 헤더를 싣지 않는다 */ +export function getTabId(): string | null { + if (tabId !== undefined) return tabId; + if (typeof window === "undefined") return null; + tabId = createUuidV4(); + return tabId; +} + +function createUuidV4(): string | null { + if (typeof crypto === "undefined") return null; + // randomUUID는 보안 컨텍스트(https·localhost)에서만 있다 — `http://`로 연 화면은 getRandomValues로 만든다 + if (typeof crypto.randomUUID === "function") return crypto.randomUUID(); + if (typeof crypto.getRandomValues !== "function") return null; + + const hex = Array.from(crypto.getRandomValues(new Uint8Array(16)), (byte) => + byte.toString(16).padStart(2, "0"), + ).join(""); + // RFC 9562 v4: 13번째 자리는 버전 4, 17번째 자리는 변형 8~b + const variant = ((Number.parseInt(hex.charAt(16), 16) & 0x3) | 0x8).toString(16); + return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-4${hex.slice(13, 16)}-${variant}${hex.slice(17, 20)}-${hex.slice(20, 32)}`; +} diff --git a/src/types/roomBookmarkStomp.ts b/src/types/roomBookmarkStomp.ts index 721e17d3..25ec194b 100644 --- a/src/types/roomBookmarkStomp.ts +++ b/src/types/roomBookmarkStomp.ts @@ -12,4 +12,6 @@ export type RoomBookmarkChangedEvent = { categoryId: number; roomId: string; type: RoomBookmarkChangedType; + /** 변경 요청을 보낸 탭 ID — 같으면 이 탭의 반향(`isOwnEcho`) */ + originTabId?: string | null; };