Files
tbphp bd539713da perf: 优化测试性能与 CI/发布流水线耗时 (#465)
* test(control): 消除测试 fixture 中的重复密钥派生

- internal/control:fixture 构造中 388 次重复执行 PBKDF2(10 万轮
  SHA256)密钥派生,改为共享单例,仅派生一次
- 新增 internal/testutil/encryptiontest,提供按 key material 值
  memoize 的共享 encryption.Service,横向覆盖 gateway、subscription、
  execution/cpa 中同样存在的重复派生(PBKDF2 占比最高达 79%)
- aesService 构造后只读,跨 fixture(含并行测试)共享安全;container
  包同类调用经排查确认属于被测装配流程本身,不适用本次改造

* test(state): 迁移测试改用共享 schema 模板

state/loader 中 openMigratedDatabase 被 21 次调用,每次都完整走一遍
迁移注册表;改为复用 sqlitetest.OpenMigrated 的模板缓存,与 control、
requestlog 部分测试已采用的方式一致。app、container、requestlog 中同
类调用点经逐点排查,均属被测对象本身或依赖真实文件/账本状态,不适用。

* ci(release): 消除 CI 与发布流水线中的耗时浪费

- race-tests 独立 setup-go 缓存命名空间,避免与 test/database-contract
  共用 key 导致 race-instrumented GOCACHE 永远无法写回
- 移除 publish-images 中零命中的 GHA buildx image cache,同时消除其
  失败会拖累发布状态判定为 partial/blocked 的关联风险
- 三个只读 job(仅用 buildx imagetools 查询)移除多余的 buildx 初始化
- native-artifact-smoke 改为按需下载单个 binary + 独立 checksums,
  不再下载完整 release-assets

* test(control): 第一批测试标记 t.Parallel(35 个文件)

internal/control 651 个顶层测试里只有 5.8% 标了 t.Parallel(),其余
在包内是排队串行执行的;race-tests 总耗时现在完全由该包的最长执行时
间决定(213s),是唯一剩余的瓶颈。

本批覆盖 35 个文件、299 个函数。排除 18 个不适用的函数:
- 16 个直接操作全局 logrus 状态的测试(catalog_sync_test.go 等 8 个
  文件),继续保持串行;Go 的 t.Parallel() 调度语义保证未标记的测试
  会在所有并行测试开始前完整跑完,两者不会交叉执行
- external_database_integration_test.go 的 2 个测试依赖
  GPT_LOAD_DATABASE_TEST_DSN:race-tests 中因未设置而被 t.Skip,标记
  并行无收益;database-contract job 中会共享同一个外部数据库实例真
  实执行,标记并行有正确性风险(唯一索引冲突/断言假设独占)

已排查确认安全:t.Setenv/os.Setenv 均为 0 处,无硬编码端口占用,每个
fixture 走独立 :memory: 数据库;本地 -count=3 重复验证无 flaky。

剩余约 297 个函数(36 个文件)作为第二批,待本批 CI 实测确认收益后
再决定是否推进。

* test(control): 第二批测试标记 t.Parallel,补齐排除说明(36 个文件)

覆盖剩余 297 个函数、36 个文件,加上第一批的 299 个,internal/control
除 18 个明确排除的函数外全部标记 t.Parallel()。

为 18 个排除函数逐一补充一行注释说明原因,避免日后被误认为遗漏而被
顺手加上 t.Parallel():
- 16 个劫持全局 logrus 状态的测试
- 2 个依赖 GPT_LOAD_DATABASE_TEST_DSN 共享外部数据库的测试

本地 -count=3 重复验证、全量 go test -count=1 . ./internal/...(58
包 ok)均通过。

* fix(release): 采纳 Review 反馈,修正文档措辞与产物保留期

- encryptiontest.Service 的文档注释修正为明确说明缓存仅在单个包的
  测试二进制内有效,不跨包共享(每个包的 go test 是独立进程)
- native-artifact-smoke 依赖的 binary-*、release-checksums 保留期
  从 1 天提高到与 release-assets 一致的 14 天:失败的 release run
  若延迟超过 1 天才重试,此前较短的保留期会导致产物过期、下载失败,
  阻塞发布流程

以下反馈经核实判定为不成立,未采纳:
- validation_test.go 中两个测试被指"竞争全局并发槽位"——经查
  newValidationWorkerForTest 每次调用构造全新独立实例(worker、
  channel、mutation coordinator 等),validationConcurrency 只是
  编译期常量,两个测试之间不存在任何运行时共享状态
2026-08-24 20:07:11 +08:00

260 lines
8.0 KiB
Go

package control
import (
"bytes"
"context"
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"strconv"
"strings"
"testing"
"github.com/gin-gonic/gin"
"gpt-load/internal/platform/config"
"gpt-load/internal/protocol"
"gpt-load/internal/state"
"gpt-load/internal/storage/models"
)
func TestControlRoutesRequireAuthenticationForGroupCreateAndModelDiscovery(t *testing.T) {
t.Parallel()
initControlI18n(t)
fixture := newServiceFixture(t)
fixture.service.executor = newRecordingDiscoveryExecutor(&recordingDiscoveryExecutorTarget{
value: protocol.OpenAICompletions,
listFn: func(context.Context, string, string, state.HeaderRules) ([]string, error) {
t.Fatal("ListModels called without valid management authentication")
return nil, nil
},
})
engine := gin.New()
NewServer(&config.Config{AuthKey: "test-auth-key"}, fixture.service).RegisterRoutes(engine)
for _, endpoint := range []struct {
name string
path string
body string
}{
{
name: "Group create",
path: "/api/groups",
body: `{"upstream_url":"https://auth.example.com","protocols":["openai-completions"],` +
`"models":[],"keys":"sk-auth-secret"}`,
},
{
name: "Model discovery",
path: "/api/models/discover",
body: `{"upstream_url":"https://auth.example.com","protocols":["openai-completions"],` +
`"keys":"sk-auth-secret"}`,
},
} {
t.Run(endpoint.name, func(t *testing.T) {
for _, authorization := range []string{"", "Bearer wrong-key"} {
before := countCreateImportRows(t, fixture)
request := httptest.NewRequest(http.MethodPost, endpoint.path, strings.NewReader(endpoint.body))
request.Header.Set("Content-Type", "application/json")
if authorization != "" {
request.Header.Set("Authorization", authorization)
}
if endpoint.path == "/api/groups" {
setRequiredTestIdempotencyHeader(request)
}
recorder := httptest.NewRecorder()
engine.ServeHTTP(recorder, request)
if recorder.Code != http.StatusUnauthorized ||
!strings.Contains(recorder.Body.String(), `"code":"UNAUTHORIZED"`) {
t.Fatalf("Authorization %q = %d %s, want 401", authorization, recorder.Code, recorder.Body.String())
}
if strings.Contains(recorder.Body.String(), "sk-auth-secret") {
t.Fatalf("response exposes plaintext key: %s", recorder.Body.String())
}
if after := countCreateImportRows(t, fixture); after != before {
t.Fatalf("rows changed from %#v to %#v", before, after)
}
}
})
}
}
func TestCreateAndImportEndpointsRequireCanonicalIdempotencyKeyBeforeMutation(t *testing.T) {
t.Parallel()
initControlI18n(t)
fixture := newServiceFixture(t)
groupID := createGroupWithCredentials(t, fixture, "seed-idempotency-header")
engine := gin.New()
NewServer(&config.Config{AuthKey: "test-auth-key"}, fixture.service).RegisterRoutes(engine)
tests := []struct {
name string
path string
body string
}{
{
name: "AccessKey create",
path: "/api/access-keys",
body: `{"name":"client"}`,
},
{
name: "Group create",
path: "/api/groups",
body: `{"channel_id":"openai","params":{},"models":[],"credentials":"K"}`,
},
{
name: "Group credential import",
path: "/api/groups/" + strconv.FormatUint(uint64(groupID), 10) + "/credentials/import",
body: `{"credentials":"K"}`,
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
before := countCreateImportRows(t, fixture)
for _, header := range []struct {
value string
wantStatus int
wantCode string
}{
{wantStatus: http.StatusPreconditionRequired, wantCode: "IDEMPOTENCY_KEY_REQUIRED"},
{
value: "not-a-canonical-uuid",
wantStatus: http.StatusBadRequest,
wantCode: "INVALID_IDEMPOTENCY_KEY",
},
} {
request := httptest.NewRequest(
http.MethodPost,
test.path,
strings.NewReader(test.body),
)
request.Header.Set("Authorization", "Bearer test-auth-key")
if header.value != "" {
request.Header.Set("Idempotency-Key", header.value)
}
recorder := httptest.NewRecorder()
engine.ServeHTTP(recorder, request)
if recorder.Code != header.wantStatus ||
!strings.Contains(recorder.Body.String(), header.wantCode) {
t.Fatalf(
"POST %s = %d %s, want %d/%s",
test.path,
recorder.Code,
recorder.Body.String(),
header.wantStatus,
header.wantCode,
)
}
if after := countCreateImportRows(t, fixture); after != before {
t.Fatalf("rows changed from %#v to %#v", before, after)
}
}
})
}
}
func TestAccessKeyCreateReplayOptionsAndRevealWireContracts(t *testing.T) {
t.Parallel()
initControlI18n(t)
fixture := newServiceFixture(t)
fixture.service.random = bytes.NewReader(make([]byte, 16))
fixture.service.operationRandom = bytes.NewReader(bytes.Repeat([]byte{0x73}, 16))
engine := gin.New()
NewServer(&config.Config{AuthKey: "test-auth-key"}, fixture.service).RegisterRoutes(engine)
const idempotencyKey = "318f47a2-9c35-4d6e-8b1a-1234567890ab"
create := func() *httptest.ResponseRecorder {
request := httptest.NewRequest(
http.MethodPost,
"/api/access-keys",
strings.NewReader(`{"name":"wire-client"}`),
)
request.Header.Set("Authorization", "Bearer test-auth-key")
request.Header.Set("Idempotency-Key", idempotencyKey)
recorder := httptest.NewRecorder()
engine.ServeHTTP(recorder, request)
return recorder
}
first := create()
if first.Code != http.StatusOK ||
first.Header().Get("Cache-Control") != "no-store" ||
first.Header().Get("Pragma") != "no-cache" {
t.Fatalf("first create = %d headers=%v body=%s", first.Code, first.Header(), first.Body)
}
var firstEnvelope struct {
Data AccessKeyCreateResult `json:"data"`
}
if err := json.Unmarshal(first.Body.Bytes(), &firstEnvelope); err != nil {
t.Fatalf("decode first create: %v", err)
}
if firstEnvelope.Data.Key == "" || firstEnvelope.Data.Replayed {
t.Fatalf("first create data = %#v", firstEnvelope.Data)
}
replay := create()
if replay.Code != http.StatusOK ||
strings.Contains(replay.Body.String(), firstEnvelope.Data.Key) {
t.Fatalf("replay create = %d %s", replay.Code, replay.Body)
}
var replayEnvelope struct {
Data AccessKeyCreateResult `json:"data"`
}
if err := json.Unmarshal(replay.Body.Bytes(), &replayEnvelope); err != nil {
t.Fatalf("decode replay create: %v", err)
}
if replayEnvelope.Data.Key != "" || !replayEnvelope.Data.Replayed ||
replayEnvelope.Data.ID != firstEnvelope.Data.ID {
t.Fatalf("replay data = %#v", replayEnvelope.Data)
}
optionsRequest := httptest.NewRequest(http.MethodGet, "/api/access-keys/options", nil)
optionsRequest.Header.Set("Authorization", "Bearer test-auth-key")
options := httptest.NewRecorder()
engine.ServeHTTP(options, optionsRequest)
if options.Code != http.StatusOK ||
strings.Contains(options.Body.String(), "masked_key") ||
strings.Contains(options.Body.String(), `"key"`) {
t.Fatalf("options = %d %s", options.Code, options.Body)
}
revealRequest := httptest.NewRequest(
http.MethodPost,
fmt.Sprintf("/api/access-keys/%d/reveal", firstEnvelope.Data.ID),
nil,
)
revealRequest.Header.Set("Authorization", "Bearer test-auth-key")
reveal := httptest.NewRecorder()
engine.ServeHTTP(reveal, revealRequest)
if reveal.Code != http.StatusOK ||
reveal.Header().Get("Cache-Control") != "no-store" ||
reveal.Header().Get("Pragma") != "no-cache" ||
!strings.Contains(reveal.Body.String(), firstEnvelope.Data.Key) {
t.Fatalf("reveal = %d headers=%v body=%s", reveal.Code, reveal.Header(), reveal.Body)
}
}
type createImportRowCounts struct {
groups int64
credentials int64
access int64
operations int64
}
func countCreateImportRows(
t *testing.T,
fixture serviceFixture,
) createImportRowCounts {
t.Helper()
var result createImportRowCounts
for model, target := range map[any]*int64{
&models.Group{}: &result.groups,
&models.Credential{}: &result.credentials,
&models.AccessKey{}: &result.access,
&models.ControlOperation{}: &result.operations,
} {
if err := fixture.db.Model(model).Count(target).Error; err != nil {
t.Fatalf("count %T: %v", model, err)
}
}
return result
}