- {virtualItems.map((virtualItem) => {
- const college = colleges[virtualItem.index];
-
- if (!college) {
- return null;
- }
-
- return (
-
+ <>
+
+ {virtualItems.map((virtualItem) => {
+ const college = colleges[virtualItem.index];
+
+ if (!college) {
+ return null;
+ }
+
+ return (
+
+
+
+ );
+ })}
+
+
+ {colleges.map((college) => (
+
- );
- })}
-
+ ))}
+
+ >
);
};
diff --git a/apps/web/.storybook/main.ts b/apps/web/.storybook/main.ts
new file mode 100644
index 000000000..991378b52
--- /dev/null
+++ b/apps/web/.storybook/main.ts
@@ -0,0 +1,23 @@
+import { fileURLToPath } from "node:url";
+import type { StorybookConfig } from "@storybook/nextjs-vite";
+import svgr from "vite-plugin-svgr";
+
+const config: StorybookConfig = {
+ stories: ["../src/**/*.stories.@(ts|tsx)"],
+ addons: ["@storybook/addon-docs", "@storybook/addon-vitest"],
+ framework: {
+ name: "@storybook/nextjs-vite",
+ options: {
+ nextConfigPath: fileURLToPath(new URL("./next.config.mjs", import.meta.url)),
+ image: { excludeFiles: ["**/*.svg"] },
+ },
+ },
+ viteFinal: async (config) => ({
+ ...config,
+ // Static files are served by staticDirs. Icon source modules must remain importable.
+ publicDir: false,
+ plugins: [...(config.plugins ?? []), svgr({ include: "**/*.svg", svgrOptions: { exportType: "default" } })],
+ }),
+ staticDirs: ["../public"],
+};
+export default config;
diff --git a/apps/web/.storybook/next.config.mjs b/apps/web/.storybook/next.config.mjs
new file mode 100644
index 000000000..9d7b9660e
--- /dev/null
+++ b/apps/web/.storybook/next.config.mjs
@@ -0,0 +1,7 @@
+// Component rendering does not need deployment rewrites or Sentry uploads.
+/** @type {import("next").NextConfig} */
+export default {
+ images: { unoptimized: true },
+ transpilePackages: ["@solid-connect/ui"],
+ typescript: { tsconfigPath: "../tsconfig.json" },
+};
diff --git a/apps/web/.storybook/preview.tsx b/apps/web/.storybook/preview.tsx
new file mode 100644
index 000000000..389165e86
--- /dev/null
+++ b/apps/web/.storybook/preview.tsx
@@ -0,0 +1,18 @@
+import type { Preview } from "@storybook/nextjs-vite";
+import "../src/styles/globals.css";
+
+const preview: Preview = {
+ parameters: { nextjs: { appDirectory: true }, layout: "padded" },
+ decorators: [
+ (Story, context) =>
+ context.parameters.layout === "fullscreen" ? (
+
+ ) : (
+
+
+
+ ),
+ ],
+ tags: ["autodocs"],
+};
+export default preview;
diff --git a/apps/web/biome.json b/apps/web/biome.json
index b776eb42c..7d9e1c502 100644
--- a/apps/web/biome.json
+++ b/apps/web/biome.json
@@ -2,7 +2,17 @@
"$schema": "https://biomejs.dev/schemas/2.3.12/schema.json",
"extends": ["../../packages/config/biome/base.json"],
"files": {
- "includes": ["**", "!node_modules", "!.next", "!build", "!dist", "!*.tsbuildinfo"]
+ "includes": [
+ "**",
+ "!node_modules",
+ "!.next",
+ "!build",
+ "!dist",
+ "!*.tsbuildinfo",
+ "!playwright-report",
+ "!test-results",
+ "!storybook-static"
+ ]
},
"vcs": {
"useIgnoreFile": false
diff --git a/apps/web/e2e/chat.spec.ts b/apps/web/e2e/chat.spec.ts
new file mode 100644
index 000000000..99059d17b
--- /dev/null
+++ b/apps/web/e2e/chat.spec.ts
@@ -0,0 +1,194 @@
+import { apiURL, expect, login, openChat, test } from "./fixtures/test";
+
+test("채팅 기록과 텍스트 전송을 합치고 중복 없이 새로고침 후 유지한다", async ({ page, request }) => {
+ await openChat(page);
+ await page.getByRole("textbox", { name: "메시지", exact: true }).fill("안녕하세요 멘토님");
+ await page.getByRole("button", { name: "메시지 보내기" }).click();
+ await expect(page.locator("[data-message-key]").filter({ hasText: "안녕하세요 멘토님" })).toHaveCount(1);
+ await expect(page.getByRole("textbox", { name: "메시지", exact: true })).toHaveValue("");
+ await expect.poll(async () => (await (await request.get(`${apiURL}/__state`)).json()).sends.length).toBe(1);
+ await page.reload();
+ await expect(page.locator("[data-message-key]").filter({ hasText: "안녕하세요 멘토님" })).toHaveCount(1);
+});
+
+test("이전 기록을 추가할 때 읽던 위치를 유지한다", async ({ page }) => {
+ await openChat(page);
+ const region = page.getByRole("region", { name: "채팅 메시지", exact: true });
+ await region.evaluate((element) => {
+ element.scrollTop = 0;
+ });
+ const anchor = page.locator("[data-message-key]").first();
+ const key = await anchor.getAttribute("data-message-key");
+ const before = (await anchor.boundingBox())!.y;
+ await page.getByRole("button", { name: "이전 메시지 더 보기" }).click();
+ await expect(page.locator("[data-message-key]")).toHaveCount(40);
+ await expect
+ .poll(async () => Math.abs((await page.locator(`[data-message-key="${key}"]`).boundingBox())!.y - before))
+ .toBeLessThan(5);
+});
+
+test("연결 중단 중 초안을 지키고 여러 페이지의 누락 메시지를 복구한다", async ({ page, request }) => {
+ await openChat(page);
+ const input = page.getByRole("textbox", { name: "메시지", exact: true });
+ await input.fill("연결 후 보낼 초안");
+ await request.post(`${apiURL}/__control`, { data: { paused: true } });
+ await expect(page.getByRole("button", { name: "메시지 보내기" })).toBeDisabled();
+ await request.post(`${apiURL}/__control`, { data: { push: 45 } });
+ await request.post(`${apiURL}/__control`, { data: { paused: false } });
+ await page.getByRole("button", { name: "다시 연결", exact: true }).click();
+ await expect(page.getByText("실시간으로 연결되어 있어요.")).toBeVisible();
+ await expect(page.locator("[data-message-key]")).toHaveCount(65);
+ for (let index = 1; index <= 45; index++)
+ await expect(page.getByText(`복구 메시지 ${index}`, { exact: true })).toHaveCount(1);
+ await expect(input).toHaveValue("연결 후 보낼 초안");
+ expect((await (await request.get(`${apiURL}/__state`)).json()).sends).toHaveLength(0);
+});
+
+test("이미지 업로드 실패 후 선택과 텍스트를 유지하고 재시도한다", async ({ page, request }) => {
+ await openChat(page);
+ await page.getByRole("textbox", { name: "메시지", exact: true }).fill("별도로 보낼 텍스트");
+ await page.getByLabel("채팅 이미지 선택").setInputFiles({
+ name: "campus.png",
+ mimeType: "image/png",
+ buffer: Buffer.from(
+ "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+jRZkAAAAASUVORK5CYII=",
+ "base64",
+ ),
+ });
+ await request.post(`${apiURL}/__control`, { data: { failUpload: true } });
+ await page.getByRole("button", { name: "이미지 1장 보내기" }).click();
+ await expect(page.getByRole("list", { name: "선택한 이미지" })).toBeVisible();
+ await expect(page.getByRole("alert").filter({ hasText: "입력 내용" })).toBeVisible();
+ await request.post(`${apiURL}/__control`, { data: { failUpload: false } });
+ await page.getByRole("button", { name: "이미지 1장 보내기" }).click();
+ await expect(page.getByRole("list", { name: "선택한 이미지" })).toHaveCount(0);
+ await expect(page.getByRole("textbox", { name: "메시지", exact: true })).toHaveValue("별도로 보낼 텍스트");
+ await expect(page.locator("[data-message-key]").getByAltText("첨부 이미지")).toHaveCount(1);
+ const state = await (await request.get(`${apiURL}/__state`)).json();
+ expect(state.uploads).toBe(2);
+ expect(state.sends).toHaveLength(1);
+});
+
+test("목록 조회 실패를 재시도로 복구하고 다른 방으로 이동한다", async ({ page, request }) => {
+ await openChat(page);
+ await request.post(`${apiURL}/__control`, { data: { failRooms: true } });
+ await page.goto("/mentor/chat");
+ await expect(page.getByText("채팅 목록을 불러오지 못했어요.")).toBeVisible({ timeout: 20_000 });
+ await request.post(`${apiURL}/__control`, { data: { failRooms: false } });
+ await page.getByRole("button", { name: "다시 시도", exact: true }).click();
+ await page.getByRole("link").filter({ hasText: "두번째 멘토" }).click();
+ await expect(page).toHaveURL("/mentor/chat/2");
+ await expect(page.getByText("아직 대화가 없어요. 첫 메시지를 보내보세요.")).toBeVisible();
+ await expect(page.locator("[data-message-key]")).toHaveCount(0);
+});
+
+test("접근 권한이 없으면 메시지와 전송을 막는다", async ({ page, request }) => {
+ await openChat(page);
+ await request.post(`${apiURL}/__control`, { data: { denied: true } });
+ await page.reload();
+ await expect(page.getByText("이 채팅방에 접근할 수 없습니다.")).toBeVisible();
+ await expect(page.locator("[data-message-key]")).toHaveCount(0);
+ await expect(page.getByRole("textbox", { name: "메시지", exact: true })).toBeDisabled();
+});
+
+test("좁은 화면에서도 작성 영역과 전송 버튼이 화면 안에 있다", async ({ page }) => {
+ await openChat(page);
+ const composer = page.getByRole("form", { name: "채팅 메시지 작성" });
+ const box = (await composer.boundingBox())!;
+ const viewport = page.viewportSize()!;
+ expect(box.x).toBeGreaterThanOrEqual(0);
+ expect(box.x + box.width).toBeLessThanOrEqual(viewport.width);
+ expect(box.y + box.height).toBeLessThanOrEqual(viewport.height);
+ expect(await page.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth)).toBe(true);
+});
+
+test("기록 조회 실패에서 재시도하면 전송 가능한 상태로 복구한다", async ({ page, request }) => {
+ await login(page);
+ await request.post(`${apiURL}/__control`, { data: { failHistory: true } });
+ await page.goto("/mentor/chat/1");
+ // Connecting also synchronizes history. Finish that attempt before enabling recovery.
+ await expect(page.getByText("실시간으로 연결되어 있어요.")).toBeVisible();
+ await expect(page.getByRole("region", { name: "채팅 메시지", exact: true }).getByRole("alert")).toBeVisible();
+ await expect(page.getByRole("button", { name: "메시지 보내기" })).toBeDisabled();
+ await request.post(`${apiURL}/__control`, { data: { failHistory: false } });
+ await page.getByRole("button", { name: "다시 시도", exact: true }).click();
+ await expect(page.getByText("기존 메시지 45", { exact: true })).toBeVisible();
+ await page.getByRole("textbox", { name: "메시지", exact: true }).fill("복구 후 전송");
+ await expect(page.getByRole("button", { name: "메시지 보내기" })).toBeEnabled();
+});
+
+test("기록 조회보다 늦게 도착한 소켓 응답도 전송을 한 번만 확인한다", async ({ page, request }) => {
+ await openChat(page);
+ await request.post(`${apiURL}/__control`, { data: { echo: false } });
+ await page.getByRole("textbox", { name: "메시지", exact: true }).fill("기록이 먼저 도착한 전송");
+ await page.getByRole("button", { name: "메시지 보내기" }).click();
+ await expect.poll(async () => (await (await request.get(`${apiURL}/__state`)).json()).sends.length).toBe(1);
+ await page.evaluate(() => window.dispatchEvent(new Event("focus")));
+ await expect(page.locator("[data-message-key]").filter({ hasText: "기록이 먼저 도착한 전송" })).toHaveCount(1);
+ await expect(page.getByText("전송 확인 중…", { exact: true })).toBeVisible();
+ await request.post(`${apiURL}/__control`, { data: { flushEcho: true } });
+ await expect(page.getByText("전송 확인 중…", { exact: true })).toHaveCount(0);
+ await expect(page.getByText("기록이 먼저 도착한 전송", { exact: true })).toHaveCount(1);
+});
+
+test("토큰 갱신의 일시 장애에서 초안과 선택 이미지를 보존하고 재연결한다", async ({ page, request }) => {
+ await openChat(page);
+ const input = page.getByRole("textbox", { name: "메시지", exact: true });
+ await input.fill("갱신 중에도 보존할 초안");
+ await page
+ .getByLabel("채팅 이미지 선택")
+ .setInputFiles({ name: "draft.png", mimeType: "image/png", buffer: Buffer.from("image") });
+ await page.clock.setFixedTime(new Date("2100-01-01T00:00:01Z"));
+ await request.post(`${apiURL}/__control`, { data: { paused: true, reissueStatus: 503 } });
+ await expect(page.getByRole("button", { name: "다시 연결", exact: true })).toBeVisible();
+ await request.post(`${apiURL}/__control`, { data: { paused: false } });
+ await page.getByRole("button", { name: "다시 연결", exact: true }).click();
+ await expect(page.getByText("채팅에 연결하지 못했어요. 다시 연결해주세요.")).toBeVisible();
+ await expect(input).toHaveValue("갱신 중에도 보존할 초안");
+ await expect(page.getByRole("list", { name: "선택한 이미지" })).toBeVisible();
+ await expect(page).toHaveURL("/mentor/chat/1");
+ await request.post(`${apiURL}/__control`, { data: { reissueStatus: 200 } });
+ await page.getByRole("button", { name: "다시 연결", exact: true }).click();
+ await expect(page.getByText("실시간으로 연결되어 있어요.")).toBeVisible();
+ await expect(input).toHaveValue("갱신 중에도 보존할 초안");
+ await expect(page.getByRole("list", { name: "선택한 이미지" })).toBeVisible();
+ expect((await (await request.get(`${apiURL}/__state`)).json()).refreshes).toBeGreaterThanOrEqual(2);
+});
+
+test("만료된 토큰으로 방에 처음 들어가도 로그인 재요청 없이 갱신과 재시도를 처리한다", async ({ page, request }) => {
+ await login(page);
+ await page.goto("/mentor/chat");
+ const room = page.getByRole("link").filter({ hasText: "두번째 멘토" });
+ await expect(room).toBeVisible();
+ await page.clock.setFixedTime(new Date("2100-01-01T00:00:01Z"));
+ await request.post(`${apiURL}/__control`, { data: { reissueStatus: 503 } });
+ await room.click();
+ await expect(page).toHaveURL("/mentor/chat/2");
+ await expect(page.getByRole("button", { name: "다시 확인", exact: true })).toBeVisible();
+ await expect(page.getByRole("link", { name: "로그인하기", exact: true })).toHaveCount(0);
+ await request.post(`${apiURL}/__control`, { data: { reissueStatus: 200 } });
+ await page.getByRole("button", { name: "다시 확인", exact: true }).click();
+ await expect(page.getByText("실시간으로 연결되어 있어요.")).toBeVisible();
+ await expect(page.getByText("아직 대화가 없어요. 첫 메시지를 보내보세요.")).toBeVisible();
+ expect((await (await request.get(`${apiURL}/__state`)).json()).refreshes).toBe(2);
+});
+
+test("이전 기록을 읽는 중 토큰이 만료되고 수신해도 초안과 전송을 유지한다", async ({ page, request }) => {
+ await openChat(page);
+ const region = page.getByRole("region", { name: "채팅 메시지", exact: true });
+ await region.evaluate((element) => {
+ element.scrollTop = 0;
+ });
+ await expect.poll(() => region.evaluate((element) => element.scrollTop)).toBe(0);
+ const input = page.getByRole("textbox", { name: "메시지", exact: true });
+ await input.fill("만료 후에도 보낼 초안");
+ await page.clock.setFixedTime(new Date("2100-01-01T00:00:01Z"));
+ await request.post(`${apiURL}/__control`, { data: { reissueStatus: 200, push: 1 } });
+ await expect(page.getByText("복구 메시지 1", { exact: true })).toHaveCount(1);
+ await expect(input).toHaveValue("만료 후에도 보낼 초안");
+ await expect(page.getByText("테스트 멘토님과 채팅", { exact: true })).toBeVisible();
+ await expect(page.getByRole("button", { name: "메시지 보내기" })).toBeEnabled();
+ await expect.poll(() => region.evaluate((element) => element.scrollTop)).toBe(0);
+ await page.getByRole("button", { name: "메시지 보내기" }).click();
+ await expect(page.locator("[data-message-key]").filter({ hasText: "만료 후에도 보낼 초안" })).toHaveCount(1);
+});
diff --git a/apps/web/e2e/fixtures/server.mjs b/apps/web/e2e/fixtures/server.mjs
new file mode 100644
index 000000000..b09ab40ef
--- /dev/null
+++ b/apps/web/e2e/fixtures/server.mjs
@@ -0,0 +1,323 @@
+import { createServer } from "node:http";
+import { WebSocket, WebSocketServer } from "ws";
+import { searchUniversities, universities, universityDetail } from "./universities.mjs";
+
+// Local-only REST + SockJS/STOMP contract fixture. Never connects to the real API.
+const host = "127.0.0.1";
+const port = 43180;
+const png = Buffer.from(
+ "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+jRZkAAAAASUVORK5CYII=",
+ "base64",
+);
+const token = `${Buffer.from('{"alg":"none"}').toString("base64url")}.${Buffer.from(JSON.stringify({ sub: "101", role: "MENTEE", home_university: "1", exp: 4102444800 })).toString("base64url")}.fixture`;
+const partner = (room) => ({
+ partnerId: room === 1 ? 202 : 203,
+ nickname: room === 1 ? "테스트 멘토" : "두번째 멘토",
+ university: "테스트 대학",
+ profileUrl: null,
+});
+const sockets = new Map();
+let state;
+function reset() {
+ for (const ws of sockets.keys()) ws.close();
+ sockets.clear();
+ state = {
+ activeToken: token,
+ reissueStatus: 401,
+ refreshes: 0,
+ pendingEchoes: [],
+ paused: false,
+ failRooms: false,
+ failHistory: false,
+ failUpload: false,
+ denied: false,
+ echo: true,
+ sends: [],
+ reads: 0,
+ uploads: 0,
+ unexpected: [],
+ rooms: new Map([
+ [1, []],
+ [2, []],
+ ]),
+ nextId: 0,
+ };
+ for (let index = 0; index < 45; index++) append(1, `기존 메시지 ${index + 1}`, 202);
+}
+function append(room, content, senderId, attachments = []) {
+ const message = {
+ id: ++state.nextId,
+ content,
+ senderId,
+ attachments,
+ createdAt: new Date(Date.UTC(2026, 9, 1, 0, 0, state.nextId)).toISOString(),
+ };
+ state.rooms.get(room).push(message);
+ return message;
+}
+function frame(ws, data) {
+ if (ws.readyState === WebSocket.OPEN) ws.send(`a${JSON.stringify([data])}`);
+}
+function broadcast(room, message) {
+ for (const [ws, subscription] of sockets) {
+ if (subscription?.room !== room) continue;
+ const body = JSON.stringify(message);
+ frame(
+ ws,
+ `MESSAGE\nsubscription:${subscription.id}\nmessage-id:${message.id}\ndestination:/topic/chat/${room}\ncontent-type:application/json\n\n${body}\0`,
+ );
+ }
+}
+reset();
+const server = createServer(async (req, res) => {
+ res.setHeader("Access-Control-Allow-Origin", "http://127.0.0.1:43181");
+ res.setHeader("Access-Control-Allow-Credentials", "true");
+ res.setHeader("Access-Control-Allow-Headers", "Content-Type,Authorization");
+ res.setHeader("Access-Control-Allow-Methods", "GET,POST,PUT,PATCH,DELETE,OPTIONS");
+ if (req.method === "OPTIONS") {
+ res.writeHead(204);
+ res.end();
+ return;
+ }
+ const url = new URL(req.url, `http://${host}:${port}`);
+ const path = url.pathname;
+ const chunks = [];
+ for await (const chunk of req) chunks.push(chunk);
+ const raw = Buffer.concat(chunks).toString();
+ const json = (data, status = 200) => {
+ res.writeHead(status, { "Content-Type": "application/json" });
+ res.end(JSON.stringify(data));
+ };
+ const expectedMethod =
+ path === "/__reset" || path === "/__control" || path.startsWith("/auth/") || path === "/file/chat"
+ ? "POST"
+ : /^\/chats\/rooms\/\d+\/read$/.test(path)
+ ? "PUT"
+ : "GET";
+ if (req.method !== expectedMethod) {
+ state.unexpected.push(`${req.method} ${path}`);
+ return json({ message: `Expected ${expectedMethod} ${path}` }, 405);
+ }
+ const protectedPath =
+ path === "/my" ||
+ path.startsWith("/chats/") ||
+ path.startsWith("/scores/") ||
+ path === "/file/chat" ||
+ path.startsWith("/applications/") ||
+ path === "/auth/sign-out";
+ if (protectedPath && req.headers.authorization !== `Bearer ${state.activeToken}`) return json({}, 401);
+ if (path === "/__health") return json({ ok: true });
+ if (path === "/__reset") {
+ reset();
+ return json({ ok: true });
+ }
+ if (path === "/__state") return json({ ...state, rooms: Object.fromEntries(state.rooms) });
+ if (path === "/__control") {
+ const control = JSON.parse(raw);
+ if (control.push) {
+ for (let index = 0; index < control.push; index++) {
+ const message = append(control.room ?? 1, `복구 메시지 ${index + 1}`, 202);
+ if (!state.paused) broadcast(control.room ?? 1, message);
+ }
+ }
+ if (control.flushEcho) {
+ for (const { room, message } of state.pendingEchoes.splice(0)) {
+ broadcast(room, message);
+ broadcast(room, message);
+ }
+ }
+ if ("reissueStatus" in control) state.reissueStatus = control.reissueStatus;
+ for (const key of ["paused", "failRooms", "failHistory", "failUpload", "denied", "echo"]) {
+ if (key in control) state[key] = control[key];
+ }
+ if (control.paused) for (const ws of sockets.keys()) ws.close();
+ return json({ ok: true });
+ }
+ if (path === "/connect/info")
+ return json({ websocket: true, cookie_needed: false, origins: ["*:*"], entropy: 12345 });
+ if (path === "/image.png") {
+ res.writeHead(200, { "Content-Type": "image/png" });
+ res.end(png);
+ return;
+ }
+ if (path === "/auth/email/sign-in") {
+ const credentials = JSON.parse(raw);
+ if (credentials.password !== "LocalTest123!") return json({ message: "이메일 또는 비밀번호를 확인해주세요." }, 401);
+ return json({ accessToken: token, refreshToken: "fixture-only" });
+ }
+ if (path === "/auth/reissue") {
+ state.refreshes++;
+ if (state.reissueStatus !== 200) return json({ message: "fixture refresh unavailable" }, state.reissueStatus);
+ state.activeToken = `${Buffer.from('{"alg":"none"}').toString("base64url")}.${Buffer.from(JSON.stringify({ sub: "101", role: "MENTEE", home_university: "1", exp: 7258118400 })).toString("base64url")}.refreshed`;
+ return json({ accessToken: state.activeToken });
+ }
+ if (path === "/auth/sign-out") return json({});
+ if (path === "/my") {
+ if (!req.headers.authorization) return json({}, 401);
+ return json({
+ role: "MENTEE",
+ nickname: "테스트 학생",
+ email: "student@example.test",
+ authType: "EMAIL",
+ profileImageUrl: null,
+ interestedCountries: ["미국"],
+ likedUniversityCount: 0,
+ likedPostCount: 0,
+ likedMentorCount: 0,
+ homeUniversityName: "인하대학교",
+ });
+ }
+ if (path === "/chats/rooms") {
+ if (state.failRooms) return json({}, 503);
+ return json({
+ chatRooms: [1, 2].map((id) => ({
+ id,
+ partner: partner(id),
+ lastChatMessage: state.rooms.get(id).at(-1)?.content ?? "",
+ lastReceivedTime: "2026-10-01T10:00:00Z",
+ unReadCount: id,
+ })),
+ });
+ }
+ const match = path.match(/^\/chats\/rooms\/(\d+)(?:\/(partner|read))?$/);
+ if (match) {
+ const room = Number(match[1]);
+ if (state.denied || !state.rooms.has(room)) return json({}, 403);
+ if (match[2] === "partner") return json(partner(room));
+ if (match[2] === "read") {
+ state.reads++;
+ return json({});
+ }
+ if (state.failHistory) return json({}, 503);
+ const page = Number(url.searchParams.get("page") ?? 1);
+ const size = Number(url.searchParams.get("size") ?? 20);
+ const messages = [...state.rooms.get(room)].reverse();
+ return json({
+ content: messages.slice((page - 1) * size, page * size),
+ nextPageNumber: page * size < messages.length ? page + 1 : -1,
+ });
+ }
+ if (path === "/file/chat") {
+ state.uploads++;
+ if (state.failUpload) return json({}, 503);
+ const count = [...raw.matchAll(/filename="/g)].length;
+ return json(Array.from({ length: count }, () => ({ fileUrl: `http://${host}:${port}/image.png` })));
+ }
+ if (path === "/news") return json({ newsResponseList: [] });
+ if (path === "/univ-apply-infos/recommend")
+ return json({ recommendedUniversities: universities.filter((item) => item.id <= 6) });
+ if (path.startsWith("/univ-apply-infos/search/"))
+ return json({ univApplyInfoPreviews: searchUniversities(url.searchParams) });
+ const universityId = path.match(/^\/univ-apply-infos\/(\d+)$/);
+ if (universityId) {
+ const detail = universityDetail(Number(universityId[1]));
+ return detail ? json(detail) : json({}, 404);
+ }
+ if (path.startsWith("/boards/"))
+ return json([
+ {
+ id: 1,
+ title: "교환학생 준비 질문",
+ content: "지원 서류 준비는 어떻게 하나요?",
+ likeCount: 0,
+ commentCount: 0,
+ createdAt: "2026-10-01T10:00:00Z",
+ updatedAt: "2026-10-01T10:00:00Z",
+ postCategory: "질문",
+ postThumbnailUrl: null,
+ postFindSiteUserResponse: { id: 202, nickname: "테스트 멘토", profileImageUrl: null },
+ },
+ ]);
+ if (path === "/scores/gpas") return json({ homeUniversityName: "인하대학교", gpaScoreStatusResponseList: [] });
+ if (path === "/scores/language-tests") return json({ languageTestScoreStatusResponseList: [] });
+ if (path === "/applications/preview") return json({ totalUniversityCount: 0, universities: [] });
+ state.unexpected.push(`${req.method} ${path}`);
+ return json({ message: `Unimplemented fixture: ${req.method} ${path}` }, 501);
+});
+const wss = new WebSocketServer({ noServer: true });
+server.on("upgrade", (req, socket, head) => {
+ const url = new URL(req.url, `http://${host}:${port}`);
+ if (url.searchParams.get("token") !== state.activeToken) {
+ state.unexpected.push("WebSocket authentication missing or invalid");
+ socket.destroy();
+ return;
+ }
+ if (state.paused || !/^\/connect\/[^/]+\/[^/]+\/websocket$/.test(url.pathname)) {
+ socket.destroy();
+ return;
+ }
+ wss.handleUpgrade(req, socket, head, (ws) => wss.emit("connection", ws));
+});
+wss.on("connection", (ws) => {
+ sockets.set(ws, null);
+ ws.send("o");
+ ws.on("close", () => sockets.delete(ws));
+ ws.on("message", (data) => {
+ for (const frameText of JSON.parse(data.toString())) {
+ for (const frameData of frameText.split("\0")) {
+ if (!frameData.trim()) continue;
+ const [headersText, ...bodyParts] = frameData.replace(/^\n+/, "").split("\n\n");
+ const [command, ...headerLines] = headersText.split("\n");
+ const headers = Object.fromEntries(
+ headerLines.map((line) => {
+ const colon = line.indexOf(":");
+ return [line.slice(0, colon), line.slice(colon + 1)];
+ }),
+ );
+ if (command === "CONNECT" || command === "STOMP") frame(ws, "CONNECTED\nversion:1.2\nheart-beat:0,0\n\n\0");
+ if (command === "SUBSCRIBE") {
+ const subscription = headers.destination?.match(/^\/topic\/chat\/(\d+)$/);
+ if (!subscription || !state.rooms.has(Number(subscription[1]))) {
+ state.unexpected.push(`STOMP SUBSCRIBE ${headers.destination}`);
+ frame(ws, "ERROR\nmessage:Invalid subscription\n\n\0");
+ continue;
+ }
+ sockets.set(ws, { room: Number(subscription[1]), id: headers.id });
+ }
+ if (command === "SEND") {
+ const destination = headers.destination?.match(/^\/publish\/chat\/(\d+)(\/image)?$/);
+ if (!destination || !state.rooms.has(Number(destination[1]))) {
+ state.unexpected.push(`STOMP SEND ${headers.destination}`);
+ frame(ws, "ERROR\nmessage:Invalid destination\n\n\0");
+ continue;
+ }
+ const room = Number(destination[1]);
+ const body = JSON.parse(bodyParts.join("\n\n"));
+ const validPayload = destination[2]
+ ? Array.isArray(body.imageUrls) &&
+ body.imageUrls.length > 0 &&
+ body.imageUrls.every((url) => typeof url === "string")
+ : body.senderId === 101 && typeof body.content === "string" && body.content.trim().length > 0;
+ if (!validPayload) {
+ state.unexpected.push(`STOMP PAYLOAD ${headers.destination}`);
+ frame(ws, "ERROR\nmessage:Invalid payload\n\n\0");
+ continue;
+ }
+ state.sends.push({ room, ...body });
+ const attachments = (body.imageUrls ?? []).map((url, id) => ({
+ id: id + 1,
+ isImage: true,
+ url,
+ thumbnailUrl: null,
+ createdAt: "2026-10-01T12:00:00Z",
+ }));
+ const message = append(room, body.content ?? "", body.senderId ?? 101, attachments);
+ if (state.echo) broadcast(room, message);
+ else state.pendingEchoes.push({ room, message });
+ }
+ if (command === "DISCONNECT") {
+ if (headers.receipt) frame(ws, `RECEIPT\nreceipt-id:${headers.receipt}\n\n\0`);
+ ws.close();
+ }
+ }
+ }
+ });
+});
+server.listen(port, host);
+function shutdown() {
+ for (const ws of sockets.keys()) ws.terminate();
+ wss.close();
+ server.close();
+}
+process.on("SIGTERM", shutdown);
+process.on("SIGINT", shutdown);
diff --git a/apps/web/e2e/fixtures/test.ts b/apps/web/e2e/fixtures/test.ts
new file mode 100644
index 000000000..90e054b6d
--- /dev/null
+++ b/apps/web/e2e/fixtures/test.ts
@@ -0,0 +1,33 @@
+import { test as base, expect } from "@playwright/test";
+
+export const apiURL = "http://127.0.0.1:43180";
+export const test = base.extend({
+ page: async ({ page, request }, use) => {
+ await request.post(`${apiURL}/__reset`);
+ // Local assets and fixture API only. Analytics and third-party SDKs are irrelevant here.
+ await page.route("**/*", (route) => {
+ const url = new URL(route.request().url());
+ return url.hostname === "127.0.0.1" || url.protocol === "data:" || url.protocol === "blob:"
+ ? route.continue()
+ : route.abort();
+ });
+ await use(page);
+ const state = await (await request.get(`${apiURL}/__state`)).json();
+ expect(state.unexpected, "Every API request must have an explicit fixture contract").toEqual([]);
+ },
+});
+export { expect };
+
+export async function login(page: import("@playwright/test").Page) {
+ await page.goto("/login");
+ await page.getByPlaceholder("이메일", { exact: true }).fill("student@example.test");
+ await page.getByPlaceholder("비밀번호", { exact: true }).fill("LocalTest123!");
+ await page.getByRole("button", { name: "로그인", exact: true }).click();
+ await expect(page).toHaveURL("/");
+}
+export async function openChat(page: import("@playwright/test").Page) {
+ await login(page);
+ await page.goto("/mentor/chat/1");
+ await expect(page.getByText("실시간으로 연결되어 있어요.")).toBeVisible();
+ await expect(page.getByText("기존 메시지 45", { exact: true })).toBeVisible();
+}
diff --git a/apps/web/e2e/fixtures/universities.mjs b/apps/web/e2e/fixtures/universities.mjs
new file mode 100644
index 000000000..8adceb9d9
--- /dev/null
+++ b/apps/web/e2e/fixtures/universities.mjs
@@ -0,0 +1,54 @@
+const homes = ["인하대학교", "경희대학교", "중앙대학교"];
+export const universities = homes.flatMap((homeUniversityName, homeIndex) =>
+ Array.from({ length: 48 }, (_, index) => ({
+ id: homeIndex * 100 + index + 1,
+ homeUniversityName,
+ term: "2027-1",
+ koreanName:
+ index === 0
+ ? "매우 긴 이름의 국제 교환학생 파견 프로그램을 운영하는 테스트 대학교"
+ : `테스트 파견대학교 ${index + 1}`,
+ englishName: `Test University ${index + 1}`,
+ country: index % 2 === 0 ? "독일" : "미국",
+ region: index % 2 === 0 ? "유럽권" : "미주권",
+ logoImageUrl: "/svgs/placeholders/university-logo-placeholder.svg",
+ backgroundImageUrl: "/svgs/placeholders/university-background-placeholder.svg",
+ studentCapacity: (index % 5) + 1,
+ languageRequirements: [
+ { languageTestType: "TOEFL_IBT", minScore: "80" },
+ { languageTestType: "IELTS", minScore: "6.0" },
+ { languageTestType: "TOEIC", minScore: "800" },
+ ],
+ })),
+);
+export function searchUniversities(params) {
+ const home = Number(params.get("homeUniversityId"));
+ const query = params.get("value") ?? "";
+ return universities.filter(
+ (university) =>
+ (!home || university.homeUniversityName === homes[home - 1]) && university.koreanName.includes(query),
+ );
+}
+export function universityDetail(id) {
+ const university = universities.find((item) => item.id === id);
+ if (!university) return null;
+ return {
+ ...university,
+ formatName: university.englishName,
+ homepageUrl: "https://example.test",
+ detailsForLocal: "예시 지역 안내",
+ tuitionFeeType: "HOME_UNIVERSITY",
+ semesterAvailableForDispatch: "1학기",
+ detailsForLanguage: "어학 성적을 제출해주세요.",
+ gpaRequirement: "3.0",
+ gpaRequirementCriteria: "4.5",
+ semesterRequirement: "2학기",
+ detailsForApply: "예시 지원 조건",
+ detailsForMajor: "전공별 조건을 확인해주세요.",
+ detailsForAccommodation: "기숙사 안내",
+ accommodationUrl: "https://example.test/accommodation",
+ detailsForEnglishCourse: "영어 강의 안내",
+ englishCourseUrl: "https://example.test/courses",
+ details: "예시 대학 정보",
+ };
+}
diff --git a/apps/web/e2e/responsive.spec.ts b/apps/web/e2e/responsive.spec.ts
new file mode 100644
index 000000000..4aaefcdea
--- /dev/null
+++ b/apps/web/e2e/responsive.spec.ts
@@ -0,0 +1,135 @@
+import { expect, login, openChat, test } from "./fixtures/test";
+
+const widths = [390, 767, 768, 1024, 1280, 1440, 1536];
+async function noHorizontalOverflow(page: import("@playwright/test").Page) {
+ await expect
+ .poll(() => page.evaluate(() => document.documentElement.scrollWidth - window.innerWidth))
+ .toBeLessThanOrEqual(1);
+}
+
+test("홈 탐색과 카드가 앱과 웹 경계에서 겹치거나 잘리지 않는다", async ({ page }) => {
+ await page.goto("/");
+ for (const width of widths) {
+ await test.step(`${width}px`, async () => {
+ await page.setViewportSize({ width, height: 1000 });
+ const desktop = page.getByRole("navigation", { name: "주요 메뉴", includeHidden: true });
+ const bottom = page.getByRole("navigation", { name: "하단 메뉴", includeHidden: true });
+ if (width >= 768) {
+ await expect(desktop).toBeVisible();
+ await expect(bottom).toBeHidden();
+ } else {
+ await expect(desktop).toBeHidden();
+ await expect(bottom).toBeVisible();
+ }
+ await expect(page.getByRole("link", { name: "학교 검색하기", exact: true })).toBeVisible();
+ await noHorizontalOverflow(page);
+ if (width < 768) {
+ const card = page.getByRole("link", {
+ name: "매우 긴 이름의 국제 교환학생 파견 프로그램을 운영하는 테스트 대학교",
+ exact: true,
+ });
+ const cardBox = (await card.boundingBox())!;
+ const arrow = (await card.locator("svg").last().boundingBox())!;
+ expect(arrow.x + arrow.width).toBeLessThanOrEqual(cardBox.x + cardBox.width);
+ }
+ if (width >= 768) {
+ const cards = page.locator('a[href="/university/inha/1"]');
+ await expect(cards.first()).toBeVisible();
+ expect((await cards.first().boundingBox())!.width).toBeGreaterThanOrEqual(220);
+ }
+ if ([390, 768, 1440].includes(width))
+ await page.screenshot({ caret: "initial", path: test.info().outputPath(`home-${width}.png`), fullPage: true });
+ });
+ }
+});
+
+test("767와 768 경계를 넘어도 학교와 어학 및 국가 조건을 보존한다", async ({ page }) => {
+ await page.setViewportSize({ width: 767, height: 1000 });
+ await page.goto("/");
+ const form = page.getByRole("region", { name: "파견학교 검색" });
+ await form.getByRole("button", { name: "경희대", exact: true }).click();
+ await form.getByRole("button", { name: /어학.*선택/ }).click();
+ await page.getByRole("button", { name: "인터넷 기반 영어 시험 (TOEFL iBT)", exact: true }).click();
+ await form
+ .getByRole("button", { name: /관심있는 나라.*나라/ })
+ .first()
+ .click();
+ await page.getByRole("button", { name: "독일", exact: true }).click();
+ const search = page.getByRole("link", { name: "학교 검색하기", exact: true });
+ const href = await search.getAttribute("href");
+ expect(href).toContain("/university/kyunghee?");
+ for (const width of [768, 1440, 390, 767]) {
+ await page.setViewportSize({ width, height: 1000 });
+ await expect(search).toHaveAttribute("href", href!);
+ await expect(form.getByRole("button", { name: "경희대", exact: true })).toHaveAttribute("aria-pressed", "true");
+ }
+ await search.click();
+ await expect(page).toHaveURL(href!);
+ await noHorizontalOverflow(page);
+});
+
+test("대학 목록에서 폭을 바꾸고 검색과 빈 결과 및 초기화를 처리한다", async ({ page }) => {
+ await page.goto("/university/inha");
+ for (const width of widths) {
+ await page.setViewportSize({ width, height: 1000 });
+ await expect(page.getByRole("link").filter({ hasText: "매우 긴 이름의 국제" }).first()).toBeVisible();
+ await noHorizontalOverflow(page);
+ if ([390, 768, 1440].includes(width))
+ await page.screenshot({ caret: "initial", path: test.info().outputPath(`catalog-${width}.png`) });
+ }
+ const search = page.getByPlaceholder("학교명 또는 국가 검색").filter({ visible: true });
+ await search.fill("아무 결과도 없는 검색어");
+ await expect(page.getByText("검색 결과가 없습니다.", { exact: true }).filter({ visible: true })).toBeVisible();
+ await search.fill("");
+ await expect(page.getByRole("link").filter({ hasText: "매우 긴 이름의 국제" }).first()).toBeVisible();
+});
+
+test("로그인 입력이 화면 전환에도 유지되고 로그인을 완료한다", async ({ page }) => {
+ await page.setViewportSize({ width: 767, height: 1000 });
+ await page.goto("/login");
+ await page.getByPlaceholder("이메일", { exact: true }).fill("student@example.test");
+ await page.getByPlaceholder("비밀번호", { exact: true }).fill("LocalTest123!");
+ for (const width of [768, 1024, 1440, 390]) {
+ await page.setViewportSize({ width, height: 1000 });
+ await expect(page.getByPlaceholder("이메일", { exact: true })).toHaveValue("student@example.test");
+ await expect(page.getByPlaceholder("비밀번호", { exact: true })).toHaveValue("LocalTest123!");
+ await noHorizontalOverflow(page);
+ if (width === 1440)
+ await page.screenshot({ caret: "initial", path: test.info().outputPath(`login-${width}.png`), fullPage: true });
+ }
+ await page.getByRole("button", { name: "로그인", exact: true }).click();
+ await expect(page).toHaveURL("/");
+});
+
+test("채팅 작성기와 참여자 탐색이 모든 폭에서 화면 안에 있다", async ({ page }) => {
+ await openChat(page);
+ const input = page.getByRole("textbox", { name: "메시지", exact: true });
+ await input.fill("화면을 바꿔도 보존할 초안");
+ for (const width of widths) {
+ await page.setViewportSize({ width, height: 1000 });
+ await expect(input).toHaveValue("화면을 바꿔도 보존할 초안");
+ await expect(page.getByText("참여자", { exact: true })).toBeVisible();
+ const composer = (await page.getByRole("form", { name: "채팅 메시지 작성" }).boundingBox())!;
+ expect(composer.x).toBeGreaterThanOrEqual(0);
+ expect(composer.x + composer.width).toBeLessThanOrEqual(width);
+ expect(composer.y + composer.height).toBeLessThanOrEqual(1000);
+ await noHorizontalOverflow(page);
+ if ([390, 768, 1440].includes(width))
+ await page.screenshot({ caret: "initial", path: test.info().outputPath(`chat-${width}.png`), fullPage: true });
+ }
+ await page.getByRole("button", { name: "메시지 보내기" }).click();
+ await expect(page.locator("[data-message-key]").filter({ hasText: "화면을 바꿔도 보존할 초안" })).toHaveCount(1);
+});
+
+test("마이페이지의 프로필과 작업 메뉴가 앱과 웹에서 접근 가능하다", async ({ page }) => {
+ await login(page);
+ await page.goto("/my");
+ for (const width of widths) {
+ await page.setViewportSize({ width, height: 1000 });
+ await expect(page.getByText(/테스트 학생/).first()).toBeVisible();
+ await expect(page.getByRole("link", { name: "공인 어학 / 학점 변경", exact: true })).toBeVisible();
+ await noHorizontalOverflow(page);
+ if ([390, 1440].includes(width))
+ await page.screenshot({ caret: "initial", path: test.info().outputPath(`my-${width}.png`), fullPage: true });
+ }
+});
diff --git a/apps/web/e2e/web-flows.spec.ts b/apps/web/e2e/web-flows.spec.ts
new file mode 100644
index 000000000..5c30eaea0
--- /dev/null
+++ b/apps/web/e2e/web-flows.spec.ts
@@ -0,0 +1,102 @@
+import { apiURL, expect, login, test } from "./fixtures/test";
+
+test("홈에서 학교 검색 링크와 준비중 안내를 제공한다", async ({ page }) => {
+ await page.goto("/");
+ await expect(page.getByRole("heading", { name: /파견 ?학교 (찾기|검색)/ })).toBeVisible();
+ await expect(page.getByRole("link").filter({ hasText: "학교 검색" }).first()).toHaveAttribute(
+ "href",
+ "/university/inha",
+ );
+ if (test.info().project.name === "mobile") {
+ await page.getByRole("button", { name: /작년도 합격 점수 확인하러 가기/ }).click();
+ await expect(page.getByText("해당 기능은 현재 준비중입니다.")).toBeVisible();
+ } else {
+ await expect(page.getByRole("link", { name: "전체 파견학교", exact: true })).toHaveAttribute("href", "/university");
+ }
+});
+
+test("이메일 로그인 실패 후 입력을 수정해 로그인하고 마이페이지를 연다", async ({ page }) => {
+ await page.goto("/login");
+ await page.getByPlaceholder("이메일", { exact: true }).fill("student@example.test");
+ await page.getByPlaceholder("비밀번호", { exact: true }).fill("wrong-password");
+ await page.getByRole("button", { name: "로그인", exact: true }).click();
+ await expect(page.getByText("이메일 또는 비밀번호를 확인해주세요.")).toBeVisible();
+ await expect(page.getByPlaceholder("이메일", { exact: true })).toHaveValue("student@example.test");
+ await page.getByPlaceholder("비밀번호", { exact: true }).fill("LocalTest123!");
+ await page.getByRole("button", { name: "로그인", exact: true }).click();
+ await expect(page).toHaveURL("/");
+ await page.getByRole("link", { name: "마이 페이지로 이동", exact: true }).click();
+ await expect(page).toHaveURL("/my");
+ await expect(page.getByText(/테스트 학생/).first()).toBeVisible();
+});
+
+for (const path of ["/my", "/mentor"]) {
+ test(`비회원의 ${path} 진입은 로그인으로 안내한다`, async ({ page }) => {
+ await page.goto(path);
+ await expect(page).toHaveURL("/login");
+ await expect(page.getByPlaceholder("이메일", { exact: true })).toBeVisible();
+ });
+}
+
+test("비회원 채팅은 로그인 링크만 제공하고 채팅 API를 호출하지 않는다", async ({ page, request }) => {
+ await page.goto("/mentor/chat/1");
+ await expect(page.getByText("채팅하려면 로그인이 필요해요.")).toBeVisible();
+ await expect(page.getByRole("link", { name: "로그인하기", exact: true })).toHaveAttribute("href", "/login");
+ await expect(page.getByRole("form", { name: "채팅 메시지 작성" })).toHaveCount(0);
+ expect((await (await request.get(`${apiURL}/__state`)).json()).reads).toBe(0);
+});
+
+test("커뮤니티에서 글쓰기를 누르면 로그인 후 원래 작성 화면으로 돌아온다", async ({ page }) => {
+ await page.goto("/community");
+ await expect(page).toHaveURL("/community/FREE");
+ await expect(page.getByText("교환학생 준비 질문", { exact: true })).toBeVisible();
+ await page.getByRole("button", { name: "글쓰기", exact: true }).click();
+ await expect(page).toHaveURL(/\/login\?redirect=/);
+ await page.getByPlaceholder("이메일", { exact: true }).fill("student@example.test");
+ await page.getByPlaceholder("비밀번호", { exact: true }).fill("LocalTest123!");
+ await page.getByRole("button", { name: "로그인", exact: true }).click();
+ await expect(page).toHaveURL("/community/FREE/create");
+ await expect(page.getByPlaceholder("제목을 입력하세요")).toBeVisible();
+});
+
+test("성적 화면에서 공인어학과 학점 탭을 전환하고 입력으로 이동한다", async ({ page }) => {
+ await login(page);
+ await page.goto("/university/score");
+ await expect(page.getByText("아직 등록된 성적이 없어요.", { exact: false })).toBeVisible();
+ await page.getByRole("button", { name: "학점", exact: true }).click();
+ await expect(page.getByRole("button", { name: "학점 입력하기" })).toBeVisible();
+ await page.getByRole("button", { name: "공인어학", exact: true }).click();
+ await page.getByRole("button", { name: "어학 성적 입력하기" }).click();
+ await expect(page).toHaveURL("/university/score/submit/language-test");
+});
+
+test("비회원 지원자 현황에서 로그인으로 이동한다", async ({ page }) => {
+ await page.goto("/university/application");
+ await expect(page.getByText("로그인하고 지금 바로 경쟁률을 확인하세요.")).toBeVisible();
+ await page.getByRole("link", { name: "로그인하러가기" }).click();
+ await expect(page).toHaveURL("/login");
+});
+
+test("약관과 잘못된 채팅방 주소를 안전하게 표시한다", async ({ page }) => {
+ await page.goto("/terms");
+ await expect(
+ page
+ .getByRole("paragraph")
+ .filter({ hasText: /^제1조 \(목적\)$/ })
+ .filter({ visible: true }),
+ ).toBeVisible();
+ await page.goto("/mentor/chat/not-a-number");
+ await expect(page.getByText("존재하지 않는 페이지입니다", { exact: true })).toBeVisible();
+ await expect(page.getByRole("form", { name: "채팅 메시지 작성" })).toHaveCount(0);
+});
+
+test("로그아웃 후에는 개인 채팅 기록에 다시 접근할 수 없다", async ({ page }) => {
+ await login(page);
+ await page.goto("/my");
+ await expect(page.getByText(/테스트 학생/).first()).toBeVisible();
+ await page.getByRole("button", { name: "로그아웃", exact: true }).click();
+ await expect(page).toHaveURL("/");
+ await page.goto("/mentor/chat/1");
+ await expect(page.getByText("채팅하려면 로그인이 필요해요.")).toBeVisible();
+ await expect(page.locator("[data-message-key]")).toHaveCount(0);
+});
diff --git a/apps/web/next.config.mjs b/apps/web/next.config.mjs
index b75f81a30..334a8e660 100644
--- a/apps/web/next.config.mjs
+++ b/apps/web/next.config.mjs
@@ -59,6 +59,7 @@ const nextConfig = {
remotePatterns: imageRemotePatterns,
formats: ["image/avif", "image/webp"],
deviceSizes: [360, 640, 768, 1024, 1280],
+ qualities: [50, 55, 60, 75],
},
// 압축 활성화
compress: true,
diff --git a/apps/web/package.json b/apps/web/package.json
index be7a557a5..153afbc4b 100644
--- a/apps/web/package.json
+++ b/apps/web/package.json
@@ -13,8 +13,15 @@
"format:check": "biome format .",
"typecheck": "tsc --noEmit",
"typecheck:ci": "tsc --noEmit -p tsconfig.ci.json",
- "ci:check": "pnpm run lint:check && pnpm run typecheck:ci",
- "analyze": "ANALYZE=true next build --webpack"
+ "ci:check": "pnpm run lint:check && pnpm run typecheck:ci && pnpm run test:chat",
+ "test": "vitest run --config vitest.config.ts",
+ "test:chat": "vitest run --config vitest.config.ts src/lib/chat src/apis/chat src/app/mentor/chat src/utils/axiosInstance.chat.test.ts src/utils/axiosInstance.session.test.ts",
+ "analyze": "ANALYZE=true next build --webpack",
+ "test:e2e": "playwright test",
+ "test:e2e:ui": "playwright test --ui",
+ "storybook": "storybook dev -p 6006 --disable-telemetry",
+ "build:storybook": "storybook build --disable-telemetry",
+ "test:stories": "vitest run --config vitest.storybook.config.ts"
},
"dependencies": {
"@hookform/resolvers": "^5.2.2",
@@ -51,15 +58,29 @@
},
"devDependencies": {
"@next/bundle-analyzer": "^16.2.9",
+ "@playwright/test": "1.64.1",
+ "@storybook/addon-docs": "10.6.5",
+ "@storybook/addon-vitest": "10.6.5",
+ "@storybook/nextjs-vite": "10.6.5",
"@svgr/webpack": "^8.1.0",
+ "@testing-library/dom": "^10.4.0",
+ "@testing-library/react": "^16.2.0",
"@types/node": "^20.11.19",
"@types/react": "19.2.15",
"@types/react-dom": "19.2.3",
+ "@types/ws": "8",
+ "@vitest/browser-playwright": "4.1.9",
"autoprefixer": "^10.4.20",
"babel-plugin-react-compiler": "1.0.0",
"critters": "^0.0.23",
+ "jsdom": "^29.1.1",
"postcss": "^8.4.45",
+ "storybook": "10.6.5",
"tailwindcss": "^3.4.10",
- "typescript": "^5.3.3"
+ "typescript": "^5.3.3",
+ "vite": "8.0.3",
+ "vite-plugin-svgr": "4.5.0",
+ "vitest": "^4.1.9",
+ "ws": "8"
}
}
diff --git a/apps/web/playwright.config.ts b/apps/web/playwright.config.ts
new file mode 100644
index 000000000..079315e5c
--- /dev/null
+++ b/apps/web/playwright.config.ts
@@ -0,0 +1,54 @@
+import { defineConfig, devices } from "@playwright/test";
+
+export default defineConfig({
+ testDir: "./e2e",
+ fullyParallel: false,
+ // One local fixture owns the chat rooms. Keep resets isolated across projects.
+ workers: 1,
+ forbidOnly: Boolean(process.env.CI),
+ retries: process.env.CI ? 1 : 0,
+ timeout: 45_000,
+ expect: { timeout: 10_000 },
+ reporter: [["list"], ["html", { open: "never" }]],
+ use: {
+ baseURL: "http://127.0.0.1:43181",
+ trace: "retain-on-failure",
+ screenshot: "only-on-failure",
+ video: "retain-on-failure",
+ },
+ projects: [
+ { name: "desktop", testIgnore: "**/responsive.spec.ts", use: { ...devices["Desktop Chrome"] } },
+ {
+ name: "mobile",
+ testIgnore: "**/responsive.spec.ts",
+ use: { ...devices["Pixel 7"], viewport: { width: 390, height: 844 } },
+ },
+ { name: "responsive", testMatch: "**/responsive.spec.ts", use: { ...devices["Desktop Chrome"] } },
+ ],
+ webServer: [
+ { command: "node e2e/fixtures/server.mjs", url: "http://127.0.0.1:43180/__health", reuseExistingServer: false },
+ {
+ command: "pnpm --filter @solid-connect/university-web exec next dev --hostname 127.0.0.1 --port 43182",
+ url: "http://127.0.0.1:43182/university/inha",
+ reuseExistingServer: false,
+ timeout: 120_000,
+ env: {
+ NEXT_PUBLIC_API_SERVER_URL: "http://127.0.0.1:43180",
+ NEXT_TELEMETRY_DISABLED: "1",
+ SENTRY_AUTH_TOKEN: "",
+ },
+ },
+ {
+ command: "pnpm exec next dev --hostname 127.0.0.1 --port 43181",
+ url: "http://127.0.0.1:43181/login",
+ reuseExistingServer: false,
+ timeout: 120_000,
+ env: {
+ NEXT_PUBLIC_API_SERVER_URL: "http://127.0.0.1:43180",
+ UNIVERSITY_WEB_DOMAIN: "http://127.0.0.1:43182",
+ NEXT_TELEMETRY_DISABLED: "1",
+ SENTRY_AUTH_TOKEN: "",
+ },
+ },
+ ],
+});
diff --git a/apps/web/src/apis/Auth/server/postReissueToken.ts b/apps/web/src/apis/Auth/server/postReissueToken.ts
index d7d350389..0c93b8bc9 100644
--- a/apps/web/src/apis/Auth/server/postReissueToken.ts
+++ b/apps/web/src/apis/Auth/server/postReissueToken.ts
@@ -1,4 +1,3 @@
-import useAuthStore from "@/lib/zustand/useAuthStore";
import { publicAxiosInstance } from "@/utils/axiosInstance";
import { isTokenExpired } from "@/utils/jwtUtils";
@@ -7,26 +6,17 @@ import { isTokenExpired } from "@/utils/jwtUtils";
* axiosInstance의 interceptor에서 사용됨
*/
const postReissueToken = async (): Promise
=> {
- try {
- const response = await publicAxiosInstance.post<{ accessToken: string }>("/auth/reissue");
- const newAccessToken = response.data.accessToken;
+ const response = await publicAxiosInstance.post<{ accessToken: string }>("/auth/reissue");
+ const newAccessToken = response.data.accessToken;
- if (!newAccessToken) {
- throw new Error("재발급된 토큰이 유효하지 않습니다.");
- }
- if (isTokenExpired(newAccessToken)) {
- throw new Error("재발급된 토큰이 이미 만료되었습니다.");
- }
-
- // 재발급 성공 시, 새로운 토큰을 Zustand 스토어에 저장
- useAuthStore.getState().setAccessToken(newAccessToken);
-
- return newAccessToken;
- } catch (error) {
- // 재발급 실패 시 스토어 상태 초기화
- useAuthStore.getState().clearAccessToken();
- throw error;
+ if (!newAccessToken) {
+ throw new Error("재발급된 토큰이 유효하지 않습니다.");
}
+ if (isTokenExpired(newAccessToken)) {
+ throw new Error("재발급된 토큰이 이미 만료되었습니다.");
+ }
+
+ return newAccessToken;
};
export default postReissueToken;
diff --git a/apps/web/src/apis/MyPage/postConfirmSchoolEmail.ts b/apps/web/src/apis/MyPage/postConfirmSchoolEmail.ts
index f1138b2cd..235b30168 100644
--- a/apps/web/src/apis/MyPage/postConfirmSchoolEmail.ts
+++ b/apps/web/src/apis/MyPage/postConfirmSchoolEmail.ts
@@ -1,7 +1,7 @@
import { useMutation, useQueryClient } from "@tanstack/react-query";
import type { AxiosError } from "axios";
-import { postReissueToken } from "@/apis/Auth";
import { SKIP_GLOBAL_ERROR_TOAST_META } from "@/lib/react-query/errorToastMeta";
+import { refreshAccessToken } from "@/utils/axiosInstance";
import { QueryKeys } from "../queryKeys";
import { myPageApi, type SchoolEmailConfirmPostRequest } from "./api";
@@ -14,7 +14,7 @@ const usePostConfirmSchoolEmail = () => {
meta: SKIP_GLOBAL_ERROR_TOAST_META,
onSuccess: async () => {
try {
- await postReissueToken();
+ await refreshAccessToken();
} catch {
// 학교 인증은 이미 완료되었으므로 토큰 갱신 실패가 성공 흐름을 막지 않게 합니다.
}
diff --git a/apps/web/src/apis/chat/api.test.ts b/apps/web/src/apis/chat/api.test.ts
new file mode 100644
index 000000000..7ba2f96aa
--- /dev/null
+++ b/apps/web/src/apis/chat/api.test.ts
@@ -0,0 +1,70 @@
+import { AxiosHeaders } from "axios";
+import { beforeEach, describe, expect, it, vi } from "vitest";
+import { axiosInstance } from "@/utils/axiosInstance";
+import { chatApi } from "./api";
+
+vi.mock("@/utils/axiosInstance", () => ({
+ axiosInstance: { get: vi.fn(), put: vi.fn() },
+}));
+
+const resolveHistory = (data: unknown) =>
+ vi.mocked(axiosInstance.get).mockResolvedValueOnce({
+ data,
+ status: 200,
+ statusText: "OK",
+ headers: {},
+ config: { headers: new AxiosHeaders() },
+ });
+
+describe("chatApi.getChatHistories", () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ });
+
+ it("accepts an empty page that still points to the next page", async () => {
+ resolveHistory({ content: [], nextPageNumber: 2 });
+ await expect(chatApi.getChatHistories({ roomId: 1 })).resolves.toEqual({ content: [], nextPageNumber: 2 });
+ });
+
+ it("keeps page one as the default and passes the cancellation signal to Axios", async () => {
+ resolveHistory({ content: [], nextPageNumber: -1 });
+ const controller = new AbortController();
+ await chatApi.getChatHistories({ roomId: 1, signal: controller.signal });
+
+ expect(axiosInstance.get).toHaveBeenCalledWith("/chats/rooms/1", {
+ params: { page: 1, size: 20 },
+ signal: controller.signal,
+ });
+ });
+
+ it.each([
+ 0,
+ 1,
+ -2,
+ 1.5,
+ Number.POSITIVE_INFINITY,
+ "2",
+ null,
+ undefined,
+ ])("rejects a cursor that cannot advance from page one: %j", async (nextPageNumber) => {
+ resolveHistory({ content: [], nextPageNumber });
+ await expect(chatApi.getChatHistories({ roomId: 1 })).rejects.toThrow();
+ });
+
+ it("fails the page if even one malformed message is present", async () => {
+ const valid = { id: 1, content: "정상 메시지", senderId: 1, createdAt: "2026-10-27T01:00:00Z" };
+ resolveHistory({ content: [valid, { ...valid, id: 2, createdAt: "invalid" }], nextPageNumber: -1 });
+
+ await expect(chatApi.getChatHistories({ roomId: 1 })).rejects.toThrow("올바르지 않은 메시지");
+ });
+
+ it.each([
+ null,
+ {},
+ { content: null, nextPageNumber: -1 },
+ { content: {}, nextPageNumber: -1 },
+ ])("rejects a malformed history envelope: %j", async (data) => {
+ resolveHistory(data);
+ await expect(chatApi.getChatHistories({ roomId: 1 })).rejects.toThrow("채팅 내역 응답");
+ });
+});
diff --git a/apps/web/src/apis/chat/api.ts b/apps/web/src/apis/chat/api.ts
index 28f58de60..5fc21bfbb 100644
--- a/apps/web/src/apis/chat/api.ts
+++ b/apps/web/src/apis/chat/api.ts
@@ -2,21 +2,13 @@ import type { AxiosResponse } from "axios";
import type { ChatMessage, ChatPartner, ChatRoom } from "@/types/chat";
import { axiosInstance } from "@/utils/axiosInstance";
import {
- normalizeChatMessage,
normalizeChatPartner,
normalizeChatRoom,
- type RawChatMessage,
+ parseChatMessage,
type RawChatPartner,
type RawChatRoom,
} from "./normalize";
-// QueryKeys for chat domain
-export const ChatQueryKeys = {
- chatRooms: "chatRooms",
- chatHistories: "chatHistories",
- partnerInfo: "partnerInfo",
-} as const;
-
// Re-export types from @/types/chat
export type { ChatMessage, ChatRoom, ChatPartner };
@@ -33,11 +25,12 @@ interface GetChatHistoriesParams {
roomId: number;
size?: number;
page?: number;
+ signal?: AbortSignal;
}
interface RawChatHistoriesResponse {
nextPageNumber: number;
- content: RawChatMessage[];
+ content: unknown[];
}
interface RawChatRoomListResponse {
@@ -45,33 +38,51 @@ interface RawChatRoomListResponse {
}
export const chatApi = {
- getChatHistories: async ({ roomId, size = 20, page = 1 }: GetChatHistoriesParams): Promise => {
+ getChatHistories: async ({
+ roomId,
+ size = 20,
+ page = 1,
+ signal,
+ }: GetChatHistoriesParams): Promise => {
const res = await axiosInstance.get(`/chats/rooms/${roomId}`, {
params: {
size,
page,
},
+ signal,
});
- return {
- nextPageNumber: res.data.nextPageNumber,
- content: (res.data.content ?? []).map(normalizeChatMessage),
- };
+ const nextPageNumber = res.data?.nextPageNumber;
+ if (
+ !Array.isArray(res.data?.content) ||
+ (nextPageNumber !== -1 && (!Number.isSafeInteger(nextPageNumber) || nextPageNumber <= page))
+ ) {
+ throw new Error("채팅 내역 응답이 올바르지 않습니다.");
+ }
+
+ const content: ChatMessage[] = [];
+ for (const rawMessage of res.data.content) {
+ const message = parseChatMessage(rawMessage);
+ if (!message) throw new Error("채팅 내역에 올바르지 않은 메시지가 있습니다.");
+ content.push(message);
+ }
+
+ return { nextPageNumber, content };
},
- getChatRooms: async (): Promise => {
- const res = await axiosInstance.get("/chats/rooms");
+ getChatRooms: async (signal?: AbortSignal): Promise => {
+ const res = await axiosInstance.get("/chats/rooms", { signal });
return {
chatRooms: (res.data.chatRooms ?? []).map(normalizeChatRoom),
};
},
- putReadChatRoom: async (roomId: number): Promise => {
- const response: AxiosResponse = await axiosInstance.put(`/chats/rooms/${roomId}/read`);
+ putReadChatRoom: async (roomId: number, signal?: AbortSignal): Promise => {
+ const response: AxiosResponse = await axiosInstance.put(`/chats/rooms/${roomId}/read`, undefined, { signal });
return response.data;
},
- getChatPartner: async (roomId: number): Promise => {
- const res = await axiosInstance.get(`/chats/rooms/${roomId}/partner`);
+ getChatPartner: async (roomId: number, signal?: AbortSignal): Promise => {
+ const res = await axiosInstance.get(`/chats/rooms/${roomId}/partner`, { signal });
return normalizeChatPartner(res.data);
},
};
diff --git a/apps/web/src/apis/chat/auth.test.ts b/apps/web/src/apis/chat/auth.test.ts
new file mode 100644
index 000000000..89967c1f1
--- /dev/null
+++ b/apps/web/src/apis/chat/auth.test.ts
@@ -0,0 +1,38 @@
+import { beforeEach, describe, expect, it, vi } from "vitest";
+import { getChatUserId } from "./auth";
+
+const token = (sub: unknown, exp: unknown = Math.floor(Date.now() / 1000) + 60) =>
+ `header.${btoa(JSON.stringify({ sub, exp }))}.signature`;
+
+describe("getChatUserId", () => {
+ beforeEach(() => {
+ vi.stubGlobal("window", {});
+ });
+
+ it("uses the same user identity for numeric and string JWT subjects", () => {
+ expect(getChatUserId(token(42))).toBe(42);
+ expect(getChatUserId(token("42"))).toBe(42);
+ });
+
+ it.each([
+ undefined,
+ null,
+ "",
+ 0,
+ -1,
+ 1.5,
+ "invalid",
+ true,
+ [1],
+ Number.MAX_SAFE_INTEGER + 1,
+ ])("does not enable personal chat data for an invalid subject: %j", (sub) => {
+ expect(getChatUserId(token(sub))).toBeNull();
+ });
+
+ it("rejects missing and expired tokens including the exact expiry boundary", () => {
+ expect(getChatUserId(null)).toBeNull();
+ expect(getChatUserId("invalid")).toBeNull();
+ expect(getChatUserId(token(42, Math.floor(Date.now() / 1000)))).toBeNull();
+ expect(getChatUserId(token(42, "9999999999"))).toBeNull();
+ });
+});
diff --git a/apps/web/src/apis/chat/auth.ts b/apps/web/src/apis/chat/auth.ts
new file mode 100644
index 000000000..1fff512ea
--- /dev/null
+++ b/apps/web/src/apis/chat/auth.ts
@@ -0,0 +1,29 @@
+import useAuthStore from "@/lib/zustand/useAuthStore";
+import { tokenParse } from "@/utils/jwtUtils";
+
+export const getChatUserId = (accessToken: string | null): number | null => {
+ const payload = tokenParse(accessToken);
+ const userId = Number(payload?.sub);
+
+ if (
+ !payload ||
+ (typeof payload.sub !== "number" && typeof payload.sub !== "string") ||
+ !Number.isSafeInteger(userId) ||
+ userId <= 0 ||
+ typeof payload.exp !== "number" ||
+ !Number.isFinite(payload.exp) ||
+ payload.exp <= Math.floor(Date.now() / 1000)
+ ) {
+ return null;
+ }
+
+ return userId;
+};
+
+export const useChatUserId = (): number | null => {
+ const accessToken = useAuthStore((state) => state.accessToken);
+ const isInitialized = useAuthStore((state) => state.isInitialized);
+ const isAuthenticated = useAuthStore((state) => state.isAuthenticated);
+
+ return isInitialized && isAuthenticated ? getChatUserId(accessToken) : null;
+};
diff --git a/apps/web/src/apis/chat/getChatMessages.ts b/apps/web/src/apis/chat/getChatMessages.ts
deleted file mode 100644
index 2d14dc061..000000000
--- a/apps/web/src/apis/chat/getChatMessages.ts
+++ /dev/null
@@ -1,38 +0,0 @@
-import { useInfiniteQuery } from "@tanstack/react-query";
-import type { AxiosError } from "axios";
-import { type ChatHistoriesResponse, type ChatMessage, ChatQueryKeys, chatApi } from "./api";
-
-/**
- * @description 채팅 히스토리를 무한 스크롤로 가져오는 훅
- */
-const useGetChatHistories = (roomId: number, size: number = 20) => {
- return useInfiniteQuery<
- ChatHistoriesResponse,
- AxiosError,
- {
- pages: ChatHistoriesResponse[];
- pageParams: number[];
- messages: ChatMessage[];
- },
- [string, number],
- number
- >({
- queryKey: [ChatQueryKeys.chatHistories, roomId],
- queryFn: ({ pageParam = 1 }: { pageParam?: number }) => chatApi.getChatHistories({ roomId, size, page: pageParam }),
- initialPageParam: 1,
- getNextPageParam: (lastPage: ChatHistoriesResponse) => {
- // nextPageNumber가 -1이면 더 이상 페이지가 없음
- return lastPage.nextPageNumber === -1 ? undefined : lastPage.nextPageNumber;
- },
- staleTime: 1000 * 60 * 5, // 5분간 캐시
- refetchOnMount: "always",
- enabled: !!roomId, // roomId가 있을 때만 쿼리 실행
- select: (data) => ({
- pages: data.pages,
- pageParams: data.pageParams,
- messages: data.pages.flatMap((page) => page.content),
- }),
- });
-};
-
-export default useGetChatHistories;
diff --git a/apps/web/src/apis/chat/getChatPartner.ts b/apps/web/src/apis/chat/getChatPartner.ts
index 56be7d482..1ac3161c0 100644
--- a/apps/web/src/apis/chat/getChatPartner.ts
+++ b/apps/web/src/apis/chat/getChatPartner.ts
@@ -1,16 +1,19 @@
import { useQuery } from "@tanstack/react-query";
import type { AxiosError } from "axios";
-import { type ChatPartner, ChatQueryKeys, chatApi } from "./api";
+import { SKIP_GLOBAL_ERROR_TOAST_META } from "@/lib/react-query/errorToastMeta";
+import { type ChatPartner, chatApi } from "./api";
+import { chatKeys } from "./queryKeys";
/**
* @description 채팅 상대방 정보를 가져오는 훅
*/
-const useGetPartnerInfo = (roomId: number) => {
+const useGetPartnerInfo = (roomId: number, userId: number) => {
return useQuery({
- queryKey: [ChatQueryKeys.partnerInfo, roomId],
- queryFn: () => chatApi.getChatPartner(roomId),
+ queryKey: chatKeys.partner(userId, roomId),
+ queryFn: ({ signal }) => chatApi.getChatPartner(roomId, signal),
staleTime: 1000 * 60 * 5,
- enabled: !!roomId,
+ enabled: Number.isSafeInteger(userId) && userId > 0 && Number.isSafeInteger(roomId) && roomId > 0,
+ meta: SKIP_GLOBAL_ERROR_TOAST_META,
});
};
diff --git a/apps/web/src/apis/chat/getChatRooms.ts b/apps/web/src/apis/chat/getChatRooms.ts
index b96a43d25..0f39785ef 100644
--- a/apps/web/src/apis/chat/getChatRooms.ts
+++ b/apps/web/src/apis/chat/getChatRooms.ts
@@ -1,15 +1,26 @@
import { useQuery } from "@tanstack/react-query";
import type { AxiosError } from "axios";
-import { ChatQueryKeys, type ChatRoom, type ChatRoomListResponse, chatApi } from "./api";
+import { SKIP_GLOBAL_ERROR_TOAST_META } from "@/lib/react-query/errorToastMeta";
+import { type ChatRoom, type ChatRoomListResponse, chatApi } from "./api";
+import { useChatUserId } from "./auth";
+import { chatKeys } from "./queryKeys";
/**
* @description 채팅방 목록을 가져오는 훅
*/
-const useGetChatRooms = () => {
+const useGetChatRooms = (contextUserId?: number) => {
+ const authenticatedUserId = useChatUserId();
+ const userId = contextUserId ?? authenticatedUserId;
+
return useQuery({
- queryKey: [ChatQueryKeys.chatRooms],
- queryFn: chatApi.getChatRooms,
- staleTime: 1000 * 60 * 5, // 5분간 캐시
+ queryKey: chatKeys.rooms(userId),
+ queryFn: ({ signal }) => chatApi.getChatRooms(signal),
+ enabled: userId !== null,
+ staleTime: 0,
+ refetchOnWindowFocus: true,
+ refetchInterval: 30_000,
+ refetchIntervalInBackground: false,
+ meta: SKIP_GLOBAL_ERROR_TOAST_META,
select: (data) => data.chatRooms,
});
};
diff --git a/apps/web/src/apis/chat/index.ts b/apps/web/src/apis/chat/index.ts
index e1a190a4a..7b6fdcb40 100644
--- a/apps/web/src/apis/chat/index.ts
+++ b/apps/web/src/apis/chat/index.ts
@@ -1,6 +1,8 @@
export type { ChatHistoriesResponse, ChatMessage, ChatPartner, ChatRoom, ChatRoomListResponse } from "./api";
-export { ChatQueryKeys, chatApi } from "./api";
-export { default as useGetChatHistories } from "./getChatMessages";
+export { chatApi } from "./api";
+export { getChatUserId, useChatUserId } from "./auth";
export { default as useGetPartnerInfo } from "./getChatPartner";
export { default as useGetChatRooms } from "./getChatRooms";
+export { parseChatMessage } from "./normalize";
export { default as usePutChatRead } from "./putReadChatRoom";
+export { chatKeys } from "./queryKeys";
diff --git a/apps/web/src/apis/chat/messages.test.ts b/apps/web/src/apis/chat/messages.test.ts
new file mode 100644
index 000000000..4d73af712
--- /dev/null
+++ b/apps/web/src/apis/chat/messages.test.ts
@@ -0,0 +1,46 @@
+import { describe, expect, it } from "vitest";
+import { getMessageKey, mergeMessages } from "@/lib/chat/messages";
+import type { ChatMessage } from "@/types/chat";
+import { parseChatMessage } from "./normalize";
+
+const message = (id: number, overrides: Partial = {}): ChatMessage => ({
+ id,
+ content: `메시지 ${id}`,
+ senderId: 1,
+ createdAt: "2026-10-27T01:00:00Z",
+ attachments: [],
+ ...overrides,
+});
+
+describe("mergeMessages", () => {
+ it("reconciles history overlap and a socket echo by server ID", () => {
+ const existing = [message(2), message(1)];
+ const incoming = [message(2, { content: "서버가 확정한 내용" }), message(3)];
+
+ expect(mergeMessages(existing, incoming)).toEqual([message(1), incoming[0], incoming[1]]);
+ expect(existing.map((item) => item.id)).toEqual([2, 1]);
+ expect(incoming.map((item) => item.id)).toEqual([2, 3]);
+ });
+
+ it("orders by timestamp before using ID for simultaneous messages", () => {
+ const earlier = message(99, { createdAt: "2026-10-27T00:59:00Z" });
+ expect(mergeMessages([message(3)], [message(2), earlier]).map((item) => item.id)).toEqual([99, 2, 3]);
+ });
+
+ it("preserves distinct messages whose fallback numeric IDs collide", () => {
+ const first = parseChatMessage({ ...message(0), id: undefined, content: "Aa" });
+ const second = parseChatMessage({ ...message(0), id: undefined, content: "BB" });
+
+ expect(first).not.toBeNull();
+ expect(second).not.toBeNull();
+ expect(first?.id).toBe(second?.id);
+ expect(getMessageKey(first!)).not.toBe(getMessageKey(second!));
+ expect(mergeMessages([first!], [second!])).toHaveLength(2);
+ });
+
+ it("deduplicates repeated messages with no server ID using their full fingerprint", () => {
+ const first = message(-12);
+ const same = { ...first, id: -30 };
+ expect(mergeMessages([first], [same])).toEqual([same]);
+ });
+});
diff --git a/apps/web/src/apis/chat/normalize.test.ts b/apps/web/src/apis/chat/normalize.test.ts
new file mode 100644
index 000000000..43ec29943
--- /dev/null
+++ b/apps/web/src/apis/chat/normalize.test.ts
@@ -0,0 +1,63 @@
+import { describe, expect, it } from "vitest";
+import { parseChatMessage } from "./normalize";
+
+const message = {
+ id: "12",
+ content: "안녕하세요",
+ senderId: "3",
+ createdAt: "2026-10-27T01:00:00Z",
+};
+
+describe("parseChatMessage", () => {
+ it("accepts the legacy siteUserId field and an omitted attachment list", () => {
+ expect(parseChatMessage({ ...message, senderId: undefined, siteUserId: "7" })).toEqual({
+ id: 12,
+ content: "안녕하세요",
+ senderId: 7,
+ createdAt: message.createdAt,
+ attachments: [],
+ });
+ });
+
+ it("normalizes valid image attachments without a thumbnail", () => {
+ const parsed = parseChatMessage({
+ ...message,
+ attachments: [{ id: "9", isImage: true, url: "/image.png", createdAt: message.createdAt }],
+ });
+
+ expect(parsed?.attachments[0]).toEqual({
+ id: 9,
+ isImage: true,
+ url: "/image.png",
+ thumbnailUrl: null,
+ createdAt: message.createdAt,
+ });
+ });
+
+ it.each([
+ null,
+ [],
+ { ...message, content: 2 },
+ { ...message, senderId: undefined },
+ { ...message, senderId: "invalid" },
+ { ...message, senderId: 0 },
+ { ...message, createdAt: "invalid" },
+ { ...message, createdAt: 123 },
+ { ...message, attachments: {} },
+ { ...message, attachments: [null] },
+ { ...message, attachments: [{ isImage: true, url: "/image.png", createdAt: "invalid" }] },
+ { ...message, attachments: [{ isImage: "true", url: "/image.png", createdAt: message.createdAt }] },
+ { ...message, attachments: [{ isImage: true, url: 12, createdAt: message.createdAt }] },
+ ])("rejects a malformed frame without throwing: %j", (value) => {
+ expect(parseChatMessage(value)).toBeNull();
+ });
+
+ it("gives the same ID to repeated messages that omit a server ID", () => {
+ const withoutId = { ...message, id: undefined };
+ const first = parseChatMessage(withoutId);
+ const second = parseChatMessage(withoutId);
+
+ expect(first?.id).toBeLessThan(0);
+ expect(first?.id).toBe(second?.id);
+ });
+});
diff --git a/apps/web/src/apis/chat/normalize.ts b/apps/web/src/apis/chat/normalize.ts
index 19534fa31..c98ed63ad 100644
--- a/apps/web/src/apis/chat/normalize.ts
+++ b/apps/web/src/apis/chat/normalize.ts
@@ -91,6 +91,61 @@ export const normalizeChatMessage = (message: RawChatMessage): ChatMessage => {
};
};
+const isRecord = (value: unknown): value is Record =>
+ typeof value === "object" && value !== null && !Array.isArray(value);
+
+const isOptionalNumericId = (value: unknown): value is NumericLike => {
+ if (value === undefined || value === null) return true;
+ if (typeof value !== "number" && typeof value !== "string") return false;
+ if (typeof value === "string" && value.trim() === "") return false;
+
+ return Number.isSafeInteger(Number(value));
+};
+
+const isTimestamp = (value: unknown): value is string =>
+ typeof value === "string" && value.trim() !== "" && Number.isFinite(Date.parse(value));
+
+const isRawAttachment = (value: unknown): value is RawChatAttachment =>
+ isRecord(value) &&
+ isOptionalNumericId(value.id) &&
+ typeof value.isImage === "boolean" &&
+ typeof value.url === "string" &&
+ value.url.trim() !== "" &&
+ (value.thumbnailUrl === undefined || value.thumbnailUrl === null || typeof value.thumbnailUrl === "string") &&
+ isTimestamp(value.createdAt);
+
+export const parseChatMessage = (value: unknown): ChatMessage | null => {
+ if (
+ !isRecord(value) ||
+ !isOptionalNumericId(value.id) ||
+ typeof value.content !== "string" ||
+ !isTimestamp(value.createdAt)
+ ) {
+ return null;
+ }
+
+ const rawSenderId = value.senderId ?? value.siteUserId;
+ if (!isOptionalNumericId(rawSenderId)) return null;
+
+ const senderId = Number(rawSenderId);
+ if (!Number.isSafeInteger(senderId) || senderId <= 0) return null;
+
+ if (
+ value.attachments !== undefined &&
+ (!Array.isArray(value.attachments) || !value.attachments.every(isRawAttachment))
+ ) {
+ return null;
+ }
+
+ return normalizeChatMessage({
+ id: value.id,
+ content: value.content,
+ senderId,
+ createdAt: value.createdAt,
+ attachments: value.attachments as RawChatAttachment[] | undefined,
+ });
+};
+
export const normalizeChatPartner = (partner: RawChatPartner): ChatPartner => ({
partnerId: toNumber(partner.partnerId ?? partner.siteUserId),
nickname: partner.nickname,
diff --git a/apps/web/src/apis/chat/putReadChatRoom.ts b/apps/web/src/apis/chat/putReadChatRoom.ts
index 0602a25cb..cf0b9ae2c 100644
--- a/apps/web/src/apis/chat/putReadChatRoom.ts
+++ b/apps/web/src/apis/chat/putReadChatRoom.ts
@@ -1,20 +1,27 @@
import { useMutation, useQueryClient } from "@tanstack/react-query";
import type { AxiosError } from "axios";
-import { ChatQueryKeys, chatApi } from "./api";
+import { SKIP_GLOBAL_ERROR_TOAST_META } from "@/lib/react-query/errorToastMeta";
+import { chatApi } from "./api";
+import { useChatUserId } from "./auth";
+import { chatKeys } from "./queryKeys";
/**
* @description 채팅방 읽음 처리 훅
*/
-const usePutChatRead = () => {
+const usePutChatRead = (contextUserId?: number) => {
const queryClient = useQueryClient();
+ const authenticatedUserId = useChatUserId();
+ const userId = contextUserId ?? authenticatedUserId;
- return useMutation({
- mutationFn: chatApi.putReadChatRoom,
- onSuccess: () => {
- // 채팅방 목록 쿼리를 무효화하여 새로 고침
- queryClient.invalidateQueries({ queryKey: [ChatQueryKeys.chatRooms] });
+ return useMutation({
+ mutationFn: (roomId) => chatApi.putReadChatRoom(roomId),
+ onMutate: () => ({ userId }),
+ onSuccess: (_data, _roomId, context) => {
+ if (context?.userId) {
+ return queryClient.invalidateQueries({ queryKey: chatKeys.rooms(context.userId) });
+ }
},
- onError: (error) => {},
+ meta: SKIP_GLOBAL_ERROR_TOAST_META,
});
};
diff --git a/apps/web/src/apis/chat/queries.dom.test.tsx b/apps/web/src/apis/chat/queries.dom.test.tsx
new file mode 100644
index 000000000..150f9dc8c
--- /dev/null
+++ b/apps/web/src/apis/chat/queries.dom.test.tsx
@@ -0,0 +1,56 @@
+// @vitest-environment jsdom
+import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
+import { act, renderHook, waitFor } from "@testing-library/react/pure";
+import type { ReactNode } from "react";
+import { expect, it, vi } from "vitest";
+import useAuthStore from "@/lib/zustand/useAuthStore";
+import { chatApi } from "./api";
+import useGetPartnerInfo from "./getChatPartner";
+import useGetChatRooms from "./getChatRooms";
+import { chatKeys } from "./queryKeys";
+
+vi.mock("./api", () => ({ chatApi: { getChatPartner: vi.fn(), getChatRooms: vi.fn() } }));
+
+it("retains authorized partner and room query identities across token expiry and rotation", async () => {
+ const token = (exp: number) => `header.${btoa(JSON.stringify({ sub: 7, role: "MENTEE", exp }))}.signature`;
+ useAuthStore.getState().setAccessToken(token(Math.floor(Date.now() / 1000) + 3600));
+ const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
+ const partner = { partnerId: 8, nickname: "상대방", profileUrl: null, university: null };
+ const rooms = { chatRooms: [] };
+ client.setQueryData(chatKeys.partner(7, 42), partner);
+ client.setQueryData(chatKeys.rooms(7), rooms);
+ vi.mocked(chatApi.getChatRooms).mockResolvedValue(rooms);
+ const wrapper = ({ children }: { children: ReactNode }) => (
+ {children}
+ );
+ const { result, rerender, unmount } = renderHook(
+ () => ({ partner: useGetPartnerInfo(42, 7), rooms: useGetChatRooms(7) }),
+ { wrapper },
+ );
+ await waitFor(() => expect(result.current.rooms.isFetching).toBe(false));
+ act(() =>
+ useAuthStore.setState({ accessToken: token(Math.floor(Date.now() / 1000) - 1), refreshStatus: "retryable" }),
+ );
+ rerender();
+ expect(result.current.partner.isSuccess).toBe(true);
+ expect(result.current.partner.data).toEqual(partner);
+ expect(result.current.rooms.isSuccess).toBe(true);
+ expect(
+ client
+ .getQueryCache()
+ .getAll()
+ .every((query) => query.queryKey[1] === 7),
+ ).toBe(true);
+ expect(chatApi.getChatPartner).not.toHaveBeenCalled();
+ act(() =>
+ useAuthStore
+ .getState()
+ .setRefreshedAccessToken(token(Math.floor(Date.now() / 1000) + 3600), useAuthStore.getState().sessionVersion),
+ );
+ rerender();
+ expect(result.current.partner.data).toEqual(partner);
+ expect(result.current.rooms.data).toEqual([]);
+ unmount();
+ client.clear();
+ useAuthStore.getState().clearAccessToken();
+});
diff --git a/apps/web/src/apis/chat/queryKeys.ts b/apps/web/src/apis/chat/queryKeys.ts
new file mode 100644
index 000000000..d79824256
--- /dev/null
+++ b/apps/web/src/apis/chat/queryKeys.ts
@@ -0,0 +1,6 @@
+export const chatKeys = {
+ all: ["chat"],
+ rooms: (userId: number | null) => ["chat", userId, "rooms"] as const,
+ partner: (userId: number | null, roomId: number) => ["chat", userId, "partner", roomId] as const,
+ outbox: (userId: number, roomId: number) => ["chat", userId, "outbox", roomId] as const,
+} as const;
diff --git a/apps/web/src/apis/image-upload/api.ts b/apps/web/src/apis/image-upload/api.ts
index fb95fe646..75e8926f7 100644
--- a/apps/web/src/apis/image-upload/api.ts
+++ b/apps/web/src/apis/image-upload/api.ts
@@ -59,7 +59,7 @@ export const imageUploadApi = {
/**
* 채팅 이미지 업로드 (로그인 후)
*/
- postUploadChatImages: async (files: File[]): Promise => {
+ postUploadChatImages: async (files: File[], signal?: AbortSignal): Promise => {
const formData = new FormData();
files.forEach((file) => {
formData.append("files", file);
@@ -67,6 +67,7 @@ export const imageUploadApi = {
const res = await axiosInstance.post(`/file/chat`, formData, {
headers: { "Content-Type": "multipart/form-data" },
+ signal,
});
return res.data.map((image) => image.fileUrl);
},
diff --git a/apps/web/src/apis/mentor/getMentorDetail.ts b/apps/web/src/apis/mentor/getMentorDetail.ts
index 75cbc7780..ed1ade6a0 100644
--- a/apps/web/src/apis/mentor/getMentorDetail.ts
+++ b/apps/web/src/apis/mentor/getMentorDetail.ts
@@ -1,5 +1,6 @@
import { useQuery } from "@tanstack/react-query";
import type { AxiosError } from "axios";
+import { SKIP_GLOBAL_ERROR_TOAST_META } from "@/lib/react-query/errorToastMeta";
import { type MentorCardDetail, MentorQueryKeys, mentorApi } from "./api";
/**
@@ -10,6 +11,7 @@ const useGetMentorDetail = (mentorId: number | null) => {
queryKey: [MentorQueryKeys.mentorDetail, mentorId!],
queryFn: () => mentorApi.getMentorDetail(mentorId!),
enabled: mentorId !== null,
+ meta: SKIP_GLOBAL_ERROR_TOAST_META,
staleTime: 1000 * 60 * 5, // 5분간 캐시
});
};
diff --git a/apps/web/src/apis/mentor/getMyMentorPage.ts b/apps/web/src/apis/mentor/getMyMentorPage.ts
index 7f0a74483..7293d73b5 100644
--- a/apps/web/src/apis/mentor/getMyMentorPage.ts
+++ b/apps/web/src/apis/mentor/getMyMentorPage.ts
@@ -1,14 +1,22 @@
import { useQuery } from "@tanstack/react-query";
import type { AxiosError } from "axios";
+import { SKIP_GLOBAL_ERROR_TOAST_META } from "@/lib/react-query/errorToastMeta";
import { type MentorCardPreview, MentorQueryKeys, mentorApi } from "./api";
+type UseGetMentorMyProfileParams = {
+ enabled?: boolean;
+ skipGlobalErrorToast?: boolean;
+};
+
/**
* @description 멘토 마이 프로필 조회 훅
*/
-const useGetMentorMyProfile = () => {
+const useGetMentorMyProfile = ({ enabled = true, skipGlobalErrorToast = false }: UseGetMentorMyProfileParams = {}) => {
return useQuery({
queryKey: [MentorQueryKeys.myMentorProfile],
queryFn: mentorApi.getMentorMyProfile,
+ enabled,
+ meta: skipGlobalErrorToast ? SKIP_GLOBAL_ERROR_TOAST_META : undefined,
staleTime: 1000 * 60 * 5, // 5분간 캐시
});
};
diff --git a/apps/web/src/app/(home)/_ui/FindLastYearScoreBar/index.tsx b/apps/web/src/app/(home)/_ui/FindLastYearScoreBar/index.tsx
index 0ac498d80..55ad11fe5 100644
--- a/apps/web/src/app/(home)/_ui/FindLastYearScoreBar/index.tsx
+++ b/apps/web/src/app/(home)/_ui/FindLastYearScoreBar/index.tsx
@@ -6,7 +6,7 @@ import { IconGraduationCap, IconRightArrow } from "@/public/svgs/home";
const FindLastYearScoreBar = () => {
return (