telegabber/calls/orchestrator_test.go
2026-05-25 06:27:20 -07:00

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
}