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