Add HealthService with Liveness (no-op) and Readiness (DB+Redis ping
with per-component timeout) checks. Expose three endpoints:
- /healthz : new liveness endpoint, zero-dependency, always 200
- /ready : new readiness endpoint, returns 503 with details on dep
failure; suitable for K8s readinessProbe and load balancers
- /health : preserved for backward compatibility, equivalent to
/healthz
Switch primary docker-compose healthcheck to /ready so the container
is only marked healthy once DB+Redis are reachable. Standalone/dev/
local compose files keep /health to avoid disrupting existing setups.
Tests: unit tests cover liveness, readiness with both deps healthy,
each dep failing independently, and per-component timeout enforcement.
94 lines
2.7 KiB
Go
94 lines
2.7 KiB
Go
package service
|
||
|
||
import (
|
||
"context"
|
||
"database/sql"
|
||
"errors"
|
||
"testing"
|
||
"time"
|
||
|
||
"github.com/DATA-DOG/go-sqlmock"
|
||
"github.com/redis/go-redis/v9"
|
||
"github.com/stretchr/testify/require"
|
||
)
|
||
|
||
func TestHealthService_Liveness_AlwaysOK(t *testing.T) {
|
||
s := NewHealthService(nil, nil)
|
||
require.NoError(t, s.Liveness())
|
||
}
|
||
|
||
func TestHealthService_Readiness_AllNilReturnsOK(t *testing.T) {
|
||
// 当所有依赖都为 nil 时(早期启动或 unit test),readiness 应直接 OK。
|
||
s := NewHealthService(nil, nil)
|
||
report := s.Readiness(context.Background())
|
||
require.True(t, report.OK)
|
||
require.Empty(t, report.Details)
|
||
}
|
||
|
||
func TestHealthService_Readiness_DBPingFails(t *testing.T) {
|
||
db, mock, err := sqlmock.New(sqlmock.MonitorPingsOption(true))
|
||
require.NoError(t, err)
|
||
defer db.Close()
|
||
|
||
mock.ExpectPing().WillReturnError(errors.New("connection refused"))
|
||
|
||
s := NewHealthService(db, nil)
|
||
report := s.Readiness(context.Background())
|
||
require.False(t, report.OK)
|
||
require.Contains(t, report.Details, "database")
|
||
require.False(t, report.Details["database"].OK)
|
||
require.Contains(t, report.Details["database"].Error, "connection refused")
|
||
}
|
||
|
||
func TestHealthService_Readiness_DBOK(t *testing.T) {
|
||
db, mock, err := sqlmock.New(sqlmock.MonitorPingsOption(true))
|
||
require.NoError(t, err)
|
||
defer db.Close()
|
||
|
||
mock.ExpectPing()
|
||
|
||
s := NewHealthService(db, nil)
|
||
report := s.Readiness(context.Background())
|
||
require.True(t, report.OK)
|
||
require.True(t, report.Details["database"].OK)
|
||
}
|
||
|
||
func TestHealthService_Readiness_RedisFails(t *testing.T) {
|
||
// 指向一个不可达端口让 redis ping 立刻失败。
|
||
rdb := redis.NewClient(&redis.Options{
|
||
Addr: "127.0.0.1:1",
|
||
DialTimeout: 200 * time.Millisecond,
|
||
ReadTimeout: 200 * time.Millisecond,
|
||
})
|
||
defer rdb.Close()
|
||
|
||
s := NewHealthService(nil, rdb)
|
||
s.timeout = 500 * time.Millisecond
|
||
report := s.Readiness(context.Background())
|
||
require.False(t, report.OK)
|
||
require.Contains(t, report.Details, "redis")
|
||
require.False(t, report.Details["redis"].OK)
|
||
}
|
||
|
||
func TestHealthService_Readiness_PerComponentTimeout(t *testing.T) {
|
||
// 验证 readiness 在超时时不会无限挂住。
|
||
db, mock, err := sqlmock.New(sqlmock.MonitorPingsOption(true))
|
||
require.NoError(t, err)
|
||
defer db.Close()
|
||
mock.ExpectPing().WillDelayFor(2 * time.Second)
|
||
|
||
s := NewHealthService(db, nil)
|
||
s.timeout = 100 * time.Millisecond
|
||
|
||
start := time.Now()
|
||
report := s.Readiness(context.Background())
|
||
elapsed := time.Since(start)
|
||
|
||
require.Less(t, elapsed, 1*time.Second, "readiness should respect per-component timeout")
|
||
require.False(t, report.OK)
|
||
require.NotEmpty(t, report.Details["database"].Error, "timeout should propagate as an error")
|
||
}
|
||
|
||
// 抑制未使用包警告(database/sql 在签名里使用)。
|
||
var _ = sql.ErrNoRows
|