package jingle import ( "context" "sync" "testing" "time" "github.com/pion/webrtc/v4" "gosrc.io/xmpp/stanza" ) // wires one Manager's outbound into the other's HandlePacket without // sockets; each Send fires on a fresh goroutine so trickle/ordering // bugs surface naturally type linkedSender struct { target *Manager wg sync.WaitGroup } func (ls *linkedSender) Send(p stanza.Packet) error { ls.wg.Add(1) go func() { defer ls.wg.Done() ls.target.HandlePacket(nil, p) }() return nil } func (ls *linkedSender) SendIQ(ctx context.Context, iq *stanza.IQ) (chan stanza.IQ, error) { ch := make(chan stanza.IQ, 1) return ch, ls.Send(iq) } func (ls *linkedSender) SendRaw(string) error { return nil } func (ls *linkedSender) wait() { ls.wg.Wait() } // two Managers cross-linked through linkedSenders func pair() (*Manager, *Manager, *linkedSender, *linkedSender) { mA := &Manager{LocalJID: "a@x/r"} mB := &Manager{LocalJID: "b@x/r"} sA := &linkedSender{target: mB} sB := &linkedSender{target: mA} mA.Sender = sA mB.Sender = sB return mA, mB, sA, sB } // pion PC with t.Cleanup func makePC(t *testing.T) *webrtc.PeerConnection { t.Helper() pc, err := webrtc.NewPeerConnection(webrtc.Configuration{}) if err != nil { t.Fatalf("NewPeerConnection: %v", err) } t.Cleanup(func() { _ = pc.Close() }) return pc } func waitFor(t *testing.T, what string, d time.Duration, fn func() bool) { t.Helper() deadline := time.Now().Add(d) for time.Now().Before(deadline) { if fn() { return } time.Sleep(20 * time.Millisecond) } t.Fatalf("waitFor(%s): timeout after %v", what, d) } // JMI preamble without media setup func TestJMIProposeRingProceed(t *testing.T) { mA, mB, sA, sB := pair() var bSess *Session var bSessMu sync.Mutex mB.OnProposal = func(p IncomingProposal) (*Session, func(), error) { s, err := New(SessionOpts{ PC: makePC(t), Sender: sB, LocalJID: mB.LocalJID, RemoteJID: p.From, SID: p.SID, Role: RoleResponder, Media: p.Media, }) if err != nil { return nil, nil, err } bSessMu.Lock() bSess = s bSessMu.Unlock() return s, nil, nil } aSess, err := New(SessionOpts{ PC: makePC(t), Sender: sA, LocalJID: mA.LocalJID, RemoteJID: "b@x", SID: "sid-1", Role: RoleInitiator, Media: []string{"audio"}, }) if err != nil { t.Fatal(err) } mA.Register(aSess) if err := aSess.Propose(context.Background()); err != nil { t.Fatalf("Propose: %v", err) } waitFor(t, "B reach ringing", 2*time.Second, func() bool { bSessMu.Lock() defer bSessMu.Unlock() return bSess != nil && bSess.State() == StateRinging }) if err := bSess.AcceptProposal(context.Background()); err != nil { t.Fatalf("AcceptProposal: %v", err) } // A enters StateProceeded; media setup will then drive it forward // (CreateOffer, etc.) - that's tested elsewhere. Allow either // StateProceeded or StateActive since the goroutine may have raced // ahead, depending on how fast pion gathers. waitFor(t, "A leave Proposed", 2*time.Second, func() bool { s := aSess.State() return s == StateProceeded || s == StateActive || s == StateTerminated }) aSess.Close() bSess.Close() sA.wait() sB.wait() } // : caller -> Terminated when callee declines while ringing func TestJMIRejectFromCallee(t *testing.T) { mA, mB, sA, sB := pair() var bSess *Session var bSessMu sync.Mutex mB.OnProposal = func(p IncomingProposal) (*Session, func(), error) { s, _ := New(SessionOpts{ PC: makePC(t), Sender: sB, LocalJID: mB.LocalJID, RemoteJID: p.From, SID: p.SID, Role: RoleResponder, Media: p.Media, }) bSessMu.Lock() bSess = s bSessMu.Unlock() return s, nil, nil } aSess, _ := New(SessionOpts{ PC: makePC(t), Sender: sA, LocalJID: mA.LocalJID, RemoteJID: "b@x", SID: "sid-rej", Role: RoleInitiator, Media: []string{"audio"}, }) mA.Register(aSess) if err := aSess.Propose(context.Background()); err != nil { t.Fatal(err) } waitFor(t, "B reach ringing", 2*time.Second, func() bool { bSessMu.Lock() defer bSessMu.Unlock() return bSess != nil && bSess.State() == StateRinging }) if err := bSess.Decline(context.Background(), ReasonDecline); err != nil { t.Fatalf("Decline: %v", err) } waitFor(t, "A terminated", 2*time.Second, func() bool { return aSess.State() == StateTerminated }) } // : callee -> Terminated when caller cancels while ringing func TestJMIRetractByCaller(t *testing.T) { mA, mB, sA, sB := pair() var bSess *Session var bSessMu sync.Mutex mB.OnProposal = func(p IncomingProposal) (*Session, func(), error) { s, _ := New(SessionOpts{ PC: makePC(t), Sender: sB, LocalJID: mB.LocalJID, RemoteJID: p.From, SID: p.SID, Role: RoleResponder, Media: p.Media, }) bSessMu.Lock() bSess = s bSessMu.Unlock() return s, nil, nil } aSess, _ := New(SessionOpts{ PC: makePC(t), Sender: sA, LocalJID: mA.LocalJID, RemoteJID: "b@x", SID: "sid-ret", Role: RoleInitiator, Media: []string{"audio"}, }) mA.Register(aSess) if err := aSess.Propose(context.Background()); err != nil { t.Fatal(err) } waitFor(t, "B reach ringing", 2*time.Second, func() bool { bSessMu.Lock() defer bSessMu.Unlock() return bSess != nil && bSess.State() == StateRinging }) if err := aSess.Retract(context.Background()); err != nil { t.Fatalf("Retract: %v", err) } waitFor(t, "B terminated", 2*time.Second, func() bool { return bSess.State() == StateTerminated }) } // duplicate-SID guard func TestJMIDuplicateProposeIgnored(t *testing.T) { mA, mB, sA, sB := pair() calls := 0 var mu sync.Mutex mB.OnProposal = func(p IncomingProposal) (*Session, func(), error) { mu.Lock() calls++ mu.Unlock() s, _ := New(SessionOpts{ PC: makePC(t), Sender: sB, LocalJID: mB.LocalJID, RemoteJID: p.From, SID: p.SID, Role: RoleResponder, Media: p.Media, }) return s, nil, nil } aSess, _ := New(SessionOpts{ PC: makePC(t), Sender: sA, LocalJID: mA.LocalJID, RemoteJID: "b@x", SID: "dup-sid", Role: RoleInitiator, Media: []string{"audio"}, }) mA.Register(aSess) _ = aSess.Propose(context.Background()) _ = aSess.Propose(context.Background()) // second one rejected by state guard, but if forced through... // To make the test actually drive a duplicate, send a raw second propose: dup := newJMIMessage(mA.LocalJID, mB.LocalJID, &JMIPropose{ ID: "dup-sid", Descriptions: []JMIDescription{{Media: "audio"}}, }) if err := sA.Send(dup); err != nil { t.Fatal(err) } // Allow dispatch to complete. sA.wait() sB.wait() mu.Lock() defer mu.Unlock() if calls != 1 { t.Errorf("OnProposal called %d times, want 1", calls) } }