From 65555a47c86e79b1885b26ff0f810b3858c30c48 Mon Sep 17 00:00:00 2001 From: zarazaex69 Date: Tue, 12 May 2026 22:00:30 +0300 Subject: [PATCH] test: port provider unit tests and fix API regressions from master - Ported unit tests for WB Stream, SaluteJazz, and Telemost API clients - Ported engine helper tests for SaluteJazz and Goolom - Fixed missing WB Stream WebSocket URL update (wss://rtc-el-01.wb.ru) - Corrected SaluteJazz header casing (X-Jazz-AuthType) - Fixed linting errors (goconst, gosec, lll) in test files --- internal/auth/salutejazz/api.go | 4 +- internal/auth/salutejazz/api_test.go | 143 ++++++++++++++++++ internal/auth/telemost/api_test.go | 65 ++++++++ internal/auth/wbstream/api.go | 4 +- internal/auth/wbstream/api_test.go | 135 +++++++++++++++++ .../engine/goolom/session_helpers_test.go | 85 +++++++++++ internal/engine/salutejazz/datapacket_test.go | 70 +++++++++ .../engine/salutejazz/session_helpers_test.go | 112 ++++++++++++++ 8 files changed, 614 insertions(+), 4 deletions(-) create mode 100644 internal/auth/salutejazz/api_test.go create mode 100644 internal/auth/telemost/api_test.go create mode 100644 internal/auth/wbstream/api_test.go create mode 100644 internal/engine/goolom/session_helpers_test.go create mode 100644 internal/engine/salutejazz/datapacket_test.go create mode 100644 internal/engine/salutejazz/session_helpers_test.go diff --git a/internal/auth/salutejazz/api.go b/internal/auth/salutejazz/api.go index 3d214ad..26b1b3f 100644 --- a/internal/auth/salutejazz/api.go +++ b/internal/auth/salutejazz/api.go @@ -17,12 +17,12 @@ import ( const ( authTypeAnonymous = "ANONYMOUS" - headerAuthType = "X-Jazz-Authtype" + headerAuthType = "X-Jazz-AuthType" headerContentType = "Content-Type" contentTypeJSON = "application/json" ) -var apiBase = "https://bk.salutejazz.ru" //nolint:gochecknoglobals // overridable base URL for tests +var apiBase = "https://bk.salutejazz.ru" //nolint:gochecknoglobals // package-level state intentional // roomInfo contains connection details for a SaluteJazz room. type roomInfo struct { diff --git a/internal/auth/salutejazz/api_test.go b/internal/auth/salutejazz/api_test.go new file mode 100644 index 0000000..1f389cf --- /dev/null +++ b/internal/auth/salutejazz/api_test.go @@ -0,0 +1,143 @@ +package salutejazz + +import ( + "context" + "encoding/json" + "errors" + "net/http" + "net/http/httptest" + "testing" + + "github.com/openlibrecommunity/olcrtc/internal/auth" +) + +func withJazzAPIServer(t *testing.T, h http.Handler) { + t.Helper() + old := apiBase + srv := httptest.NewServer(h) + t.Cleanup(func() { + apiBase = old + srv.Close() + }) + apiBase = srv.URL +} + +func TestCreateMeetingAndPreconnect(t *testing.T) { + mux := http.NewServeMux() + mux.HandleFunc("POST /room/create-meeting", func(w http.ResponseWriter, r *http.Request) { + if r.Header.Get("X-Jazz-Authtype") != authTypeAnonymous { + t.Fatalf("missing auth header: %v", r.Header) + } + _ = json.NewEncoder(w).Encode(createResponse{RoomID: "room-1", Password: "pass"}) //nolint:gosec + }) + mux.HandleFunc("POST /room/room-1/preconnect", func(w http.ResponseWriter, _ *http.Request) { + _ = json.NewEncoder(w).Encode(map[string]string{connectorURLKey: testConnector}) + }) + + withJazzAPIServer(t, mux) + + headers := map[string]string{ + headerAuthType: authTypeAnonymous, + "Content-Type": "application/json", + } + created, err := createMeeting(context.Background(), headers) + if err != nil { + t.Fatalf("createMeeting() error = %v", err) + } + if created.RoomID != "room-1" || created.Password != "pass" { + t.Fatalf("createMeeting() = %+v", created) + } + + connector, err := preconnect(context.Background(), "room-1", "pass", headers) + if err != nil { + t.Fatalf("preconnect() error = %v", err) + } + if connector != testConnector { + t.Fatalf("preconnect() = %q", connector) + } +} + +const ( + testRoomID = "new-room" + testPassword = "new-pass" + testConnector = "wss://connector" + connectorURLKey = "connectorUrl" +) + +func TestCreateRoomAndJoinRoom(t *testing.T) { + mux := http.NewServeMux() + mux.HandleFunc("POST /room/create-meeting", func(w http.ResponseWriter, _ *http.Request) { + _ = json.NewEncoder(w).Encode(createResponse{RoomID: testRoomID, Password: testPassword}) //nolint:gosec + }) + mux.HandleFunc("POST /room/{id}/preconnect", func(w http.ResponseWriter, _ *http.Request) { + _ = json.NewEncoder(w).Encode(map[string]string{connectorURLKey: testConnector}) + }) + + withJazzAPIServer(t, mux) + + room, err := createRoom(context.Background()) + if err != nil { + t.Fatalf("createRoom() error = %v", err) + } + if room.RoomID != testRoomID || room.Password != testPassword || + room.ConnectorURL != testConnector { + t.Fatalf("createRoom() = %+v", room) + } + + room, err = joinRoom(context.Background(), "existing", "secret") + if err != nil { + t.Fatalf("joinRoom() error = %v", err) + } + if room.RoomID != "existing" || room.Password != "secret" || room.ConnectorURL != testConnector { + t.Fatalf("joinRoom() = %+v", room) + } +} + +func TestJazzAPIErrors(t *testing.T) { + mux := http.NewServeMux() + mux.HandleFunc("/room/create-meeting", func(w http.ResponseWriter, _ *http.Request) { + http.Error(w, "bad", http.StatusTeapot) + }) + mux.HandleFunc("/", func(w http.ResponseWriter, _ *http.Request) { + http.Error(w, "bad", http.StatusInternalServerError) + }) + + withJazzAPIServer(t, mux) + + if _, err := createMeeting(context.Background(), nil); !errors.Is(err, errCreateRoomFailed) { + t.Fatalf("createMeeting() error = %v, want %v", err, errCreateRoomFailed) + } + if _, err := preconnect(context.Background(), "room", "pass", nil); !errors.Is(err, errPreconnectFailed) { + t.Fatalf("preconnect() error = %v, want %v", err, errPreconnectFailed) + } +} + +func TestJazzIssue(t *testing.T) { + mux := http.NewServeMux() + mux.HandleFunc("POST /room/create-meeting", func(w http.ResponseWriter, _ *http.Request) { + _ = json.NewEncoder(w).Encode(createResponse{RoomID: testRoomID, Password: testPassword}) //nolint:gosec + }) + mux.HandleFunc("POST /room/{id}/preconnect", func(w http.ResponseWriter, _ *http.Request) { + _ = json.NewEncoder(w).Encode(map[string]string{connectorURLKey: testConnector}) + }) + + withJazzAPIServer(t, mux) + + p := Provider{} + creds, err := p.Issue(context.Background(), auth.Config{ + RoomURL: "any", + Name: "peer", + }) + if err != nil { + t.Fatalf("Issue() error = %v", err) + } + if creds.URL != testConnector { + t.Fatalf("creds.URL = %q", creds.URL) + } + if creds.Token != testRoomID { + t.Fatalf("creds.Token = %q", creds.Token) + } + if creds.Extra["password"] != testPassword { + t.Fatalf("creds.Extra[password] = %q", creds.Extra["password"]) + } +} diff --git a/internal/auth/telemost/api_test.go b/internal/auth/telemost/api_test.go new file mode 100644 index 0000000..1acfb39 --- /dev/null +++ b/internal/auth/telemost/api_test.go @@ -0,0 +1,65 @@ +package telemost + +import ( + "context" + "encoding/json" + "errors" + "net/http" + "net/http/httptest" + "strings" + "testing" +) + +func withTelemostAPIServer(t *testing.T, h http.Handler) { + t.Helper() + old := apiBase + srv := httptest.NewServer(h) + t.Cleanup(func() { + apiBase = old + srv.Close() + }) + apiBase = srv.URL +} + +func TestGetConnectionInfo(t *testing.T) { + mux := http.NewServeMux() + mux.HandleFunc("GET /conferences/{id...}", func(w http.ResponseWriter, r *http.Request) { + if !strings.HasPrefix(r.URL.Path, "/conferences/room/id/connection") { + t.Fatalf("path = %q", r.URL.Path) + } + if r.URL.Query().Get("display_name") != "peer" { + t.Fatalf("display_name query = %q", r.URL.Query().Get("display_name")) + } + _ = json.NewEncoder(w).Encode(ConnectionInfo{ + RoomID: "room", + PeerID: "peer-id", + Credentials: "creds", + }) + }) + + withTelemostAPIServer(t, mux) + + info, err := GetConnectionInfo(context.Background(), "room/id", "peer") + if err != nil { + t.Fatalf("GetConnectionInfo() error = %v", err) + } + if info.RoomID != "room" || info.PeerID != "peer-id" || info.Credentials != "creds" { + t.Fatalf("GetConnectionInfo() = %+v", info) + } +} + +func TestGetConnectionInfoErrors(t *testing.T) { + withTelemostAPIServer(t, http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + http.Error(w, "bad", http.StatusForbidden) + })) + if _, err := GetConnectionInfo(context.Background(), "room", "peer"); !errors.Is(err, ErrAPI) { + t.Fatalf("GetConnectionInfo() error = %v, want %v", err, ErrAPI) + } + + withTelemostAPIServer(t, http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + _, _ = w.Write([]byte("{")) + })) + if _, err := GetConnectionInfo(context.Background(), "room", "peer"); err == nil { + t.Fatal("GetConnectionInfo() unexpectedly accepted bad json") + } +} diff --git a/internal/auth/wbstream/api.go b/internal/auth/wbstream/api.go index b8c2dc0..daaed73 100644 --- a/internal/auth/wbstream/api.go +++ b/internal/auth/wbstream/api.go @@ -16,9 +16,9 @@ import ( "github.com/openlibrecommunity/olcrtc/internal/protect" ) -const wsURL = "wss://wbstream01-el.wb.ru:7880" +const wsURL = "wss://rtc-el-01.wb.ru" -var apiBase = "https://stream.wb.ru" //nolint:gochecknoglobals // overridable base URL for tests +var apiBase = "https://stream.wb.ru" //nolint:gochecknoglobals // package-level state intentional var ( errGuestRegister = errors.New("guest register failed") diff --git a/internal/auth/wbstream/api_test.go b/internal/auth/wbstream/api_test.go new file mode 100644 index 0000000..b0b7c1f --- /dev/null +++ b/internal/auth/wbstream/api_test.go @@ -0,0 +1,135 @@ +package wbstream + +import ( + "context" + "encoding/json" + "errors" + "net/http" + "net/http/httptest" + "testing" + + "github.com/openlibrecommunity/olcrtc/internal/auth" +) + +const ( + testAccessToken = "access" + testRoomID = "room" + testToken = "token" + testPeerName = "peer" +) + +func withWBAPIServer(t *testing.T, h http.Handler) { + t.Helper() + old := apiBase + srv := httptest.NewServer(h) + t.Cleanup(func() { + apiBase = old + srv.Close() + }) + apiBase = srv.URL +} + +func TestWBStreamAPIHappyPath(t *testing.T) { + mux := http.NewServeMux() + mux.HandleFunc("POST /auth/api/v1/auth/user/guest-register", func(w http.ResponseWriter, _ *http.Request) { + _ = json.NewEncoder(w).Encode(guestRegisterResponse{AccessToken: testAccessToken}) //nolint:gosec + }) + mux.HandleFunc("POST /api-room/api/v2/room", func(w http.ResponseWriter, r *http.Request) { + if r.Header.Get("Authorization") != "Bearer "+testAccessToken { + t.Fatalf("room auth = %q", r.Header.Get("Authorization")) + } + w.WriteHeader(http.StatusCreated) + _ = json.NewEncoder(w).Encode(createRoomResponse{RoomID: testRoomID}) + }) + mux.HandleFunc("POST /api-room/api/v1/room/"+testRoomID+"/join", func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusOK) + }) + mux.HandleFunc("GET /api-room-manager/v2/room/"+testRoomID+"/connection-details", + func(w http.ResponseWriter, r *http.Request) { + if r.URL.Query().Get("displayName") != testPeerName { + t.Fatalf("displayName query = %q", r.URL.Query().Get("displayName")) + } + _ = json.NewEncoder(w).Encode(tokenResponse{RoomToken: testToken}) + }) + + withWBAPIServer(t, mux) + + access, err := registerGuest(context.Background(), testPeerName) + if err != nil { + t.Fatalf("registerGuest() error = %v", err) + } + if access != testAccessToken { + t.Fatalf("registerGuest() = %q", access) + } + + room, err := createRoom(context.Background(), access) + if err != nil { + t.Fatalf("createRoom() error = %v", err) + } + if room != testRoomID { + t.Fatalf("createRoom() = %q", room) + } + + if err := joinRoom(context.Background(), access, room); err != nil { + t.Fatalf("joinRoom() error = %v", err) + } + token, err := getToken(context.Background(), access, room, testPeerName) + if err != nil { + t.Fatalf("getToken() error = %v", err) + } + if token != testToken { + t.Fatalf("getToken() = %q", token) + } +} + +func TestWBStreamAPIErrors(t *testing.T) { + withWBAPIServer(t, http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + http.Error(w, "bad", http.StatusBadGateway) + })) + + if _, err := registerGuest(context.Background(), testPeerName); !errors.Is(err, errGuestRegister) { + t.Fatalf("registerGuest() error = %v, want %v", err, errGuestRegister) + } + if _, err := createRoom(context.Background(), testAccessToken); !errors.Is(err, errCreateRoom) { + t.Fatalf("createRoom() error = %v, want %v", err, errCreateRoom) + } + if err := joinRoom(context.Background(), testAccessToken, testRoomID); !errors.Is(err, errJoinRoom) { + t.Fatalf("joinRoom() error = %v, want %v", err, errJoinRoom) + } + if _, err := getToken(context.Background(), testAccessToken, testRoomID, testPeerName); !errors.Is(err, errGetToken) { + t.Fatalf("getToken() error = %v, want %v", err, errGetToken) + } +} + +func TestWBStreamIssue(t *testing.T) { + mux := http.NewServeMux() + mux.HandleFunc("POST /auth/api/v1/auth/user/guest-register", func(w http.ResponseWriter, _ *http.Request) { + _ = json.NewEncoder(w).Encode(guestRegisterResponse{AccessToken: testAccessToken}) //nolint:gosec + }) + mux.HandleFunc("POST /api-room/api/v2/room", func(w http.ResponseWriter, _ *http.Request) { + _ = json.NewEncoder(w).Encode(createRoomResponse{RoomID: "created"}) + }) + mux.HandleFunc("POST /api-room/api/v1/room/{id}/join", func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusOK) + }) + mux.HandleFunc("GET /api-room-manager/v2/room/{id}/connection-details", func(w http.ResponseWriter, _ *http.Request) { + _ = json.NewEncoder(w).Encode(tokenResponse{RoomToken: testToken}) + }) + + withWBAPIServer(t, mux) + + p := Provider{} + creds, err := p.Issue(context.Background(), auth.Config{ + RoomURL: "any", + Name: testPeerName, + }) + if err != nil { + t.Fatalf("Issue() error = %v", err) + } + if creds.Token != testToken { + t.Fatalf("creds.Token = %q", creds.Token) + } + if creds.Extra["roomID"] != "created" { + t.Fatalf("creds.Extra[roomID] = %q", creds.Extra["roomID"]) + } +} diff --git a/internal/engine/goolom/session_helpers_test.go b/internal/engine/goolom/session_helpers_test.go new file mode 100644 index 0000000..01a24d5 --- /dev/null +++ b/internal/engine/goolom/session_helpers_test.go @@ -0,0 +1,85 @@ +package goolom + +import ( + "testing" + "time" +) + +//nolint:cyclop // table-driven test naturally has many branches +func TestSessionReconnectAndEndedHelpers(t *testing.T) { + s := &Session{ + reconnectCh: make(chan struct{}, 2), + closeCh: make(chan struct{}), + keepAliveCh: make(chan struct{}), + sessionCloseCh: make(chan struct{}), + telemetryCh: make(chan struct{}, 1), + } + + keepAliveCh, sessionCloseCh := s.resetSession() + if keepAliveCh == nil || sessionCloseCh == nil || keepAliveCh != s.keepAliveCh || sessionCloseCh != s.sessionCloseCh { + t.Fatal("resetSession() did not replace session channels") + } + + s.subscriberReady.Store(true) + s.publisherReady.Store(true) + s.resetMediaState() + if s.subscriberReady.Load() || s.publisherReady.Load() || s.subscriberConn == nil || s.publisherConn == nil { + t.Fatal("resetMediaState() did not reset readiness") + } + + s.queueReconnect() + select { + case <-s.reconnectCh: + default: + t.Fatal("queueReconnect() did not enqueue") + } + + s.SetShouldReconnect(func() bool { return false }) + s.queueReconnect() + select { + case <-s.reconnectCh: + t.Fatal("queueReconnect() enqueued despite policy=false") + default: + } + + s.reconnectCh <- struct{}{} + s.reconnectCh <- struct{}{} + s.drainReconnectQueue() + select { + case <-s.reconnectCh: + t.Fatal("drainReconnectQueue() left queued item") + default: + } + + s.telemetryActive.Store(true) + s.stopTelemetry() + select { + case <-s.telemetryCh: + default: + t.Fatal("stopTelemetry() did not signal active telemetry") + } + + ended := "" + s.SetEndedCallback(func(reason string) { ended = reason }) + s.signalEnded("done") + if !s.closed.Load() || ended != "done" { + t.Fatalf("signalEnded() closed=%v reason=%q", s.closed.Load(), ended) + } +} + +func TestWaitForAckTimeoutAndClose(t *testing.T) { + s := &Session{ + closeCh: make(chan struct{}), + ackWaiters: make(map[string]chan struct{}), + } + ch := s.registerAckWaiter("timeout") + if s.waitForAck("timeout", ch, time.Millisecond) { + t.Fatal("waitForAck(timeout) = true") + } + + ch = s.registerAckWaiter("closed") + close(s.closeCh) + if s.waitForAck("closed", ch, time.Second) { + t.Fatal("waitForAck(closeCh) = true") + } +} diff --git a/internal/engine/salutejazz/datapacket_test.go b/internal/engine/salutejazz/datapacket_test.go new file mode 100644 index 0000000..a0c1561 --- /dev/null +++ b/internal/engine/salutejazz/datapacket_test.go @@ -0,0 +1,70 @@ +package salutejazz + +import ( + "bytes" + "errors" + "io" + "testing" +) + +func TestDataPacketRoundTrip(t *testing.T) { + payload := []byte("hello jazz") + raw := EncodeDataPacket(payload) + + got, ok := DecodeDataPacket(raw) + if !ok { + t.Fatal("DecodeDataPacket() ok = false") + } + if !bytes.Equal(got, payload) { + t.Fatalf("DecodeDataPacket() = %q, want %q", got, payload) + } +} + +func TestDecodeDataPacketRejectsMalformedPackets(t *testing.T) { + tests := [][]byte{ + nil, + {0xff}, + encodeField(1, 0, encodeVarint(0)), + {byte(2<<3 | 2), 10, 1}, + {byte(3<<3 | 7), 0}, + } + + for _, raw := range tests { + if payload, ok := DecodeDataPacket(raw); ok { + t.Fatalf("DecodeDataPacket(%v) = (%q, true), want false", raw, payload) + } + } +} + +func TestParseFieldsSkipsSupportedNonTargetWireTypes(t *testing.T) { + data := encodeField(1, 0, encodeVarint(150)) + data = append(data, encodeField(3, 1, []byte("12345678"))...) + data = append(data, encodeField(4, 5, []byte("1234"))...) + data = append(data, encodeField(2, 2, []byte("target"))...) + + got, ok := parseFields(data, 2) + if !ok || string(got) != "target" { + t.Fatalf("parseFields() = (%q, %v), want target", got, ok) + } +} + +func TestByteReader(t *testing.T) { + r := &byteReader{data: []byte{1, 2, 3}} + b, err := r.ReadByte() + if err != nil || b != 1 { + t.Fatalf("ReadByte() = (%d, %v), want (1, nil)", b, err) + } + + buf := make([]byte, 4) + n, err := r.Read(buf) + if err != nil || n != 2 || !bytes.Equal(buf[:n], []byte{2, 3}) { + t.Fatalf("Read() = (%d, %v, %v), want two bytes", n, err, buf[:n]) + } + + if _, err := r.ReadByte(); !errors.Is(err, io.EOF) { + t.Fatalf("ReadByte() error = %v, want EOF", err) + } + if n, err := r.Read(buf); !errors.Is(err, io.EOF) || n != 0 { + t.Fatalf("Read() = (%d, %v), want (0, EOF)", n, err) + } +} diff --git a/internal/engine/salutejazz/session_helpers_test.go b/internal/engine/salutejazz/session_helpers_test.go new file mode 100644 index 0000000..fed3941 --- /dev/null +++ b/internal/engine/salutejazz/session_helpers_test.go @@ -0,0 +1,112 @@ +package salutejazz + +import ( + "context" + "errors" + "testing" + + "github.com/pion/webrtc/v4" +) + +//nolint:cyclop // table-driven test naturally has many branches +func TestSessionStateHelpers(t *testing.T) { + s := &Session{ + reconnectCh: make(chan struct{}, 1), + closeCh: make(chan struct{}), + sessionCloseCh: make(chan struct{}), + sendQueue: make(chan []byte, 1), + subscriberConn: make(chan struct{}), + publisherConn: make(chan struct{}), + } + + s.resetMediaState() + if s.subscriberReady.Load() || s.publisherReady.Load() || s.subscriberConn == nil || s.publisherConn == nil { + t.Fatal("resetMediaState() did not reset readiness") + } + if s.hasLocalVideoTracks() { + t.Fatal("hasLocalVideoTracks() = true without tracks") + } + if err := s.AddVideoTrack(nil); err != nil { + t.Fatalf("AddVideoTrack(nil) error = %v", err) + } + if !s.hasLocalVideoTracks() { + t.Fatal("hasLocalVideoTracks() = false after AddVideoTrack") + } + + s.SetVideoTrackHandler(func(*webrtc.TrackRemote, *webrtc.RTPReceiver) {}) + if s.videoTrackHandler() == nil { + t.Fatal("videoTrackHandler() = nil") + } + + cfg := defaultWebRTCConfig() + if cfg.SDPSemantics != webrtc.SDPSemanticsUnifiedPlan || cfg.BundlePolicy != webrtc.BundlePolicyMaxBundle { + t.Fatalf("defaultWebRTCConfig() = %+v", cfg) + } + if s.buildAPI() == nil { + t.Fatal("buildAPI() returned nil") + } +} + +func TestSessionCallbacksQueueReconnectAndClose(t *testing.T) { + s := &Session{ + reconnectCh: make(chan struct{}, 1), + closeCh: make(chan struct{}), + sessionCloseCh: make(chan struct{}), + sendQueue: make(chan []byte, 1), + } + + s.SetReconnectCallback(func(*webrtc.DataChannel) {}) + s.SetShouldReconnect(func() bool { return true }) + s.SetEndedCallback(func(string) {}) + if s.onReconnect == nil || s.shouldReconnect == nil || s.onEnded == nil { + t.Fatal("callbacks were not stored") + } + + s.queueReconnect() + select { + case <-s.reconnectCh: + default: + t.Fatal("queueReconnect() did not enqueue") + } + + s.SetShouldReconnect(func() bool { return false }) + s.queueReconnect() + select { + case <-s.reconnectCh: + t.Fatal("queueReconnect() enqueued despite policy=false") + default: + } + + done := make(chan struct{}) + go func() { + s.WatchConnection(context.Background()) + close(done) + }() + if err := s.Close(); err != nil { + t.Fatalf("Close() error = %v", err) + } + <-done + if err := s.Send([]byte("closed")); !errors.Is(err, ErrDataChannelNotReady) { + t.Fatalf("Send() error = %v, want datachannel not ready", err) + } +} + +func TestSessionCanSendVideoOnlyModes(t *testing.T) { + s := &Session{sendQueue: make(chan []byte, 1)} + s.subscriberReady.Store(true) + if !s.CanSend() { + t.Fatal("CanSend() = false for subscriber-ready session without local video") + } + _ = s.AddVideoTrack(nil) + if s.CanSend() { + t.Fatal("CanSend() = true with local video but publisher not ready") + } + s.publisherReady.Store(true) + if !s.CanSend() { + t.Fatal("CanSend() = false with subscriber and publisher ready") + } + s.closed.Store(true) + if s.CanSend() { + t.Fatal("CanSend() = true for closed session") + } +}