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") } }) } }