Files
creator-hub/internal/controlplane/api/gateway_health_test.go
T
rogee a94bdf7921 feat(gateway): 网关健康状态——后台定时探活只保留最近一次结果并在列表展示
- 迁移 1043:gateway 表加 health_status/last_check_reason/last_checked_at
- Store.RecordGatewayCheck 覆盖写最近一次探活结果,Create/Update/List/Get 全量返回健康字段
- api.RecordGatewayHealthChecks 并发探 /healthz:连接失败或 5xx 判离线,其余判在线(401 等异常写入原因供排查)
- workers 每 30 秒一轮,app 启动接线
- 网关管理列表新增健康状态列:在线/离线/未检查 Tag,原因与检查时间收进 Tooltip
2026-09-29 14:47:49 +08:00

123 lines
4.6 KiB
Go

package api
import (
"context"
"errors"
"net/http"
"net/http/httptest"
"sync"
"testing"
"time"
hub "git.ipao.vip/rogee/creator-hub/internal/environment"
)
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)
}
}