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 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-; 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; 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 ") } } 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 ") } } 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 }