Covers each new code path that the design's §6 table requires: - Subscribe -> 406 on non-H264 / non-Opus offer (TestHandler_Subscribe_406OnCodecMismatch) - Subscribe -> 503 when total cap exhausted (TestHandler_Subscribe_503OnTotalCap) - Subscribe -> 503 when per-stream cap exhausted (TestHandler_Subscribe_503OnPerStreamCap) - Trickle -> 404 on unknown resource (TestHandler_Trickle_404WhenUnknown) - preflight -> 204 + CORS headers (TestHandler_PreflightCORS) - Register installs all 5 routes (TestHandler_RegisterMountsAllRoutes) - Close drains the index without panicking (TestHandler_Close_DrainsPeers) - requireH264AndOpus table-driven (TestRequireH264AndOpus) Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
251 lines
7.7 KiB
Go
251 lines
7.7 KiB
Go
package webrtc
|
|
|
|
import (
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"strings"
|
|
"sync/atomic"
|
|
"testing"
|
|
|
|
"github.com/labstack/echo/v4"
|
|
|
|
corewebrtc "github.com/datarhei/core/v16/core/webrtc"
|
|
)
|
|
|
|
// minimalH264OpusOffer returns an SDP offer that includes both H264
|
|
// and Opus rtpmap lines — passes requireH264AndOpus but is otherwise
|
|
// nonsense, so CreatePeerFromSources will fail downstream when this
|
|
// is wired through. Use it only in tests that don't reach the
|
|
// PeerConnection path.
|
|
func minimalH264OpusOffer() string {
|
|
return "v=0\r\n" +
|
|
"o=- 0 0 IN IP4 0.0.0.0\r\ns=-\r\nt=0 0\r\n" +
|
|
"m=video 9 UDP/TLS/RTP/SAVPF 102\r\n" +
|
|
"a=rtpmap:102 H264/90000\r\n" +
|
|
"m=audio 9 UDP/TLS/RTP/SAVPF 111\r\n" +
|
|
"a=rtpmap:111 opus/48000/2\r\n"
|
|
}
|
|
|
|
// nonH264Offer is missing H264 entirely. Triggers requireH264AndOpus.
|
|
func nonH264Offer() string {
|
|
return "v=0\r\n" +
|
|
"m=video 9 UDP/TLS/RTP/SAVPF 96\r\n" +
|
|
"a=rtpmap:96 VP8/90000\r\n" +
|
|
"m=audio 9 UDP/TLS/RTP/SAVPF 111\r\n" +
|
|
"a=rtpmap:111 opus/48000/2\r\n"
|
|
}
|
|
|
|
// TestHandler_Subscribe_406OnCodecMismatch verifies an offer that
|
|
// doesn't include H264 yields 406, per the design's error matrix.
|
|
func TestHandler_Subscribe_406OnCodecMismatch(t *testing.T) {
|
|
sub := newTestSubsystem(t)
|
|
sub.mu.Lock()
|
|
sub.streams["s"] = &processStream{id: "s"}
|
|
sub.mu.Unlock()
|
|
h := NewHandler(sub, 0)
|
|
|
|
e := echo.New()
|
|
req := httptest.NewRequest(http.MethodPost, "/whep/s", strings.NewReader(nonH264Offer()))
|
|
rec := httptest.NewRecorder()
|
|
c := e.NewContext(req, rec)
|
|
c.SetParamNames("id")
|
|
c.SetParamValues("s")
|
|
|
|
if err := h.Subscribe(c); err != nil {
|
|
t.Fatalf("Subscribe: %v", err)
|
|
}
|
|
if rec.Code != http.StatusNotAcceptable {
|
|
t.Fatalf("expected 406, got %d: %s", rec.Code, rec.Body.String())
|
|
}
|
|
if !strings.Contains(rec.Body.String(), "H264") {
|
|
t.Errorf("body should mention missing codec: %q", rec.Body.String())
|
|
}
|
|
}
|
|
|
|
// TestHandler_Subscribe_503OnTotalCap simulates the total cap being
|
|
// exhausted by another subscriber. We don't actually create real peers
|
|
// (would need a real PeerConnection); instead we pre-load the atomic
|
|
// counter so the cap check fires.
|
|
func TestHandler_Subscribe_503OnTotalCap(t *testing.T) {
|
|
sub := newTestSubsystem(t)
|
|
sub.mu.Lock()
|
|
sub.streams["s"] = &processStream{id: "s"}
|
|
sub.mu.Unlock()
|
|
h := NewHandlerWithCaps(sub, 1, 100)
|
|
atomic.StoreInt64(&h.count, 1) // simulate one in-flight peer
|
|
|
|
e := echo.New()
|
|
req := httptest.NewRequest(http.MethodPost, "/whep/s", strings.NewReader(minimalH264OpusOffer()))
|
|
rec := httptest.NewRecorder()
|
|
c := e.NewContext(req, rec)
|
|
c.SetParamNames("id")
|
|
c.SetParamValues("s")
|
|
_ = h.Subscribe(c)
|
|
if rec.Code != http.StatusServiceUnavailable {
|
|
t.Fatalf("expected 503, got %d: %s", rec.Code, rec.Body.String())
|
|
}
|
|
if !strings.Contains(rec.Body.String(), corewebrtc.ErrPeerCapReached.Error()) {
|
|
t.Errorf("body should mention peer cap: %q", rec.Body.String())
|
|
}
|
|
}
|
|
|
|
// TestHandler_Subscribe_503OnPerStreamCap simulates the per-stream cap
|
|
// being exhausted. Same trick as above but populating the per-stream
|
|
// index directly.
|
|
func TestHandler_Subscribe_503OnPerStreamCap(t *testing.T) {
|
|
sub := newTestSubsystem(t)
|
|
sub.mu.Lock()
|
|
sub.streams["s"] = &processStream{id: "s"}
|
|
sub.mu.Unlock()
|
|
h := NewHandlerWithCaps(sub, 100, 1)
|
|
// Drop a placeholder peer into the per-stream bucket so the cap
|
|
// arithmetic trips on the next subscribe.
|
|
h.mu.Lock()
|
|
h.peersByStream["s"] = map[string]*corewebrtc.Peer{"existing": nil}
|
|
h.mu.Unlock()
|
|
|
|
e := echo.New()
|
|
req := httptest.NewRequest(http.MethodPost, "/whep/s", strings.NewReader(minimalH264OpusOffer()))
|
|
rec := httptest.NewRecorder()
|
|
c := e.NewContext(req, rec)
|
|
c.SetParamNames("id")
|
|
c.SetParamValues("s")
|
|
_ = h.Subscribe(c)
|
|
if rec.Code != http.StatusServiceUnavailable {
|
|
t.Fatalf("expected 503, got %d: %s", rec.Code, rec.Body.String())
|
|
}
|
|
if !strings.Contains(rec.Body.String(), "per-stream") {
|
|
t.Errorf("body should mention per-stream cap: %q", rec.Body.String())
|
|
}
|
|
}
|
|
|
|
// TestHandler_Trickle_404WhenUnknown verifies a PATCH for an unknown
|
|
// resource returns 404 (we still treat the resource as authoritative
|
|
// here; only DELETE is idempotent per spec).
|
|
func TestHandler_Trickle_404WhenUnknown(t *testing.T) {
|
|
h := NewHandler(newTestSubsystem(t), 0)
|
|
|
|
e := echo.New()
|
|
req := httptest.NewRequest(http.MethodPatch, "/whep/id/unknown", strings.NewReader(""))
|
|
rec := httptest.NewRecorder()
|
|
c := e.NewContext(req, rec)
|
|
c.SetParamNames("id", "resource")
|
|
c.SetParamValues("id", "unknown")
|
|
|
|
if err := h.Trickle(c); err != nil {
|
|
t.Fatalf("Trickle: %v", err)
|
|
}
|
|
if rec.Code != http.StatusNotFound {
|
|
t.Fatalf("expected 404, got %d", rec.Code)
|
|
}
|
|
}
|
|
|
|
// TestHandler_PreflightCORS verifies OPTIONS returns 204 with the
|
|
// browser-friendly CORS headers.
|
|
func TestHandler_PreflightCORS(t *testing.T) {
|
|
h := NewHandler(newTestSubsystem(t), 0)
|
|
|
|
e := echo.New()
|
|
req := httptest.NewRequest(http.MethodOptions, "/whep/x", nil)
|
|
rec := httptest.NewRecorder()
|
|
c := e.NewContext(req, rec)
|
|
c.SetParamNames("id")
|
|
c.SetParamValues("x")
|
|
|
|
if err := h.preflight(c); err != nil {
|
|
t.Fatalf("preflight: %v", err)
|
|
}
|
|
if rec.Code != http.StatusNoContent {
|
|
t.Fatalf("expected 204, got %d", rec.Code)
|
|
}
|
|
hh := rec.Header()
|
|
for _, k := range []string{
|
|
"Access-Control-Allow-Origin",
|
|
"Access-Control-Allow-Methods",
|
|
"Access-Control-Allow-Headers",
|
|
"Access-Control-Expose-Headers",
|
|
} {
|
|
if hh.Get(k) == "" {
|
|
t.Errorf("missing CORS header %q", k)
|
|
}
|
|
}
|
|
}
|
|
|
|
// TestHandler_RegisterMountsAllRoutes is a sanity check that
|
|
// Handler.Register installs OPTIONS / POST / DELETE / PATCH on the
|
|
// expected paths. Echo's Group has no public route enumerator, so we
|
|
// dispatch synthetic requests and assert the right methods are
|
|
// reachable.
|
|
func TestHandler_RegisterMountsAllRoutes(t *testing.T) {
|
|
h := NewHandler(newTestSubsystem(t), 0)
|
|
e := echo.New()
|
|
g := e.Group("")
|
|
h.Register(g)
|
|
|
|
cases := []struct {
|
|
method, path string
|
|
want int
|
|
}{
|
|
{http.MethodOptions, "/whep/foo", http.StatusNoContent},
|
|
{http.MethodOptions, "/whep/foo/bar", http.StatusNoContent},
|
|
{http.MethodPost, "/whep/foo", http.StatusNotFound}, // stream missing -> 404
|
|
{http.MethodDelete, "/whep/foo/bar", http.StatusNoContent},
|
|
{http.MethodPatch, "/whep/foo/bar", http.StatusNotFound},
|
|
}
|
|
for _, tc := range cases {
|
|
req := httptest.NewRequest(tc.method, tc.path, strings.NewReader(""))
|
|
rec := httptest.NewRecorder()
|
|
e.ServeHTTP(rec, req)
|
|
if rec.Code != tc.want {
|
|
t.Errorf("%s %s: got %d want %d (%s)", tc.method, tc.path, rec.Code, tc.want, rec.Body.String())
|
|
}
|
|
}
|
|
}
|
|
|
|
// TestHandler_Close_DrainsPeers seeds a fake peer into the index and
|
|
// verifies Close clears it without panicking.
|
|
func TestHandler_Close_DrainsPeers(t *testing.T) {
|
|
h := NewHandler(newTestSubsystem(t), 0)
|
|
h.mu.Lock()
|
|
h.peersByStream["s"] = map[string]*corewebrtc.Peer{"r1": nil}
|
|
h.peerStream["r1"] = "s"
|
|
atomic.StoreInt64(&h.count, 1)
|
|
h.mu.Unlock()
|
|
|
|
h.Close()
|
|
if got := atomic.LoadInt64(&h.count); got != 0 {
|
|
t.Errorf("count after Close = %d, want 0", got)
|
|
}
|
|
h.mu.Lock()
|
|
if len(h.peersByStream) != 0 || len(h.peerStream) != 0 {
|
|
t.Errorf("indexes not cleared")
|
|
}
|
|
h.mu.Unlock()
|
|
}
|
|
|
|
// TestRequireH264AndOpus covers the SDP scanner's positive +
|
|
// negative cases.
|
|
func TestRequireH264AndOpus(t *testing.T) {
|
|
cases := []struct {
|
|
name string
|
|
sdp string
|
|
ok bool
|
|
}{
|
|
{"both", minimalH264OpusOffer(), true},
|
|
{"missing h264", nonH264Offer(), false},
|
|
{"missing opus", "m=video 9 UDP/TLS/RTP/SAVPF 102\r\na=rtpmap:102 H264/90000\r\n", false},
|
|
{"capitalized", "a=rtpmap:111 OPUS/48000\r\na=rtpmap:102 H264/90000", true},
|
|
{"empty", "", false},
|
|
}
|
|
for _, c := range cases {
|
|
t.Run(c.name, func(t *testing.T) {
|
|
err := requireH264AndOpus(c.sdp)
|
|
if c.ok && err != nil {
|
|
t.Errorf("expected ok, got %v", err)
|
|
}
|
|
if !c.ok && err == nil {
|
|
t.Errorf("expected error")
|
|
}
|
|
})
|
|
}
|
|
}
|