Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
82 changes: 82 additions & 0 deletions src/lib/api/client.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
31 changes: 30 additions & 1 deletion src/lib/api/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -15,7 +41,10 @@ export async function apiFetch(
input: RequestInfo | URL,
init?: RequestInit,
): Promise<Response> {
const { response: res, didRefresh } = await authenticatedFetch(input, init);
const { response: res, didRefresh } = await authenticatedFetch(
input,
withClientTabIdHeader(init),
);

if (res.status === 401) {
expireClientSessionAndRedirect({
Expand Down
9 changes: 2 additions & 7 deletions src/lib/plan/scheduleItemPlaces.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<void> {
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 });
}

/**
Expand Down
75 changes: 75 additions & 0 deletions src/lib/stomp/bookmarks-dispatch.test.ts
Original file line number Diff line number Diff line change
@@ -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();
},
);
});
11 changes: 3 additions & 8 deletions src/lib/stomp/bookmarks-dispatch.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 없음) */
Expand All @@ -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;
}

Expand Down
48 changes: 48 additions & 0 deletions src/lib/stomp/own-echo.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
21 changes: 21 additions & 0 deletions src/lib/stomp/own-echo.ts
Original file line number Diff line number Diff line change
@@ -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;
}
2 changes: 2 additions & 0 deletions src/lib/stomp/schedule-events.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string>([
Expand Down
24 changes: 23 additions & 1 deletion src/lib/stomp/schedules-dispatch.order.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(() => ({
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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();
Expand Down
Loading