Skip to content

Commit 5e4da54

Browse files
SiYGclaude
andcommitted
feat: add comprehensive unit tests for priority 1 components
- Set up Vitest testing infrastructure with React support - Add unit tests for useAssistantSettings hook with localStorage handling - Add unit tests for github.ts utility functions (URL building, API fetching) - Add unit tests for utils.ts cn function (className merging) - Add unit tests for chat API route with provider selection - Add unit tests for docs-tree API route with file system operations - Fixed all failing tests and linting issues - All 69 tests now pass successfully Test coverage includes happy paths, edge cases, and error handling scenarios. 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <noreply@anthropic.com>
1 parent e28d41c commit 5e4da54

File tree

10 files changed

+1630
-2
lines changed

10 files changed

+1630
-2
lines changed

CLAUDE.md

Lines changed: 135 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,135 @@
1+
# CLAUDE.md
2+
3+
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
4+
5+
## Common Development Commands
6+
7+
### Installation and Setup
8+
9+
```bash
10+
# Install dependencies using pnpm (recommended)
11+
pnpm install
12+
13+
# Or using npm
14+
npm install
15+
```
16+
17+
### Development
18+
19+
```bash
20+
# Start development server on http://localhost:3000
21+
pnpm dev
22+
23+
# Build the project
24+
pnpm build
25+
26+
# Start production server
27+
pnpm start
28+
29+
# Process MDX files (runs automatically after install)
30+
pnpm postinstall
31+
```
32+
33+
### Code Quality
34+
35+
```bash
36+
# Run linting
37+
pnpm lint
38+
39+
# Run type checking
40+
pnpm typecheck
41+
42+
# Check image compliance with project rules
43+
pnpm lint:images
44+
45+
# Migrate images to proper directory structure
46+
pnpm migrate:images
47+
```
48+
49+
### Git Commits
50+
51+
- The project uses Husky for git hooks and lint-staged for pre-commit formatting
52+
- Prettier will automatically format files on commit
53+
- On Windows + VSCode/Cursor, use command line (`git commit`) instead of GUI to avoid Husky bugs
54+
55+
## Project Architecture
56+
57+
### Tech Stack
58+
59+
- **Framework**: Next.js 15 with App Router
60+
- **Documentation**: Fumadocs MDX (文档系统)
61+
- **Styling**: Tailwind CSS v4
62+
- **UI Components**: Fumadocs UI + custom components
63+
- **Authentication**: NextAuth (beta)
64+
- **AI Integration**: Vercel AI SDK with Assistant UI
65+
- **Database**: Prisma with Neon (PostgreSQL)
66+
67+
### Directory Structure
68+
69+
```
70+
app/
71+
├── api/ # API routes (auth, chat, docs-tree)
72+
├── components/ # React components
73+
│ ├── assistant-ui/ # AI assistant components
74+
│ └── ui/ # Reusable UI components
75+
├── docs/ # MDX documentation content
76+
│ ├── ai/ # AI-related documentation
77+
│ ├── computer-science/ # CS topics
78+
│ ├── frontend/ # Frontend development
79+
│ └── [...slug]/ # Dynamic routing for docs
80+
├── hooks/ # Custom React hooks
81+
└── layout.tsx # Root layout with providers
82+
```
83+
84+
### Documentation Structure
85+
86+
- Uses "Folder as a Book" pattern - each folder can have an `index.mdx` for overview
87+
- URLs are auto-generated from file structure (e.g., `docs/ai/llm-basics/index.mdx``/ai/llm-basics`)
88+
- File naming: use `kebab-case` and numeric prefixes for ordering (e.g., `01-intro.mdx`)
89+
- Numeric prefixes are stripped from final URLs
90+
91+
### Image Management
92+
93+
- Images should be placed in `./<basename>.assets/` directory alongside the MDX file
94+
- Example: `foo.mdx` → images go in `./foo.assets/`
95+
- Auto-migration scripts handle image placement during commits
96+
- Site-wide images: `/images/site/*`
97+
- Component demos: `/images/components/<name>/*`
98+
99+
### MDX Frontmatter
100+
101+
Required fields:
102+
103+
```yaml
104+
---
105+
title: Document Title
106+
---
107+
```
108+
109+
Optional fields:
110+
111+
```yaml
112+
---
113+
description: Brief description
114+
date: "2025-01-01"
115+
tags:
116+
- tag1
117+
- tag2
118+
---
119+
```
120+
121+
### Key Features
122+
123+
1. **AI Assistant**: Integrated chat interface with support for multiple AI providers
124+
2. **Internationalization**: Using next-intl for multi-language support
125+
3. **Search**: Orama search integration for documentation
126+
4. **Comments**: Giscus integration for discussion
127+
5. **Math Support**: KaTeX for mathematical expressions
128+
6. **Authentication**: GitHub OAuth integration
129+
130+
### Development Considerations
131+
132+
- The project uses Fumadocs for documentation, refer to [Fumadocs docs](https://fumadocs.dev/docs) for UI components
133+
- Math expressions use remark-math and rehype-katex plugins
134+
- Authentication is handled via NextAuth with Neon database adapter
135+
- The project includes pre-configured GitHub Actions for automated deployment

app/api/chat/route.test.ts

Lines changed: 262 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,262 @@
1+
import { describe, it, expect, vi, beforeEach } from "vitest";
2+
import { POST } from "./route";
3+
import { streamText } from "ai";
4+
import { createOpenAI } from "@ai-sdk/openai";
5+
import { createGoogleGenerativeAI } from "@ai-sdk/google";
6+
7+
// Mock the AI SDKs
8+
vi.mock("@ai-sdk/openai", () => ({
9+
createOpenAI: vi.fn(),
10+
}));
11+
12+
vi.mock("@ai-sdk/google", () => ({
13+
createGoogleGenerativeAI: vi.fn(),
14+
}));
15+
16+
vi.mock("ai", () => ({
17+
streamText: vi.fn(),
18+
convertToModelMessages: vi.fn((messages) => messages),
19+
UIMessage: {},
20+
}));
21+
22+
describe("chat API route", () => {
23+
const mockStreamText = vi.mocked(streamText);
24+
const mockCreateOpenAI = vi.mocked(createOpenAI);
25+
const mockCreateGoogleGenerativeAI = vi.mocked(createGoogleGenerativeAI);
26+
27+
beforeEach(() => {
28+
vi.clearAllMocks();
29+
});
30+
31+
it("should return error when API key is missing", async () => {
32+
const request = new Request("http://localhost:3000/api/chat", {
33+
method: "POST",
34+
body: JSON.stringify({
35+
messages: [{ role: "user", content: "Hello" }],
36+
provider: "openai",
37+
}),
38+
});
39+
40+
const response = await POST(request);
41+
const data = await response.json();
42+
43+
expect(response.status).toBe(400);
44+
expect(data.error).toBe(
45+
"API key is required. Please configure your API key in the settings.",
46+
);
47+
});
48+
49+
it("should return error when API key is empty string", async () => {
50+
const request = new Request("http://localhost:3000/api/chat", {
51+
method: "POST",
52+
body: JSON.stringify({
53+
messages: [{ role: "user", content: "Hello" }],
54+
provider: "openai",
55+
apiKey: " ",
56+
}),
57+
});
58+
59+
const response = await POST(request);
60+
const data = await response.json();
61+
62+
expect(response.status).toBe(400);
63+
expect(data.error).toBe(
64+
"API key is required. Please configure your API key in the settings.",
65+
);
66+
});
67+
68+
it("should use OpenAI provider by default", async () => {
69+
const mockModel = vi.fn();
70+
const mockOpenAIInstance = vi.fn(() => mockModel);
71+
mockCreateOpenAI.mockReturnValue(mockOpenAIInstance);
72+
73+
const mockStreamResponse = {
74+
toUIMessageStreamResponse: vi.fn(() => new Response()),
75+
};
76+
mockStreamText.mockReturnValue(mockStreamResponse);
77+
78+
const request = new Request("http://localhost:3000/api/chat", {
79+
method: "POST",
80+
body: JSON.stringify({
81+
messages: [{ role: "user", content: "Hello" }],
82+
apiKey: "test-api-key",
83+
}),
84+
});
85+
86+
await POST(request);
87+
88+
expect(mockCreateOpenAI).toHaveBeenCalledWith({
89+
apiKey: "test-api-key",
90+
});
91+
expect(mockOpenAIInstance).toHaveBeenCalledWith("gpt-4.1-nano");
92+
expect(mockStreamText).toHaveBeenCalledWith({
93+
model: mockModel,
94+
system: expect.stringContaining("You are a helpful AI assistant"),
95+
messages: [{ role: "user", content: "Hello" }],
96+
});
97+
});
98+
99+
it("should use Gemini provider when specified", async () => {
100+
const mockModel = vi.fn();
101+
const mockGeminiInstance = vi.fn(() => mockModel);
102+
mockCreateGoogleGenerativeAI.mockReturnValue(mockGeminiInstance);
103+
104+
const mockStreamResponse = {
105+
toUIMessageStreamResponse: vi.fn(() => new Response()),
106+
};
107+
mockStreamText.mockReturnValue(mockStreamResponse);
108+
109+
const request = new Request("http://localhost:3000/api/chat", {
110+
method: "POST",
111+
body: JSON.stringify({
112+
messages: [{ role: "user", content: "Hello" }],
113+
provider: "gemini",
114+
apiKey: "test-gemini-key",
115+
}),
116+
});
117+
118+
await POST(request);
119+
120+
expect(mockCreateGoogleGenerativeAI).toHaveBeenCalledWith({
121+
apiKey: "test-gemini-key",
122+
});
123+
expect(mockGeminiInstance).toHaveBeenCalledWith("models/gemini-2.0-flash");
124+
});
125+
126+
it("should include page context in system message", async () => {
127+
const mockModel = vi.fn();
128+
const mockOpenAIInstance = vi.fn(() => mockModel);
129+
mockCreateOpenAI.mockReturnValue(mockOpenAIInstance);
130+
131+
const mockStreamResponse = {
132+
toUIMessageStreamResponse: vi.fn(() => new Response()),
133+
};
134+
mockStreamText.mockReturnValue(mockStreamResponse);
135+
136+
const pageContext = {
137+
title: "Test Page",
138+
description: "Test Description",
139+
content: "Page content here",
140+
slug: "test-page",
141+
};
142+
143+
const request = new Request("http://localhost:3000/api/chat", {
144+
method: "POST",
145+
body: JSON.stringify({
146+
messages: [{ role: "user", content: "Tell me about this page" }],
147+
apiKey: "test-api-key",
148+
pageContext,
149+
}),
150+
});
151+
152+
await POST(request);
153+
154+
expect(mockStreamText).toHaveBeenCalledWith({
155+
model: expect.anything(),
156+
system: expect.stringContaining("Page Title: Test Page"),
157+
messages: expect.anything(),
158+
});
159+
160+
const systemMessage = mockStreamText.mock.calls[0][0].system;
161+
expect(systemMessage).toContain("Page Description: Test Description");
162+
expect(systemMessage).toContain("Page URL: /docs/test-page");
163+
expect(systemMessage).toContain("Page Content:\nPage content here");
164+
});
165+
166+
it("should use custom system message when provided", async () => {
167+
const mockModel = vi.fn();
168+
const mockOpenAIInstance = vi.fn(() => mockModel);
169+
mockCreateOpenAI.mockReturnValue(mockOpenAIInstance);
170+
171+
const mockStreamResponse = {
172+
toUIMessageStreamResponse: vi.fn(() => new Response()),
173+
};
174+
mockStreamText.mockReturnValue(mockStreamResponse);
175+
176+
const customSystem = "You are a specialized assistant for coding tasks.";
177+
178+
const request = new Request("http://localhost:3000/api/chat", {
179+
method: "POST",
180+
body: JSON.stringify({
181+
messages: [{ role: "user", content: "Help me code" }],
182+
apiKey: "test-api-key",
183+
system: customSystem,
184+
}),
185+
});
186+
187+
await POST(request);
188+
189+
expect(mockStreamText).toHaveBeenCalledWith({
190+
model: expect.anything(),
191+
system: customSystem,
192+
messages: expect.anything(),
193+
});
194+
});
195+
196+
it("should handle API errors gracefully", async () => {
197+
const mockOpenAIInstance = vi.fn(() => {
198+
throw new Error("API Error");
199+
});
200+
mockCreateOpenAI.mockReturnValue(mockOpenAIInstance);
201+
202+
const consoleSpy = vi.spyOn(console, "error").mockImplementation(() => {});
203+
204+
const request = new Request("http://localhost:3000/api/chat", {
205+
method: "POST",
206+
body: JSON.stringify({
207+
messages: [{ role: "user", content: "Hello" }],
208+
apiKey: "test-api-key",
209+
}),
210+
});
211+
212+
const response = await POST(request);
213+
const data = await response.json();
214+
215+
expect(response.status).toBe(500);
216+
expect(data.error).toBe("Failed to process chat request");
217+
expect(consoleSpy).toHaveBeenCalledWith(
218+
"Chat API error:",
219+
expect.any(Error),
220+
);
221+
222+
consoleSpy.mockRestore();
223+
});
224+
225+
it("should handle partial page context", async () => {
226+
const mockModel = vi.fn();
227+
const mockOpenAIInstance = vi.fn(() => mockModel);
228+
mockCreateOpenAI.mockReturnValue(mockOpenAIInstance);
229+
230+
const mockStreamResponse = {
231+
toUIMessageStreamResponse: vi.fn(() => new Response()),
232+
};
233+
mockStreamText.mockReturnValue(mockStreamResponse);
234+
235+
const pageContext = {
236+
title: "Test Page",
237+
content: "Page content here",
238+
// Missing description and slug
239+
};
240+
241+
const request = new Request("http://localhost:3000/api/chat", {
242+
method: "POST",
243+
body: JSON.stringify({
244+
messages: [{ role: "user", content: "Tell me about this page" }],
245+
apiKey: "test-api-key",
246+
pageContext,
247+
}),
248+
});
249+
250+
await POST(request);
251+
252+
const systemMessage = mockStreamText.mock.calls[0][0].system;
253+
expect(systemMessage).toContain("Page Title: Test Page");
254+
expect(systemMessage).toContain("Page Content:\nPage content here");
255+
expect(systemMessage).not.toContain("Page Description:");
256+
expect(systemMessage).not.toContain("Page URL:");
257+
});
258+
259+
// Note: Testing maxDuration export directly would require dynamic imports
260+
// which don't work well with vitest mocking. The maxDuration is set to 30
261+
// in the route file and this is verified by the actual behavior during runtime.
262+
});

0 commit comments

Comments
 (0)