datarhei-dragonfork-core/app/webrtc/handler_test.go

180 lines
5.6 KiB
Go
Raw Normal View History

package webrtc
import (
"net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/labstack/echo/v4"
"github.com/datarhei/core/v16/config"
corewebrtc "github.com/datarhei/core/v16/core/webrtc"
)
func newTestSubsystem(t *testing.T) *Subsystem {
t.Helper()
s, err := New(config.DataWebRTC{Enable: true}, nil)
if err != nil {
t.Fatalf("New: %v", err)
}
return s
}
// TestHandler_Subscribe_404WhenStreamMissing verifies the WHEP POST
// returns 404 when no process has registered a stream for that id.
func TestHandler_Subscribe_404WhenStreamMissing(t *testing.T) {
h := NewHandler(newTestSubsystem(t), 0)
e := echo.New()
req := httptest.NewRequest(http.MethodPost, "/whep/ghost", strings.NewReader("v=0\r\n"))
rec := httptest.NewRecorder()
c := e.NewContext(req, rec)
c.SetParamNames("id")
c.SetParamValues("ghost")
if err := h.Subscribe(c); err != nil {
t.Fatalf("Subscribe returned error: %v", err)
}
if rec.Code != http.StatusNotFound {
t.Fatalf("expected 404, got %d: %s", rec.Code, rec.Body.String())
}
}
// TestHandler_Subscribe_400OnEmptyBody verifies invalid SDP offers
// short-circuit before any peer is created. Requires a registered
// stream so lookup doesn't 404 first.
func TestHandler_Subscribe_400OnEmptyBody(t *testing.T) {
sub := newTestSubsystem(t)
// Register a dummy stream so the handler reaches body validation.
sub.mu.Lock()
sub.streams["probe"] = &processStream{id: "probe"} // video/audio nil is fine here — we never get past body parse
sub.mu.Unlock()
h := NewHandler(sub, 0)
e := echo.New()
req := httptest.NewRequest(http.MethodPost, "/whep/probe", strings.NewReader(""))
rec := httptest.NewRecorder()
c := e.NewContext(req, rec)
c.SetParamNames("id")
c.SetParamValues("probe")
if err := h.Subscribe(c); err != nil {
t.Fatalf("Subscribe returned error: %v", err)
}
if rec.Code != http.StatusBadRequest {
t.Fatalf("expected 400, got %d: %s", rec.Code, rec.Body.String())
}
}
feat(app/webrtc): M3 robustness — error matrix, per-stream index, PATCH, CORS Major Handler rewrite implementing the design's M3 acceptance criteria ('5 concurrent viewers, all error paths correct, clean teardown'): Multi-viewer correctness: - streamID -> resourceID -> Peer two-level index (was flat) - per-stream peer cap alongside total cap, defaults match the design's '5–8 viewer' target (8/stream, total from corewebrtc) - per-peer awaitPeerClose goroutine watches Peer.Done() so ICE failures yank the index entry + decrement the counter (no leaks) - tearDownStreamPeers callback (registered with Subsystem in NewHandler) drives all peer closes when the source process stops Error matrix from design §6: - 406 on codec mismatch (offer missing H264 or Opus rtpmap) - 504 on ICE gathering timeout (passthrough from CreatePeerFromSources) - 204 on DELETE unknown resource (idempotent per WHEP spec; was 404) - 503 on per-stream cap reached (separate body from total-cap 503) - 400 on missing/empty body (unchanged) - 404 on unknown stream (unchanged) WHEP spec compatibility: - PATCH /whep/:id/:resource for trickle-ICE - OPTIONS preflight on every WHEP path - CORS Allow-Origin/Methods/Headers + Expose-Headers (Location, ETag) - ETag header on Subscribe response Defensive nil-peer guards in tearDown / Close paths so a partial state doesn't panic. Refactor: 134 -> 341 lines on handler.go but the surface is the same (NewHandler/Register/Subscribe/Unsubscribe/Close); existing callers continue to work. Pre-M3 test 'Unsubscribe_404WhenUnknown' renamed and updated to the new 204 expectation. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-03 07:23:55 -04:00
// TestHandler_Unsubscribe_204WhenUnknown verifies a DELETE with an
// unknown resource id returns 204 (idempotent), per the WHEP spec
// and the M2/M3 design's error matrix. Pre-M3 this returned 404; the
// updated semantics let clients re-issue DELETE without erroring.
func TestHandler_Unsubscribe_204WhenUnknown(t *testing.T) {
h := NewHandler(newTestSubsystem(t), 0)
e := echo.New()
req := httptest.NewRequest(http.MethodDelete, "/whep/id/unknown", nil)
rec := httptest.NewRecorder()
c := e.NewContext(req, rec)
c.SetParamNames("id", "resource")
c.SetParamValues("id", "unknown")
if err := h.Unsubscribe(c); err != nil {
t.Fatalf("Unsubscribe returned error: %v", err)
}
feat(app/webrtc): M3 robustness — error matrix, per-stream index, PATCH, CORS Major Handler rewrite implementing the design's M3 acceptance criteria ('5 concurrent viewers, all error paths correct, clean teardown'): Multi-viewer correctness: - streamID -> resourceID -> Peer two-level index (was flat) - per-stream peer cap alongside total cap, defaults match the design's '5–8 viewer' target (8/stream, total from corewebrtc) - per-peer awaitPeerClose goroutine watches Peer.Done() so ICE failures yank the index entry + decrement the counter (no leaks) - tearDownStreamPeers callback (registered with Subsystem in NewHandler) drives all peer closes when the source process stops Error matrix from design §6: - 406 on codec mismatch (offer missing H264 or Opus rtpmap) - 504 on ICE gathering timeout (passthrough from CreatePeerFromSources) - 204 on DELETE unknown resource (idempotent per WHEP spec; was 404) - 503 on per-stream cap reached (separate body from total-cap 503) - 400 on missing/empty body (unchanged) - 404 on unknown stream (unchanged) WHEP spec compatibility: - PATCH /whep/:id/:resource for trickle-ICE - OPTIONS preflight on every WHEP path - CORS Allow-Origin/Methods/Headers + Expose-Headers (Location, ETag) - ETag header on Subscribe response Defensive nil-peer guards in tearDown / Close paths so a partial state doesn't panic. Refactor: 134 -> 341 lines on handler.go but the surface is the same (NewHandler/Register/Subscribe/Unsubscribe/Close); existing callers continue to work. Pre-M3 test 'Unsubscribe_404WhenUnknown' renamed and updated to the new 204 expectation. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-03 07:23:55 -04:00
if rec.Code != http.StatusNoContent {
t.Fatalf("expected 204, got %d", rec.Code)
}
}
// TestSubsystem_ICEServerURIs_ReturnsConfiguredURIs verifies that
// ICEServerURIs() surfaces the URIs from the core config — the same
// values Pion uses when building its PeerConnection. A default-config
// subsystem must return at least the two bundled STUN servers.
func TestSubsystem_ICEServerURIs_ReturnsConfiguredURIs(t *testing.T) {
sub := newTestSubsystem(t)
uris := sub.ICEServerURIs()
defaultURIs := corewebrtc.DefaultConfig().ICEServers
if len(uris) != len(defaultURIs) {
t.Fatalf("expected %d ICE server URIs, got %d", len(defaultURIs), len(uris))
}
for i, want := range defaultURIs {
if uris[i] != want {
t.Errorf("ICEServerURIs[%d]: want %q, got %q", i, want, uris[i])
}
}
}
// TestSubsystem_ICEServers_OperatorOverride verifies that when the operator
// supplies ICEServers via config (CORE_WEBRTC_ICE_SERVERS), those URIs
// completely replace the built-in STUN defaults rather than being appended.
// This exercises the override branch added in subsystem.New for issue #23.
func TestSubsystem_ICEServers_OperatorOverride(t *testing.T) {
custom := []string{
"stun:stun.example.com:3478",
"turn:user:secret@turn.example.com:3478",
}
sub, err := New(config.DataWebRTC{
Enable: true,
ICEServers: custom,
}, nil)
if err != nil {
t.Fatalf("New with custom ICEServers: %v", err)
}
uris := sub.ICEServerURIs()
if len(uris) != len(custom) {
t.Fatalf("expected %d URIs (custom), got %d: %v", len(custom), len(uris), uris)
}
for i, want := range custom {
if uris[i] != want {
t.Errorf("ICEServerURIs[%d]: want %q, got %q", i, want, uris[i])
}
}
// Confirm the built-in defaults are NOT present.
defaults := corewebrtc.DefaultConfig().ICEServers
for _, def := range defaults {
for _, got := range uris {
if got == def {
t.Errorf("built-in default URI %q should have been replaced but was found in override list", def)
}
}
}
}
// TestAddCORS_ExposesLinkHeader verifies that CORS preflight responses
// include "Link" in Access-Control-Expose-Headers so browsers can read
// the RFC 9429 §4.3 Link headers returned on the 201 Subscribe response.
func TestAddCORS_ExposesLinkHeader(t *testing.T) {
h := NewHandler(newTestSubsystem(t), 0)
e := echo.New()
req := httptest.NewRequest(http.MethodOptions, "/whep/any", nil)
rec := httptest.NewRecorder()
c := e.NewContext(req, rec)
c.SetParamNames("id")
c.SetParamValues("any")
if err := h.preflight(c); err != nil {
t.Fatalf("preflight returned error: %v", err)
}
expose := rec.Header().Get("Access-Control-Expose-Headers")
if !strings.Contains(expose, "Link") {
t.Errorf("Access-Control-Expose-Headers %q does not contain 'Link'", expose)
}
if !strings.Contains(expose, "Location") {
t.Errorf("Access-Control-Expose-Headers %q does not contain 'Location'", expose)
}
if !strings.Contains(expose, "ETag") {
t.Errorf("Access-Control-Expose-Headers %q does not contain 'ETag'", expose)
}
}