mirror of
https://github.com/EZ-Api/ez-api.git
synced 2026-01-13 17:47:51 +00:00
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:
@@ -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
43
TESTING.md
Normal 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` 的回归保护
|
||||
- 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 校验字段/格式稳定
|
||||
|
||||
4
go.mod
4
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
|
||||
|
||||
8
go.sum
8
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=
|
||||
|
||||
105
internal/api/provider_handler_test.go
Normal file
105
internal/api/provider_handler_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
57
internal/middleware/request_id_test.go
Normal file
57
internal/middleware/request_id_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
|
||||
58
internal/service/sync_test.go
Normal file
58
internal/service/sync_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user