2026-04-25 01:18:30 +08:00

148 lines
5.0 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// Package healthcheck 验证 im-service HTTP/gRPC 健康检查对 listener、drain 和下游连接的映射。
package healthcheck
import (
"context"
"encoding/json"
"net"
"net/http"
"net/http/httptest"
"testing"
"time"
"google.golang.org/grpc"
"google.golang.org/grpc/credentials/insecure"
healthpb "google.golang.org/grpc/health/grpc_health_v1"
)
// okRuntime 模拟进程内 session/store 状态正常的 RuntimeProbe。
type okRuntime struct{}
// HealthCheck 返回 nil使测试聚焦在 listener、drain 和 room gRPC 可连接性。
func (okRuntime) HealthCheck() error {
return nil
}
// TestHTTPHealthLiveAndReady 验证 HTTP live/ready 响应码、JSON 契约和 drain 行为。
func TestHTTPHealthLiveAndReady(t *testing.T) {
conn := startRoomGRPC(t)
state := NewState("im-node-test", okRuntime{}, conn)
// listener serving 标记由 app.Run 维护,测试里手动设置来隔离 handler 行为。
state.MarkHTTPServing()
state.MarkGRPCServing()
waitReady(t, state)
handler := NewHTTPHandler(state)
liveRecorder := httptest.NewRecorder()
handler.Live(liveRecorder, httptest.NewRequest(http.MethodGet, "/healthz/live", nil))
if liveRecorder.Code != http.StatusOK {
t.Fatalf("live status mismatch: got %d want %d body=%s", liveRecorder.Code, http.StatusOK, liveRecorder.Body.String())
}
var live Response
if err := json.NewDecoder(liveRecorder.Body).Decode(&live); err != nil {
t.Fatalf("decode live response failed: %v", err)
}
if live.Status != "ok" || live.Service != "im-service" || live.NodeID != "im-node-test" {
t.Fatalf("unexpected live response: %+v", live)
}
// ready 成功依赖 listener、非 drain 和 room-service gRPC 可连接。
readyRecorder := httptest.NewRecorder()
handler.Ready(readyRecorder, httptest.NewRequest(http.MethodGet, "/healthz/ready", nil))
if readyRecorder.Code != http.StatusOK {
t.Fatalf("ready status mismatch: got %d want %d body=%s", readyRecorder.Code, http.StatusOK, readyRecorder.Body.String())
}
state.MarkDraining()
// 进入 drain 后 live 仍可成功,但 ready 必须失败,防止继续接新连接。
drainingRecorder := httptest.NewRecorder()
handler.Ready(drainingRecorder, httptest.NewRequest(http.MethodGet, "/healthz/ready", nil))
if drainingRecorder.Code != http.StatusServiceUnavailable {
t.Fatalf("draining ready status mismatch: got %d want %d body=%s", drainingRecorder.Code, http.StatusServiceUnavailable, drainingRecorder.Body.String())
}
var draining Response
if err := json.NewDecoder(drainingRecorder.Body).Decode(&draining); err != nil {
t.Fatalf("decode draining response failed: %v", err)
}
if draining.Status != "fail" || len(draining.Checks) == 0 {
t.Fatalf("draining response should expose failing checks: %+v", draining)
}
}
// TestGRPCHealthCheckMapsReadiness 验证标准 gRPC health 的 SERVING 状态严格映射 ready。
func TestGRPCHealthCheckMapsReadiness(t *testing.T) {
conn := startRoomGRPC(t)
state := NewState("im-node-test", okRuntime{}, conn)
state.MarkHTTPServing()
state.MarkGRPCServing()
waitReady(t, state)
server := NewGRPCServer(state)
resp, err := server.Check(context.Background(), &healthpb.HealthCheckRequest{Service: "im-service"})
if err != nil {
t.Fatalf("gRPC health check failed: %v", err)
}
if resp.GetStatus() != healthpb.HealthCheckResponse_SERVING {
t.Fatalf("gRPC health status mismatch: got %s want SERVING", resp.GetStatus())
}
state.MarkDraining()
// drain 后 gRPC health 也必须变为 NOT_SERVING方便内部调用方停止路由。
resp, err = server.Check(context.Background(), &healthpb.HealthCheckRequest{Service: "im-service"})
if err != nil {
t.Fatalf("gRPC draining health check failed: %v", err)
}
if resp.GetStatus() != healthpb.HealthCheckResponse_NOT_SERVING {
t.Fatalf("gRPC draining health status mismatch: got %s want NOT_SERVING", resp.GetStatus())
}
}
// startRoomGRPC 启动一个最小 gRPC server让 ready 检查可以建立真实连接。
func startRoomGRPC(t *testing.T) *grpc.ClientConn {
t.Helper()
listener, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatalf("listen fake room gRPC failed: %v", err)
}
server := grpc.NewServer()
go func() {
// 测试只需要连接状态变为 Ready不需要注册具体 room service。
_ = server.Serve(listener)
}()
conn, err := grpc.Dial(listener.Addr().String(), grpc.WithTransportCredentials(insecure.NewCredentials()))
if err != nil {
t.Fatalf("dial fake room gRPC failed: %v", err)
}
t.Cleanup(func() {
// 清理顺序保证 client conn、server 和 listener 都不会泄漏到其他测试。
_ = conn.Close()
server.Stop()
_ = listener.Close()
})
return conn
}
// waitReady 等待 gRPC 惰性连接完成,避免测试在 Dial 后立即断言 ready 导致偶发失败。
func waitReady(t *testing.T, state *State) {
t.Helper()
deadline := time.Now().Add(2 * time.Second)
for time.Now().Before(deadline) {
if Healthy(state.Ready(context.Background())) {
return
}
time.Sleep(10 * time.Millisecond)
}
t.Fatalf("state did not become ready before deadline: %+v", state.Ready(context.Background()))
}