veza/veza-backend-api/internal/handlers/live_health_handler_test.go
senke 698859cc52 feat(backend,web): surface RTMP ingest health on the Go Live page
Fifth item of the v1.0.6 backlog. "Go Live" was silent when the
nginx-rtmp profile wasn't up — an artist could copy the RTMP URL +
stream key, fire up OBS, hit "Start Streaming" and broadcast into the
void with no in-UI signal that the ingest wasn't listening. The audit
flagged this 🟡 ("livestream sans feedback UI si nginx-rtmp down").

Backend (`GET /api/v1/live/health`)
  * `LiveHealthHandler` TCP-dials `NGINX_RTMP_ADDR` (default
    `localhost:1935`) with a 2s timeout. Reports `rtmp_reachable`,
    `rtmp_addr`, a UI-safe `error` string (no raw dial target in the
    body — avoids leaking internal hostnames to the browser), and
    `last_check_at`.
  * 15s TTL cache protected by a mutex so a burst of page loads can't
    hammer the ingest. First call dials; subsequent calls within TTL
    serve the cached verdict.
  * Response ships `Cache-Control: private, max-age=15` so browsers
    piggy-back the same quarter-minute window.
  * When the dial fails the handler emits a WARN log so an operator
    watching backend logs sees the outage before a user does.
  * Public endpoint — no auth. The "RTMP is up / down" signal has no
    sensitive payload and is useful pre-login too.

Frontend
  * `useLiveHealth()` hook: react-query with 15s stale time, 1 retry,
    then falls back to an optimistic `{ rtmpReachable: true }` — we'd
    rather miss a banner than flash a false negative during a transient
    blip on the health endpoint itself.
  * `LiveRtmpHealthBanner`: amber, non-blocking banner with a Retry
    button that invalidates the health query. Copy explicitly tells the
    artist their stream key is still valid but broadcasting now won't
    reach anyone.
  * `GoLivePage` wraps `GoLiveView` in a vertical stack with the banner
    above — the view itself stays unchanged (the key + instructions
    remain readable even when the ingest is down).

Tests
  * 3 Go tests: live listener reports reachable + Cache-Control header;
    dead address reports unreachable + UI-safe error (asserts no
    `127.0.0.1` leak); TTL cache survives listener teardown within
    window.
  * 3 Vitest tests: banner renders nothing when reachable; banner
    visible + Retry enabled when unreachable; Retry invalidates the
    right query key.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-16 23:52:36 +02:00

89 lines
2.8 KiB
Go

package handlers
import (
"encoding/json"
"net"
"net/http"
"net/http/httptest"
"testing"
"github.com/gin-gonic/gin"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"go.uber.org/zap/zaptest"
)
// TestLiveHealthHandler_UnreachableReportsFalse: no listener on the address
// → RtmpReachable=false + a UI-safe error string (not the raw dial message).
func TestLiveHealthHandler_UnreachableReportsFalse(t *testing.T) {
gin.SetMode(gin.TestMode)
// 127.0.0.1:0 tries to connect to port 0 which always refuses.
t.Setenv("NGINX_RTMP_ADDR", "127.0.0.1:0")
router := gin.New()
router.GET("/live/health", LiveHealthHandler(zaptest.NewLogger(t)))
req := httptest.NewRequest(http.MethodGet, "/live/health", nil)
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
assert.Equal(t, http.StatusOK, w.Code)
var resp LiveHealthResponse
require.NoError(t, json.Unmarshal(w.Body.Bytes(), &resp))
assert.False(t, resp.RtmpReachable)
assert.Equal(t, "127.0.0.1:0", resp.RtmpAddr)
assert.Equal(t, "RTMP ingest server is not reachable", resp.Error)
assert.False(t, resp.LastCheckAt.IsZero())
}
// TestLiveHealthHandler_ReachableReportsTrue: stand up a real TCP listener
// on an ephemeral port and point the checker at it.
func TestLiveHealthHandler_ReachableReportsTrue(t *testing.T) {
gin.SetMode(gin.TestMode)
listener, err := net.Listen("tcp", "127.0.0.1:0")
require.NoError(t, err)
defer listener.Close()
t.Setenv("NGINX_RTMP_ADDR", listener.Addr().String())
router := gin.New()
router.GET("/live/health", LiveHealthHandler(zaptest.NewLogger(t)))
req := httptest.NewRequest(http.MethodGet, "/live/health", nil)
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
assert.Equal(t, http.StatusOK, w.Code)
var resp LiveHealthResponse
require.NoError(t, json.Unmarshal(w.Body.Bytes(), &resp))
assert.True(t, resp.RtmpReachable)
assert.Empty(t, resp.Error)
assert.Equal(t, "private, max-age=15", w.Header().Get("Cache-Control"))
}
// TestLiveHealthChecker_CachesResults: two calls within the TTL dial exactly
// once. Verified by letting the listener close after the first dial and
// confirming the cached "reachable" state still returns until TTL expires.
func TestLiveHealthChecker_CachesResults(t *testing.T) {
listener, err := net.Listen("tcp", "127.0.0.1:0")
require.NoError(t, err)
addr := listener.Addr().String()
checker := &liveHealthChecker{
addr: addr,
ttl: 30 * 1e9, // 30s
dialer: &net.Dialer{Timeout: 1e9},
}
first := checker.CurrentStatus()
assert.True(t, first.RtmpReachable)
// Kill the listener — a fresh dial now would fail.
listener.Close()
// Still within TTL → cached true.
second := checker.CurrentStatus()
assert.True(t, second.RtmpReachable, "cached result must survive until TTL expires")
assert.Equal(t, first.LastCheckAt, second.LastCheckAt)
}