Files
creator-hub/internal/controlplane/api/gateway_health_test.go
T
rogee 17abec449f
douyin-release-gate / verify (push) Failing after 3m42s
style(gateway): gofmt 探活测试文件
2026-09-30 14:33:37 +08:00

274 lines
11 KiB
Go

package api
import (
"context"
"errors"
"net/http"
"net/http/httptest"
"sync"
"testing"
"time"
hub "git.ipao.vip/rogee/creator-hub/internal/environment"
"github.com/gofiber/fiber/v3"
)
// setGatewayHealthProbeTimeout 临时替换探活超时,返回旧值供 restore 恢复。
func setGatewayHealthProbeTimeout(d time.Duration) time.Duration {
old := gatewayHealthProbeTimeout
gatewayHealthProbeTimeout = d
return old
}
// restoreGatewayHealthProbeTimeout 恢复探活超时旧值。
func restoreGatewayHealthProbeTimeout(old time.Duration) func() {
return func() { gatewayHealthProbeTimeout = old }
}
type recordedGatewayCheck struct {
name string
status string
reason string
}
// healthMemoryStore 在 memoryStore 之上记录 RecordGatewayCheck 调用,供健康检查断言。
type healthMemoryStore struct {
*memoryStore
mu sync.Mutex
checks []recordedGatewayCheck
}
func (s *healthMemoryStore) RecordGatewayCheck(_ context.Context, name, status, reason string) (hub.Gateway, error) {
s.mu.Lock()
defer s.mu.Unlock()
s.checks = append(s.checks, recordedGatewayCheck{name: name, status: status, reason: reason})
return hub.Gateway{Name: name, HealthStatus: status, LastCheckReason: reason}, nil
}
func TestClassifyGatewayHealth(t *testing.T) {
for _, test := range []struct {
name string
status int
callErr error
wantStatus string
wantReasonHad bool // 是否应携带非空 reason
}{
{name: "204 healthy without reason", status: http.StatusNoContent, wantStatus: "healthy", wantReasonHad: false},
{name: "200 healthy without reason", status: http.StatusOK, wantStatus: "healthy", wantReasonHad: false},
{name: "401 reachable but unauthorized", status: http.StatusUnauthorized, wantStatus: "healthy", wantReasonHad: true},
{name: "404 reachable but misrouted", status: http.StatusNotFound, wantStatus: "healthy", wantReasonHad: true},
{name: "503 unhealthy", status: http.StatusServiceUnavailable, wantStatus: "unhealthy", wantReasonHad: true},
{name: "connection error unhealthy", callErr: errors.New(`Get "http://gw:8081/healthz": dial tcp: connection refused`), wantStatus: "unhealthy", wantReasonHad: true},
} {
t.Run(test.name, func(t *testing.T) {
status, reason := classifyGatewayHealth(test.status, test.callErr)
if status != test.wantStatus {
t.Fatalf("classify status = %q, want %q (reason=%q)", status, test.wantStatus, reason)
}
if test.wantReasonHad != (reason != "") {
t.Fatalf("classify reason = %q, wantReasonHad=%v", reason, test.wantReasonHad)
}
})
}
}
func TestClassifyGatewayHealthTruncatesReason(t *testing.T) {
long := errors.New(string(make([]byte, 1024)))
_, reason := classifyGatewayHealth(0, long)
if len(reason) != 300 {
t.Fatalf("reason length = %d, want 300", len(reason))
}
}
func TestRecordGatewayHealthChecksPersistsLatestResult(t *testing.T) {
reachable := &fakeGateway{token: "token-a"}
reachableServer := httptest.NewServer(reachable.handler(t))
t.Cleanup(reachableServer.Close)
closedServer := httptest.NewServer(http.HandlerFunc(func(http.ResponseWriter, *http.Request) {}))
closedServer.Close() // 端口已释放,保证连接失败
store := &healthMemoryStore{memoryStore: newMemoryStore()}
store.gateways["gw-live"] = hub.Gateway{Name: "gw-live", Endpoint: reachableServer.URL, Token: reachable.token}
store.gateways["gw-down"] = hub.Gateway{Name: "gw-down", Endpoint: closedServer.URL, Token: "unused-token"}
if err := recordGatewayHealthChecks(context.Background(), store); err != nil {
t.Fatalf("record gateway health checks returned error: %v", err)
}
byName := map[string]recordedGatewayCheck{}
for _, check := range store.checks {
byName[check.name] = check
}
if len(store.checks) != 2 {
t.Fatalf("expected one check per gateway, got %#v", store.checks)
}
if byName["gw-live"].status != "healthy" {
t.Fatalf("reachable gateway status = %q, want healthy", byName["gw-live"].status)
}
if byName["gw-live"].reason != "" {
t.Fatalf("reachable gateway reason = %q, want empty", byName["gw-live"].reason)
}
if byName["gw-down"].status != "unhealthy" || byName["gw-down"].reason == "" {
t.Fatalf("unreachable gateway = %#v, want unhealthy with reason", byName["gw-down"])
}
if requests := reachable.recorded(); len(requests) != 1 || requests[0].path != "/healthz" {
t.Fatalf("reachable gateway probes = %#v, want exactly one /healthz", requests)
}
}
func TestRecordGatewayHealthChecksEmptyListIsNoop(t *testing.T) {
store := &healthMemoryStore{memoryStore: newMemoryStore()}
if err := recordGatewayHealthChecks(context.Background(), store); err != nil {
t.Fatalf("empty list returned error: %v", err)
}
if len(store.checks) != 0 {
t.Fatalf("empty list recorded checks: %#v", store.checks)
}
}
// 编译期约束:探活超时常量必须明显小于调度间隔,避免探活互相堆叠。
func TestGatewayHealthProbeTimeoutBounded(t *testing.T) {
if gatewayHealthProbeTimeout > 10*time.Second {
t.Fatalf("probe timeout %v must stay below scheduling cadence", gatewayHealthProbeTimeout)
}
}
// waitForGatewayCheck 轮询等待指定网关的立即探活结果落库;超时判失败。
func waitForGatewayCheck(t *testing.T, store *healthMemoryStore, name string) recordedGatewayCheck {
t.Helper()
deadline := time.Now().Add(2 * time.Second)
for time.Now().Before(deadline) {
store.mu.Lock()
for _, check := range store.checks {
if check.name == name {
store.mu.Unlock()
return check
}
}
store.mu.Unlock()
time.Sleep(5 * time.Millisecond)
}
t.Fatalf("gateway %q health check was not recorded within deadline", name)
return recordedGatewayCheck{}
}
// healthzProbeServer 返回校验 Bearer token 后立即响应 204 的 /healthz 测试服务。
func healthzProbeServer(t *testing.T, token string, probeStarted chan struct{}, respond func(http.ResponseWriter)) *httptest.Server {
t.Helper()
server := httptest.NewServer(http.HandlerFunc(func(response http.ResponseWriter, request *http.Request) {
if request.URL.Path != "/healthz" {
response.WriteHeader(http.StatusNotFound)
return
}
if request.Header.Get("Authorization") != "Bearer "+token {
response.WriteHeader(http.StatusUnauthorized)
return
}
if probeStarted != nil {
close(probeStarted)
}
if respond != nil {
respond(response)
return
}
response.WriteHeader(http.StatusNoContent)
}))
t.Cleanup(server.Close)
return server
}
// TestCreateGatewayProbesHealthImmediately:注册网关成功后立即按注册的 endpoint 与 token
// 对 /healthz 探活并落库,结果为 healthy。
func TestCreateGatewayProbesHealthImmediately(t *testing.T) {
store := &healthMemoryStore{memoryStore: newMemoryStore()}
probeStarted := make(chan struct{})
server := healthzProbeServer(t, "unit-test-gateway-token", probeStarted, nil)
app := fiber.New()
registerHubWithNetwork(app, store, fakeExitProbe{}, func(hub.NetworkExitAccess) (string, error) { return "", nil })
response := do(app, http.MethodPost, "/api/gateways",
`{"name":"gw-new","endpoint":"`+server.URL+`","token":"unit-test-gateway-token"}`)
if response.Code != http.StatusCreated {
t.Fatalf("create gateway returned %d: %s", response.Code, response.Body.String())
}
select {
case <-probeStarted:
case <-time.After(2 * time.Second):
t.Fatal("gateway health probe was not triggered after create")
}
check := waitForGatewayCheck(t, store, "gw-new")
if check.status != "healthy" || check.reason != "" {
t.Fatalf("created gateway check = %#v, want healthy without reason", check)
}
}
// TestCreateGatewayDoesNotBlockOnSlowGateway:网关探活被拖住时注册请求必须立即返回,
// 后台探活超时后把 unhealthy 结果落库。
func TestCreateGatewayDoesNotBlockOnSlowGateway(t *testing.T) {
t.Cleanup(restoreGatewayHealthProbeTimeout(setGatewayHealthProbeTimeout(250 * time.Millisecond)))
store := &healthMemoryStore{memoryStore: newMemoryStore()}
server := healthzProbeServer(t, "unit-test-gateway-token", nil, func(response http.ResponseWriter) {
time.Sleep(800 * time.Millisecond) // 超过探活超时,若探活同步执行则注册请求会被拖住
response.WriteHeader(http.StatusNoContent)
})
app := fiber.New()
registerHubWithNetwork(app, store, fakeExitProbe{}, func(hub.NetworkExitAccess) (string, error) { return "", nil })
started := time.Now()
response := do(app, http.MethodPost, "/api/gateways",
`{"name":"gw-slow","endpoint":"`+server.URL+`","token":"unit-test-gateway-token"}`)
if response.Code != http.StatusCreated {
t.Fatalf("create gateway returned %d: %s", response.Code, response.Body.String())
}
if elapsed := time.Since(started); elapsed > 500*time.Millisecond {
t.Fatalf("create gateway blocked %v on health probe", elapsed)
}
check := waitForGatewayCheck(t, store, "gw-slow")
if check.status != "unhealthy" || check.reason == "" {
t.Fatalf("slow gateway check = %#v, want unhealthy with reason", check)
}
}
// TestUpdateGatewayProbesHealthImmediately:编辑网关成功后立即按新 endpoint 探活并覆盖落库。
func TestUpdateGatewayProbesHealthImmediately(t *testing.T) {
store := &healthMemoryStore{memoryStore: newMemoryStore()}
store.gateways["gw-1"] = hub.Gateway{Name: "gw-1", Endpoint: "http://gw-old:8081", Token: "unit-test-gateway-token"}
server := healthzProbeServer(t, "unit-test-gateway-token", nil, nil)
app := fiber.New()
registerHubWithNetwork(app, store, fakeExitProbe{}, func(hub.NetworkExitAccess) (string, error) { return "", nil })
response := do(app, http.MethodPut, "/api/gateways/gw-1",
`{"name":"gw-1","endpoint":"`+server.URL+`","token":""}`)
if response.Code != http.StatusOK {
t.Fatalf("update gateway returned %d: %s", response.Code, response.Body.String())
}
check := waitForGatewayCheck(t, store, "gw-1")
if check.status != "healthy" || check.reason != "" {
t.Fatalf("updated gateway check = %#v, want healthy without reason", check)
}
}
// TestUpdateGatewayUnreachableRecordsUnhealthy:编辑指向不可达地址时保存照常成功,
// 后台探活把 unhealthy 结果落库供页面展示。
func TestUpdateGatewayUnreachableRecordsUnhealthy(t *testing.T) {
t.Cleanup(restoreGatewayHealthProbeTimeout(setGatewayHealthProbeTimeout(300 * time.Millisecond)))
store := &healthMemoryStore{memoryStore: newMemoryStore()}
store.gateways["gw-1"] = hub.Gateway{Name: "gw-1", Endpoint: "http://gw-old:8081", Token: "unit-test-gateway-token"}
closedServer := httptest.NewServer(http.HandlerFunc(func(http.ResponseWriter, *http.Request) {}))
closedServer.Close() // 端口已释放,保证连接失败
app := fiber.New()
registerHubWithNetwork(app, store, fakeExitProbe{}, func(hub.NetworkExitAccess) (string, error) { return "", nil })
response := do(app, http.MethodPut, "/api/gateways/gw-1",
`{"name":"gw-1","endpoint":"`+closedServer.URL+`","token":""}`)
if response.Code != http.StatusOK {
t.Fatalf("update gateway returned %d: %s", response.Code, response.Body.String())
}
check := waitForGatewayCheck(t, store, "gw-1")
if check.status != "unhealthy" || check.reason == "" {
t.Fatalf("unreachable gateway check = %#v, want unhealthy with reason", check)
}
}