mirror of
https://dev.narayana.im/narayana/telegabber.git
synced 2026-08-05 12:17:06 +00:00
376 lines
9.4 KiB
Go
376 lines
9.4 KiB
Go
package calls
|
|
|
|
import (
|
|
"context"
|
|
"sync"
|
|
"sync/atomic"
|
|
"testing"
|
|
"time"
|
|
|
|
"dev.narayana.im/narayana/telegabber/calls/signaling/tgsig"
|
|
"dev.narayana.im/narayana/telegabber/calls/signaling/xmppsig"
|
|
"dev.narayana.im/narayana/telegabber/xmpp/jingle"
|
|
|
|
"github.com/pion/webrtc/v4"
|
|
"github.com/zelenin/go-tdlib/client"
|
|
"gosrc.io/xmpp/stanza"
|
|
"gotgcalls/ntgcalls"
|
|
)
|
|
|
|
type captureSender struct {
|
|
mu sync.Mutex
|
|
sent []stanza.Packet
|
|
}
|
|
|
|
func (c *captureSender) Send(p stanza.Packet) error {
|
|
c.mu.Lock()
|
|
c.sent = append(c.sent, p)
|
|
c.mu.Unlock()
|
|
return nil
|
|
}
|
|
|
|
func (c *captureSender) SendIQ(ctx context.Context, iq *stanza.IQ) (chan stanza.IQ, error) {
|
|
ch := make(chan stanza.IQ, 1)
|
|
return ch, c.Send(iq)
|
|
}
|
|
|
|
func (c *captureSender) sentCopy() []stanza.Packet {
|
|
c.mu.Lock()
|
|
defer c.mu.Unlock()
|
|
out := make([]stanza.Packet, len(c.sent))
|
|
copy(out, c.sent)
|
|
return out
|
|
}
|
|
|
|
func (c *captureSender) hasExt(match func(stanza.MsgExtension) bool) bool {
|
|
for _, p := range c.sentCopy() {
|
|
msg, ok := p.(*stanza.Message)
|
|
if !ok {
|
|
continue
|
|
}
|
|
for _, ext := range msg.Extensions {
|
|
if match(ext) {
|
|
return true
|
|
}
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
type fakeTdlib struct {
|
|
nextCallID atomic.Int32
|
|
|
|
created chan *client.CreateCallRequest
|
|
accepted chan *client.AcceptCallRequest
|
|
discard chan *client.DiscardCallRequest
|
|
}
|
|
|
|
func newFakeTdlib() *fakeTdlib {
|
|
return &fakeTdlib{
|
|
created: make(chan *client.CreateCallRequest, 4),
|
|
accepted: make(chan *client.AcceptCallRequest, 4),
|
|
discard: make(chan *client.DiscardCallRequest, 4),
|
|
}
|
|
}
|
|
|
|
func (f *fakeTdlib) CreateCall(req *client.CreateCallRequest) (*client.CallId, error) {
|
|
id := f.nextCallID.Add(1)
|
|
select {
|
|
case f.created <- req:
|
|
default:
|
|
}
|
|
return &client.CallId{Id: id}, nil
|
|
}
|
|
func (f *fakeTdlib) AcceptCall(req *client.AcceptCallRequest) (*client.Ok, error) {
|
|
select {
|
|
case f.accepted <- req:
|
|
default:
|
|
}
|
|
return &client.Ok{}, nil
|
|
}
|
|
func (f *fakeTdlib) DiscardCall(req *client.DiscardCallRequest) (*client.Ok, error) {
|
|
select {
|
|
case f.discard <- req:
|
|
default:
|
|
}
|
|
return &client.Ok{}, nil
|
|
}
|
|
func (f *fakeTdlib) SendCallSignalingData(req *client.SendCallSignalingDataRequest) (*client.Ok, error) {
|
|
return &client.Ok{}, nil
|
|
}
|
|
|
|
// fakeNtg is a no-op: orchestrator tests don't exercise media setup.
|
|
type fakeNtg struct{}
|
|
|
|
func (f *fakeNtg) OnSignal(ntgcalls.SignalCallback) {}
|
|
func (f *fakeNtg) OnConnectionChange(ntgcalls.ConnectionChangeCallback) {}
|
|
func (f *fakeNtg) CreateP2PCall(int64) error { return nil }
|
|
func (f *fakeNtg) SkipExchange(int64, []byte, bool) error { return nil }
|
|
func (f *fakeNtg) ConnectP2P(int64, []ntgcalls.RTCServer, []string, bool) error {
|
|
return nil
|
|
}
|
|
func (f *fakeNtg) SendSignalingData(int64, []byte) error { return nil }
|
|
func (f *fakeNtg) Stop(int64) error { return nil }
|
|
|
|
type fixture struct {
|
|
t *testing.T
|
|
sender *captureSender
|
|
tdlib *fakeTdlib
|
|
ntg *fakeNtg
|
|
jingleMgr *jingle.Manager
|
|
tgMgr *tgsig.Manager
|
|
tgAdapter *tgsig.Adapter
|
|
xmppAd *xmppsig.Adapter
|
|
orch *Orchestrator
|
|
userBare string
|
|
}
|
|
|
|
func newFixture(t *testing.T, opts ...func(*Config)) *fixture {
|
|
t.Helper()
|
|
sender := &captureSender{}
|
|
tdlib := newFakeTdlib()
|
|
ntg := &fakeNtg{}
|
|
|
|
jm := &jingle.Manager{LocalJID: "gw.example", Sender: sender}
|
|
tgMgr := tgsig.NewManager(nil)
|
|
|
|
const userBare = "user@example"
|
|
tgAdapter := tgsig.New(tdlib, ntg, nil, userBare, tgMgr)
|
|
xmppAd := xmppsig.NewAdapter(xmppsig.AdapterConfig{
|
|
Sender: sender,
|
|
LocalJID: "gw.example",
|
|
Manager: jm,
|
|
PCFactory: testPCFactory(t),
|
|
})
|
|
|
|
lookup := func(jid string) (*tgsig.Adapter, *xmppsig.Adapter, bool) {
|
|
if jid != userBare {
|
|
return nil, nil, false
|
|
}
|
|
return tgAdapter, xmppAd, true
|
|
}
|
|
|
|
cfg := Config{
|
|
JingleManager: jm,
|
|
TgManager: tgMgr,
|
|
Lookup: lookup,
|
|
}
|
|
for _, opt := range opts {
|
|
opt(&cfg)
|
|
}
|
|
orch := New(cfg)
|
|
tgMgr.SetIncomingHandler(orch.NewFromTelegram)
|
|
|
|
return &fixture{
|
|
t: t,
|
|
sender: sender,
|
|
tdlib: tdlib,
|
|
ntg: ntg,
|
|
jingleMgr: jm,
|
|
tgMgr: tgMgr,
|
|
tgAdapter: tgAdapter,
|
|
xmppAd: xmppAd,
|
|
orch: orch,
|
|
userBare: userBare,
|
|
}
|
|
}
|
|
|
|
func testPCFactory(t *testing.T) func() (*webrtc.PeerConnection, error) {
|
|
return func() (*webrtc.PeerConnection, error) {
|
|
pc, err := webrtc.NewPeerConnection(webrtc.Configuration{})
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
t.Cleanup(func() { _ = pc.Close() })
|
|
return pc, nil
|
|
}
|
|
}
|
|
|
|
func TestIncomingProposeFiresTelegramCreateCall(t *testing.T) {
|
|
fx := newFixture(t)
|
|
|
|
fx.jingleMgr.HandlePacket(nil, buildProposeMessage(fx.userBare+"/r", "12345@gw.example", "sid-in-1"))
|
|
|
|
select {
|
|
case req := <-fx.tdlib.created:
|
|
if req.UserId != 12345 {
|
|
t.Fatalf("CreateCall userID = %d, want 12345", req.UserId)
|
|
}
|
|
case <-time.After(1 * time.Second):
|
|
t.Fatalf("tdlib.CreateCall never invoked")
|
|
}
|
|
|
|
// No <ringing> should have been auto-sent: Observer is wired.
|
|
if fx.sender.hasExt(func(e stanza.MsgExtension) bool { _, ok := e.(*jingle.JMIRinging); return ok }) {
|
|
t.Fatalf("expected no auto-<ringing>; observer should gate it")
|
|
}
|
|
}
|
|
|
|
func TestRetractTearsDownTelegramPeer(t *testing.T) {
|
|
fx := newFixture(t)
|
|
|
|
fx.jingleMgr.HandlePacket(nil, buildProposeMessage(fx.userBare+"/r", "12345@gw.example", "sid-rj-1"))
|
|
|
|
select {
|
|
case <-fx.tdlib.created:
|
|
case <-time.After(1 * time.Second):
|
|
t.Fatalf("tdlib.CreateCall never invoked")
|
|
}
|
|
|
|
// Caller retracts (they originated; <retract/> is the cancel verb).
|
|
fx.jingleMgr.HandlePacket(nil, buildJMIMessage(fx.userBare+"/r", "gw.example", &jingle.JMIRetract{ID: "sid-rj-1"}))
|
|
|
|
select {
|
|
case <-fx.tdlib.discard:
|
|
// good
|
|
case <-time.After(1 * time.Second):
|
|
t.Fatalf("tdlib.DiscardCall never invoked after <retract>")
|
|
}
|
|
}
|
|
|
|
func TestRingTimeoutTearsDownBothSides(t *testing.T) {
|
|
fx := newFixture(t, func(c *Config) { c.RingTimeout = 100 * time.Millisecond })
|
|
|
|
fx.jingleMgr.HandlePacket(nil, buildProposeMessage(fx.userBare+"/r", "12345@gw.example", "sid-to-1"))
|
|
|
|
select {
|
|
case <-fx.tdlib.created:
|
|
case <-time.After(1 * time.Second):
|
|
t.Fatalf("tdlib.CreateCall never invoked")
|
|
}
|
|
|
|
select {
|
|
case <-fx.tdlib.discard:
|
|
// good
|
|
case <-time.After(2 * time.Second):
|
|
t.Fatalf("tdlib.DiscardCall never invoked on ring timeout")
|
|
}
|
|
|
|
waitForCond(t, "reject emitted", 1*time.Second, func() bool {
|
|
return fx.sender.hasExt(func(e stanza.MsgExtension) bool {
|
|
_, ok := e.(*jingle.JMIReject)
|
|
return ok
|
|
})
|
|
})
|
|
}
|
|
|
|
func TestNewFromTelegramSendsPropose(t *testing.T) {
|
|
fx := newFixture(t)
|
|
|
|
fx.tgMgr.OnUpdateCall(fx.userBare, &client.UpdateCall{
|
|
Call: &client.Call{
|
|
Id: 42,
|
|
UserId: 67890,
|
|
IsOutgoing: false,
|
|
IsVideo: false,
|
|
State: &client.CallStatePending{},
|
|
},
|
|
})
|
|
|
|
waitForCond(t, "propose emitted", 1*time.Second, func() bool {
|
|
return fx.sender.hasExt(func(e stanza.MsgExtension) bool {
|
|
_, ok := e.(*jingle.JMIPropose)
|
|
return ok
|
|
})
|
|
})
|
|
}
|
|
|
|
func TestTelegramOriginatedProceedFiresTdlibAccept(t *testing.T) {
|
|
fx := newFixture(t)
|
|
|
|
fx.tgMgr.OnUpdateCall(fx.userBare, &client.UpdateCall{
|
|
Call: &client.Call{
|
|
Id: 42,
|
|
UserId: 67890,
|
|
IsOutgoing: false,
|
|
State: &client.CallStatePending{},
|
|
},
|
|
})
|
|
|
|
sid := waitForProposedSID(t, fx.sender, 1*time.Second)
|
|
|
|
fx.jingleMgr.HandlePacket(nil, buildJMIMessage(fx.userBare+"/r", "gw.example", &jingle.JMIProceed{ID: sid}))
|
|
|
|
select {
|
|
case req := <-fx.tdlib.accepted:
|
|
if req.CallId != 42 {
|
|
t.Fatalf("AcceptCall callID = %d, want 42", req.CallId)
|
|
}
|
|
case <-time.After(1 * time.Second):
|
|
t.Fatalf("tdlib.AcceptCall never invoked")
|
|
}
|
|
}
|
|
|
|
func TestTelegramOriginatedRejectDiscardsTelegramSide(t *testing.T) {
|
|
fx := newFixture(t)
|
|
|
|
fx.tgMgr.OnUpdateCall(fx.userBare, &client.UpdateCall{
|
|
Call: &client.Call{
|
|
Id: 99,
|
|
UserId: 67890,
|
|
IsOutgoing: false,
|
|
State: &client.CallStatePending{},
|
|
},
|
|
})
|
|
|
|
sid := waitForProposedSID(t, fx.sender, 1*time.Second)
|
|
|
|
fx.jingleMgr.HandlePacket(nil, buildJMIMessage(fx.userBare+"/r", "gw.example", &jingle.JMIReject{ID: sid}))
|
|
|
|
select {
|
|
case req := <-fx.tdlib.discard:
|
|
if req.CallId != 99 {
|
|
t.Fatalf("DiscardCall callID = %d, want 99", req.CallId)
|
|
}
|
|
case <-time.After(1 * time.Second):
|
|
t.Fatalf("tdlib.DiscardCall never invoked after <reject>")
|
|
}
|
|
}
|
|
|
|
func waitForProposedSID(t *testing.T, s *captureSender, d time.Duration) string {
|
|
t.Helper()
|
|
deadline := time.Now().Add(d)
|
|
for time.Now().Before(deadline) {
|
|
for _, p := range s.sentCopy() {
|
|
msg, ok := p.(*stanza.Message)
|
|
if !ok {
|
|
continue
|
|
}
|
|
for _, ext := range msg.Extensions {
|
|
if pr, ok := ext.(*jingle.JMIPropose); ok {
|
|
return pr.ID
|
|
}
|
|
}
|
|
}
|
|
time.Sleep(10 * time.Millisecond)
|
|
}
|
|
t.Fatalf("waitForProposedSID: timeout after %v", d)
|
|
return ""
|
|
}
|
|
|
|
func waitForCond(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(10 * time.Millisecond)
|
|
}
|
|
t.Fatalf("waitForCond(%s): timeout after %v", what, d)
|
|
}
|
|
|
|
func buildProposeMessage(from, to, sid string) *stanza.Message {
|
|
m := stanza.NewMessage(stanza.Attrs{Type: stanza.MessageTypeChat, From: from, To: to, Id: "m-" + sid})
|
|
m.Extensions = []stanza.MsgExtension{&jingle.JMIPropose{
|
|
ID: sid,
|
|
Descriptions: []jingle.JMIDescription{{Media: "audio"}},
|
|
}}
|
|
return &m
|
|
}
|
|
|
|
func buildJMIMessage(from, to string, ext stanza.MsgExtension) *stanza.Message {
|
|
m := stanza.NewMessage(stanza.Attrs{Type: stanza.MessageTypeChat, From: from, To: to, Id: "m-jmi"})
|
|
m.Extensions = []stanza.MsgExtension{ext}
|
|
return &m
|
|
}
|