From d0011f3eb20bb4af792904a489d464822b364210 Mon Sep 17 00:00:00 2001 From: zenfun Date: Sun, 14 Dec 2025 00:35:35 +0800 Subject: [PATCH] test: add comprehensive unit tests for provider, middleware, and sync service - Add TESTING.md documentation explaining unit test conventions and integration testing setup - Add miniredis and sqlite dependencies to go.mod for in-memory testing - Add provider_handler_test.go ensuring Vertex providers default google_location to "global" - Add request_id_test.go verifying request ID generation and header preservation - Add sync_test.go validating Redis snapshot writes and routing key generation - Update README.md with testing section referencing new documentation --- README.md | 4 + TESTING.md | 43 ++++++++++ go.mod | 4 + go.sum | 8 ++ internal/api/provider_handler_test.go | 105 +++++++++++++++++++++++++ internal/middleware/request_id_test.go | 57 ++++++++++++++ internal/service/sync_test.go | 58 ++++++++++++++ 7 files changed, 279 insertions(+) create mode 100644 TESTING.md create mode 100644 internal/api/provider_handler_test.go create mode 100644 internal/middleware/request_id_test.go create mode 100644 internal/service/sync_test.go diff --git a/README.md b/README.md index 496197e..b6fceab 100644 --- a/README.md +++ b/README.md @@ -95,6 +95,10 @@ cd ez-api 脚本会拉起 `docker-compose.integration.yml` 中的服务,运行带 `integration` tag 的 Go 测试,并在完成后清理容器和卷。 +## 测试 + +- 单元测试:`go test ./...`(测试文件与源码同目录,更多约定见 `TESTING.md`) + ## 日志 - 业务代码统一使用标准库 `log/slog`(`logger.Info("msg", "k", v)` 风格)。 diff --git a/TESTING.md b/TESTING.md new file mode 100644 index 0000000..669ff57 --- /dev/null +++ b/TESTING.md @@ -0,0 +1,43 @@ +# Testing (ez-api) + +本仓库的单元测试采用 Go 标准组织方式:单元测试与源码同目录(`*_test.go`),默认通过 `go test ./...` 运行。 + +此外,本仓库仍保留 `./test` 下的 docker-compose 集成测试(带 `integration` tag),用于端到端验证。 + +## 运行 + +### 单元测试 + +```bash +go test ./... +``` + +### 集成测试(docker compose) + +```bash +./test/integration.sh +``` + +## 说明 + +- 单元测试不依赖 Docker,不访问公网。 +- 对 Redis 相关逻辑优先使用 `miniredis`(纯内存、接近真实命令行为)。 +- 对 DB 相关逻辑优先使用 sqlite in-memory(只用于测试),避免引入外部依赖。 + +## 分阶段计划(长期) + +### 阶段 1(已落地/优先级最高) + +- provider 归一化:Vertex 默认 `google_location=global` 的回归保护 +- SyncService:Redis snapshot 写入与 routing key 生成 +- request_id:Gin middleware 透传/生成 + +### 阶段 2(扩覆盖) + +- Master/Key:token hash、epoch、scope、状态机分支 +- LogWriter:批处理/刷盘边界(可用 fake clock) + +### 阶段 3(契约测试) + +- 与 DP 的 snapshot schema 契约:用 `testdata` golden 校验字段/格式稳定 + diff --git a/go.mod b/go.mod index 118f511..3347d4e 100644 --- a/go.mod +++ b/go.mod @@ -3,6 +3,7 @@ module github.com/ez-api/ez-api go 1.24.5 require ( + github.com/alicebob/miniredis/v2 v2.35.0 github.com/ez-api/foundation v0.1.0 github.com/gin-gonic/gin v1.11.0 github.com/redis/go-redis/v9 v9.17.2 @@ -12,6 +13,7 @@ require ( github.com/swaggo/swag v1.16.6 golang.org/x/crypto v0.44.0 gorm.io/driver/postgres v1.6.0 + gorm.io/driver/sqlite v1.6.0 gorm.io/gorm v1.31.1 ) @@ -52,6 +54,7 @@ require ( github.com/leodido/go-urn v1.4.0 // indirect github.com/mattn/go-colorable v0.1.13 // indirect github.com/mattn/go-isatty v0.0.20 // indirect + github.com/mattn/go-sqlite3 v1.14.22 // indirect github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect github.com/modern-go/reflect2 v1.0.2 // indirect github.com/pelletier/go-toml/v2 v2.2.4 // indirect @@ -66,6 +69,7 @@ require ( github.com/subosito/gotenv v1.6.0 // indirect github.com/twitchyliquid64/golang-asm v0.15.1 // indirect github.com/ugorji/go/codec v1.3.0 // indirect + github.com/yuin/gopher-lua v1.1.1 // indirect go.uber.org/mock v0.5.0 // indirect go.yaml.in/yaml/v3 v3.0.4 // indirect golang.org/x/arch v0.20.0 // indirect diff --git a/go.sum b/go.sum index cb62155..6795d9f 100644 --- a/go.sum +++ b/go.sum @@ -1,5 +1,7 @@ github.com/KyleBanks/depth v1.2.1 h1:5h8fQADFrWtarTdtDudMmGsC7GPbOAu6RVB3ffsVFHc= github.com/KyleBanks/depth v1.2.1/go.mod h1:jzSb9d0L43HxTQfT+oSA1EEp2q+ne2uh6XgeJcm8brE= +github.com/alicebob/miniredis/v2 v2.35.0 h1:QwLphYqCEAo1eu1TqPRN2jgVMPBweeQcR21jeqDCONI= +github.com/alicebob/miniredis/v2 v2.35.0/go.mod h1:TcL7YfarKPGDAthEtl5NBeHZfeUQj6OXMm/+iu5cLMM= github.com/bsm/ginkgo/v2 v2.12.0 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs= github.com/bsm/ginkgo/v2 v2.12.0/go.mod h1:SwYbGRRDovPVboqFv0tPTcG1sN61LM1Z4ARdbAV9g4c= github.com/bsm/gomega v1.27.10 h1:yeMWxP2pV2fG3FgAODIY8EiRE3dy0aeFYt4l7wh6yKA= @@ -105,6 +107,8 @@ github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/ github.com/mattn/go-isatty v0.0.19/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/mattn/go-sqlite3 v1.14.22 h1:2gZY6PC6kBnID23Tichd1K+Z0oS6nE/XwU+Vz/5o4kU= +github.com/mattn/go-sqlite3 v1.14.22/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y= github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg= github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= @@ -161,6 +165,8 @@ github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2 github.com/ugorji/go/codec v1.3.0 h1:Qd2W2sQawAfG8XSvzwhBeoGq71zXOC/Q1E9y/wUcsUA= github.com/ugorji/go/codec v1.3.0/go.mod h1:pRBVtBSKl77K30Bv8R2P+cLSGaTtex6fsA2Wjqmfxj4= github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= +github.com/yuin/gopher-lua v1.1.1 h1:kYKnWBjvbNP4XLT3+bPEwAXJx262OhaHDWDVOPjL46M= +github.com/yuin/gopher-lua v1.1.1/go.mod h1:GBR0iDaNXjAgGg9zfCvksxSRnQx76gclCIb7kdAd1Pw= go.uber.org/mock v0.5.0 h1:KAMbZvZPyBPWgD14IrIQ38QCyjwpvVVV6K/bHl1IwQU= go.uber.org/mock v0.5.0/go.mod h1:ge71pBPLYDk7QIi1LupWxdAykm7KIEFchiOqd6z7qMM= go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc= @@ -220,5 +226,7 @@ gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gorm.io/driver/postgres v1.6.0 h1:2dxzU8xJ+ivvqTRph34QX+WrRaJlmfyPqXmoGVjMBa4= gorm.io/driver/postgres v1.6.0/go.mod h1:vUw0mrGgrTK+uPHEhAdV4sfFELrByKVGnaVRkXDhtWo= +gorm.io/driver/sqlite v1.6.0 h1:WHRRrIiulaPiPFmDcod6prc4l2VGVWHz80KspNsxSfQ= +gorm.io/driver/sqlite v1.6.0/go.mod h1:AO9V1qIQddBESngQUKWL9yoH93HIeA1X6V633rBwyT8= gorm.io/gorm v1.31.1 h1:7CA8FTFz/gRfgqgpeKIBcervUn3xSyPUmr6B2WXJ7kg= gorm.io/gorm v1.31.1/go.mod h1:XyQVbO2k6YkOis7C2437jSit3SsDK72s7n7rsSHd+Gs= diff --git a/internal/api/provider_handler_test.go b/internal/api/provider_handler_test.go new file mode 100644 index 0000000..af57edb --- /dev/null +++ b/internal/api/provider_handler_test.go @@ -0,0 +1,105 @@ +package api + +import ( + "bytes" + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "testing" + + "github.com/alicebob/miniredis/v2" + "github.com/ez-api/ez-api/internal/dto" + "github.com/ez-api/ez-api/internal/model" + "github.com/ez-api/ez-api/internal/service" + "github.com/gin-gonic/gin" + "github.com/redis/go-redis/v9" + "gorm.io/driver/sqlite" + "gorm.io/gorm" +) + +func newTestHandler(t *testing.T) (*Handler, *gorm.DB) { + t.Helper() + gin.SetMode(gin.TestMode) + + // Use a unique in-memory DB per test to avoid cross-test interference. + dsn := fmt.Sprintf("file:%s?mode=memory&cache=shared", t.Name()) + db, err := gorm.Open(sqlite.Open(dsn), &gorm.Config{}) + if err != nil { + t.Fatalf("open sqlite: %v", err) + } + if err := db.AutoMigrate(&model.Provider{}); err != nil { + t.Fatalf("migrate: %v", err) + } + + mr := miniredis.RunT(t) + rdb := redis.NewClient(&redis.Options{Addr: mr.Addr()}) + sync := service.NewSyncService(rdb) + + return NewHandler(db, sync, nil), db +} + +func TestCreateProvider_DefaultsVertexLocationGlobal(t *testing.T) { + h, _ := newTestHandler(t) + + r := gin.New() + r.POST("/admin/providers", h.CreateProvider) + + reqBody := dto.ProviderDTO{ + Name: "g1", + Type: "vertex-express", + Group: "default", + Models: []string{"gemini-3-pro-preview"}, + } + b, _ := json.Marshal(reqBody) + + req := httptest.NewRequest(http.MethodPost, "/admin/providers", bytes.NewReader(b)) + req.Header.Set("Content-Type", "application/json") + rr := httptest.NewRecorder() + r.ServeHTTP(rr, req) + + if rr.Code != http.StatusCreated { + t.Fatalf("expected 201, got %d body=%s", rr.Code, rr.Body.String()) + } + var got model.Provider + if err := json.Unmarshal(rr.Body.Bytes(), &got); err != nil { + t.Fatalf("unmarshal: %v", err) + } + if got.GoogleLocation != "global" { + t.Fatalf("expected google_location=global, got %q", got.GoogleLocation) + } +} + +func TestUpdateProvider_DefaultsVertexLocationGlobalWhenMissing(t *testing.T) { + h, db := newTestHandler(t) + + existing := &model.Provider{ + Name: "g2", + Type: "vertex", + Group: "default", + Models: "gemini-3-pro-preview", + Status: "active", + } + if err := db.Create(existing).Error; err != nil { + t.Fatalf("create provider: %v", err) + } + + r := gin.New() + r.PUT("/admin/providers/:id", h.UpdateProvider) + + req := httptest.NewRequest(http.MethodPut, fmt.Sprintf("/admin/providers/%d", existing.ID), bytes.NewReader([]byte(`{}`))) + req.Header.Set("Content-Type", "application/json") + rr := httptest.NewRecorder() + r.ServeHTTP(rr, req) + + if rr.Code != http.StatusOK { + t.Fatalf("expected 200, got %d body=%s", rr.Code, rr.Body.String()) + } + var got model.Provider + if err := json.Unmarshal(rr.Body.Bytes(), &got); err != nil { + t.Fatalf("unmarshal: %v", err) + } + if got.GoogleLocation != "global" { + t.Fatalf("expected google_location=global, got %q", got.GoogleLocation) + } +} diff --git a/internal/middleware/request_id_test.go b/internal/middleware/request_id_test.go new file mode 100644 index 0000000..002ffdc --- /dev/null +++ b/internal/middleware/request_id_test.go @@ -0,0 +1,57 @@ +package middleware + +import ( + "net/http" + "net/http/httptest" + "testing" + + "github.com/gin-gonic/gin" +) + +func TestRequestID_GeneratesAndEchoes(t *testing.T) { + gin.SetMode(gin.TestMode) + + r := gin.New() + r.Use(RequestID()) + r.GET("/x", func(c *gin.Context) { + c.String(200, c.GetHeader("X-Request-ID")) + }) + + req := httptest.NewRequest(http.MethodGet, "/x", nil) + rr := httptest.NewRecorder() + r.ServeHTTP(rr, req) + + if rr.Code != 200 { + t.Fatalf("expected 200, got %d", rr.Code) + } + id := rr.Header().Get("X-Request-ID") + if id == "" { + t.Fatalf("expected X-Request-ID response header") + } + if rr.Body.String() != id { + t.Fatalf("expected body to echo X-Request-ID, got %q want %q", rr.Body.String(), id) + } +} + +func TestRequestID_PreservesIncomingHeader(t *testing.T) { + gin.SetMode(gin.TestMode) + + r := gin.New() + r.Use(RequestID()) + r.GET("/x", func(c *gin.Context) { + c.String(200, c.GetHeader("X-Request-ID")) + }) + + req := httptest.NewRequest(http.MethodGet, "/x", nil) + req.Header.Set("X-Request-ID", "req-abc") + rr := httptest.NewRecorder() + r.ServeHTTP(rr, req) + + if rr.Code != 200 { + t.Fatalf("expected 200, got %d", rr.Code) + } + if got := rr.Header().Get("X-Request-ID"); got != "req-abc" { + t.Fatalf("expected preserved X-Request-ID, got %q", got) + } +} + diff --git a/internal/service/sync_test.go b/internal/service/sync_test.go new file mode 100644 index 0000000..ed2b830 --- /dev/null +++ b/internal/service/sync_test.go @@ -0,0 +1,58 @@ +package service + +import ( + "encoding/json" + "testing" + + "github.com/alicebob/miniredis/v2" + "github.com/ez-api/ez-api/internal/model" + "github.com/redis/go-redis/v9" +) + +func TestSyncProvider_WritesSnapshotAndRouting(t *testing.T) { + mr := miniredis.RunT(t) + rdb := redis.NewClient(&redis.Options{Addr: mr.Addr()}) + + svc := NewSyncService(rdb) + + p := &model.Provider{ + Name: "p1", + Type: "vertex-express", + Group: "default", + Models: "gemini-3-pro-preview", + Status: "active", + AutoBan: true, + GoogleProject: "", + GoogleLocation: "global", + } + p.ID = 42 + + if err := svc.SyncProvider(p); err != nil { + t.Fatalf("SyncProvider: %v", err) + } + + raw := mr.HGet("config:providers", "42") + if raw == "" { + t.Fatalf("expected config:providers hash entry") + } + + var snap map[string]any + if err := json.Unmarshal([]byte(raw), &snap); err != nil { + t.Fatalf("invalid snapshot json: %v", err) + } + if snap["google_location"] != "global" { + t.Fatalf("expected google_location=global, got %v", snap["google_location"]) + } + + routeKey := "route:group:default:gemini-3-pro-preview" + if !mr.Exists(routeKey) { + t.Fatalf("expected routing key %q to exist", routeKey) + } + ok, err := mr.SIsMember(routeKey, "42") + if err != nil { + t.Fatalf("SIsMember: %v", err) + } + if !ok { + t.Fatalf("expected provider id 42 in routing set %q", routeKey) + } +}