mirror of
https://dev.narayana.im/narayana/telegabber.git
synced 2026-08-05 12:17:06 +00:00
260 lines
6.5 KiB
Go
260 lines
6.5 KiB
Go
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()
|
|
}
|
|
|
|
// <reject>: 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
|
|
})
|
|
}
|
|
|
|
// <retract>: 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)
|
|
}
|
|
}
|