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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,9 @@ jobs:
- name: Lint
run: pnpm lint

- name: Unit tests
run: pnpm test

e2e:
name: End-to-end tests
runs-on: ubuntu-latest
Expand Down
51 changes: 51 additions & 0 deletions __tests__/helpers.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
import { vi } from 'vitest'

export function jsonRequest(
url: string,
body: unknown,
init: RequestInit & { headers?: Record<string, string> } = {},
): Request {
const headers = new Headers(init.headers)
if (!headers.has('content-type')) {
headers.set('content-type', 'application/json')
}

return new Request(url, {
method: init.method ?? 'POST',
headers,
body: JSON.stringify(body),
})
}

export interface SupabaseMockOptions {
existing?: { id: string; status: string } | null
fetchError?: boolean
insertError?: boolean
updateError?: boolean
}

export function createSupabaseMock(options: SupabaseMockOptions = {}) {
const eqAfterUpdate = vi
.fn()
.mockResolvedValue(
options.updateError ? { error: { message: 'update failed' } } : { error: null },
)
const update = vi.fn().mockReturnValue({ eq: eqAfterUpdate })
const insert = vi
.fn()
.mockResolvedValue(
options.insertError ? { error: { message: 'insert failed' } } : { error: null },
)
const single = vi
.fn()
.mockResolvedValue(
options.fetchError || !options.existing
? { data: null, error: { message: 'not found' } }
: { data: options.existing, error: null },
)
const eqAfterSelect = vi.fn().mockReturnValue({ single })
const select = vi.fn().mockReturnValue({ eq: eqAfterSelect })
const from = vi.fn().mockReturnValue({ select, update, insert })

return { from, select, eqAfterSelect, single, update, eqAfterUpdate, insert }
}
124 changes: 124 additions & 0 deletions app/actions/__tests__/feedback.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,124 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'

const mockIsRateLimited = vi.fn(() => false)

vi.mock('@/lib/rate-limit', () => ({
createRateLimiter: () => mockIsRateLimited,
}))

const { submitFeedback } = await import('../feedback')

describe('submitFeedback', () => {
const originalFetch = globalThis.fetch

beforeEach(() => {
vi.clearAllMocks()
mockIsRateLimited.mockReturnValue(false)
globalThis.fetch = vi.fn().mockResolvedValue(new Response(null, { status: 204 }))
})

afterEach(() => {
globalThis.fetch = originalFetch
delete process.env.GITHUB_FEEDBACK_TOKEN
delete process.env.GITHUB_DISCUSSION_CATEGORY_ID
delete process.env.GITHUB_DISCUSSION_REPO_ID
delete process.env.DISCORD_FEEDBACK_WEBHOOK
})

it('rejects invalid payloads before rate limiting', async () => {
const result = await submitFeedback({
opinion: 'great',
message: 'nice',
url: '/docs/test',
} as never)

expect(result).toEqual({ success: false })
expect(mockIsRateLimited).not.toHaveBeenCalled()
})

it('rejects external URLs to prevent open redirects', async () => {
const result = await submitFeedback({
opinion: 'good',
message: 'helpful',
url: 'https://evil.example/docs',
})

expect(result).toEqual({ success: false })
expect(mockIsRateLimited).not.toHaveBeenCalled()
})

it('rejects protocol-relative URLs', async () => {
const result = await submitFeedback({
opinion: 'bad',
message: 'confusing',
url: '//evil.example/docs',
})

expect(result).toEqual({ success: false })
})

it('returns false when rate limited by page URL', async () => {
mockIsRateLimited.mockReturnValue(true)

const result = await submitFeedback({
opinion: 'good',
message: 'clear docs',
url: '/docs/quick-start',
})

expect(result).toEqual({ success: false })
expect(mockIsRateLimited).toHaveBeenCalledWith('/docs/quick-start')
expect(globalThis.fetch).not.toHaveBeenCalled()
})

it('accepts valid feedback and posts to configured integrations', async () => {
process.env.GITHUB_FEEDBACK_TOKEN = 'gh-token'
process.env.GITHUB_DISCUSSION_CATEGORY_ID = 'category-id'
process.env.GITHUB_DISCUSSION_REPO_ID = 'repo-id'
process.env.DISCORD_FEEDBACK_WEBHOOK = 'https://discord.test/webhook'

const result = await submitFeedback({
opinion: 'bad',
message: ' Needs more examples ',
url: '/docs/configuration',
})

expect(result).toEqual({ success: true })
expect(globalThis.fetch).toHaveBeenCalledTimes(2)
})

it('truncates overly long messages', async () => {
process.env.GITHUB_FEEDBACK_TOKEN = 'gh-token'
process.env.GITHUB_DISCUSSION_CATEGORY_ID = 'category-id'
process.env.GITHUB_DISCUSSION_REPO_ID = 'repo-id'

const longMessage = 'a'.repeat(2500)

const result = await submitFeedback({
opinion: 'good',
message: longMessage,
url: '/docs/limits',
})

expect(result).toEqual({ success: true })

const githubCall = vi
.mocked(globalThis.fetch)
.mock.calls.find(([url]) => url === 'https://api.github.com/graphql')
expect(githubCall).toBeDefined()

const payload = JSON.parse(String(githubCall?.[1]?.body))
expect(payload.variables.body.length).toBeLessThan(longMessage.length)
expect(payload.variables.body).not.toContain('a'.repeat(2500))
})

it('treats non-string messages as empty', async () => {
const result = await submitFeedback({
opinion: 'good',
message: 123 as never,
url: '/docs/no-message',
})

expect(result).toEqual({ success: true })
})
})
157 changes: 157 additions & 0 deletions app/api/chat/__tests__/route.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,157 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { jsonRequest } from '@/__tests__/helpers'

const mockCheckRateLimit = vi.fn()
const mockStreamText = vi.fn()
const mockConvertToModelMessages = vi.fn()
const mockToUIMessageStreamResponse = vi.fn()

vi.mock('@/lib/ratelimit', () => ({
checkRateLimit: (...args: unknown[]) => mockCheckRateLimit(...args),
}))

vi.mock('ai', () => ({
streamText: (...args: unknown[]) => mockStreamText(...args),
tool: (definition: unknown) => definition,
stepCountIs: (count: number) => count,
convertToModelMessages: (...args: unknown[]) => mockConvertToModelMessages(...args),
}))

vi.mock('@openrouter/ai-sdk-provider', () => ({
createOpenRouter: () => ({
chat: () => 'mock-model',
}),
}))

vi.mock('@/lib/source', () => ({
docsSource: {
getPages: () => [],
},
}))

vi.mock('node:fs/promises', () => ({
readFile: vi.fn(),
}))

const { POST } = await import('../route')

function chatRequest(messages: unknown[], headers: Record<string, string> = {}): Request {
return jsonRequest('https://example.com/api/chat', { messages }, { headers })
}

describe('POST /api/chat', () => {
const originalApiKey = process.env.OPENROUTER_API_KEY

beforeEach(() => {
vi.clearAllMocks()
process.env.OPENROUTER_API_KEY = 'test-key'
mockCheckRateLimit.mockResolvedValue({ allowed: true })
mockConvertToModelMessages.mockResolvedValue([{ role: 'user', content: 'hello' }])
mockToUIMessageStreamResponse.mockReturnValue(
new Response('0:"Hello from the docs assistant"\n', {
status: 200,
headers: { 'content-type': 'text/plain; charset=utf-8' },
}),
)
mockStreamText.mockReturnValue({
toUIMessageStreamResponse: mockToUIMessageStreamResponse,
})
})

afterEach(() => {
process.env.OPENROUTER_API_KEY = originalApiKey
})

it('returns 429 when rate limited', async () => {
const reset = Date.now() + 30_000
mockCheckRateLimit.mockResolvedValue({ allowed: false, reset })

const response = await POST(chatRequest([{ role: 'user', content: 'hello' }]))
const body = await response.json()

expect(response.status).toBe(429)
expect(body).toEqual({ error: 'Too many requests. Please try again shortly.' })
expect(response.headers.get('Retry-After')).toBe(String(Math.ceil((reset - Date.now()) / 1000)))
expect(mockStreamText).not.toHaveBeenCalled()
})

it('returns 503 when OPENROUTER_API_KEY is not configured', async () => {
delete process.env.OPENROUTER_API_KEY

const response = await POST(chatRequest([{ role: 'user', content: 'hello' }]))
const body = await response.json()

expect(response.status).toBe(503)
expect(body).toEqual({ error: 'OPENROUTER_API_KEY is not configured' })
expect(mockStreamText).not.toHaveBeenCalled()
})

it('returns a UI message stream response in search mode', async () => {
const response = await POST(chatRequest([{ role: 'user', content: 'How do I deploy?' }]))

expect(response.status).toBe(200)
expect(await response.text()).toContain('Hello from the docs assistant')
expect(mockStreamText).toHaveBeenCalledWith(
expect.objectContaining({
tools: expect.objectContaining({
search: expect.any(Object),
navigate: expect.any(Object),
}),
toolChoice: 'auto',
}),
)
expect(mockToUIMessageStreamResponse).toHaveBeenCalledOnce()
})

it('uses page mode without tools when a valid docs page header is provided', async () => {
const response = await POST(
chatRequest([{ role: 'user', content: 'Summarize this page' }], {
'x-chat-mode': 'page',
'x-chat-page': '/docs/quick-start/install',
}),
)

expect(response.status).toBe(200)
expect(mockStreamText).toHaveBeenCalledWith(
expect.not.objectContaining({
tools: expect.anything(),
}),
)
expect(mockToUIMessageStreamResponse).toHaveBeenCalledOnce()
})

it('rejects traversal in x-chat-page and falls back to search mode', async () => {
await POST(
chatRequest([{ role: 'user', content: 'Show me docs' }], {
'x-chat-mode': 'page',
'x-chat-page': '/docs/../secrets',
}),
)

expect(mockStreamText).toHaveBeenCalledWith(
expect.objectContaining({
tools: expect.objectContaining({
search: expect.any(Object),
navigate: expect.any(Object),
}),
}),
)
})

it('rejects external URLs in x-chat-page and falls back to search mode', async () => {
await POST(
chatRequest([{ role: 'user', content: 'Show me docs' }], {
'x-chat-mode': 'page',
'x-chat-page': 'https://evil.example/docs',
}),
)

expect(mockStreamText).toHaveBeenCalledWith(
expect.objectContaining({
tools: expect.objectContaining({
search: expect.any(Object),
}),
}),
)
})
})
Loading
Loading