Initial Commit
This commit is contained in:
@@ -0,0 +1,879 @@
|
||||
# Snap 백엔드 연결 Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** snap 목업(sessions/conversations/stream)을 걷어내고 base-backend(000)의 세션 기반 chat 계약(`/api/v1/chat`)에 real 연결한다 — 세션 목록/조회/생성 + SSE 토큰 스트리밍 + LLM 자동제목, 그리고 .NET 웹뷰용 Bearer 토큰 주입 seam.
|
||||
|
||||
**Architecture:** 기존 seam 에 real 구현을 끼운다. `snap.api.ts` 3함수는 mock 반환 → `apiGet/apiList/apiPost` 호출로 교체, `snap.stream.ts` 는 `mockStream` 제거하고 `streamLLM("/chat/stream")` 로 직결. `streamLLM` 에 `title` 이벤트 파싱 추가, 인증은 `getAccessToken()` provider 한 곳을 만들어 `client.ts`(axios 요청 인터셉터)와 `sse.ts`(헤더)가 참조 — 토큰 있으면 `Authorization: Bearer`, 없으면(=오늘) 쿠키 폴백. UI/store/컴포넌트는 안 건드린다.
|
||||
|
||||
**Tech Stack:** React 18 + TS + Vite + Zustand + TanStack Query + axios + `@microsoft/fetch-event-source` + vitest + @testing-library/react.
|
||||
|
||||
## Global Constraints
|
||||
|
||||
- 작업 디렉토리: `D:\project\021.code-assistant-v2\2_frontend` (모든 경로 이 기준 상대).
|
||||
- git: 브랜치 `feat/snap-backend-connect` (이미 생성됨) → **태스크마다 자기 파일만 add 후 커밋, push 금지**.
|
||||
- 게이트: 자기 파일 `npx eslint <파일>` 클린 + 해당 테스트 통과. Wave 경계에서 `npm run build`.
|
||||
- baseline lint 주의: 프로젝트 전체 `npm run lint` 에 이번 작업 무관 기존 에러/경고 있음 — 스코프는 항상 `npx eslint <자기 파일>`.
|
||||
- 응답·주석 톤: 한글 반말 (CLAUDE.md 0번). 백엔드 계약은 camelCase(CamelModel 정렬).
|
||||
- import 별칭: `@/` → `src/`.
|
||||
- dev 서버: `npm run dev` → http://localhost:15173. 백엔드: base-backend 로컬 8001, Vite proxy `/api`→8001.
|
||||
- 테스트: vitest. 네트워크/스트림은 mock (실제 백엔드 호출 금지). 실제 백엔드 연동은 §검증에서 육안.
|
||||
- **건드리지 말 것**: `features/chat/*`, `chatStore`, `DashboardLayout`, snap 의 store/components/pages. 이번 수정 파일은 아래 8개 태스크에 명시된 것만.
|
||||
|
||||
---
|
||||
|
||||
## 파일 구조 (수정/신규/삭제)
|
||||
|
||||
```
|
||||
신규:
|
||||
src/lib/auth/tokenProvider.ts # accessToken 단일 소스 (get/set)
|
||||
src/lib/auth/tokenProvider.test.ts
|
||||
src/lib/streaming/streamLLM.test.ts # title 이벤트 테스트 (신규)
|
||||
src/features/snap/hooks/useSnapChat.test.tsx
|
||||
|
||||
수정:
|
||||
src/lib/streaming/streamLLM.ts # title 이벤트 + onTitle 핸들러
|
||||
src/lib/api/client.ts # 요청 인터셉터 Bearer 주입
|
||||
src/lib/api/client.test.ts # Bearer 테스트 추가
|
||||
src/lib/streaming/sse.ts # Authorization 헤더 (토큰 있을 때)
|
||||
src/lib/streaming/sse.test.ts # Bearer 테스트 추가
|
||||
src/features/snap/api/snap.api.ts # mock → apiGet/apiList/apiPost
|
||||
src/features/snap/api/snap.api.test.tsx # mock client 로 재작성
|
||||
src/features/snap/api/snap.stream.ts # USE_MOCK 제거, streamLLM 직결 + onTitle
|
||||
src/features/snap/api/snap.stream.test.ts# streamLLM mock 으로 재작성
|
||||
src/features/snap/hooks/useSnapChat.ts # title 캐시 패치 + 409 처리 + 중복 가드
|
||||
|
||||
삭제:
|
||||
src/features/snap/mock/sessions.ts
|
||||
src/features/snap/mock/conversations.ts
|
||||
src/features/snap/mock/stream.ts
|
||||
src/features/snap/mock/mock.test.ts
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Wave 실행 맵
|
||||
|
||||
| Wave | Task | 병렬성 | 파일(disjoint) | 의존 |
|
||||
|---|---|---|---|---|
|
||||
| W1 | T1 tokenProvider | 병렬 | `lib/auth/tokenProvider.ts(+test)` | — |
|
||||
| W1 | T2 streamLLM title | 병렬 | `lib/streaming/streamLLM.ts(+test)` | — |
|
||||
| W1 | T3 snap.api real | 병렬 | `features/snap/api/snap.api.ts(+test)` | — |
|
||||
| W2 | T4 client Bearer | 병렬 | `lib/api/client.ts(+test)` | T1 |
|
||||
| W2 | T5 sse Bearer | 병렬 | `lib/streaming/sse.ts(+test)` | T1 |
|
||||
| W2 | T6 snap.stream real+title | 병렬 | `features/snap/api/snap.stream.ts(+test)` | T2 |
|
||||
| W3 | T7 useSnapChat title+409 | 병렬 | `features/snap/hooks/useSnapChat.ts(+test)` | T3,T6 |
|
||||
| W3 | T8 목업 제거 | 병렬 | `mock/*.ts` 삭제 | T3,T6 |
|
||||
|
||||
같은 wave = 파일 disjoint + 시그니처 import 없음. W1 셋은 서로 안 엮임. W2 셋은 각각 W1 산출(getAccessToken/onTitle)만 소비하고 서로 안 엮임. W3 둘은 파일 disjoint.
|
||||
|
||||
---
|
||||
|
||||
## Task T1: tokenProvider (accessToken 단일 소스)
|
||||
|
||||
**Files:**
|
||||
- Create: `src/lib/auth/tokenProvider.ts`
|
||||
- Test: `src/lib/auth/tokenProvider.test.ts`
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: 없음
|
||||
- Produces: `getAccessToken(): string | null`, `setAccessToken(token: string | null): void`
|
||||
|
||||
- [ ] **Step 1: 실패 테스트 작성** — `src/lib/auth/tokenProvider.test.ts`
|
||||
|
||||
```ts
|
||||
import { describe, it, expect, afterEach } from "vitest"
|
||||
import { getAccessToken, setAccessToken } from "./tokenProvider"
|
||||
|
||||
afterEach(() => setAccessToken(null))
|
||||
|
||||
describe("tokenProvider", () => {
|
||||
it("기본값은 null (쿠키 모드)", () => {
|
||||
expect(getAccessToken()).toBeNull()
|
||||
})
|
||||
it("set 하면 그 토큰을 돌려준다", () => {
|
||||
setAccessToken("abc")
|
||||
expect(getAccessToken()).toBe("abc")
|
||||
})
|
||||
it("null 로 다시 초기화 가능", () => {
|
||||
setAccessToken("abc")
|
||||
setAccessToken(null)
|
||||
expect(getAccessToken()).toBeNull()
|
||||
})
|
||||
})
|
||||
```
|
||||
|
||||
- [ ] **Step 2: 실패 확인**
|
||||
|
||||
Run: `npx vitest run src/lib/auth/tokenProvider.test.ts`
|
||||
Expected: FAIL — `Failed to resolve import "./tokenProvider"`
|
||||
|
||||
- [ ] **Step 3: 구현** — `src/lib/auth/tokenProvider.ts`
|
||||
|
||||
```ts
|
||||
// .NET 웹뷰 호스트가 주입할 accessToken 의 단일 소스.
|
||||
// 브라우저(오늘)에선 기본 null → 쿠키 인증. 나중에 .NET 이 setAccessToken 으로 채우면
|
||||
// client.ts / sse.ts 가 Authorization: Bearer 로 전환한다.
|
||||
let accessToken: string | null = null
|
||||
|
||||
export function getAccessToken(): string | null {
|
||||
return accessToken
|
||||
}
|
||||
|
||||
export function setAccessToken(token: string | null): void {
|
||||
accessToken = token
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 4: 통과 확인**
|
||||
|
||||
Run: `npx vitest run src/lib/auth/tokenProvider.test.ts`
|
||||
Expected: PASS (3 tests)
|
||||
|
||||
- [ ] **Step 5: lint + 커밋**
|
||||
|
||||
```bash
|
||||
npx eslint src/lib/auth/tokenProvider.ts src/lib/auth/tokenProvider.test.ts
|
||||
git add src/lib/auth/tokenProvider.ts src/lib/auth/tokenProvider.test.ts
|
||||
git commit -m "feat(auth): add accessToken provider seam for .NET webview"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task T2: streamLLM 에 title 이벤트 추가
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/lib/streaming/streamLLM.ts`
|
||||
- Test: `src/lib/streaming/streamLLM.test.ts` (신규)
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: 없음 (기존 `streamSSE`)
|
||||
- Produces: `LLMStreamHandlers.onTitle?: (title: string) => void` — T6 이 소비
|
||||
|
||||
- [ ] **Step 1: 실패 테스트 작성** — `src/lib/streaming/streamLLM.test.ts`
|
||||
|
||||
```ts
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest"
|
||||
import { streamLLM } from "./streamLLM"
|
||||
import * as sse from "./sse"
|
||||
|
||||
vi.mock("./sse", () => ({ streamSSE: vi.fn() }))
|
||||
|
||||
describe("streamLLM title 이벤트", () => {
|
||||
beforeEach(() => vi.clearAllMocks())
|
||||
|
||||
it("title 이벤트를 onTitle 로 전달한다", async () => {
|
||||
const streamSSE = sse.streamSSE as ReturnType<typeof vi.fn>
|
||||
streamSSE.mockImplementation(async (opts: { onEvent: (e: { event: string; data: string }) => void }) => {
|
||||
opts.onEvent({ event: "title", data: JSON.stringify({ title: "판매문서 조인" }) })
|
||||
})
|
||||
const onTitle = vi.fn()
|
||||
await streamLLM({
|
||||
path: "/chat/stream",
|
||||
body: {},
|
||||
handlers: { onToken: vi.fn(), onDone: vi.fn(), onTitle },
|
||||
})
|
||||
expect(onTitle).toHaveBeenCalledWith("판매문서 조인")
|
||||
})
|
||||
|
||||
it("malformed title 은 조용히 무시(throw 안 함)", async () => {
|
||||
const streamSSE = sse.streamSSE as ReturnType<typeof vi.fn>
|
||||
streamSSE.mockImplementation(async (opts: { onEvent: (e: { event: string; data: string }) => void }) => {
|
||||
opts.onEvent({ event: "title", data: "not-json" })
|
||||
})
|
||||
const onTitle = vi.fn()
|
||||
await expect(
|
||||
streamLLM({ path: "/x", body: {}, handlers: { onToken: vi.fn(), onDone: vi.fn(), onTitle } }),
|
||||
).resolves.toBeUndefined()
|
||||
expect(onTitle).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
```
|
||||
|
||||
- [ ] **Step 2: 실패 확인**
|
||||
|
||||
Run: `npx vitest run src/lib/streaming/streamLLM.test.ts`
|
||||
Expected: FAIL — `onTitle` 이 호출 안 됨 (title 분기 없음)
|
||||
|
||||
- [ ] **Step 3: 구현** — `src/lib/streaming/streamLLM.ts`
|
||||
|
||||
3-1. `LLMStreamHandlers` 인터페이스에 `onTitle` 추가 (기존 `onToken` 위, 라인 61 근처):
|
||||
|
||||
```ts
|
||||
/** `title` 이벤트 — 첫 메시지 후 LLM 이 지은 세션 제목 (선택) */
|
||||
onTitle?: (title: string) => void
|
||||
/** `token` 이벤트 — 토큰 조각 누적해서 표시 */
|
||||
onToken: (delta: string) => void
|
||||
```
|
||||
|
||||
3-2. `ErrorPayload` 인터페이스 아래(라인 87 근처)에 payload 타입 추가:
|
||||
|
||||
```ts
|
||||
interface TitlePayload {
|
||||
title: string
|
||||
}
|
||||
```
|
||||
|
||||
3-3. `onEvent` 스위치에서 `result` 분기 다음에 `title` 분기 추가 (라인 126 `else if (e.event === "clarify")` 앞):
|
||||
|
||||
```ts
|
||||
} else if (e.event === "title") {
|
||||
try {
|
||||
const payload = JSON.parse(e.data) as TitlePayload
|
||||
if (typeof payload.title === "string") handlers.onTitle?.(payload.title)
|
||||
} catch {
|
||||
// 제목 갱신은 부가정보 — 실패해도 토큰 흐름엔 영향 없음
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 4: 통과 확인**
|
||||
|
||||
Run: `npx vitest run src/lib/streaming/streamLLM.test.ts`
|
||||
Expected: PASS (2 tests)
|
||||
|
||||
- [ ] **Step 5: lint + 커밋**
|
||||
|
||||
```bash
|
||||
npx eslint src/lib/streaming/streamLLM.ts src/lib/streaming/streamLLM.test.ts
|
||||
git add src/lib/streaming/streamLLM.ts src/lib/streaming/streamLLM.test.ts
|
||||
git commit -m "feat(streaming): parse SSE title event into onTitle handler"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task T3: snap.api 목업 → real HTTP
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/features/snap/api/snap.api.ts`
|
||||
- Test: `src/features/snap/api/snap.api.test.tsx` (재작성)
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: `apiGet<T>(path)`, `apiPost<T>(path, body?)`, `apiList<T>(path)` from `@/lib/api/client`
|
||||
- Produces: `useSessionList()` → `SnapSession[]` (queryKey `["snap","sessions"]`), `useSessionMessages(id)` → `SnapSessionDetail` (queryKey `["snap","session",id]`), `useCreateSession()` → mutation `SnapSession`
|
||||
|
||||
- [ ] **Step 1: 테스트 재작성 (실패)** — `src/features/snap/api/snap.api.test.tsx` 전체 교체
|
||||
|
||||
```tsx
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest"
|
||||
import { renderHook, waitFor } from "@testing-library/react"
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query"
|
||||
import { useSessionList, useSessionMessages } from "./snap.api"
|
||||
import * as client from "@/lib/api/client"
|
||||
|
||||
vi.mock("@/lib/api/client")
|
||||
|
||||
function wrapper({ children }: { children: React.ReactNode }) {
|
||||
const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } })
|
||||
return <QueryClientProvider client={qc}>{children}</QueryClientProvider>
|
||||
}
|
||||
|
||||
const SESSION = {
|
||||
id: "s1",
|
||||
title: "t",
|
||||
titleLlm: null,
|
||||
isGenerating: false,
|
||||
createdAt: "2026-07-18T00:00:00Z",
|
||||
updatedAt: "2026-07-18T00:00:00Z",
|
||||
}
|
||||
|
||||
describe("snap.api (real)", () => {
|
||||
beforeEach(() => vi.clearAllMocks())
|
||||
|
||||
it("useSessionList 는 GET /chat/sessions 의 items 를 반환", async () => {
|
||||
vi.mocked(client.apiList).mockResolvedValue({ items: [SESSION], meta: null, counts: 1 })
|
||||
const { result } = renderHook(() => useSessionList(), { wrapper })
|
||||
await waitFor(() => expect(result.current.isSuccess).toBe(true))
|
||||
expect(client.apiList).toHaveBeenCalledWith("/chat/sessions")
|
||||
expect(result.current.data![0].id).toBe("s1")
|
||||
})
|
||||
|
||||
it("useSessionMessages 는 GET /chat/sessions/{id}/messages 를 호출", async () => {
|
||||
vi.mocked(client.apiGet).mockResolvedValue({ ...SESSION, messages: [] })
|
||||
const { result } = renderHook(() => useSessionMessages("s1"), { wrapper })
|
||||
await waitFor(() => expect(result.current.isSuccess).toBe(true))
|
||||
expect(client.apiGet).toHaveBeenCalledWith("/chat/sessions/s1/messages")
|
||||
expect(result.current.data!.messages).toEqual([])
|
||||
})
|
||||
})
|
||||
```
|
||||
|
||||
- [ ] **Step 2: 실패 확인**
|
||||
|
||||
Run: `npx vitest run src/features/snap/api/snap.api.test.tsx`
|
||||
Expected: FAIL — 현재 `snap.api.ts` 가 mock 반환이라 `client.apiList` 미호출
|
||||
|
||||
- [ ] **Step 3: 구현** — `src/features/snap/api/snap.api.ts` 전체 교체
|
||||
|
||||
```ts
|
||||
import { useQuery, useMutation } from "@tanstack/react-query"
|
||||
import { apiGet, apiList, apiPost } from "@/lib/api/client"
|
||||
import type { SnapSession, SnapSessionDetail } from "../contract/types"
|
||||
|
||||
export function useSessionList() {
|
||||
return useQuery({
|
||||
queryKey: ["snap", "sessions"],
|
||||
queryFn: async (): Promise<SnapSession[]> => {
|
||||
const { items } = await apiList<SnapSession>("/chat/sessions")
|
||||
return items
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export function useSessionMessages(id: string) {
|
||||
return useQuery({
|
||||
queryKey: ["snap", "session", id],
|
||||
enabled: !!id,
|
||||
queryFn: (): Promise<SnapSessionDetail> =>
|
||||
apiGet<SnapSessionDetail>(`/chat/sessions/${id}/messages`),
|
||||
})
|
||||
}
|
||||
|
||||
export function useCreateSession() {
|
||||
return useMutation({
|
||||
mutationFn: (): Promise<SnapSession> => apiPost<SnapSession>("/chat/sessions", {}),
|
||||
})
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 4: 통과 확인**
|
||||
|
||||
Run: `npx vitest run src/features/snap/api/snap.api.test.tsx`
|
||||
Expected: PASS (2 tests)
|
||||
|
||||
- [ ] **Step 5: lint + 커밋**
|
||||
|
||||
```bash
|
||||
npx eslint src/features/snap/api/snap.api.ts src/features/snap/api/snap.api.test.tsx
|
||||
git add src/features/snap/api/snap.api.ts src/features/snap/api/snap.api.test.tsx
|
||||
git commit -m "feat(snap): wire session list/detail/create to real backend"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task T4: client.ts 요청 인터셉터 Bearer 주입
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/lib/api/client.ts`
|
||||
- Test: `src/lib/api/client.test.ts` (describe 블록 추가)
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: `getAccessToken()` from `@/lib/auth/tokenProvider` (T1)
|
||||
- Produces: `apiClient` 가 토큰 있으면 `Authorization: Bearer <t>` 헤더 부착
|
||||
|
||||
- [ ] **Step 1: 실패 테스트 추가** — `src/lib/api/client.test.ts` 파일 끝에 아래 추가. 파일 상단 import 에 `setAccessToken`, `apiClient`, `afterEach` 가 없으면 추가.
|
||||
|
||||
```ts
|
||||
import { afterEach } from "vitest"
|
||||
import { apiClient } from "./client"
|
||||
import { setAccessToken } from "@/lib/auth/tokenProvider"
|
||||
|
||||
describe("apiClient Bearer 주입", () => {
|
||||
afterEach(() => {
|
||||
setAccessToken(null)
|
||||
delete apiClient.defaults.adapter
|
||||
})
|
||||
|
||||
it("토큰 있으면 Authorization: Bearer 헤더를 붙인다", async () => {
|
||||
setAccessToken("tok123")
|
||||
let seen: unknown
|
||||
apiClient.defaults.adapter = async (config) => {
|
||||
seen = config.headers.Authorization
|
||||
return { data: { success: true, data: null }, status: 200, statusText: "OK", headers: {}, config } as never
|
||||
}
|
||||
await apiClient.get("/ping")
|
||||
expect(seen).toBe("Bearer tok123")
|
||||
})
|
||||
|
||||
it("토큰 없으면 Authorization 를 안 붙인다(쿠키 모드)", async () => {
|
||||
setAccessToken(null)
|
||||
let seen: unknown = "sentinel"
|
||||
apiClient.defaults.adapter = async (config) => {
|
||||
seen = config.headers.Authorization
|
||||
return { data: { success: true, data: null }, status: 200, statusText: "OK", headers: {}, config } as never
|
||||
}
|
||||
await apiClient.get("/ping")
|
||||
expect(seen).toBeUndefined()
|
||||
})
|
||||
})
|
||||
```
|
||||
|
||||
- [ ] **Step 2: 실패 확인**
|
||||
|
||||
Run: `npx vitest run src/lib/api/client.test.ts`
|
||||
Expected: FAIL — 토큰 넣어도 `Authorization` 이 undefined (인터셉터 없음)
|
||||
|
||||
- [ ] **Step 3: 구현** — `src/lib/api/client.ts`
|
||||
|
||||
3-1. import 추가 (파일 상단 import 그룹):
|
||||
|
||||
```ts
|
||||
import { getAccessToken } from "@/lib/auth/tokenProvider"
|
||||
```
|
||||
|
||||
3-2. `apiClient` 생성 직후(라인 26 `})` 다음), 응답 인터셉터 위에 요청 인터셉터 추가:
|
||||
|
||||
```ts
|
||||
// .NET 웹뷰 호스트가 토큰을 주입하면 Bearer 로, 아니면(=오늘) 쿠키로.
|
||||
apiClient.interceptors.request.use((config) => {
|
||||
const token = getAccessToken()
|
||||
if (token) config.headers.Authorization = `Bearer ${token}`
|
||||
return config
|
||||
})
|
||||
```
|
||||
|
||||
- [ ] **Step 4: 통과 확인**
|
||||
|
||||
Run: `npx vitest run src/lib/api/client.test.ts`
|
||||
Expected: PASS (기존 테스트 + 새 2개)
|
||||
|
||||
- [ ] **Step 5: lint + 커밋**
|
||||
|
||||
```bash
|
||||
npx eslint src/lib/api/client.ts src/lib/api/client.test.ts
|
||||
git add src/lib/api/client.ts src/lib/api/client.test.ts
|
||||
git commit -m "feat(api): inject Bearer token from provider when present"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task T5: sse.ts Authorization 헤더
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/lib/streaming/sse.ts`
|
||||
- Test: `src/lib/streaming/sse.test.ts` (테스트 추가)
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: `getAccessToken()` from `@/lib/auth/tokenProvider` (T1)
|
||||
- Produces: SSE 요청이 토큰 있으면 `Authorization: Bearer` 헤더 포함
|
||||
|
||||
- [ ] **Step 1: 실패 테스트 추가** — `src/lib/streaming/sse.test.ts`
|
||||
|
||||
1-1. 상단 import 에 추가:
|
||||
|
||||
```ts
|
||||
import { afterEach } from "vitest"
|
||||
import { setAccessToken } from "@/lib/auth/tokenProvider"
|
||||
```
|
||||
|
||||
1-2. `describe("streamSSE", ...)` 안, 기존 `beforeEach` 아래에 추가:
|
||||
|
||||
```ts
|
||||
afterEach(() => setAccessToken(null))
|
||||
|
||||
it("토큰 있으면 Authorization: Bearer 헤더를 추가", async () => {
|
||||
setAccessToken("tok")
|
||||
const fetchEventSource = fes.fetchEventSource as ReturnType<typeof vi.fn>
|
||||
fetchEventSource.mockResolvedValue(undefined)
|
||||
|
||||
await streamSSE({ path: "/chat/stream", body: { message: "hi" }, onEvent: vi.fn() })
|
||||
|
||||
const [, opts] = fetchEventSource.mock.calls[0]
|
||||
expect(opts.headers.Authorization).toBe("Bearer tok")
|
||||
})
|
||||
```
|
||||
|
||||
- [ ] **Step 2: 실패 확인**
|
||||
|
||||
Run: `npx vitest run src/lib/streaming/sse.test.ts`
|
||||
Expected: FAIL — 새 테스트에서 `Authorization` 이 undefined
|
||||
|
||||
- [ ] **Step 3: 구현** — `src/lib/streaming/sse.ts`
|
||||
|
||||
3-1. import 추가 (라인 2 `env` import 아래):
|
||||
|
||||
```ts
|
||||
import { getAccessToken } from "@/lib/auth/tokenProvider"
|
||||
```
|
||||
|
||||
3-2. `open()` 함수 시작부에서 헤더를 토큰 유무로 구성 (기존 `await fetchEventSource(...)` 의 인라인 `headers` 를 교체):
|
||||
|
||||
```ts
|
||||
async function open(opts: StreamSSEOptions, retried: boolean): Promise<void> {
|
||||
const token = getAccessToken()
|
||||
const headers: Record<string, string> = { "Content-Type": "application/json" }
|
||||
if (token) headers.Authorization = `Bearer ${token}`
|
||||
await fetchEventSource(`${env.apiBaseUrl}${opts.path}`, {
|
||||
method: "POST",
|
||||
credentials: "include",
|
||||
headers,
|
||||
body: JSON.stringify(opts.body),
|
||||
signal: opts.signal,
|
||||
openWhenHidden: true,
|
||||
onopen: async (res) => {
|
||||
if (res.ok) return
|
||||
if (res.status === 401 && !retried) {
|
||||
throw new RetryableUnauthorized()
|
||||
}
|
||||
throw new Error(`SSE open failed: ${res.status}`)
|
||||
},
|
||||
onmessage: (msg) => {
|
||||
opts.onEvent({ event: msg.event || "message", data: msg.data, id: msg.id })
|
||||
},
|
||||
onerror: (err) => {
|
||||
if (err instanceof RetryableUnauthorized) throw err
|
||||
// 자동 reconnect 방지: throw하면 종료
|
||||
opts.onError?.(err)
|
||||
throw err
|
||||
},
|
||||
})
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 4: 통과 확인**
|
||||
|
||||
Run: `npx vitest run src/lib/streaming/sse.test.ts`
|
||||
Expected: PASS (기존 3개 + 새 1개). 기존 "쿠키 기반" 테스트의 `not.toHaveProperty("Authorization")` 는 토큰 null 이라 그대로 통과.
|
||||
|
||||
- [ ] **Step 5: lint + 커밋**
|
||||
|
||||
```bash
|
||||
npx eslint src/lib/streaming/sse.ts src/lib/streaming/sse.test.ts
|
||||
git add src/lib/streaming/sse.ts src/lib/streaming/sse.test.ts
|
||||
git commit -m "feat(streaming): add Authorization header to SSE when token present"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task T6: snap.stream USE_MOCK 제거 + streamLLM 직결 + onTitle
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/features/snap/api/snap.stream.ts`
|
||||
- Test: `src/features/snap/api/snap.stream.test.ts` (재작성)
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: `streamLLM` (T2 의 `onTitle` 포함)
|
||||
- Produces: `SnapStreamHandlers { onToken, onDone, onTitle?, onError? }`, `snapStream(req, handlers, opts?)` — T7 이 소비
|
||||
|
||||
- [ ] **Step 1: 테스트 재작성 (실패)** — `src/features/snap/api/snap.stream.test.ts` 전체 교체
|
||||
|
||||
```ts
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest"
|
||||
import { snapStream } from "./snap.stream"
|
||||
import * as streaming from "@/lib/streaming"
|
||||
|
||||
vi.mock("@/lib/streaming", () => ({ streamLLM: vi.fn() }))
|
||||
|
||||
describe("snapStream (real)", () => {
|
||||
beforeEach(() => vi.clearAllMocks())
|
||||
|
||||
it("streamLLM 을 /chat/stream 계약으로 호출한다", async () => {
|
||||
const streamLLM = streaming.streamLLM as ReturnType<typeof vi.fn>
|
||||
streamLLM.mockResolvedValue(undefined)
|
||||
|
||||
await snapStream({ sessionId: "s1", content: "hi" }, { onToken: vi.fn(), onDone: vi.fn() })
|
||||
|
||||
expect(streamLLM).toHaveBeenCalledTimes(1)
|
||||
const arg = streamLLM.mock.calls[0][0]
|
||||
expect(arg.path).toBe("/chat/stream")
|
||||
expect(arg.body).toEqual({ sessionId: "s1", content: "hi" })
|
||||
})
|
||||
|
||||
it("onToken/onTitle/onDone/onError 를 그대로 배선한다", async () => {
|
||||
const streamLLM = streaming.streamLLM as ReturnType<typeof vi.fn>
|
||||
streamLLM.mockImplementation(
|
||||
async (opts: { handlers: { onToken: (d: string) => void; onTitle?: (t: string) => void; onDone: (p: object) => void } }) => {
|
||||
opts.handlers.onToken("a")
|
||||
opts.handlers.onTitle?.("제목")
|
||||
opts.handlers.onDone({})
|
||||
},
|
||||
)
|
||||
const onToken = vi.fn()
|
||||
const onTitle = vi.fn()
|
||||
const onDone = vi.fn()
|
||||
|
||||
await snapStream({ sessionId: "s1", content: "x" }, { onToken, onDone, onTitle })
|
||||
|
||||
expect(onToken).toHaveBeenCalledWith("a")
|
||||
expect(onTitle).toHaveBeenCalledWith("제목")
|
||||
expect(onDone).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
```
|
||||
|
||||
- [ ] **Step 2: 실패 확인**
|
||||
|
||||
Run: `npx vitest run src/features/snap/api/snap.stream.test.ts`
|
||||
Expected: FAIL — 현재 `USE_MOCK=true` 라 `streamLLM` 미호출
|
||||
|
||||
- [ ] **Step 3: 구현** — `src/features/snap/api/snap.stream.ts` 전체 교체
|
||||
|
||||
```ts
|
||||
import { streamLLM } from "@/lib/streaming"
|
||||
import type { SnapStreamRequest } from "../contract/types"
|
||||
|
||||
export interface SnapStreamHandlers {
|
||||
onToken: (delta: string) => void
|
||||
onDone: () => void
|
||||
onTitle?: (title: string) => void
|
||||
onError?: (e: Error) => void
|
||||
}
|
||||
|
||||
// base-backend POST /chat/stream (SSE) 직결. token/done/error/title 이벤트 소비.
|
||||
export function snapStream(
|
||||
req: SnapStreamRequest,
|
||||
handlers: SnapStreamHandlers,
|
||||
opts?: { signal?: AbortSignal },
|
||||
): Promise<void> {
|
||||
return streamLLM({
|
||||
path: "/chat/stream",
|
||||
body: req,
|
||||
signal: opts?.signal,
|
||||
handlers: {
|
||||
onToken: handlers.onToken,
|
||||
onDone: () => handlers.onDone(),
|
||||
onTitle: handlers.onTitle,
|
||||
onError: handlers.onError,
|
||||
},
|
||||
})
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 4: 통과 확인**
|
||||
|
||||
Run: `npx vitest run src/features/snap/api/snap.stream.test.ts`
|
||||
Expected: PASS (2 tests)
|
||||
|
||||
- [ ] **Step 5: lint + 커밋**
|
||||
|
||||
```bash
|
||||
npx eslint src/features/snap/api/snap.stream.ts src/features/snap/api/snap.stream.test.ts
|
||||
git add src/features/snap/api/snap.stream.ts src/features/snap/api/snap.stream.test.ts
|
||||
git commit -m "feat(snap): stream via real /chat/stream and forward title event"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task T7: useSnapChat — title 캐시 패치 + 409 처리 + 중복 가드
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/features/snap/hooks/useSnapChat.ts`
|
||||
- Test: `src/features/snap/hooks/useSnapChat.test.tsx` (신규)
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: `snapStream` + `SnapStreamHandlers.onTitle` (T6), queryKeys `["snap","sessions"]`/`["snap","session",id]` (T3), `useSnapChatStore`
|
||||
- Produces: `useSnapChat(sessionId)` → `{ send, stop }` (시그니처 불변)
|
||||
|
||||
- [ ] **Step 1: 실패 테스트 작성** — `src/features/snap/hooks/useSnapChat.test.tsx`
|
||||
|
||||
```tsx
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest"
|
||||
import { renderHook } from "@testing-library/react"
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query"
|
||||
import { toast } from "sonner"
|
||||
import { useSnapChat } from "./useSnapChat"
|
||||
import * as stream from "../api/snap.stream"
|
||||
import { useSnapChatStore } from "../store/snapChatStore"
|
||||
|
||||
vi.mock("../api/snap.stream")
|
||||
vi.mock("sonner", () => ({ toast: { error: vi.fn() } }))
|
||||
|
||||
let qc: QueryClient
|
||||
function wrapper({ children }: { children: React.ReactNode }) {
|
||||
return <QueryClientProvider client={qc}>{children}</QueryClientProvider>
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
qc = new QueryClient()
|
||||
useSnapChatStore.getState().reset()
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
describe("useSnapChat", () => {
|
||||
it("onTitle 이 세션 목록 캐시의 title 을 갱신한다", async () => {
|
||||
qc.setQueryData(
|
||||
["snap", "sessions"],
|
||||
[{ id: "s1", title: null, titleLlm: null, isGenerating: false, createdAt: "", updatedAt: "" }],
|
||||
)
|
||||
vi.mocked(stream.snapStream).mockImplementation(async (_req, handlers) => {
|
||||
handlers.onTitle?.("새 제목")
|
||||
})
|
||||
const { result } = renderHook(() => useSnapChat("s1"), { wrapper })
|
||||
await result.current.send("hi")
|
||||
const list = qc.getQueryData(["snap", "sessions"]) as { id: string; title: string | null }[]
|
||||
expect(list[0].title).toBe("새 제목")
|
||||
})
|
||||
|
||||
it("409 에러면 '이미 생성 중' 안내 toast", async () => {
|
||||
vi.mocked(stream.snapStream).mockImplementation(async (_req, handlers) => {
|
||||
handlers.onError?.(new Error("SSE open failed: 409"))
|
||||
})
|
||||
const { result } = renderHook(() => useSnapChat("s1"), { wrapper })
|
||||
await result.current.send("hi")
|
||||
expect(toast.error).toHaveBeenCalledWith(expect.stringContaining("이미 생성 중"))
|
||||
})
|
||||
|
||||
it("open 실패로 snapStream 이 reject 해도 send 는 throw 하지 않는다", async () => {
|
||||
// sse.ts 는 open 실패 시 onError 를 부른 뒤 promise 도 reject 한다(이중 신호).
|
||||
// send 는 그 rejection 을 삼켜 unhandled rejection 을 막아야 한다.
|
||||
vi.mocked(stream.snapStream).mockRejectedValue(new Error("SSE open failed: 500"))
|
||||
const { result } = renderHook(() => useSnapChat("s1"), { wrapper })
|
||||
await expect(result.current.send("hi")).resolves.toBeUndefined()
|
||||
})
|
||||
|
||||
it("이미 스트리밍 중이면 두 번째 send 는 무시(중복 가드)", async () => {
|
||||
useSnapChatStore.getState().setStreaming(true)
|
||||
const { result } = renderHook(() => useSnapChat("s1"), { wrapper })
|
||||
await result.current.send("hi")
|
||||
expect(stream.snapStream).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
```
|
||||
|
||||
- [ ] **Step 2: 실패 확인**
|
||||
|
||||
Run: `npx vitest run src/features/snap/hooks/useSnapChat.test.tsx`
|
||||
Expected: FAIL — title 미갱신 / 409 분기 없음 / 가드 없음
|
||||
|
||||
- [ ] **Step 3: 구현** — `src/features/snap/hooks/useSnapChat.ts` 전체 교체
|
||||
|
||||
```ts
|
||||
import { useCallback } from "react"
|
||||
import { toast } from "sonner"
|
||||
import { useQueryClient, type QueryClient } from "@tanstack/react-query"
|
||||
import { useSnapChatStore } from "../store/snapChatStore"
|
||||
import { snapStream } from "../api/snap.stream"
|
||||
import type { SnapSession, SnapSessionDetail } from "../contract/types"
|
||||
|
||||
// title 이벤트 → react-query 세션 캐시(목록 + 상세)의 title 갱신.
|
||||
function patchSessionTitle(
|
||||
queryClient: QueryClient,
|
||||
sessionId: string,
|
||||
title: string,
|
||||
): void {
|
||||
queryClient.setQueryData<SnapSession[]>(["snap", "sessions"], (prev) =>
|
||||
prev?.map((s) => (s.id === sessionId ? { ...s, title } : s)),
|
||||
)
|
||||
queryClient.setQueryData<SnapSessionDetail>(["snap", "session", sessionId], (prev) =>
|
||||
prev ? { ...prev, title } : prev,
|
||||
)
|
||||
}
|
||||
|
||||
/** store + snapStream 배선 — 전송/중단. sessionId 는 현재 열린 세션. */
|
||||
export function useSnapChat(sessionId: string) {
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
const send = useCallback(
|
||||
async (text: string) => {
|
||||
const store = useSnapChatStore.getState()
|
||||
if (store.isStreaming) return // 이 클라이언트가 이미 생성 중 — 중복 전송 차단
|
||||
store.currentController?.abort()
|
||||
store.addUserMessage(text)
|
||||
store.startAssistantMessage()
|
||||
store.setStreaming(true)
|
||||
const ctrl = new AbortController()
|
||||
store.setController(ctrl)
|
||||
try {
|
||||
await snapStream(
|
||||
{ sessionId, content: text },
|
||||
{
|
||||
onToken: (d) => useSnapChatStore.getState().appendChunk(d),
|
||||
onDone: () => {},
|
||||
onTitle: (title) => patchSessionTitle(queryClient, sessionId, title),
|
||||
onError: (e) => {
|
||||
if (e.message.includes("409")) {
|
||||
toast.error("이미 생성 중인 세션이야. 잠깐 기다렸다 다시 보내.")
|
||||
} else {
|
||||
toast.error(`스트림 오류: ${e.message}`)
|
||||
}
|
||||
},
|
||||
},
|
||||
{ signal: ctrl.signal },
|
||||
)
|
||||
} catch {
|
||||
// sse.ts 는 open 실패(409/5xx) 시 onError 를 부른 뒤 promise 도 reject 한다.
|
||||
// 오류 표시는 위 onError 에서 이미 함 → 여기선 rejection 만 삼켜 unhandled 방지.
|
||||
// abort 는 라이브러리가 resolve 처리하므로 여기로 안 옴.
|
||||
} finally {
|
||||
const s = useSnapChatStore.getState()
|
||||
if (s.currentController === ctrl) {
|
||||
s.setController(null)
|
||||
s.setStreaming(false)
|
||||
}
|
||||
}
|
||||
},
|
||||
[sessionId, queryClient],
|
||||
)
|
||||
|
||||
const stop = useCallback(() => useSnapChatStore.getState().stop(), [])
|
||||
|
||||
return { send, stop }
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 4: 통과 확인**
|
||||
|
||||
Run: `npx vitest run src/features/snap/hooks/useSnapChat.test.tsx`
|
||||
Expected: PASS (3 tests)
|
||||
|
||||
- [ ] **Step 5: lint + 커밋**
|
||||
|
||||
```bash
|
||||
npx eslint src/features/snap/hooks/useSnapChat.ts src/features/snap/hooks/useSnapChat.test.tsx
|
||||
git add src/features/snap/hooks/useSnapChat.ts src/features/snap/hooks/useSnapChat.test.tsx
|
||||
git commit -m "feat(snap): patch session title on title event, handle 409 and dup send"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task T8: 목업 제거
|
||||
|
||||
**Files:**
|
||||
- Delete: `src/features/snap/mock/sessions.ts`, `src/features/snap/mock/conversations.ts`, `src/features/snap/mock/stream.ts`, `src/features/snap/mock/mock.test.ts`
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: 없음 (T3 에서 `snap.api` 가, T6 에서 `snap.stream` 이 이미 mock import 제거)
|
||||
- Produces: 없음
|
||||
|
||||
- [ ] **Step 1: 잔여 import 확인 (없어야 함)**
|
||||
|
||||
Run: `git grep -n "mock/sessions\|mock/conversations\|mock/stream\|MOCK_SESSIONS\|MOCK_CONVERSATIONS\|mockStream" src/`
|
||||
Expected: 매치 없음(삭제할 mock 파일 자신 제외). 매치 나오면 그 파일부터 정리.
|
||||
|
||||
- [ ] **Step 2: 삭제**
|
||||
|
||||
```bash
|
||||
git rm src/features/snap/mock/sessions.ts src/features/snap/mock/conversations.ts src/features/snap/mock/stream.ts src/features/snap/mock/mock.test.ts
|
||||
```
|
||||
|
||||
- [ ] **Step 3: 타입/빌드 확인**
|
||||
|
||||
Run: `npx tsc --noEmit`
|
||||
Expected: 에러 없음 (dangling import 없음)
|
||||
|
||||
- [ ] **Step 4: 커밋**
|
||||
|
||||
```bash
|
||||
git commit -m "chore(snap): remove mock session/conversation/stream data"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Wave 경계 게이트
|
||||
|
||||
각 Wave 끝에서:
|
||||
|
||||
- [ ] **W1 후**: `npx vitest run src/lib/auth src/lib/streaming/streamLLM.test.ts src/features/snap/api/snap.api.test.tsx` → 전부 PASS
|
||||
- [ ] **W2 후**: `npx vitest run src/lib/api/client.test.ts src/lib/streaming/sse.test.ts src/features/snap/api/snap.stream.test.ts` → 전부 PASS
|
||||
- [ ] **W3 후**: `npm run build` → 성공 + `npx vitest run src/features/snap` → PASS
|
||||
|
||||
CLAUDE.md 5·8번: 각 Wave 완료 후 `docs/working/snap-backend-connect-wave-N.md` 떨어뜨리고, 디버깅 사건 있었으면 `docs/troubleshootings/` 기록.
|
||||
|
||||
---
|
||||
|
||||
## 검증 (수동 · 실제 백엔드)
|
||||
|
||||
1. base-backend 로컬 8001 기동 + LLM(NVIDIA NIM) 설정 확인.
|
||||
2. `npm run dev` → http://localhost:15173 → 로그인.
|
||||
3. `/snap` → 세션 목록이 **실제 DB 세션**으로 뜸 (mock 아님).
|
||||
4. `/snap/new` → 첫 메시지 전송 → 실제 LLM 토큰이 타이핑되듯 스트리밍 → 완료 후 사이드바 제목이 LLM 이 지은 제목으로 자동 변경 (title 이벤트).
|
||||
5. 기존 세션 재진입 → 과거 메시지 렌더 정상.
|
||||
6. 생성 중 같은 세션에 재전송 → 중복 가드 또는 409 toast.
|
||||
7. Network 탭: `POST /api/v1/chat/stream` 이 `text/event-stream` 으로 열리고 쿠키 전송됨.
|
||||
|
||||
---
|
||||
|
||||
## 미룸 (범위 밖 — 후속 spec)
|
||||
|
||||
- rename(PATCH)/delete/search 엔드포인트
|
||||
- `subagent_start`/`subagent_done` 진행 UI, `usage` 토큰 카운터
|
||||
- Bearer 모드 refresh(.NET 호스트 위임) 실제 배선 — 호스트 생기면
|
||||
Reference in New Issue
Block a user