mirror of
https://github.com/coder/coder.git
synced 2026-06-02 20:48:20 +00:00
c650aabbef
My agent added `//nolint:testpackage` to a test file on one of my PRs. Again. This PR cleans it up across the entire repo and updates the in-repo conventions so future agents stop doing it. The repo already has a precedent for white-box tests that need to touch unexported symbols: `*_internal_test.go` (145+ existing files). The `testpackage` linter's default `skip-regexp` exempts that filename suffix, so the `//nolint:testpackage` directive is unnecessary in every case where someone reached for it. This PR renames 51 such files to `*_internal_test.go` via `git mv` so blame and history follow, and strips the dead directive from 2 files that were already correctly named (`coderd/oauth2provider/authorize_internal_test.go`, `coderd/x/chatd/advisor_internal_test.go`). `.claude/docs/TESTING.md` now documents the rule explicitly under *Test Package Naming*, which is imported into the root `AGENTS.md` via `@.claude/docs/TESTING.md`. The rule: prefer `package foo_test`; if you need internal access, rename the file to `*_internal_test.go` rather than adding a nolint directive.
317 lines
10 KiB
Go
317 lines
10 KiB
Go
package integrationtest
|
|
|
|
import (
|
|
"bufio"
|
|
"bytes"
|
|
"context"
|
|
"io"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"testing"
|
|
|
|
"github.com/stretchr/testify/require"
|
|
|
|
"github.com/coder/coder/v2/aibridge"
|
|
"github.com/coder/coder/v2/aibridge/config"
|
|
"github.com/coder/coder/v2/aibridge/fixtures"
|
|
"github.com/coder/coder/v2/aibridge/intercept/apidump"
|
|
"github.com/coder/coder/v2/aibridge/internal/testutil"
|
|
"github.com/coder/coder/v2/aibridge/provider"
|
|
)
|
|
|
|
const osSep = string(filepath.Separator)
|
|
|
|
func TestAPIDump(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
cases := []struct {
|
|
name string
|
|
fixture []byte
|
|
providerFunc func(addr, dumpDir string) aibridge.Provider
|
|
path string
|
|
headers http.Header
|
|
expectProviderDir string
|
|
}{
|
|
{
|
|
name: "anthropic",
|
|
fixture: fixtures.AntSimple,
|
|
providerFunc: func(addr, dumpDir string) aibridge.Provider {
|
|
return provider.NewAnthropic(anthropicCfgWithAPIDump(addr, apiKey, dumpDir), nil)
|
|
},
|
|
path: pathAnthropicMessages,
|
|
expectProviderDir: config.ProviderAnthropic,
|
|
},
|
|
{
|
|
name: "openai_chat_completions",
|
|
fixture: fixtures.OaiChatSimple,
|
|
providerFunc: func(addr, dumpDir string) aibridge.Provider {
|
|
return provider.NewOpenAI(openaiCfgWithAPIDump(addr, apiKey, dumpDir))
|
|
},
|
|
path: pathOpenAIChatCompletions,
|
|
expectProviderDir: config.ProviderOpenAI,
|
|
},
|
|
{
|
|
name: "openai_responses",
|
|
fixture: fixtures.OaiResponsesBlockingSimple,
|
|
providerFunc: func(addr, dumpDir string) aibridge.Provider {
|
|
return provider.NewOpenAI(openaiCfgWithAPIDump(addr, apiKey, dumpDir))
|
|
},
|
|
path: pathOpenAIResponses,
|
|
expectProviderDir: config.ProviderOpenAI,
|
|
},
|
|
{
|
|
name: "copilot_chat_completions",
|
|
fixture: fixtures.OaiChatSimple,
|
|
providerFunc: func(addr, dumpDir string) aibridge.Provider {
|
|
return provider.NewCopilot(config.Copilot{BaseURL: addr, APIDumpDir: dumpDir})
|
|
},
|
|
path: pathCopilotChatCompletions,
|
|
headers: http.Header{"Authorization": {"Bearer test-copilot-token"}},
|
|
expectProviderDir: config.ProviderCopilot,
|
|
},
|
|
{
|
|
name: "copilot_responses",
|
|
fixture: fixtures.OaiResponsesBlockingSimple,
|
|
providerFunc: func(addr, dumpDir string) aibridge.Provider {
|
|
return provider.NewCopilot(config.Copilot{BaseURL: addr, APIDumpDir: dumpDir})
|
|
},
|
|
path: pathCopilotResponses,
|
|
headers: http.Header{"Authorization": {"Bearer test-copilot-token"}},
|
|
expectProviderDir: config.ProviderCopilot,
|
|
},
|
|
{
|
|
name: "copilot_custom_name_chat_completions",
|
|
fixture: fixtures.OaiChatSimple,
|
|
providerFunc: func(addr, dumpDir string) aibridge.Provider {
|
|
return provider.NewCopilot(config.Copilot{
|
|
Name: "copilot-business",
|
|
BaseURL: addr,
|
|
APIDumpDir: dumpDir,
|
|
})
|
|
},
|
|
path: "/copilot-business/chat/completions",
|
|
headers: http.Header{"Authorization": {"Bearer test-copilot-token"}},
|
|
expectProviderDir: "copilot-business",
|
|
},
|
|
{
|
|
name: "copilot_custom_name_responses",
|
|
fixture: fixtures.OaiChatSimple,
|
|
providerFunc: func(addr, dumpDir string) aibridge.Provider {
|
|
return provider.NewCopilot(config.Copilot{
|
|
Name: "copilot-enterprise",
|
|
BaseURL: addr,
|
|
APIDumpDir: dumpDir,
|
|
})
|
|
},
|
|
path: "/copilot-enterprise/chat/completions",
|
|
headers: http.Header{"Authorization": {"Bearer test-copilot-token"}},
|
|
expectProviderDir: "copilot-enterprise",
|
|
},
|
|
}
|
|
|
|
for _, tc := range cases {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
ctx, cancel := context.WithTimeout(t.Context(), testutil.WaitLong)
|
|
t.Cleanup(cancel)
|
|
|
|
// Setup mock upstream server.
|
|
fix := fixtures.Parse(t, tc.fixture)
|
|
srv := newMockUpstream(ctx, t, newFixtureResponse(fix))
|
|
|
|
// Create temp dir for API dumps.
|
|
dumpDir := t.TempDir()
|
|
|
|
bridgeServer := newBridgeTestServer(ctx, t, srv.URL,
|
|
withCustomProvider(tc.providerFunc(srv.URL, dumpDir)),
|
|
)
|
|
|
|
resp, err := bridgeServer.makeRequest(t, http.MethodPost, tc.path, fix.Request(), tc.headers)
|
|
require.NoError(t, err)
|
|
defer resp.Body.Close()
|
|
require.Equal(t, http.StatusOK, resp.StatusCode)
|
|
_, err = io.ReadAll(resp.Body)
|
|
require.NoError(t, err)
|
|
|
|
// Verify dump files were created.
|
|
interceptions := bridgeServer.Recorder.RecordedInterceptions()
|
|
require.Len(t, interceptions, 1)
|
|
interceptionID := interceptions[0].ID
|
|
|
|
// Find dump files for this interception by walking the dump directory.
|
|
var reqDumpFile, respDumpFile string
|
|
err = filepath.Walk(dumpDir, func(path string, info os.FileInfo, err error) error {
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if info.IsDir() {
|
|
return nil
|
|
}
|
|
// Files are named: {timestamp}-{interceptionID}.{req|resp}.txt
|
|
if strings.Contains(path, interceptionID) {
|
|
if strings.HasSuffix(path, apidump.SuffixRequest) {
|
|
reqDumpFile = path
|
|
} else if strings.HasSuffix(path, apidump.SuffixResponse) {
|
|
respDumpFile = path
|
|
}
|
|
}
|
|
return nil
|
|
})
|
|
require.NoError(t, err)
|
|
require.NotEmpty(t, reqDumpFile, "request dump file should exist")
|
|
require.NotEmpty(t, respDumpFile, "response dump file should exist")
|
|
|
|
// Verify dump files are in the correct provider subdirectory.
|
|
require.Contains(t, reqDumpFile, filepath.Join(dumpDir, tc.expectProviderDir)+osSep,
|
|
"request dump should be in the %s provider directory", tc.expectProviderDir)
|
|
require.Contains(t, respDumpFile, filepath.Join(dumpDir, tc.expectProviderDir)+osSep,
|
|
"response dump should be in the %s provider directory", tc.expectProviderDir)
|
|
|
|
// Verify request dump contains expected HTTP request format.
|
|
reqDumpData, err := os.ReadFile(reqDumpFile)
|
|
require.NoError(t, err)
|
|
|
|
// Parse the dumped HTTP request.
|
|
dumpReq, err := http.ReadRequest(bufio.NewReader(bytes.NewReader(reqDumpData)))
|
|
require.NoError(t, err)
|
|
dumpBody, err := io.ReadAll(dumpReq.Body)
|
|
require.NoError(t, err)
|
|
|
|
// Compare requests semantically (key order may differ).
|
|
require.JSONEq(t, string(dumpBody), string(fix.Request()), "request body JSON should match semantically")
|
|
|
|
// Verify response dump contains expected HTTP response format.
|
|
respDumpData, err := os.ReadFile(respDumpFile)
|
|
require.NoError(t, err)
|
|
|
|
// Parse the dumped HTTP response.
|
|
dumpResp, err := http.ReadResponse(bufio.NewReader(bytes.NewReader(respDumpData)), nil)
|
|
require.NoError(t, err)
|
|
defer dumpResp.Body.Close()
|
|
require.Equal(t, http.StatusOK, dumpResp.StatusCode)
|
|
dumpRespBody, err := io.ReadAll(dumpResp.Body)
|
|
require.NoError(t, err)
|
|
|
|
// Compare responses semantically (key order may differ).
|
|
expectedRespBody := fix.NonStreaming()
|
|
require.JSONEq(t, string(expectedRespBody), string(dumpRespBody), "response body JSON should match semantically")
|
|
|
|
bridgeServer.Recorder.VerifyAllInterceptionsEnded(t)
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestAPIDumpPassthrough(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
const responseBody = `{"object":"list","data":[{"id":"gpt-4","object":"model"}]}`
|
|
|
|
cases := []struct {
|
|
name string
|
|
providerFunc func(addr string, dumpDir string) aibridge.Provider
|
|
requestPath string
|
|
expectDumpName string
|
|
}{
|
|
{
|
|
name: "anthropic",
|
|
providerFunc: func(addr string, dumpDir string) aibridge.Provider {
|
|
return provider.NewAnthropic(anthropicCfgWithAPIDump(addr, apiKey, dumpDir), nil)
|
|
},
|
|
requestPath: "/anthropic/v1/models",
|
|
expectDumpName: "-v1-models-",
|
|
},
|
|
{
|
|
name: "openai",
|
|
providerFunc: func(addr string, dumpDir string) aibridge.Provider {
|
|
return provider.NewOpenAI(openaiCfgWithAPIDump(addr, apiKey, dumpDir))
|
|
},
|
|
requestPath: "/openai/v1/models",
|
|
expectDumpName: "-models-",
|
|
},
|
|
{
|
|
name: "copilot",
|
|
providerFunc: func(addr string, dumpDir string) aibridge.Provider {
|
|
return provider.NewCopilot(config.Copilot{BaseURL: addr, APIDumpDir: dumpDir})
|
|
},
|
|
requestPath: "/copilot/models",
|
|
expectDumpName: "-models-",
|
|
},
|
|
}
|
|
|
|
for _, tc := range cases {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
ctx, cancel := context.WithTimeout(t.Context(), testutil.WaitLong)
|
|
t.Cleanup(cancel)
|
|
|
|
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
w.Header().Set("Content-Type", "application/json")
|
|
w.Write([]byte(responseBody))
|
|
}))
|
|
t.Cleanup(upstream.Close)
|
|
|
|
dumpDir := t.TempDir()
|
|
|
|
bridgeServer := newBridgeTestServer(ctx, t, upstream.URL,
|
|
withCustomProvider(tc.providerFunc(upstream.URL, dumpDir)),
|
|
)
|
|
|
|
resp, err := bridgeServer.makeRequest(t, http.MethodGet, tc.requestPath, nil)
|
|
require.NoError(t, err)
|
|
defer resp.Body.Close()
|
|
|
|
// Find dump files in the passthrough directory.
|
|
passthroughDir := filepath.Join(dumpDir, tc.name, "passthrough")
|
|
var reqDumpFile, respDumpFile string
|
|
err = filepath.Walk(passthroughDir, func(path string, info os.FileInfo, err error) error {
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if info.IsDir() {
|
|
return nil
|
|
}
|
|
if strings.HasSuffix(path, apidump.SuffixRequest) {
|
|
reqDumpFile = path
|
|
} else if strings.HasSuffix(path, apidump.SuffixResponse) {
|
|
respDumpFile = path
|
|
}
|
|
return nil
|
|
})
|
|
require.NoError(t, err, "walking failed: %v", err)
|
|
|
|
require.NotEmpty(t, reqDumpFile, "request dump file should exist")
|
|
require.FileExists(t, reqDumpFile)
|
|
require.Contains(t, reqDumpFile, osSep+"passthrough"+osSep)
|
|
require.Contains(t, reqDumpFile, tc.expectDumpName)
|
|
|
|
require.NotEmpty(t, respDumpFile, "response dump file should exist")
|
|
require.FileExists(t, respDumpFile)
|
|
require.Contains(t, respDumpFile, osSep+"passthrough"+osSep)
|
|
require.Contains(t, respDumpFile, tc.expectDumpName)
|
|
|
|
// Verify request dump.
|
|
reqDumpData, err := os.ReadFile(reqDumpFile)
|
|
require.NoError(t, err)
|
|
dumpReq, err := http.ReadRequest(bufio.NewReader(bytes.NewReader(reqDumpData)))
|
|
require.NoError(t, err)
|
|
require.Equal(t, http.MethodGet, dumpReq.Method)
|
|
|
|
// Verify response dump.
|
|
respDumpData, err := os.ReadFile(respDumpFile)
|
|
require.NoError(t, err)
|
|
dumpResp, err := http.ReadResponse(bufio.NewReader(bytes.NewReader(respDumpData)), nil)
|
|
require.NoError(t, err)
|
|
defer dumpResp.Body.Close()
|
|
require.Equal(t, http.StatusOK, dumpResp.StatusCode)
|
|
dumpRespBody, err := io.ReadAll(dumpResp.Body)
|
|
require.NoError(t, err)
|
|
require.JSONEq(t, responseBody, string(dumpRespBody))
|
|
})
|
|
}
|
|
}
|