* 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 只是 编译期常量,两个测试之间不存在任何运行时共享状态
340 lines
9.3 KiB
Go
340 lines
9.3 KiB
Go
package control
|
|
|
|
import (
|
|
"encoding/json"
|
|
"fmt"
|
|
"net/http"
|
|
"path/filepath"
|
|
"strings"
|
|
"sync"
|
|
"sync/atomic"
|
|
"testing"
|
|
|
|
"github.com/gin-gonic/gin"
|
|
gormsqlite "github.com/glebarez/sqlite"
|
|
"gorm.io/gorm"
|
|
"gorm.io/gorm/logger"
|
|
|
|
"gpt-load/internal/accessquota"
|
|
"gpt-load/internal/catalog"
|
|
"gpt-load/internal/channel"
|
|
"gpt-load/internal/gateway"
|
|
"gpt-load/internal/health"
|
|
"gpt-load/internal/platform/encryption"
|
|
"gpt-load/internal/platform/httproute"
|
|
"gpt-load/internal/platform/i18n"
|
|
"gpt-load/internal/requestlog"
|
|
"gpt-load/internal/state"
|
|
stateloader "gpt-load/internal/state/loader"
|
|
"gpt-load/internal/storage"
|
|
"gpt-load/internal/storage/models"
|
|
"gpt-load/internal/subscription"
|
|
subscriptionproviders "gpt-load/internal/subscription/providers"
|
|
subscriptionruntime "gpt-load/internal/subscription/runtime"
|
|
"gpt-load/internal/testutil/encryptiontest"
|
|
"gpt-load/internal/testutil/sqlitetest"
|
|
)
|
|
|
|
var (
|
|
controlI18nOnce sync.Once
|
|
controlI18nErr error
|
|
testIdempotencySequence atomic.Uint64
|
|
)
|
|
|
|
type blockingDecryptService struct {
|
|
encryption.Service
|
|
started chan<- struct{}
|
|
release <-chan struct{}
|
|
}
|
|
|
|
func (service blockingDecryptService) Decrypt(ciphertext string) (string, error) {
|
|
close(service.started)
|
|
<-service.release
|
|
return service.Service.Decrypt(ciphertext)
|
|
}
|
|
|
|
func loadCreatedGroupModels(t *testing.T, fixture serviceFixture, groupID uint) []GroupModel {
|
|
t.Helper()
|
|
var group models.Group
|
|
if err := fixture.db.First(&group, groupID).Error; err != nil {
|
|
t.Fatalf("query group %d: %v", groupID, err)
|
|
}
|
|
var result []GroupModel
|
|
if err := json.Unmarshal(group.Models, &result); err != nil {
|
|
t.Fatalf("decode group %d models: %v", groupID, err)
|
|
}
|
|
if result == nil {
|
|
result = make([]GroupModel, 0)
|
|
}
|
|
return result
|
|
}
|
|
|
|
func createGroupWithCredentials(t *testing.T, fixture serviceFixture, credentials string) uint {
|
|
t.Helper()
|
|
name := fmt.Sprintf("credential-group-%d", testIdempotencySequence.Add(1))
|
|
result, err := fixture.service.CreateGroup(t.Context(), GroupCreateRequest{
|
|
Name: &name, ChannelID: channel.OpenAI, Params: json.RawMessage(`{}`),
|
|
Models: optionalGroupModels{Set: true, Values: []GroupModel{{ID: "gpt-4o"}}},
|
|
Credentials: credentials, ConnectionType: "api_key",
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("CreateGroup(%q) error = %v", name, err)
|
|
}
|
|
return result.GroupID
|
|
}
|
|
|
|
// RegisterRoutes keeps package tests concise while production registration
|
|
// remains exclusively owned by the shared HTTP route registry.
|
|
func (s *Server) RegisterRoutes(engine *gin.Engine) {
|
|
registry, err := httproute.NewRegistry(s.HTTPModule())
|
|
if err != nil {
|
|
panic(err)
|
|
}
|
|
if err := registry.Bind(engine); err != nil {
|
|
panic(err)
|
|
}
|
|
}
|
|
|
|
func registerGatewayRoutes(t *testing.T, engine *gin.Engine, handler *gateway.Handler) {
|
|
t.Helper()
|
|
registry, err := httproute.NewRegistry(handler.HTTPModule())
|
|
if err != nil {
|
|
t.Fatalf("NewRegistry(gateway) error = %v", err)
|
|
}
|
|
if err := registry.Bind(engine); err != nil {
|
|
t.Fatalf("Bind(gateway) error = %v", err)
|
|
}
|
|
}
|
|
|
|
func setRequiredTestIdempotencyHeader(request *http.Request) {
|
|
sequence := testIdempotencySequence.Add(1)
|
|
request.Header.Set(
|
|
"Idempotency-Key",
|
|
fmt.Sprintf("00000000-0000-4000-8000-%012x", sequence),
|
|
)
|
|
}
|
|
|
|
type serviceFixture struct {
|
|
db *gorm.DB
|
|
manager *state.Manager
|
|
registry *state.CredentialRegistry
|
|
channelRegistry *channel.Registry
|
|
priceRuntime *PriceRuntime
|
|
catalogRuntime *catalog.Runtime
|
|
encryption encryption.Service
|
|
stats *health.StatsStore
|
|
mutations *health.MutationCoordinator
|
|
requestLogStats *staticRequestLogStatsReader
|
|
accessQuota *accessquota.Runtime
|
|
service *Service
|
|
}
|
|
|
|
type staticRequestLogStatsReader struct {
|
|
value requestlog.Stats
|
|
fn func() requestlog.Stats
|
|
}
|
|
|
|
func mustBuildCompileInput(t *testing.T, db *gorm.DB) state.CompileInput {
|
|
t.Helper()
|
|
input, err := stateloader.BuildCompileInput(t.Context(), db)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
return input
|
|
}
|
|
|
|
func (reader *staticRequestLogStatsReader) Stats() requestlog.Stats {
|
|
if reader.fn != nil {
|
|
return reader.fn()
|
|
}
|
|
return reader.value
|
|
}
|
|
|
|
func initControlI18n(t *testing.T) {
|
|
t.Helper()
|
|
controlI18nOnce.Do(func() {
|
|
gin.SetMode(gin.ReleaseMode)
|
|
controlI18nErr = i18n.Init()
|
|
})
|
|
if controlI18nErr != nil {
|
|
t.Fatalf("i18n.Init() error = %v", controlI18nErr)
|
|
}
|
|
}
|
|
|
|
func newServiceFixture(t *testing.T) serviceFixture {
|
|
t.Helper()
|
|
return newServiceFixtureWithDatabase(t, sqlitetest.OpenMigrated(t))
|
|
}
|
|
|
|
func mustEnsureInitialPrices(t *testing.T, fixture serviceFixture) {
|
|
t.Helper()
|
|
if err := fixture.service.EnsureInitialState(t.Context()); err != nil {
|
|
t.Fatalf("EnsureInitialState() error = %v", err)
|
|
}
|
|
if fixture.priceRuntime.Load() == nil {
|
|
t.Fatal("EnsureInitialState() did not publish PriceTable")
|
|
}
|
|
}
|
|
|
|
func newFileServiceFixture(t *testing.T) (serviceFixture, string) {
|
|
t.Helper()
|
|
dsn := filepath.Join(t.TempDir(), "control.db")
|
|
return newServiceFixtureWithDSN(t, dsn), dsn
|
|
}
|
|
|
|
func newServiceFixtureWithDSN(t *testing.T, dsn string) serviceFixture {
|
|
t.Helper()
|
|
return newServiceFixtureWithDatabase(t, openControlTestDBWithDSN(t, dsn))
|
|
}
|
|
|
|
func newServiceFixtureWithDatabase(t *testing.T, db *gorm.DB) serviceFixture {
|
|
t.Helper()
|
|
manager := state.NewManager()
|
|
accessQuota := accessquota.NewRuntime()
|
|
manager.SetSnapshotReconciler(controlAccessQuotaReconciler{runtime: accessQuota})
|
|
registry := state.NewCredentialRegistry()
|
|
channelRegistry := channel.NewRegistry()
|
|
keyService := encryptiontest.Service(t, "control-test-master-key-material-2026")
|
|
if _, err := manager.Publish(state.CompileInput{}); err != nil {
|
|
t.Fatalf("manager.Publish(empty) error = %v", err)
|
|
}
|
|
stats := health.NewStatsStore()
|
|
mutations := health.NewMutationCoordinator()
|
|
subscriptions, err := subscriptionruntime.NewRuntime(channelRegistry, subscriptionproviders.Implementations()...)
|
|
if err != nil {
|
|
t.Fatalf("subscriptionruntime.NewRuntime() error = %v", err)
|
|
}
|
|
subscriptionCredentials := subscription.NewCredentialManager(db, keyService, registry, mutations, subscriptions)
|
|
requestLogStats := &staticRequestLogStatsReader{}
|
|
priceRuntime := NewPriceRuntime()
|
|
catalogRuntime := &catalog.Runtime{}
|
|
service := NewService(
|
|
db,
|
|
manager,
|
|
registry,
|
|
priceRuntime,
|
|
catalogRuntime,
|
|
nil,
|
|
keyService,
|
|
controlHTTPExecutor{},
|
|
subscriptionCredentials,
|
|
nil,
|
|
nil,
|
|
nil,
|
|
stats,
|
|
mutations,
|
|
requestLogStats,
|
|
accessQuota,
|
|
channelRegistry,
|
|
)
|
|
installCodexControlTestHooks(service)
|
|
// Tests opt into reset-credit upstream calls explicitly; no fixture may
|
|
// reach a real provider by accident.
|
|
setCodexResetCreditObservation(service, nil)
|
|
return serviceFixture{
|
|
db: db, manager: manager, registry: registry, channelRegistry: channelRegistry, encryption: keyService,
|
|
priceRuntime: priceRuntime, catalogRuntime: catalogRuntime,
|
|
stats: stats, mutations: mutations, requestLogStats: requestLogStats, accessQuota: accessQuota,
|
|
service: service,
|
|
}
|
|
}
|
|
|
|
type controlAccessQuotaReconciler struct {
|
|
runtime *accessquota.Runtime
|
|
}
|
|
|
|
func (reconciler controlAccessQuotaReconciler) ReconcileConfigSnapshot(snapshot *state.ConfigSnapshot) error {
|
|
return reconciler.runtime.Reconcile(snapshot.AccessQuotaDefinitions())
|
|
}
|
|
|
|
func openControlTestDB(t *testing.T) *gorm.DB {
|
|
t.Helper()
|
|
return openControlTestDBWithDSN(t, ":memory:")
|
|
}
|
|
|
|
func openControlTestDBWithDSN(t *testing.T, dsn string) *gorm.DB {
|
|
t.Helper()
|
|
db := openControlTestDBWithoutMigration(t, dsn)
|
|
if err := storage.AutoMigrate(db); err != nil {
|
|
t.Fatalf("storage.AutoMigrate() error = %v", err)
|
|
}
|
|
return db
|
|
}
|
|
|
|
func openControlTestDBWithoutMigration(t *testing.T, dsn string) *gorm.DB {
|
|
t.Helper()
|
|
db, err := storage.Open(dsn)
|
|
if err != nil {
|
|
t.Fatalf("storage.Open(%q) error = %v", dsn, err)
|
|
}
|
|
sqlDB, err := db.DB()
|
|
if err != nil {
|
|
t.Fatalf("db.DB() error = %v", err)
|
|
}
|
|
t.Cleanup(func() {
|
|
if err := sqlDB.Close(); err != nil {
|
|
t.Errorf("close control test database: %v", err)
|
|
}
|
|
})
|
|
return db
|
|
}
|
|
|
|
func holdRollbackJournalReadLock(t *testing.T, appDB *gorm.DB, dsn string) func() {
|
|
t.Helper()
|
|
if err := appDB.Exec("PRAGMA busy_timeout = 1").Error; err != nil {
|
|
t.Fatalf("set app busy_timeout: %v", err)
|
|
}
|
|
var mode string
|
|
if err := appDB.Raw("PRAGMA journal_mode = DELETE").Scan(&mode).Error; err != nil {
|
|
t.Fatalf("set rollback journal: %v", err)
|
|
}
|
|
if !strings.EqualFold(mode, "delete") {
|
|
t.Fatalf("journal_mode = %q, want delete", mode)
|
|
}
|
|
|
|
blocker, err := gorm.Open(
|
|
gormsqlite.Open(dsn+"?_pragma=busy_timeout(1)"),
|
|
&gorm.Config{Logger: logger.Default.LogMode(logger.Silent)},
|
|
)
|
|
if err != nil {
|
|
t.Fatalf("open blocker: %v", err)
|
|
}
|
|
blockerSQL, err := blocker.DB()
|
|
if err != nil {
|
|
t.Fatalf("blocker DB(): %v", err)
|
|
}
|
|
readTx := blocker.Begin()
|
|
if readTx.Error != nil {
|
|
t.Fatal(readTx.Error)
|
|
}
|
|
var count int64
|
|
if err := readTx.Table("groups").Count(&count).Error; err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
var once sync.Once
|
|
release := func() {
|
|
once.Do(func() {
|
|
if err := readTx.Rollback().Error; err != nil {
|
|
t.Errorf("release read lock: %v", err)
|
|
}
|
|
if err := blockerSQL.Close(); err != nil {
|
|
t.Errorf("close blocker: %v", err)
|
|
}
|
|
})
|
|
}
|
|
t.Cleanup(release)
|
|
return release
|
|
}
|
|
|
|
func assertGroupCount(t *testing.T, db *gorm.DB, want int64) {
|
|
t.Helper()
|
|
var got int64
|
|
if err := db.Table("groups").Count(&got).Error; err != nil {
|
|
t.Fatalf("count groups: %v", err)
|
|
}
|
|
if got != want {
|
|
t.Fatalf("group count = %d, want %d", got, want)
|
|
}
|
|
}
|