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
This commit is contained in:
zenfun
2025-12-14 00:35:35 +08:00
parent 50da2ff668
commit d0011f3eb2
7 changed files with 279 additions and 0 deletions

View File

@@ -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)` 风格)。

43
TESTING.md Normal file
View File

@@ -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` 的回归保护
- SyncServiceRedis snapshot 写入与 routing key 生成
- request_idGin middleware 透传/生成
### 阶段 2扩覆盖
- Master/Keytoken hash、epoch、scope、状态机分支
- LogWriter批处理/刷盘边界(可用 fake clock
### 阶段 3契约测试
- 与 DP 的 snapshot schema 契约:用 `testdata` golden 校验字段/格式稳定

4
go.mod
View File

@@ -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

8
go.sum
View File

@@ -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=

View File

@@ -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)
}
}

View File

@@ -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)
}
}

View File

@@ -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)
}
}