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
39 changes: 38 additions & 1 deletion biome.json
Original file line number Diff line number Diff line change
Expand Up @@ -64,5 +64,42 @@
"semicolons": "asNeeded",
"quoteStyle": "single"
}
}
},
"overrides": [
{
"includes": [
"src/**",
"!**/__tests__/**",
"!**/*.test.ts",
"!**/*.test.tsx",
"!**/*.spec.ts",
"!**/*.spec.tsx"
],
"linter": {
"rules": {
"style": {
"noRestrictedImports": {
"level": "error",
"options": {
"patterns": [
{
"group": [
"**/__tests__/**",
"**/test/**",
"test/**",
"**/*.test",
"**/*.test.*",
"**/*.spec",
"**/*.spec.*"
],
"message": "Production code must not import test files or the test harness (test/**, **/__tests__/**, *.test.*, *.spec.*)."
}
]
}
}
}
}
}
}
]
}
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,7 @@
"drizzle-kit": "^0.31.4",
"husky": "^9.1.7",
"lint-staged": "^16.1.5",
"msw": "^2.15.0",
"server-only": "^0.0.1",
"supabase": "^2.39.2",
"tailwindcss": "^4",
Expand Down
284 changes: 280 additions & 4 deletions pnpm-lock.yaml

Large diffs are not rendered by default.

9 changes: 8 additions & 1 deletion test/integration/setup.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,19 @@
import postgres from 'postgres'
import { afterAll, beforeEach, inject } from 'vitest'
import { afterAll, afterEach, beforeAll, beforeEach, inject } from 'vitest'
import { server } from '../msw/server'
import { applyPlaceholderServerEnv } from '../support/placeholder-env'

// Runs in every worker BEFORE any test file imports `@/db`. Point the app's DB
// singleton (`src/db/index.ts` reads `env.DATABASE_URL` at import) at the
// container, and satisfy the rest of the server-env Zod schema with placeholders.
applyPlaceholderServerEnv()

// MSW fakes Dropbox + Copilot over HTTP. `error` mode flags any unmocked call;
// per-test overrides are cleared between tests.
beforeAll(() => server.listen({ onUnhandledRequest: 'error' }))
afterEach(() => server.resetHandlers())
afterAll(() => server.close())

// SAFETY: only ever run (and TRUNCATE) against a local Testcontainers DB. If the
// injected URL is anything but localhost, refuse to start — never touch a remote
// / production database.
Expand Down
51 changes: 51 additions & 0 deletions test/msw/errors.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
import httpStatus from 'http-status'
import { HttpResponse } from 'msw'

function statusText(status: number): string {
const text = (httpStatus as unknown as Record<number, string>)[status]
return typeof text === 'string' ? text : 'Error'
}

// Copilot: the SDK turns a 4xx JSON body into the ApiError shape isCopilotApiError checks.
export function copilotError({ status, body }: { status: number; body: Record<string, unknown> }) {
return HttpResponse.json(body, { status, statusText: statusText(status) })
}

export const copilotFolderExists = () =>
copilotError({ status: 400, body: { message: 'Folder already exists' } })

export const copilotNotFound = (body: Record<string, unknown> = { message: 'Not found' }) =>
copilotError({ status: 404, body })

// Dropbox: the SDK puts the JSON body on DropboxResponseError.error.
// Prod reads err.error.error.path['.tag'] and err.error.error_summary.
export function dropboxRpcError({
status,
errorSummary,
error,
}: {
status: number
errorSummary: string
error: Record<string, unknown>
}) {
return HttpResponse.json(
{ error_summary: errorSummary, error },
{ status, statusText: statusText(status) },
)
}

// not_found via the path tag (get_metadata branch).
export const dropboxGetMetadataNotFound = () =>
dropboxRpcError({
status: 409,
errorSummary: 'path/not_found/..',
error: { '.tag': 'path', path: { '.tag': 'not_found' } },
})

// not_found via the error_summary prefix; path tag also set so both branches match.
export const dropboxPathLookupNotFound = () =>
dropboxRpcError({
status: 409,
errorSummary: 'path_lookup/not_found/..',
error: { '.tag': 'path', path: { '.tag': 'not_found' } },
})
7 changes: 7 additions & 0 deletions test/msw/handlers/copilot.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
import { HttpResponse, http } from 'msw'
import { COPILOT_HOST } from '../hosts'

// Empty page by default. Override with paginateCopilotListFiles / mockCopilot.
const listFiles = http.get(`${COPILOT_HOST}/v1/files`, () => HttpResponse.json({ data: [] }))

export const copilotBaseHandlers = [listFiles]
21 changes: 21 additions & 0 deletions test/msw/handlers/dropbox.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import { HttpResponse, http } from 'msw'
import { DROPBOX_RPC_HOST } from '../hosts'

// The SDK refreshes the token before every rpc call, so answer it here.
const oauthToken = http.post(`${DROPBOX_RPC_HOST}/oauth2/token`, () =>
HttpResponse.json({
access_token: 'test-access-token',
token_type: 'bearer',
expires_in: 14400,
}),
)

// Empty folder by default. Override with paginateDropboxListFolder / mockDropboxRpc.
const listFolder = http.post(`${DROPBOX_RPC_HOST}/2/files/list_folder`, () =>
HttpResponse.json({ entries: [], cursor: 'end', has_more: false }),
)
const listFolderContinue = http.post(`${DROPBOX_RPC_HOST}/2/files/list_folder/continue`, () =>
HttpResponse.json({ entries: [], cursor: 'end', has_more: false }),
)

export const dropboxBaseHandlers = [oauthToken, listFolder, listFolderContinue]
4 changes: 4 additions & 0 deletions test/msw/hosts.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
// Dependency-free so other files can import it without an import cycle via server.ts.
export const DROPBOX_RPC_HOST = 'https://api.dropboxapi.com'
export const DROPBOX_CONTENT_HOST = 'https://content.dropboxapi.com'
export const COPILOT_HOST = 'https://api.copilot.app'
5 changes: 5 additions & 0 deletions test/msw/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
export * from './errors'
export * from './hosts'
export * from './overrides'
export * from './pagination'
export { server } from './server'
154 changes: 154 additions & 0 deletions test/msw/msw-harness.integration.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,154 @@
import { copilotApi } from 'copilot-node-sdk'
import { DropboxResponseError } from 'dropbox'
import { HttpResponse } from 'msw'
import { describe, expect, it } from 'vitest'
import { CopilotAPI, isCopilotApiError } from '@/lib/copilot/CopilotAPI'
import { DropboxClient } from '@/lib/dropbox/DropboxClient'
import {
copilotFolderExists,
copilotNotFound,
dropboxGetMetadataNotFound,
dropboxPathLookupNotFound,
mockCopilot,
mockDropboxContent,
mockDropboxRpc,
paginateCopilotListFiles,
paginateDropboxListFolder,
} from '../msw'
import { server } from '../msw/server'

describe('MSW harness — interception', () => {
it('rejects an unhandled request (proves error mode)', async () => {
await expect(fetch('https://unregistered.example.com/x')).rejects.toThrow()
})
})

describe('MSW harness — base handlers + hosts', () => {
it('serves an empty Dropbox folder listing to the real SDK (node-fetch)', async () => {
const dbx = new DropboxClient('refresh-token', null)
const entries = await dbx.getAllFilesFolders('/root', false, true)
expect(entries).toEqual([])
})

it('serves an empty Copilot file page to the real SDK (undici fetch)', async () => {
const page = await new CopilotAPI('token')._listFiles('ch_1')
expect(page.data).toEqual([])
})

it('routes content-host requests through an override (node-fetch manualFetch)', async () => {
mockDropboxContent('/files/download', () =>
HttpResponse.json(
{},
{ status: 200, headers: { 'Dropbox-API-Result': JSON.stringify({ size: 5 }) } },
),
)
const dbx = new DropboxClient('refresh-token', 'ns_1')
const { contentLength } = await dbx._downloadFile({
urlPath: '/files/download',
filePath: '/a.txt',
rootNamespaceId: 'ns_1',
refreshToken: 'refresh-token',
})
expect(contentLength).toBe('5')
})
})

describe('MSW harness — Copilot error shapes', () => {
it('reproduces isCopilotApiError 400 "Folder already exists"', async () => {
mockCopilot('/v1/files', () => copilotFolderExists())
const client = copilotApi({ apiKey: 'k', token: 't' })
try {
await client.listFiles({ channelId: 'ch' })
expect.unreachable('listFiles should have thrown')
} catch (err) {
expect(isCopilotApiError(err)).toBe(true)
if (isCopilotApiError(err)) {
expect(err.status).toBe(400)
expect(err.body.message).toBe('Folder already exists')
}
}
})

it('reproduces isCopilotApiError 404', async () => {
mockCopilot('/v1/files', () => copilotNotFound())
const client = copilotApi({ apiKey: 'k', token: 't' })
try {
await client.listFiles({ channelId: 'ch' })
expect.unreachable('listFiles should have thrown')
} catch (err) {
expect(isCopilotApiError(err)).toBe(true)
if (isCopilotApiError(err)) expect(err.status).toBe(404)
}
})
})

describe('MSW harness — Dropbox error shapes', () => {
it('reproduces DropboxResponseError 409 with error.path not_found', async () => {
mockDropboxRpc('/2/files/get_metadata', () => dropboxGetMetadataNotFound())
const dbx = new DropboxClient('refresh-token', null).getDropboxClient()
try {
await dbx.filesGetMetadata({ path: '/missing.txt' })
expect.unreachable('filesGetMetadata should have thrown')
} catch (err) {
expect(err).toBeInstanceOf(DropboxResponseError)
const e = err as DropboxResponseError<{ error?: { path?: { '.tag'?: string } } }>
expect(e.status).toBe(409)
expect(e.error.error?.path?.['.tag']).toBe('not_found')
}
})

it('reproduces DropboxResponseError 409 with path_lookup/not_found summary', async () => {
mockDropboxRpc('/2/files/get_metadata', () => dropboxPathLookupNotFound())
const dbx = new DropboxClient('refresh-token', null).getDropboxClient()
try {
await dbx.filesGetMetadata({ path: '/missing.txt' })
expect.unreachable('filesGetMetadata should have thrown')
} catch (err) {
expect(err).toBeInstanceOf(DropboxResponseError)
const e = err as DropboxResponseError<{ error_summary?: string }>
expect(e.status).toBe(409)
expect(e.error.error_summary?.startsWith('path_lookup/not_found')).toBe(true)
}
})
})

describe('MSW harness — pagination', () => {
it('traverses all Dropbox list_folder pages', async () => {
const entries = Array.from({ length: 250 }, (_, i) => ({
'.tag': 'file',
id: `id:${i}`,
name: `f${i}.txt`,
path_lower: `/f${i}.txt`,
path_display: `/f${i}.txt`,
}))
server.use(...paginateDropboxListFolder(entries, { pageSize: 100 }))
const dbx = new DropboxClient('refresh-token', null)
const result = await dbx.getAllFilesFolders('/root', false, true)
expect(result).toHaveLength(250)
})

it('traverses all Copilot listFiles pages via nextToken', async () => {
const items = Array.from({ length: 250 }, (_, i) => ({
id: `00000000-0000-4000-8000-${String(i).padStart(12, '0')}`,
channelId: 'ch_1',
name: `f${i}`,
object: 'file',
path: `/f${i}`,
}))
server.use(paginateCopilotListFiles(items, { pageSize: 100 }))
const api = new CopilotAPI('token')
const all: unknown[] = []
let nextToken: string | undefined
do {
const pageResult = await api._listFiles('ch_1', nextToken)
all.push(...pageResult.data)
nextToken = pageResult.nextToken
} while (nextToken)
expect(all).toHaveLength(250)
})

it('rejects a non-positive pageSize instead of producing a non-progressing paginator', () => {
expect(() => paginateDropboxListFolder([1], { pageSize: 0 })).toThrow(/positive integer/)
expect(() => paginateCopilotListFiles([1], { pageSize: 0 })).toThrow(/positive integer/)
})
})
25 changes: 25 additions & 0 deletions test/msw/overrides.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
import { type HttpResponseResolver, http } from 'msw'
import { COPILOT_HOST, DROPBOX_CONTENT_HOST, DROPBOX_RPC_HOST } from './hosts'
import { server } from './server'

// Per-test overrides on the right host. They win over base handlers and are
// cleared by resetHandlers() in afterEach.
export function mockDropboxRpc(path: string, resolver: HttpResponseResolver): void {
server.use(http.post(`${DROPBOX_RPC_HOST}${path}`, resolver))
}

export function mockDropboxContent(path: string, resolver: HttpResponseResolver): void {
// Content endpoints (download/upload) POST via node-fetch.
server.use(http.post(`${DROPBOX_CONTENT_HOST}${path}`, resolver))
}

// Any msw HTTP verb — Copilot uses GET, DELETE, and PATCH/PUT.
type HttpMethod = keyof typeof http

export function mockCopilot(
path: string,
resolver: HttpResponseResolver,
method: HttpMethod = 'get',
): void {
server.use(http[method](`${COPILOT_HOST}${path}`, resolver))
}
50 changes: 50 additions & 0 deletions test/msw/pagination.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
import { type HttpHandler, HttpResponse, http } from 'msw'
import { COPILOT_HOST, DROPBOX_RPC_HOST } from './hosts'

// Each paginator tracks only the offset (from the cursor / nextToken), so one
// registration fakes one listing per test. Concurrent listings need separate ones.

// A non-positive (or non-integer) pageSize yields empty pages that never advance
// the offset while has_more stays true — a consumer would loop forever. Fail fast.
function assertPositivePageSize(fn: string, pageSize: number): void {
if (!Number.isInteger(pageSize) || pageSize < 1) {
throw new Error(`${fn}: pageSize must be a positive integer, got ${pageSize}`)
}
}

// Caller passes entries; this owns the cursor/has_more protocol.
export function paginateDropboxListFolder(
entries: unknown[],
{ pageSize = 100 }: { pageSize?: number } = {},
): HttpHandler[] {
Comment thread
greptile-apps[bot] marked this conversation as resolved.
assertPositivePageSize('paginateDropboxListFolder', pageSize)
const page = (offset: number) => {
const slice = entries.slice(offset, offset + pageSize)
const nextOffset = offset + slice.length
return { entries: slice, cursor: `cursor:${nextOffset}`, has_more: nextOffset < entries.length }
}
return [
http.post(`${DROPBOX_RPC_HOST}/2/files/list_folder`, () => HttpResponse.json(page(0))),
http.post(`${DROPBOX_RPC_HOST}/2/files/list_folder/continue`, async ({ request }) => {
const { cursor } = (await request.json()) as { cursor: string }
const offset = Number(cursor.split(':')[1] ?? 0)
return HttpResponse.json(page(offset))
}),
]
}

// Caller passes items; paginates via nextToken.
export function paginateCopilotListFiles(
items: unknown[],
{ pageSize = 100 }: { pageSize?: number } = {},
): HttpHandler {
Comment thread
greptile-apps[bot] marked this conversation as resolved.
assertPositivePageSize('paginateCopilotListFiles', pageSize)
return http.get(`${COPILOT_HOST}/v1/files`, ({ request }) => {
const token = new URL(request.url).searchParams.get('nextToken')
const offset = token ? Number(token.split(':')[1] ?? 0) : 0
const slice = items.slice(offset, offset + pageSize)
const nextOffset = offset + slice.length
const nextToken = nextOffset < items.length ? `token:${nextOffset}` : undefined
return HttpResponse.json({ data: slice, ...(nextToken ? { nextToken } : {}) })
})
}
Loading
Loading