diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 648b661f9..a0c510966 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -36,6 +36,7 @@ jobs: - 'pnpm-lock.yaml' - 'pnpm-workspace.yaml' - 'scripts/**' + - 'packages/**' - 'turbo.json' - '.github/workflows/**' @@ -216,3 +217,75 @@ jobs: run: pnpm --filter @solid-connect/admin run build env: NODE_ENV: production + + web-e2e: + name: Web - E2E (${{ matrix.project }}) + runs-on: ubuntu-latest + timeout-minutes: 15 + needs: [detect-changes, web-quality-check] + if: | + (needs.detect-changes.outputs.web == 'true' || needs.detect-changes.outputs.root == 'true') && + needs.web-quality-check.result == 'success' + strategy: + fail-fast: false + matrix: + project: [desktop, mobile] + steps: + - uses: actions/checkout@v4 + - uses: pnpm/action-setup@v3 + with: + version: 9 + - uses: actions/setup-node@v4 + with: + node-version: "22.x" + cache: pnpm + - run: pnpm install --frozen-lockfile + - run: pnpm --filter @solid-connect/web exec playwright install --with-deps chromium + - name: Run browser flows against the local fixture API + run: pnpm --filter @solid-connect/web run test:e2e --project=${{ matrix.project }} + - name: Upload browser report and failure traces + if: always() + uses: actions/upload-artifact@v4 + with: + name: web-e2e-${{ matrix.project }} + path: | + apps/web/playwright-report/ + apps/web/test-results/ + retention-days: 7 + if-no-files-found: ignore + + web-stories: + name: Web - Storybook + runs-on: ubuntu-latest + timeout-minutes: 15 + needs: [detect-changes, web-quality-check] + if: | + (needs.detect-changes.outputs.web == 'true' || needs.detect-changes.outputs.root == 'true') && + needs.web-quality-check.result == 'success' + env: + STORYBOOK_DISABLE_TELEMETRY: "1" + steps: + - uses: actions/checkout@v4 + - uses: pnpm/action-setup@v3 + with: + version: 9 + - uses: actions/setup-node@v4 + with: + node-version: "22.x" + cache: pnpm + - run: pnpm install --frozen-lockfile + - run: pnpm --filter @solid-connect/web exec playwright install --with-deps chromium + - name: Build component documentation + run: pnpm --filter @solid-connect/web run build:storybook + - name: Run story render and interaction tests in Chromium + run: pnpm --filter @solid-connect/web run test:stories + - name: Upload Storybook and failure screenshots + if: always() + uses: actions/upload-artifact@v4 + with: + name: web-storybook + path: | + apps/web/storybook-static/ + apps/web/test-results/storybook/ + retention-days: 7 + if-no-files-found: ignore diff --git a/.gitignore b/.gitignore index 02e8e113c..00e139c38 100644 --- a/.gitignore +++ b/.gitignore @@ -50,3 +50,8 @@ docs/superpowers/ # Turborepo .turbo **/.turbo + +# Browser test and Storybook artifacts +**/playwright-report/ +**/test-results/ +**/storybook-static/ 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..e4795df04 --- /dev/null +++ b/apps/web/.storybook/preview.tsx @@ -0,0 +1,15 @@ +import type { Preview } from "@storybook/nextjs-vite"; +import "../src/styles/globals.css"; + +const preview: Preview = { + parameters: { nextjs: { appDirectory: true }, layout: "padded" }, + decorators: [ + (Story) => ( +
+ +
+ ), + ], + 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..52d105c02 --- /dev/null +++ b/apps/web/e2e/fixtures/server.mjs @@ -0,0 +1,315 @@ +import { createServer } from "node:http"; +import { WebSocket, WebSocketServer } from "ws"; + +// 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: [] }); + if (path.startsWith("/univ-apply-infos/search/")) return json({ univApplyInfoPreviews: [] }); + 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/web-flows.spec.ts b/apps/web/e2e/web-flows.spec.ts new file mode 100644 index 000000000..46d3b8465 --- /dev/null +++ b/apps/web/e2e/web-flows.spec.ts @@ -0,0 +1,93 @@ +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", + ); + await page.getByRole("button", { name: /작년도 합격 점수 확인하러 가기/ }).click(); + await expect(page.getByText("해당 기능은 현재 준비중입니다.")).toBeVisible(); +}); + +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("테스트 학생님은")).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.getByText("제1조 (목적)", { exact: 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("테스트 학생님은")).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/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..4c73ad3a9 --- /dev/null +++ b/apps/web/playwright.config.ts @@ -0,0 +1,38 @@ +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", use: { ...devices["Desktop Chrome"] } }, + { name: "mobile", use: { ...devices["Pixel 7"], viewport: { width: 390, height: 844 } } }, + ], + webServer: [ + { command: "node e2e/fixtures/server.mjs", url: "http://127.0.0.1:43180/__health", reuseExistingServer: false }, + { + 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/app/mentor/chat/[chatId]/_ui/ChatContent/_hooks/useChatListHandler.ts b/apps/web/src/app/mentor/chat/[chatId]/_ui/ChatContent/_hooks/useChatListHandler.ts deleted file mode 100644 index c6b97a6a1..000000000 --- a/apps/web/src/app/mentor/chat/[chatId]/_ui/ChatContent/_hooks/useChatListHandler.ts +++ /dev/null @@ -1,500 +0,0 @@ -import type { Client } from "@stomp/stompjs"; -import { useQueryClient } from "@tanstack/react-query"; -import { useCallback, useEffect, useRef } from "react"; -import { ChatQueryKeys, useGetChatHistories } from "@/apis/chat"; -import useConnectWebSocket from "@/lib/web-socket/useConnectWebSocket"; -import { type ChatMessage, ConnectionStatus } from "@/types/chat"; -import { normalizeImageUrlToUploadCdn } from "@/utils/cdnUrl"; -// --- 프로젝트 내부 의존성 --- -import useInfinityScroll from "@/utils/useInfinityScroll"; - -const BOTTOM_PROXIMITY_THRESHOLD = 80; -const OUTBOUND_TEXT_SCROLL_CONFIRM_TIMEOUT_MS = 5000; -const INITIAL_SCROLL_SETTLE_DELAYS_MS = [100, 350, 800]; - -interface PendingOutboundTextScroll { - id: string; - senderId: number; - content: string; - timeoutId: ReturnType; -} - -const getMessageDedupeKey = (message: ChatMessage): string => { - if (message.id > 0) { - return `id:${message.id}`; - } - - const attachmentKey = message.attachments - .map((attachment) => `${attachment.isImage ? "image" : "file"}:${attachment.url}:${attachment.createdAt}`) - .join(","); - - return `fallback:${message.senderId}:${message.createdAt}:${message.content}:${attachmentKey}`; -}; - -const getImageUrlKeys = (url: string | null | undefined) => { - if (!url) return []; - - const normalizedUrl = normalizeImageUrlToUploadCdn(url); - return Array.from(new Set([url, normalizedUrl].filter((key) => key.length > 0))); -}; - -const normalizePendingText = (content: string) => content.trim(); - -const useChatListHandler = (chatId: number) => { - // --- 1. State 및 Ref 선언 --- - const clientRef = useRef(null); - const messagesEndRef = useRef(null); // 새 메시지 수신 시 자동 스크롤을 위한 ref - const scrollContainerRef = useRef(null); // 실제 스크롤 컨테이너 ref - const hasInitialAutoScrolledRef = useRef(false); - const prevMessageCountRef = useRef(0); - const prevChatIdRef = useRef(chatId); - const shouldForceScrollToBottomRef = useRef(false); - const pendingOutboundTextScrollsRef = useRef([]); - const imagePreviewByUrlRef = useRef>(new Map()); - const objectUrlsRef = useRef([]); - const queryClient = useQueryClient(); - - const scrollToBottom = useCallback(() => { - const container = scrollContainerRef.current; - if (!container) return; - - container.scrollTop = container.scrollHeight; - }, []); - - const removePendingOutboundTextScrolls = useCallback((ids: Set) => { - pendingOutboundTextScrollsRef.current = pendingOutboundTextScrollsRef.current.filter((item) => { - if (!ids.has(item.id)) return true; - - clearTimeout(item.timeoutId); - return false; - }); - }, []); - - const clearPendingOutboundTextScrolls = useCallback(() => { - pendingOutboundTextScrollsRef.current.forEach((item) => clearTimeout(item.timeoutId)); - pendingOutboundTextScrollsRef.current = []; - }, []); - - // --- 2. 하위 Hooks 호출 --- - - // API를 통해 채팅 기록을 페이지 단위로 가져옵니다. - const { - data: chatHistoryPages, - fetchNextPage, - hasNextPage, - isFetchingNextPage, - isLoading, - } = useGetChatHistories(chatId); - - // WebSocket 연결을 관리하고 현재 연결 상태를 받아옵니다. - const { connectionStatus, submittedMessages, setSubmittedMessages } = useConnectWebSocket({ - roomId: chatId, - clientRef, - }); - - const invalidateChatPreviewQueries = useCallback(() => { - queryClient.invalidateQueries({ queryKey: [ChatQueryKeys.chatHistories, chatId], refetchType: "none" }); - queryClient.invalidateQueries({ queryKey: [ChatQueryKeys.chatRooms], refetchType: "none" }); - }, [chatId, queryClient]); - - // 화면 상단에 도달했을 때 이전 채팅 기록을 불러오는 무한 스크롤 Hook입니다. - const { lastElementRef: topDetectorRef } = useInfinityScroll({ - fetchNextPage, - hasNextPage, - isDirectionTop: true, - }); - - // --- 3. Effects --- - - // API로부터 받아온 채팅 기록(페이지 배열)을 단일 메시지 배열로 가공하고 정렬합니다. - useEffect(() => { - if (chatHistoryPages?.pages) { - const allMessages = chatHistoryPages.pages.flatMap((page) => page.content || []); - const sortedMessages = allMessages.sort( - (a, b) => new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime(), - ); - // Deduplicate by id, keeping the last occurrence (chronological order) - const dedupedMessages: ChatMessage[] = []; - const seenIds = new Set(); - for (let i = sortedMessages.length - 1; i >= 0; i--) { - const msg = sortedMessages[i]; - const dedupeKey = getMessageDedupeKey(msg); - if (!seenIds.has(dedupeKey)) { - seenIds.add(dedupeKey); - dedupedMessages.unshift(msg); - } - } - setSubmittedMessages(dedupedMessages); - } - }, [chatHistoryPages, setSubmittedMessages]); - - useEffect(() => { - if (imagePreviewByUrlRef.current.size === 0) return; - - const matchedPreviewUrls = new Set(); - let hasChanged = false; - - const messagesWithPreviews = submittedMessages.map((message) => { - let hasAttachmentChanged = false; - const attachments = message.attachments.map((attachment) => { - if (!attachment.isImage || attachment.previewUrl || attachment.isOptimistic) { - return attachment; - } - - const previewUrl = [...getImageUrlKeys(attachment.url), ...getImageUrlKeys(attachment.thumbnailUrl)] - .map((key) => imagePreviewByUrlRef.current.get(key)) - .find((value): value is string => Boolean(value)); - - if (!previewUrl) { - return attachment; - } - - hasAttachmentChanged = true; - matchedPreviewUrls.add(previewUrl); - return { - ...attachment, - previewUrl, - }; - }); - - if (!hasAttachmentChanged) { - return message; - } - - hasChanged = true; - return { - ...message, - attachments, - }; - }); - - if (matchedPreviewUrls.size === 0) return; - - const reconciledMessages = messagesWithPreviews.filter((message) => { - const shouldRemoveOptimisticMessage = - message.attachments.length > 0 && - message.attachments.every( - (attachment) => - attachment.isOptimistic && attachment.previewUrl && matchedPreviewUrls.has(attachment.previewUrl), - ); - - if (shouldRemoveOptimisticMessage) { - hasChanged = true; - return false; - } - - return true; - }); - - if (!hasChanged) return; - - imagePreviewByUrlRef.current.forEach((previewUrl, key) => { - if (matchedPreviewUrls.has(previewUrl)) { - imagePreviewByUrlRef.current.delete(key); - } - }); - - setSubmittedMessages(reconciledMessages); - }, [submittedMessages, setSubmittedMessages]); - - // 채팅방 전환 시 자동 스크롤 상태를 초기화합니다. - useEffect(() => { - if (prevChatIdRef.current === chatId) return; - - objectUrlsRef.current.forEach((url) => URL.revokeObjectURL(url)); - objectUrlsRef.current = []; - imagePreviewByUrlRef.current.clear(); - clearPendingOutboundTextScrolls(); - prevChatIdRef.current = chatId; - hasInitialAutoScrolledRef.current = false; - prevMessageCountRef.current = 0; - shouldForceScrollToBottomRef.current = false; - }, [chatId, clearPendingOutboundTextScrolls]); - - // 초기 메시지가 렌더링되면 최초 1회만 하단으로 이동합니다. - useEffect(() => { - if (isLoading || submittedMessages.length === 0 || hasInitialAutoScrolledRef.current) { - return; - } - - const rafIds: number[] = []; - const timeoutIds: ReturnType[] = []; - - const scheduleScrollToBottom = () => { - const rafId = requestAnimationFrame(() => { - scrollToBottom(); - }); - rafIds.push(rafId); - }; - - scheduleScrollToBottom(); - const doubleRafId = requestAnimationFrame(() => { - scrollToBottom(); - scheduleScrollToBottom(); - }); - rafIds.push(doubleRafId); - - INITIAL_SCROLL_SETTLE_DELAYS_MS.forEach((delayMs) => { - const timeoutId = setTimeout(scrollToBottom, delayMs); - timeoutIds.push(timeoutId); - }); - - hasInitialAutoScrolledRef.current = true; - prevMessageCountRef.current = submittedMessages.length; - - return () => { - rafIds.forEach((rafId) => cancelAnimationFrame(rafId)); - timeoutIds.forEach((timeoutId) => clearTimeout(timeoutId)); - }; - }, [isLoading, scrollToBottom, submittedMessages.length]); - - // 신규 메시지 도착 시 하단 근처라면 유지하고, 내가 보낸 메시지는 현재 위치와 무관하게 하단으로 이동합니다. - useEffect(() => { - if (isLoading || isFetchingNextPage) return; - - const currentMessageCount = submittedMessages.length; - const prevMessageCount = prevMessageCountRef.current; - const container = scrollContainerRef.current; - - if (!container) { - prevMessageCountRef.current = currentMessageCount; - return; - } - - if (currentMessageCount <= prevMessageCount) { - prevMessageCountRef.current = currentMessageCount; - return; - } - - if (!hasInitialAutoScrolledRef.current) { - prevMessageCountRef.current = currentMessageCount; - return; - } - - const newMessages = submittedMessages.slice(prevMessageCount); - const matchedPendingOutboundTextIds = new Set(); - const hasConfirmedOutboundText = newMessages.some((message) => { - const pendingOutboundText = pendingOutboundTextScrollsRef.current.find( - (item) => item.senderId === message.senderId && item.content === normalizePendingText(message.content), - ); - - if (!pendingOutboundText) return false; - - matchedPendingOutboundTextIds.add(pendingOutboundText.id); - return true; - }); - - if (matchedPendingOutboundTextIds.size > 0) { - removePendingOutboundTextScrolls(matchedPendingOutboundTextIds); - } - - const distanceFromBottom = container.scrollHeight - container.scrollTop - container.clientHeight; - const shouldForceScrollToBottom = shouldForceScrollToBottomRef.current || hasConfirmedOutboundText; - - if (shouldForceScrollToBottom || distanceFromBottom <= BOTTOM_PROXIMITY_THRESHOLD) { - const rafId = requestAnimationFrame(() => { - scrollToBottom(); - }); - - shouldForceScrollToBottomRef.current = false; - prevMessageCountRef.current = currentMessageCount; - - return () => cancelAnimationFrame(rafId); - } - - prevMessageCountRef.current = currentMessageCount; - }, [isLoading, isFetchingNextPage, removePendingOutboundTextScrolls, scrollToBottom, submittedMessages]); - - // --- 4. Handler 함수 --- - - /** 텍스트 메시지를 WebSocket을 통해 서버로 전송합니다. */ - const sendTextMessage = useCallback( - (content: string, senderId: number) => { - const normalizedContent = normalizePendingText(content); - if (normalizedContent === "") return; // 빈 메시지 전송 방지 - - if (clientRef.current?.active && connectionStatus === ConnectionStatus.Connected) { - // WebSocket으로 메시지 전송 - clientRef.current.publish({ - destination: `/publish/chat/${chatId}`, - body: JSON.stringify({ content, senderId }), - }); - - const pendingTextScrollId = `${Date.now()}-${Math.random()}`; - const timeoutId = setTimeout(() => { - pendingOutboundTextScrollsRef.current = pendingOutboundTextScrollsRef.current.filter( - (item) => item.id !== pendingTextScrollId, - ); - }, OUTBOUND_TEXT_SCROLL_CONFIRM_TIMEOUT_MS); - - pendingOutboundTextScrollsRef.current.push({ - id: pendingTextScrollId, - senderId, - content: normalizedContent, - timeoutId, - }); - - requestAnimationFrame(scrollToBottom); - invalidateChatPreviewQueries(); - } else { - // 여기에 메시지 전송 실패에 대한 UI 피드백 로직을 추가할 수 있습니다. (e.g., alert, toast) - } - }, - [chatId, connectionStatus, invalidateChatPreviewQueries, scrollToBottom], - ); // chatId와 connectionStatus가 변경될 경우에만 함수를 재생성 - - const sendImageMessage = useCallback( - (imageUrls: string[], previewUrls: string[] = []) => { - if (imageUrls.length === 0) return false; - - if (clientRef.current?.active && connectionStatus === ConnectionStatus.Connected) { - imageUrls.forEach((imageUrl, index) => { - const previewUrl = previewUrls[index]; - if (!previewUrl) return; - - getImageUrlKeys(imageUrl).forEach((key) => { - imagePreviewByUrlRef.current.set(key, previewUrl); - }); - }); - - clientRef.current.publish({ - destination: `/publish/chat/${chatId}/image`, - body: JSON.stringify({ imageUrls }), - }); - invalidateChatPreviewQueries(); - - return true; - } - - return false; - }, - [chatId, connectionStatus, invalidateChatPreviewQueries], - ); - - /** 이미지 파일만 미리보기 메시지로 추가 */ - const addImageMessagePreview = useCallback( - (files: File[], senderId: number) => { - const newMessages: ChatMessage[] = []; - const previewUrls: string[] = []; - files.forEach((file) => { - if (file.type.startsWith("image/")) { - const tempId = Date.now() + Math.random(); - const imageUrl = URL.createObjectURL(file); - objectUrlsRef.current.push(imageUrl); - previewUrls.push(imageUrl); - newMessages.push({ - id: tempId, - content: `이미지: ${file.name}`, - senderId, - createdAt: new Date().toISOString(), - attachments: [ - { - id: tempId, - isImage: true, - url: imageUrl, - thumbnailUrl: imageUrl, - previewUrl: imageUrl, - isOptimistic: true, - createdAt: new Date().toISOString(), - }, - ], - }); - } - }); - if (newMessages.length > 0) { - shouldForceScrollToBottomRef.current = true; - setSubmittedMessages((prev) => [...prev, ...newMessages]); - } - return previewUrls; - }, - [setSubmittedMessages], - ); - - const removeImageMessagePreviews = useCallback( - (previewUrls: string[]) => { - if (previewUrls.length === 0) return; - - const previewUrlSet = new Set(previewUrls); - previewUrls.forEach((previewUrl) => { - URL.revokeObjectURL(previewUrl); - }); - objectUrlsRef.current = objectUrlsRef.current.filter((objectUrl) => !previewUrlSet.has(objectUrl)); - - setSubmittedMessages((prev) => - prev.filter( - (message) => - message.attachments.length === 0 || - !message.attachments.every( - (attachment) => - attachment.isOptimistic && attachment.previewUrl && previewUrlSet.has(attachment.previewUrl), - ), - ), - ); - }, - [setSubmittedMessages], - ); - - /** 이미지가 아닌 파일을 미리보기 메시지로 추가 */ - const addFileMessagePreview = useCallback( - (files: File[], senderId: number) => { - const newMessages: ChatMessage[] = []; - files.forEach((file) => { - if (!file.type.startsWith("image/")) { - const tempId = Date.now() + Math.random(); - newMessages.push({ - id: tempId, - content: `파일: ${file.name}`, - senderId, - createdAt: new Date().toISOString(), - attachments: [ - { - id: tempId, - isImage: false, - url: URL.createObjectURL(file), - thumbnailUrl: null, - createdAt: new Date().toISOString(), - }, - ], - }); - } - }); - if (newMessages.length > 0) { - shouldForceScrollToBottomRef.current = true; - setSubmittedMessages((prev) => [...prev, ...newMessages]); - } - }, - [setSubmittedMessages], - ); - - // Cleanup created object URLs on unmount - useEffect(() => { - return () => { - objectUrlsRef.current.forEach((url) => URL.revokeObjectURL(url)); - objectUrlsRef.current = []; - clearPendingOutboundTextScrolls(); - }; - }, [clearPendingOutboundTextScrolls]); - - // --- 5. 최종 반환 객체 --- - return { - // Data & State - messages: submittedMessages, - connectionStatus, - isLoading, // 초기 데이터 로딩 상태 - isFetchingNextPage, // 이전 기록 로딩 상태 - - // Refs - scrollContainerRef, // 실제 스크롤 컨테이너 ref - messagesEndRef, // 자동 스크롤을 위한 ref - topDetectorRef, // 무한 스크롤 감지를 위한 ref - - // Handlers - sendTextMessage, - sendImageMessage, - addImageMessagePreview, - removeImageMessagePreviews, - addFileMessagePreview, - }; -}; - -export default useChatListHandler; diff --git a/apps/web/src/app/mentor/chat/[chatId]/_ui/ChatContent/_hooks/usePutChatReadHandler.ts b/apps/web/src/app/mentor/chat/[chatId]/_ui/ChatContent/_hooks/usePutChatReadHandler.ts deleted file mode 100644 index f40124ccd..000000000 --- a/apps/web/src/app/mentor/chat/[chatId]/_ui/ChatContent/_hooks/usePutChatReadHandler.ts +++ /dev/null @@ -1,14 +0,0 @@ -import { useEffect } from "react"; - -import { usePutChatRead } from "@/apis/chat"; - -const usePutChatReadHandler = (chatId: number) => { - const { mutate: putChatRead } = usePutChatRead(); - - useEffect(() => { - if (chatId) { - putChatRead(chatId); - } - }, [chatId, putChatRead]); -}; -export default usePutChatReadHandler; diff --git a/apps/web/src/app/mentor/chat/[chatId]/_ui/ChatContent/_ui/ChatInputBar/ChatInputBar.stories.tsx b/apps/web/src/app/mentor/chat/[chatId]/_ui/ChatContent/_ui/ChatInputBar/ChatInputBar.stories.tsx new file mode 100644 index 000000000..1a2459b57 --- /dev/null +++ b/apps/web/src/app/mentor/chat/[chatId]/_ui/ChatContent/_ui/ChatInputBar/ChatInputBar.stories.tsx @@ -0,0 +1,84 @@ +import type { Meta, StoryObj } from "@storybook/nextjs-vite"; +import { expect, fn, userEvent, within } from "storybook/test"; +import ChatInputBar from "./index"; + +const meta = { + title: "Chat/Composer", + component: ChatInputBar, + args: { onSendText: fn(async () => true), onSendImages: fn(async () => true) }, +} satisfies Meta; +export default meta; +type Story = StoryObj; +export const Ready: Story = { + play: async ({ canvasElement, args }) => { + const canvas = within(canvasElement); + await expect(canvas.getByRole("button", { name: "메시지 보내기" })).toBeDisabled(); + await userEvent.type(canvas.getByRole("textbox", { name: "메시지" }), "교환학생 준비가 궁금해요"); + await userEvent.click(canvas.getByRole("button", { name: "메시지 보내기" })); + await expect(args.onSendText).toHaveBeenCalledWith("교환학생 준비가 궁금해요"); + await expect(canvas.getByRole("textbox")).toHaveValue(""); + }, +}; +export const Offline: Story = { + args: { canSend: false }, + play: async ({ canvasElement, args }) => { + const canvas = within(canvasElement); + await userEvent.type(canvas.getByRole("textbox"), "연결되면 보낼 초안"); + await expect(canvas.getByRole("textbox")).toHaveValue("연결되면 보낼 초안"); + await expect(canvas.getByRole("button", { name: "메시지 보내기" })).toBeDisabled(); + await expect(args.onSendText).not.toHaveBeenCalled(); + }, +}; +export const AccessDenied: Story = { args: { disabled: true, canSend: false } }; +export const SendFailure: Story = { + args: { onSendText: fn(async () => false) }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + await userEvent.type(canvas.getByRole("textbox"), "다시 보낼 메시지"); + await userEvent.click(canvas.getByRole("button", { name: "메시지 보내기" })); + await expect(canvas.getByRole("alert")).toBeVisible(); + await expect(canvas.getByRole("textbox")).toHaveValue("다시 보낼 메시지"); + }, +}; +export const ImageSelection: Story = { + play: async ({ canvasElement, args }) => { + const canvas = within(canvasElement); + await userEvent.type(canvas.getByRole("textbox"), "이미지와 별도로 남길 초안"); + const file = new File(["image"], "campus.png", { type: "image/png" }); + await userEvent.upload(canvas.getByLabelText("채팅 이미지 선택"), file); + await expect(canvas.getByRole("list", { name: "선택한 이미지" })).toBeVisible(); + await userEvent.click(canvas.getByRole("button", { name: "이미지 1장 보내기" })); + await expect(args.onSendImages).toHaveBeenCalledWith([file]); + await expect(canvas.getByRole("textbox")).toHaveValue("이미지와 별도로 남길 초안"); + await expect(canvas.queryByRole("list", { name: "선택한 이미지" })).not.toBeInTheDocument(); + }, +}; + +export const ImageUploadFailure: Story = { + args: { onSendImages: fn(async () => false) }, + play: async ({ canvasElement, args }) => { + const canvas = within(canvasElement); + await userEvent.type(canvas.getByRole("textbox"), "업로드 실패에도 남길 초안"); + const file = new File([''], "campus.svg", { + type: "image/svg+xml", + }); + await userEvent.upload(canvas.getByLabelText("채팅 이미지 선택"), file); + await userEvent.click(canvas.getByRole("button", { name: "이미지 1장 보내기" })); + await expect(args.onSendImages).toHaveBeenCalledWith([file]); + await expect(canvas.getByRole("alert")).toBeVisible(); + await expect(canvas.getByRole("list", { name: "선택한 이미지" })).toBeVisible(); + await expect(canvas.getByRole("textbox")).toHaveValue("업로드 실패에도 남길 초안"); + }, +}; + +export const Uploading: Story = { + args: { onSendText: fn(() => new Promise(() => {})) }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + await userEvent.type(canvas.getByRole("textbox"), "전송 중에는 중복 입력을 막아요"); + await userEvent.click(canvas.getByRole("button", { name: "메시지 보내기" })); + await expect(canvas.getByRole("form")).toHaveAttribute("aria-busy", "true"); + await expect(canvas.getByRole("textbox")).toBeDisabled(); + await expect(canvas.getByRole("button", { name: "메시지 보내기" })).toBeDisabled(); + }, +}; diff --git a/apps/web/src/app/mentor/chat/[chatId]/_ui/ChatContent/_ui/ChatInputBar/_hooks/useFileHandler.ts b/apps/web/src/app/mentor/chat/[chatId]/_ui/ChatContent/_ui/ChatInputBar/_hooks/useFileHandler.ts deleted file mode 100644 index 2ec4a7788..000000000 --- a/apps/web/src/app/mentor/chat/[chatId]/_ui/ChatContent/_ui/ChatInputBar/_hooks/useFileHandler.ts +++ /dev/null @@ -1,67 +0,0 @@ -import { zodResolver } from "@hookform/resolvers/zod"; -import { useRef, useState } from "react"; -import { useForm } from "react-hook-form"; -import { z } from "zod"; - -// Zod 스키마 정의 -const fileSchema = z.object({ - files: z.array(z.instanceof(File)).min(1, "파일을 선택해주세요"), -}); - -// 타입 정의 -type FileForm = z.infer; - -type UseFileHandlerProps = { - onSendFiles: (data: { files: File[] }) => void; - setIsAttachmentOptionsOpen: (isOpen: boolean) => void; -}; - -const useFileHandler = ({ onSendFiles, setIsAttachmentOptionsOpen }: UseFileHandlerProps) => { - // 파일용 폼 - const fileForm = useForm({ - resolver: zodResolver(fileSchema), - }); - - // state - const [selectedFiles, setSelectedFiles] = useState([]); - - // ref - const fileInputRef = useRef(null); - - // 파일이 있는지 여부 - const hasFiles = selectedFiles.length > 0; - - const handleFileChange = (e: React.ChangeEvent) => { - const files = e.target.files; - if (files) { - const fileArray = Array.from(files); - setSelectedFiles(fileArray); - // 파일 선택 시 첨부파일 옵션 닫기 - setIsAttachmentOptionsOpen(false); - } - }; - - const handleFileClick = () => { - // 파일 선택 트리거 - fileInputRef.current?.click(); - }; - - const onSubmitFiles = (data: FileForm) => { - onSendFiles(data); - setSelectedFiles([]); - if (fileInputRef.current) fileInputRef.current.value = ""; - }; - - return { - fileInputRef, - selectedFiles, - setSelectedFiles, - fileForm, - onSubmitFiles, - hasFiles, - handleFileChange, - handleFileClick, - }; -}; - -export default useFileHandler; diff --git a/apps/web/src/app/mentor/chat/[chatId]/_ui/ChatContent/_ui/ChatInputBar/_hooks/useImageHandler.ts b/apps/web/src/app/mentor/chat/[chatId]/_ui/ChatContent/_ui/ChatInputBar/_hooks/useImageHandler.ts deleted file mode 100644 index 16fb17388..000000000 --- a/apps/web/src/app/mentor/chat/[chatId]/_ui/ChatContent/_ui/ChatInputBar/_hooks/useImageHandler.ts +++ /dev/null @@ -1,67 +0,0 @@ -import { zodResolver } from "@hookform/resolvers/zod"; -import { useRef, useState } from "react"; -import { useForm } from "react-hook-form"; -import { z } from "zod"; - -// Zod 스키마 정의 -const imageSchema = z.object({ - images: z.array(z.instanceof(File)).min(1, "이미지를 선택해주세요"), -}); - -// 타입 정의 -type ImageForm = z.infer; - -type UseImageHandlerProps = { - onSendImages: (data: { images: File[] }) => void; - setIsAttachmentOptionsOpen: (isOpen: boolean) => void; -}; - -const useImageHandler = ({ onSendImages, setIsAttachmentOptionsOpen }: UseImageHandlerProps) => { - // 이미지용 폼 - const imageForm = useForm({ - resolver: zodResolver(imageSchema), - }); - - // state - const [selectedImages, setSelectedImages] = useState([]); - - // ref - const imageInputRef = useRef(null); - - // 이미지가 있는지 여부 - const hasImages = selectedImages.length > 0; - - const handleImageChange = (e: React.ChangeEvent) => { - const files = e.target.files; - if (files) { - const fileArray = Array.from(files); - setSelectedImages(fileArray); - // 이미지 선택 시 첨부파일 옵션 닫기 - setIsAttachmentOptionsOpen(false); - } - }; - - const handleAlbumClick = () => { - // 이미지 파일 선택 트리거 - imageInputRef.current?.click(); - }; - - const onSubmitImages = (data: ImageForm) => { - onSendImages(data); - setSelectedImages([]); - if (imageInputRef.current) imageInputRef.current.value = ""; - }; - - return { - imageInputRef, - selectedImages, - setSelectedImages, - imageForm, - onSubmitImages, - hasImages, - handleImageChange, - handleAlbumClick, - }; -}; - -export default useImageHandler; diff --git a/apps/web/src/app/mentor/chat/[chatId]/_ui/ChatContent/_ui/ChatInputBar/_hooks/useMessageHandler.ts b/apps/web/src/app/mentor/chat/[chatId]/_ui/ChatContent/_ui/ChatInputBar/_hooks/useMessageHandler.ts deleted file mode 100644 index e3a9502f6..000000000 --- a/apps/web/src/app/mentor/chat/[chatId]/_ui/ChatContent/_ui/ChatInputBar/_hooks/useMessageHandler.ts +++ /dev/null @@ -1,41 +0,0 @@ -import { zodResolver } from "@hookform/resolvers/zod"; -import { useForm } from "react-hook-form"; -import { z } from "zod"; - -// Zod 스키마 정의 -const messageSchema = z.object({ - message: z.string().min(1, "메시지를 입력해주세요").trim(), -}); - -// 타입 정의 -type MessageForm = z.infer; - -type UseMessageHandlerProps = { - onSendMessage: (data: { message: string }) => void; -}; - -const useMessageHandler = ({ onSendMessage }: UseMessageHandlerProps) => { - // 텍스트 메시지용 폼 - const messageForm = useForm({ - resolver: zodResolver(messageSchema), - defaultValues: { message: "" }, - }); - - const watchedMessage = messageForm.watch("message", ""); - const isMessageEmpty = !watchedMessage?.trim(); - - const onSubmitMessage = (data: MessageForm) => { - onSendMessage({ - message: data.message, - }); - messageForm.reset(); - }; - - return { - messageForm, - onSubmitMessage, - isMessageEmpty, - }; -}; - -export default useMessageHandler; diff --git a/apps/web/src/app/mentor/chat/[chatId]/_ui/ChatContent/_ui/ChatInputBar/index.dom.test.tsx b/apps/web/src/app/mentor/chat/[chatId]/_ui/ChatContent/_ui/ChatInputBar/index.dom.test.tsx new file mode 100644 index 000000000..4200a782b --- /dev/null +++ b/apps/web/src/app/mentor/chat/[chatId]/_ui/ChatContent/_ui/ChatInputBar/index.dom.test.tsx @@ -0,0 +1,190 @@ +// @vitest-environment jsdom +import { act, createEvent, fireEvent, render, screen } from "@testing-library/react/pure"; +import { beforeEach, expect, it, vi } from "vitest"; +import ChatInputBar from "."; + +vi.mock("@/public/svgs/mentor", () => ({ + IconAlbum: () => , + IconDirectMessage: () => , +})); + +const createObjectURL = vi.fn<(file: Blob) => string>(); +const revokeObjectURL = vi.fn<(url: string) => void>(); + +beforeEach(() => { + createObjectURL.mockReset().mockImplementation(() => `blob:preview-${createObjectURL.mock.calls.length}`); + revokeObjectURL.mockReset(); + vi.stubGlobal("URL", Object.assign(class extends URL {}, { createObjectURL, revokeObjectURL })); +}); + +const getMessage = () => screen.getByRole("textbox", { name: "메시지" }) as HTMLTextAreaElement; +const getForm = () => screen.getByRole("form", { name: "채팅 메시지 작성" }); +const chooseImages = (files: File[]) => + fireEvent.change(screen.getByLabelText("채팅 이미지 선택"), { target: { files } }); + +it("does not publish whitespace through Enter or form submission", () => { + const onSendText = vi.fn().mockResolvedValue(true); + render(); + fireEvent.change(getMessage(), { target: { value: " \n " } }); + fireEvent.keyDown(getMessage(), { key: "Enter" }); + fireEvent.submit(getForm()); + expect(onSendText).not.toHaveBeenCalled(); + expect(getMessage().value).toBe(" \n "); +}); + +it("keeps IME confirmation and Shift+Enter separate from sending", async () => { + const onSendText = vi.fn().mockResolvedValue(true); + render(); + fireEvent.change(getMessage(), { target: { value: " 안녕하세요\n반갑습니다 " } }); + fireEvent.compositionStart(getMessage()); + fireEvent.keyDown(getMessage(), { key: "Enter", isComposing: true }); + fireEvent.compositionEnd(getMessage()); + fireEvent.keyDown(getMessage(), { key: "Enter", keyCode: 229 }); + const lineBreak = createEvent.keyDown(getMessage(), { key: "Enter", shiftKey: true }); + fireEvent(getMessage(), lineBreak); + expect(lineBreak.defaultPrevented).toBe(false); + expect(onSendText).not.toHaveBeenCalled(); + + await act(async () => { + fireEvent.keyDown(getMessage(), { key: "Enter" }); + }); + expect(onSendText).toHaveBeenCalledExactlyOnceWith("안녕하세요\n반갑습니다"); + expect(getMessage().value).toBe(""); +}); + +it("locks immediately while sending and preserves text after a refused send", async () => { + let finishSend: (accepted: boolean) => void = () => {}; + const onSendText = vi.fn().mockImplementation( + () => + new Promise((resolve) => { + finishSend = resolve; + }), + ); + render(); + fireEvent.change(getMessage(), { target: { value: "원래 메시지" } }); + + act(() => { + fireEvent.submit(getForm()); + fireEvent.submit(getForm()); + fireEvent.change(getMessage(), { target: { value: "요청 중 변경" } }); + }); + expect(onSendText).toHaveBeenCalledTimes(1); + expect(getMessage().disabled).toBe(true); + expect(getMessage().value).toBe("원래 메시지"); + + await act(async () => { + finishSend(false); + }); + expect(getMessage().value).toBe("원래 메시지"); + expect(getMessage().disabled).toBe(false); + expect(screen.getByRole("alert").textContent).toContain("입력 내용은 유지했어요"); +}); + +it("keeps a thrown send draft and clears only after a later accepted send", async () => { + const onSendText = vi.fn().mockRejectedValueOnce(new Error("failed")).mockResolvedValueOnce(true); + render(); + fireEvent.change(getMessage(), { target: { value: "다시 보낼 내용" } }); + + await act(async () => { + fireEvent.submit(getForm()); + }); + expect(screen.getByRole("alert").textContent).toContain("입력 내용을 유지"); + expect(getMessage().value).toBe("다시 보낼 내용"); + + await act(async () => { + fireEvent.submit(getForm()); + }); + expect(getMessage().value).toBe(""); + expect(screen.queryByRole("alert")).toBeNull(); +}); + +it("retains images and their stable previews on failure and releases them after success", async () => { + const onSendImages = vi.fn().mockResolvedValueOnce(false).mockResolvedValueOnce(true); + const onSendText = vi.fn().mockResolvedValue(true); + const props = { onSendText, onSendImages }; + const { rerender, unmount } = render(); + fireEvent.change(getMessage(), { target: { value: "이미지 다음에 보낼 글" } }); + const first = new File(["first"], "first.png", { type: "image/png" }); + const second = new File(["second"], "second.jpg", { type: "image/jpeg" }); + chooseImages([first, second]); + expect(createObjectURL).toHaveBeenCalledTimes(2); + const firstPreview = screen.getByRole("img", { name: "first.png" }).getAttribute("src"); + + rerender(); + rerender(); + expect(createObjectURL).toHaveBeenCalledTimes(2); + expect(screen.getByRole("img", { name: "first.png" }).getAttribute("src")).toBe(firstPreview); + + fireEvent.click(screen.getByRole("button", { name: "second.jpg 제거" })); + expect(revokeObjectURL).toHaveBeenCalledExactlyOnceWith("blob:preview-2"); + await act(async () => { + fireEvent.click(screen.getByRole("button", { name: "이미지 1장 보내기" })); + }); + expect(onSendImages).toHaveBeenLastCalledWith([first]); + expect(screen.getByRole("img", { name: "first.png" })).toBeTruthy(); + expect(revokeObjectURL).toHaveBeenCalledTimes(1); + + await act(async () => { + fireEvent.click(screen.getByRole("button", { name: "이미지 1장 보내기" })); + }); + expect(screen.queryByRole("list", { name: "선택한 이미지" })).toBeNull(); + expect(getMessage().value).toBe("이미지 다음에 보낼 글"); + expect(onSendText).not.toHaveBeenCalled(); + expect(revokeObjectURL).toHaveBeenCalledTimes(2); + + chooseImages([first]); + unmount(); + expect(revokeObjectURL).toHaveBeenCalledTimes(3); +}); + +it("prevents duplicate image submissions and keeps selection immutable while uploading", async () => { + let finishSend: (accepted: boolean) => void = () => {}; + const onSendImages = vi.fn().mockImplementation( + () => + new Promise((resolve) => { + finishSend = resolve; + }), + ); + render(); + const first = new File(["image"], "image.png", { type: "image/png" }); + chooseImages([first]); + fireEvent.submit(getForm()); + fireEvent.submit(getForm()); + fireEvent.click(screen.getByRole("button", { name: "image.png 제거" })); + chooseImages([new File(["other"], "other.png", { type: "image/png" })]); + expect(onSendImages).toHaveBeenCalledExactlyOnceWith([first]); + expect(screen.queryByRole("img", { name: "other.png" })).toBeNull(); + expect((screen.getByRole("button", { name: "image.png 제거" }) as HTMLButtonElement).disabled).toBe(true); + + await act(async () => { + finishSend(false); + }); + expect(screen.getByRole("img", { name: "image.png" })).toBeTruthy(); +}); + +it("rejects non-image selection and never exposes a file-send action", () => { + const onSendImages = vi.fn().mockResolvedValue(true); + render(); + chooseImages([new File(["pdf"], "document.pdf", { type: "application/pdf" })]); + expect(screen.getByRole("alert").textContent).toBe("이미지만 보낼 수 있어요."); + expect(screen.queryByRole("list", { name: "선택한 이미지" })).toBeNull(); + expect(onSendImages).not.toHaveBeenCalled(); +}); + +it("keeps composing while disconnected and sends the same draft after reconnect", async () => { + const props = { onSendText: vi.fn().mockResolvedValue(true), onSendImages: vi.fn().mockResolvedValue(true) }; + const { rerender } = render(); + fireEvent.change(getMessage(), { target: { value: "연결 복구 중 작성한 메시지" } }); + expect(getMessage().disabled).toBe(false); + expect((screen.getByRole("button", { name: "메시지 보내기" }) as HTMLButtonElement).disabled).toBe(true); + fireEvent.submit(getForm()); + expect(props.onSendText).not.toHaveBeenCalled(); + + rerender(); + expect(getMessage().value).toBe("연결 복구 중 작성한 메시지"); + await act(async () => { + fireEvent.submit(getForm()); + }); + expect(props.onSendText).toHaveBeenCalledExactlyOnceWith("연결 복구 중 작성한 메시지"); + expect(getMessage().value).toBe(""); +}); diff --git a/apps/web/src/app/mentor/chat/[chatId]/_ui/ChatContent/_ui/ChatInputBar/index.tsx b/apps/web/src/app/mentor/chat/[chatId]/_ui/ChatContent/_ui/ChatInputBar/index.tsx index 53346ffd5..2f2c2e7a3 100644 --- a/apps/web/src/app/mentor/chat/[chatId]/_ui/ChatContent/_ui/ChatInputBar/index.tsx +++ b/apps/web/src/app/mentor/chat/[chatId]/_ui/ChatContent/_ui/ChatInputBar/index.tsx @@ -1,231 +1,212 @@ -import clsx from "clsx"; -import { useState } from "react"; -import Image from "@/components/ui/FallbackImage"; -import { IconAlbum, IconDirectMessage, IconFile, IconPlusK200, IconXWhite } from "@/public/svgs/mentor"; -import { downloadLocalFile } from "@/utils/fileUtils"; -import useFileHandler from "./_hooks/useFileHandler"; -import useImageHandler from "./_hooks/useImageHandler"; -import useMessageHandler from "./_hooks/useMessageHandler"; +"use client"; + +import { type ChangeEvent, type FormEvent, type KeyboardEvent, useEffect, useId, useRef, useState } from "react"; +import { IconAlbum, IconDirectMessage } from "@/public/svgs/mentor"; interface ChatInputBarProps { - onSendMessage: (data: { message: string }) => void; - onSendImages: (data: { images: File[] }) => void; - onSendFiles: (data: { files: File[] }) => void; + disabled?: boolean; + canSend?: boolean; + onSendText: (content: string) => Promise; + onSendImages: (files: File[]) => Promise; +} + +interface SelectedImage { + id: number; + file: File; } -const ChatInputBar = ({ onSendMessage, onSendImages, onSendFiles }: ChatInputBarProps) => { - const [isAttachmentOptionsOpen, setIsAttachmentOptionsOpen] = useState(false); - - const { messageForm, onSubmitMessage, isMessageEmpty } = useMessageHandler({ onSendMessage }); - const { - imageInputRef, - selectedImages, - setSelectedImages, - imageForm, - onSubmitImages, - hasImages, - handleImageChange, - handleAlbumClick, - } = useImageHandler({ onSendImages, setIsAttachmentOptionsOpen }); - - const { - fileInputRef, - selectedFiles, - setSelectedFiles, - fileForm, - onSubmitFiles, - hasFiles, - handleFileChange, - handleFileClick, - } = useFileHandler({ onSendFiles, setIsAttachmentOptionsOpen }); - - const hasAttachments = hasImages || hasFiles; - - // 통합 전송 함수 - DirectMessage 버튼 클릭 시 호출 - const handleSend = () => { - if (hasImages) { - imageForm.setValue("images", selectedImages); - imageForm.handleSubmit(onSubmitImages)(); - } else if (hasFiles) { - fileForm.setValue("files", selectedFiles); - fileForm.handleSubmit(onSubmitFiles)(); - } else if (!isMessageEmpty) { - messageForm.handleSubmit(onSubmitMessage)(); +const ImagePreview = ({ file }: { file: File }) => { + const [previewUrl, setPreviewUrl] = useState(null); + + useEffect( + function createImagePreview() { + const objectUrl = URL.createObjectURL(file); + setPreviewUrl(objectUrl); + + return function releaseImagePreview() { + URL.revokeObjectURL(objectUrl); + }; + }, + [file], + ); + + return previewUrl ? ( + {file.name} + ) : ( +
+ ); +}; + +const ChatInputBar = ({ disabled = false, canSend = true, onSendText, onSendImages }: ChatInputBarProps) => { + const [message, setMessage] = useState(""); + const [selectedImages, setSelectedImages] = useState([]); + const [isSubmitting, setIsSubmitting] = useState(false); + const [error, setError] = useState(null); + const imageInputRef = useRef(null); + const isSubmittingRef = useRef(false); + const isComposingRef = useRef(false); + const nextImageIdRef = useRef(0); + const hintId = useId(); + const hasImages = selectedImages.length > 0; + const isLocked = disabled || isSubmitting; + + const send = async () => { + if (disabled || !canSend || isSubmittingRef.current || isComposingRef.current) return; + + const content = message.trim(); + if (!hasImages && !content) return; + + isSubmittingRef.current = true; + setIsSubmitting(true); + setError(null); + + try { + const isAccepted = hasImages + ? await onSendImages(selectedImages.map((image) => image.file)) + : await onSendText(content); + + if (isAccepted) { + if (hasImages) { + setSelectedImages([]); + } else { + setMessage(""); + } + } else { + setError("연결을 확인한 뒤 다시 보내주세요. 입력 내용은 유지했어요."); + } + } catch { + setError("전송하지 못했어요. 입력 내용을 유지했으니 다시 시도해주세요."); + } finally { + isSubmittingRef.current = false; + setIsSubmitting(false); } }; + const handleSubmit = (event: FormEvent) => { + event.preventDefault(); + void send(); + }; + + const handleKeyDown = (event: KeyboardEvent) => { + if (event.key !== "Enter" || event.shiftKey) return; + if (event.nativeEvent.isComposing || event.nativeEvent.keyCode === 229 || isComposingRef.current) return; + + event.preventDefault(); + void send(); + }; + + const handleImageChange = (event: ChangeEvent) => { + const files = Array.from(event.target.files ?? []); + event.target.value = ""; + if (disabled || isSubmittingRef.current || files.length === 0) return; + + const images = files.filter((file) => file.type.startsWith("image/")); + setError(images.length === files.length ? null : "이미지만 보낼 수 있어요."); + setSelectedImages((previous) => [...previous, ...images.map((file) => ({ id: nextImageIdRef.current++, file }))]); + }; + return ( -
- {/* 숨겨진 파일 입력 요소들 */} - - -
- {/* 선택된 파일들 미리보기 */} - {(selectedImages.length > 0 || selectedFiles.length > 0) && ( -
- {selectedImages.map((file, index) => ( -
- - -
- ))} - {selectedFiles.map((file, index) => ( -
- + + {hasImages && ( +
+

+ 이미지 {selectedImages.length}장만 전송해요. 작성한 메시지는 유지돼요. +

+
    + {selectedImages.map(({ id, file }) => ( +
  • + -
+ ))} -
- )} - - {/* 메시지 폼 */} -
{ - e.preventDefault(); - handleSend(); + +
+ )} + +
+ +