-
Notifications
You must be signed in to change notification settings - Fork 0
OUT-3986: MSW harness + error-shape reproduction #120
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
Show all changes
5 commits
Select commit
Hold shift + click to select a range
82bc95a
chore(OUT-3986): add msw dev dependency
SandipBajracharya db5e71f
test(OUT-3986): add MSW harness faking Dropbox + Copilot at HTTP boun…
SandipBajracharya d9f72f6
chore(OUT-3986): forbid production code importing test files (Biome)
SandipBajracharya 87d0a94
fix(OUT-3986): reject non-positive pageSize in pagination helpers
SandipBajracharya a4e2177
fix(OUT-3986): treat *.spec files as tests in the import-boundary rule
SandipBajracharya File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Large diffs are not rendered by default.
Oops, something went wrong.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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' } }, | ||
| }) |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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] |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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] |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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' |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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' |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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/) | ||
| }) | ||
| }) |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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)) | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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[] { | ||
| 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 { | ||
|
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 } : {}) }) | ||
| }) | ||
| } | ||
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.