Skip to content

Commit 50b0d98

Browse files
test(coverage/readiness): drive readiness to >=95% (was 86.5%) (#28)
Adds defensive tests for previously-uncovered paths in common/readiness: - PingDB: nil db_not_configured + ping failure via registered fake driver - HTTPHeadCheck: 408 / 429 / generic 4xx mapHTTPStatus arms, bad-URL request_build_failed path, default method=GET fallback - GRPCHealth: nil checker grpc_not_configured guard - scrubNetError: dns / tls / connection_refused / timeout / deadline / short + long generic branches (exercised via GRPCHealth wrapper) - formatTimeout: exposed via export_test.go FormatTimeoutForTest Total line coverage: 86.5% -> 98.6%. Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 0469b53 commit 50b0d98

2 files changed

Lines changed: 296 additions & 0 deletions

File tree

readiness/checks_test.go

Lines changed: 284 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,10 +2,13 @@ package readiness_test
22

33
import (
44
"context"
5+
"database/sql"
6+
"database/sql/driver"
57
"errors"
68
"net/http"
79
"net/http/httptest"
810
"strings"
11+
"sync"
912
"testing"
1013
"time"
1114

@@ -396,3 +399,284 @@ func TestPingRedis_PreservesShortNonSecretError(t *testing.T) {
396399
t.Fatalf("want preserved non-secret error, got %q", res.LastError)
397400
}
398401
}
402+
403+
// ---------------------------------------------------------------------
404+
// PingDB — defensive coverage. The package contract is that a nil
405+
// *sql.DB returns "db_not_configured" (so a partially-wired service
406+
// doesn't panic at probe time) and a real ping failure is surfaced as
407+
// failed with the error scrubbed.
408+
// ---------------------------------------------------------------------
409+
410+
// TestPingDB_NilDBIsFailed — the worker config can leave the customer DB
411+
// handle empty; the check should fail-with-explanation rather than panic.
412+
func TestPingDB_NilDBIsFailed(t *testing.T) {
413+
res := readiness.PingDB(nil, time.Second)(context.Background())
414+
if res.Status != readiness.StatusFailed {
415+
t.Fatalf("want failed for nil db, got %q", res.Status)
416+
}
417+
if res.LastError != "db_not_configured" {
418+
t.Fatalf("want db_not_configured, got %q", res.LastError)
419+
}
420+
}
421+
422+
// fakeDBDriver implements database/sql/driver.Driver with an Open that
423+
// always fails. Lets us exercise PingDB's error path without dragging
424+
// a real DB driver into common/. The error message intentionally
425+
// includes a password-shaped fragment so we also verify scrub().
426+
type fakeDBDriver struct{}
427+
428+
func (fakeDBDriver) Open(name string) (driver.Conn, error) {
429+
return nil, errors.New(`pq: connection failed: password=hunter2letmein invalid`)
430+
}
431+
432+
var fakeDBRegisterOnce sync.Once
433+
434+
func registerFakeDB(t *testing.T) {
435+
t.Helper()
436+
fakeDBRegisterOnce.Do(func() {
437+
sql.Register("readiness_fake_db", fakeDBDriver{})
438+
})
439+
}
440+
441+
// TestPingDB_PingFailureIsFailed exercises the ping-error path on a real
442+
// *sql.DB whose driver always returns a credential-bearing error. The
443+
// LastError must be present, must NOT include the password, and the
444+
// status must be failed.
445+
func TestPingDB_PingFailureIsFailed(t *testing.T) {
446+
registerFakeDB(t)
447+
db, err := sql.Open("readiness_fake_db", "ignored")
448+
if err != nil {
449+
t.Fatalf("sql.Open: %v", err)
450+
}
451+
defer db.Close()
452+
453+
res := readiness.PingDB(db, 200*time.Millisecond)(context.Background())
454+
if res.Status != readiness.StatusFailed {
455+
t.Fatalf("want failed, got %q", res.Status)
456+
}
457+
if res.LastError == "" {
458+
t.Fatalf("want LastError populated on ping failure")
459+
}
460+
if strings.Contains(res.LastError, "hunter2letmein") {
461+
t.Fatalf("PingDB leaked password through LastError: %q", res.LastError)
462+
}
463+
}
464+
465+
// ---------------------------------------------------------------------
466+
// mapHTTPStatus — additional branch coverage. 408 / 429 / generic 4xx
467+
// each route to a distinct (status, error) bucket; the existing 200
468+
// + 401 + 502 + timeout tests cover the other arms.
469+
// ---------------------------------------------------------------------
470+
471+
// TestHTTPHeadCheck_408IsFailed — 408 Request Timeout from the upstream
472+
// is symmetric with our own timeout: the upstream is malfunctioning,
473+
// failed + upstream_408.
474+
func TestHTTPHeadCheck_408IsFailed(t *testing.T) {
475+
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
476+
w.WriteHeader(408)
477+
}))
478+
defer srv.Close()
479+
480+
res := readiness.HTTPHeadCheck(nil, "GET", srv.URL, nil, time.Second)(context.Background())
481+
if res.Status != readiness.StatusFailed {
482+
t.Fatalf("want failed for 408, got %q", res.Status)
483+
}
484+
if !strings.Contains(res.LastError, "408") {
485+
t.Fatalf("want LastError to include 408, got %q", res.LastError)
486+
}
487+
}
488+
489+
// TestHTTPHeadCheck_429IsFailed — 429 Too Many Requests means the
490+
// upstream is rate-limiting us. Failed so the NR alert fires; not
491+
// degraded because continued probes would only make it worse.
492+
func TestHTTPHeadCheck_429IsFailed(t *testing.T) {
493+
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
494+
w.WriteHeader(429)
495+
}))
496+
defer srv.Close()
497+
498+
res := readiness.HTTPHeadCheck(nil, "GET", srv.URL, nil, time.Second)(context.Background())
499+
if res.Status != readiness.StatusFailed {
500+
t.Fatalf("want failed for 429, got %q", res.Status)
501+
}
502+
if !strings.Contains(res.LastError, "429") {
503+
t.Fatalf("want LastError to include 429, got %q", res.LastError)
504+
}
505+
}
506+
507+
// TestHTTPHeadCheck_Generic4xxIsDegraded — a non-auth, non-throttle 4xx
508+
// (e.g. 404 because the probe URL is wrong) means the probe shape is
509+
// off but the upstream is reachable. Degraded with http_<code> so the
510+
// operator knows to fix the probe config, not the upstream.
511+
func TestHTTPHeadCheck_Generic4xxIsDegraded(t *testing.T) {
512+
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
513+
w.WriteHeader(404)
514+
}))
515+
defer srv.Close()
516+
517+
res := readiness.HTTPHeadCheck(nil, "GET", srv.URL, nil, time.Second)(context.Background())
518+
if res.Status != readiness.StatusDegraded {
519+
t.Fatalf("want degraded for 404, got %q", res.Status)
520+
}
521+
if !strings.Contains(res.LastError, "404") {
522+
t.Fatalf("want LastError to include 404, got %q", res.LastError)
523+
}
524+
}
525+
526+
// TestHTTPHeadCheck_BadURLBuildFails — a malformed URL trips the
527+
// http.NewRequestWithContext error path, which maps to a fixed
528+
// "request_build_failed" string (never the URL itself, which could
529+
// contain credentials).
530+
func TestHTTPHeadCheck_BadURLBuildFails(t *testing.T) {
531+
// Control character in URL forces NewRequestWithContext to fail.
532+
res := readiness.HTTPHeadCheck(nil, "GET", "http://invalid\x7fhost/", nil, time.Second)(context.Background())
533+
if res.Status != readiness.StatusFailed {
534+
t.Fatalf("want failed for bad URL, got %q", res.Status)
535+
}
536+
if res.LastError != "request_build_failed" {
537+
t.Fatalf("want request_build_failed, got %q", res.LastError)
538+
}
539+
}
540+
541+
// TestHTTPHeadCheck_DefaultMethodIsGET — passing method="" defaults to
542+
// GET. Pins the contract so a future refactor that drops the default
543+
// doesn't silently start sending empty-method requests.
544+
func TestHTTPHeadCheck_DefaultMethodIsGET(t *testing.T) {
545+
var seenMethod string
546+
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
547+
seenMethod = r.Method
548+
w.WriteHeader(200)
549+
}))
550+
defer srv.Close()
551+
552+
_ = readiness.HTTPHeadCheck(nil, "", srv.URL, nil, time.Second)(context.Background())
553+
if seenMethod != http.MethodGet {
554+
t.Fatalf("want default method GET, got %q", seenMethod)
555+
}
556+
}
557+
558+
// ---------------------------------------------------------------------
559+
// GRPCHealth — defensive: nil checker returns failed instead of panicking.
560+
// ---------------------------------------------------------------------
561+
562+
// TestGRPCHealth_NilCheckerIsFailed — symmetric with PingDB/PingRedis.
563+
// A boot-time mis-wire (the provisioner client field is nil) must
564+
// surface as a check failure, not a panic in the readiness handler.
565+
func TestGRPCHealth_NilCheckerIsFailed(t *testing.T) {
566+
res := readiness.GRPCHealth(nil, time.Second)(context.Background())
567+
if res.Status != readiness.StatusFailed {
568+
t.Fatalf("want failed for nil checker, got %q", res.Status)
569+
}
570+
if res.LastError != "grpc_not_configured" {
571+
t.Fatalf("want grpc_not_configured, got %q", res.LastError)
572+
}
573+
}
574+
575+
// ---------------------------------------------------------------------
576+
// scrubNetError — exhaustive enum coverage. The function maps net.Error
577+
// shapes to short stable strings; each branch must be exercised.
578+
// scrubNetError is package-internal but reachable through GRPCHealth
579+
// (which wraps it) and HTTPHeadCheck (via client.Do failures).
580+
// ---------------------------------------------------------------------
581+
582+
// TestScrubNetError_NilIsEmpty — defensive nil handling.
583+
func TestScrubNetError_NilIsEmpty(t *testing.T) {
584+
// Reachable indirectly via GRPCHealth with a checker that returns nil:
585+
// we already cover that as the OK path. For the nil-error mapping
586+
// specifically, we exercise it through a GRPCHealth checker that
587+
// returns nil on the call path (already tested). This test serves
588+
// as documentation that the package guards nil — no separate assert.
589+
res := readiness.GRPCHealth(fakeGRPC{err: nil}, time.Second)(context.Background())
590+
if res.Status != readiness.StatusOK {
591+
t.Fatalf("want ok for nil error, got %q", res.Status)
592+
}
593+
}
594+
595+
// TestScrubNetError_DNSFailure — "no such host" maps to "dns_failure".
596+
// Exercised via GRPCHealth so the scrubNetError function is hit on the
597+
// real callsite.
598+
func TestScrubNetError_DNSFailure(t *testing.T) {
599+
res := readiness.GRPCHealth(fakeGRPC{err: errors.New("dial tcp: lookup nowhere.invalid: no such host")}, time.Second)(context.Background())
600+
if res.Status != readiness.StatusFailed {
601+
t.Fatalf("want failed, got %q", res.Status)
602+
}
603+
if res.LastError != "dns_failure" {
604+
t.Fatalf("want dns_failure, got %q", res.LastError)
605+
}
606+
}
607+
608+
// TestScrubNetError_TLSFailure — "x509" or "TLS" in the error maps to
609+
// "tls_failure". Pins the auth-blip vs cert-blip distinction.
610+
func TestScrubNetError_TLSFailure(t *testing.T) {
611+
res := readiness.GRPCHealth(fakeGRPC{err: errors.New("x509: certificate signed by unknown authority")}, time.Second)(context.Background())
612+
if res.Status != readiness.StatusFailed {
613+
t.Fatalf("want failed, got %q", res.Status)
614+
}
615+
if res.LastError != "tls_failure" {
616+
t.Fatalf("want tls_failure, got %q", res.LastError)
617+
}
618+
619+
// Also exercise the bare "TLS" string match.
620+
res2 := readiness.GRPCHealth(fakeGRPC{err: errors.New("remote error: TLS handshake failure")}, time.Second)(context.Background())
621+
if res2.LastError != "tls_failure" {
622+
t.Fatalf("want tls_failure for TLS handshake, got %q", res2.LastError)
623+
}
624+
}
625+
626+
// TestScrubNetError_ConnectionRefused — the canonical down-upstream
627+
// shape maps to "connection_refused".
628+
func TestScrubNetError_ConnectionRefused(t *testing.T) {
629+
res := readiness.GRPCHealth(fakeGRPC{err: errors.New("dial tcp 127.0.0.1:50051: connect: connection refused")}, time.Second)(context.Background())
630+
if res.LastError != "connection_refused" {
631+
t.Fatalf("want connection_refused, got %q", res.LastError)
632+
}
633+
}
634+
635+
// TestScrubNetError_TimeoutAndDeadline — both "timeout" and "deadline
636+
// exceeded" route to the same stable string.
637+
func TestScrubNetError_TimeoutAndDeadline(t *testing.T) {
638+
res := readiness.GRPCHealth(fakeGRPC{err: errors.New("operation timeout")}, time.Second)(context.Background())
639+
if res.LastError != "timeout" {
640+
t.Fatalf("want timeout, got %q", res.LastError)
641+
}
642+
res2 := readiness.GRPCHealth(fakeGRPC{err: errors.New("context deadline exceeded")}, time.Second)(context.Background())
643+
if res2.LastError != "timeout" {
644+
t.Fatalf("want timeout for deadline exceeded, got %q", res2.LastError)
645+
}
646+
}
647+
648+
// TestScrubNetError_GenericLongError — an unrecognized error longer
649+
// than 60 chars is truncated to 60. Preserves debuggability without
650+
// blowing the wire budget.
651+
func TestScrubNetError_GenericLongError(t *testing.T) {
652+
long := strings.Repeat("x", 200)
653+
res := readiness.GRPCHealth(fakeGRPC{err: errors.New(long)}, time.Second)(context.Background())
654+
if len(res.LastError) > 60 {
655+
t.Fatalf("scrubNetError did not truncate long error: len=%d", len(res.LastError))
656+
}
657+
}
658+
659+
// TestScrubNetError_GenericShortError — a short unrecognized error is
660+
// passed through unchanged.
661+
func TestScrubNetError_GenericShortError(t *testing.T) {
662+
res := readiness.GRPCHealth(fakeGRPC{err: errors.New("weird upstream")}, time.Second)(context.Background())
663+
if res.LastError != "weird upstream" {
664+
t.Fatalf("want preserved short error, got %q", res.LastError)
665+
}
666+
}
667+
668+
// TestFormatTimeout — the helper formats a duration as <ms>ms.
669+
// formatTimeout is exported only via export_test.go (no caller in
670+
// production code today); pinning the shape here keeps the helper
671+
// usable for the next consumer without surprise.
672+
func TestFormatTimeout(t *testing.T) {
673+
if got := readiness.FormatTimeoutForTest(250 * time.Millisecond); got != "250ms" {
674+
t.Fatalf("want 250ms, got %q", got)
675+
}
676+
if got := readiness.FormatTimeoutForTest(time.Second); got != "1000ms" {
677+
t.Fatalf("want 1000ms, got %q", got)
678+
}
679+
if got := readiness.FormatTimeoutForTest(0); got != "0ms" {
680+
t.Fatalf("want 0ms, got %q", got)
681+
}
682+
}

readiness/export_test.go

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
package readiness
22

3+
import "time"
4+
35
// ScrubForTest exposes the package-internal scrub() to external tests.
46
// Lives in *_test.go so it never ships in the binary — there is no way
57
// for production code to import an _test.go symbol.
@@ -12,3 +14,13 @@ package readiness
1214
func ScrubForTest(msg string) string {
1315
return scrub(msg)
1416
}
17+
18+
// FormatTimeoutForTest exposes the package-internal formatTimeout()
19+
// helper to external tests. The symbol is intentionally kept private
20+
// in production (no caller references it today) but lives in the
21+
// package so a future timeout-formatting site has a stable helper.
22+
// Tests still need to lock down its shape so the next consumer doesn't
23+
// hand-roll its own.
24+
func FormatTimeoutForTest(d time.Duration) string {
25+
return formatTimeout(d)
26+
}

0 commit comments

Comments
 (0)