package tgsig import ( "testing" "time" "dev.narayana.im/narayana/telegabber/calls/signaling" "gotgcalls/ntgcalls" "github.com/zelenin/go-tdlib/client" ) type fakeTdlib struct { createCalls []*client.CreateCallRequest acceptCalls []*client.AcceptCallRequest discardCalls []*client.DiscardCallRequest sentSignal []*client.SendCallSignalingDataRequest nextCallID int32 } func (f *fakeTdlib) CreateCall(req *client.CreateCallRequest) (*client.CallId, error) { f.createCalls = append(f.createCalls, req) f.nextCallID++ return &client.CallId{Id: f.nextCallID}, nil } func (f *fakeTdlib) AcceptCall(req *client.AcceptCallRequest) (*client.Ok, error) { f.acceptCalls = append(f.acceptCalls, req) return &client.Ok{}, nil } func (f *fakeTdlib) DiscardCall(req *client.DiscardCallRequest) (*client.Ok, error) { f.discardCalls = append(f.discardCalls, req) return &client.Ok{}, nil } func (f *fakeTdlib) SendCallSignalingData(req *client.SendCallSignalingDataRequest) (*client.Ok, error) { f.sentSignal = append(f.sentSignal, req) return &client.Ok{}, nil } type fakeNtg struct { onSignal ntgcalls.SignalCallback onConnCh ntgcalls.ConnectionChangeCallback stopCalls []int64 } func (f *fakeNtg) OnSignal(cb ntgcalls.SignalCallback) { f.onSignal = cb } func (f *fakeNtg) OnConnectionChange(cb ntgcalls.ConnectionChangeCallback) { f.onConnCh = cb } 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(chatID int64) error { f.stopCalls = append(f.stopCalls, chatID) return nil } // recorders sitting in place of the real per-side handlers, so tests can // observe which side the bridge fires Terminate on type recCaller struct { started bool terminated bool terminateReason signaling.TerminationReason } func (r *recCaller) Start() { r.started = true } func (r *recCaller) Terminate(reason signaling.TerminationReason) { r.terminated = true r.terminateReason = reason } type recCallee struct { ringing bool accepted bool terminated bool terminateReason signaling.TerminationReason } func (r *recCallee) Ringing() { r.ringing = true } func (r *recCallee) Accept() { r.accepted = true } func (r *recCallee) Terminate(reason signaling.TerminationReason) { r.terminated = true r.terminateReason = reason } type noopTimers struct{} func (noopTimers) Set(signaling.TimerKind, time.Duration) {} func (noopTimers) Cancel(signaling.TimerKind) {} type tgFixture struct { mgr *Manager adapter *Adapter tdlib *fakeTdlib ntg *fakeNtg caller *recCaller callee *recCallee bridge *signaling.Bridge } func newFixture() *tgFixture { tdlib := &fakeTdlib{} ntg := &fakeNtg{} mgr := NewManager(nil) a := New(tdlib, ntg, nil, "jid@gw", mgr) return &tgFixture{ mgr: mgr, adapter: a, tdlib: tdlib, ntg: ntg, caller: &recCaller{}, callee: &recCallee{}, } } // TG-originated bridge: tg is the bridge's Callee, recCaller plays the xmpp // side. Returns *callBase so the test can drive its state. func (f *tgFixture) buildTGOriginated(callID int32, userID int64) *callBase { tgSide := f.adapter.NewCallee(callID, userID, false) f.bridge = signaling.New(signaling.Config{ Caller: f.caller, // xmpp-side (recorder) Callee: f.callee, // tg-side (recorder; the real tg side is registered with the Manager for endCall lookup) Timers: noopTimers{}, }) tgSide.callBase.Bind(f.bridge) return &tgSide.callBase } // buildXmppOriginated wires an XMPP-originated bridge: tg-side is the bridge's // Caller. Manually registers the callBase since NewCaller doesn't auto-register // (production code registers from Caller.Start after tdlib.CreateCall succeeds). func (f *tgFixture) buildXmppOriginated(callID int32, userID int64) *callBase { tgSide := f.adapter.NewCaller(userID, false) tgSide.callBase.setCallID(callID) f.adapter.registerSide(userID, &tgSide.callBase) f.mgr.Register(f.adapter.jid, callID, &tgSide.callBase) f.bridge = signaling.New(signaling.Config{ Caller: f.caller, // tg-side here (recorder; real tg side is what the Manager has) Callee: f.callee, // xmpp-side Timers: noopTimers{}, }) tgSide.callBase.Bind(f.bridge) return &tgSide.callBase } func discardUpdate(callID int32, userID int64, isOutgoing bool, reason client.CallDiscardReason) *client.UpdateCall { return &client.UpdateCall{ Call: &client.Call{ Id: callID, UserId: userID, IsOutgoing: isOutgoing, IsVideo: false, State: &client.CallStateDiscarded{Reason: reason}, }, } } func hangingUpUpdate(callID int32, userID int64, isOutgoing bool) *client.UpdateCall { return &client.UpdateCall{ Call: &client.Call{ Id: callID, UserId: userID, IsOutgoing: isOutgoing, IsVideo: false, State: &client.CallStateHangingUp{}, }, } } func pendingUpdate(callID int32, userID int64, isOutgoing, isReceived bool) *client.UpdateCall { return &client.UpdateCall{ Call: &client.Call{ Id: callID, UserId: userID, IsOutgoing: isOutgoing, IsVideo: false, State: &client.CallStatePending{IsCreated: true, IsReceived: isReceived}, }, } } // every tdlib teardown event -> bridge.Terminate on both halves with the // mapped reason; catches new discard reasons leaking through as Unknown func TestTGTeardownMatrix_TGOriginated(t *testing.T) { errorUpdate := func(callID int32, userID int64) *client.UpdateCall { return &client.UpdateCall{Call: &client.Call{ Id: callID, UserId: userID, IsOutgoing: false, State: &client.CallStateError{Error: &client.Error{Code: 500, Message: "boom"}}, }} } cases := []struct { name string build func(callID int32, userID int64) *client.UpdateCall want signaling.TerminationReason }{ {"discard/declined", func(c int32, u int64) *client.UpdateCall { return discardUpdate(c, u, false, &client.CallDiscardReasonDeclined{}) }, signaling.ReasonDecline}, {"discard/missed", func(c int32, u int64) *client.UpdateCall { return discardUpdate(c, u, false, &client.CallDiscardReasonMissed{}) }, signaling.ReasonRingTimeout}, {"discard/disconnected", func(c int32, u int64) *client.UpdateCall { return discardUpdate(c, u, false, &client.CallDiscardReasonDisconnected{}) }, signaling.ReasonMediaFailed}, {"discard/hungUp", func(c int32, u int64) *client.UpdateCall { return discardUpdate(c, u, false, &client.CallDiscardReasonHungUp{}) }, signaling.ReasonHangup}, {"discard/empty", func(c int32, u int64) *client.UpdateCall { return discardUpdate(c, u, false, &client.CallDiscardReasonEmpty{}) }, signaling.ReasonHangup}, {"hangingUp", func(c int32, u int64) *client.UpdateCall { return hangingUpUpdate(c, u, false) }, signaling.ReasonHangup}, {"error", errorUpdate, signaling.ReasonMediaFailed}, } for i, tc := range cases { t.Run(tc.name, func(t *testing.T) { f := newFixture() callID := int32(500 + i) userID := int64(8000 + i) f.buildTGOriginated(callID, userID) f.bridge.Start() f.bridge.Ringing() f.mgr.OnUpdateCall("jid@gw", tc.build(callID, userID)) if !f.caller.terminated || !f.callee.terminated { t.Fatalf("both sides should tear down: caller=%v callee=%v", f.caller.terminated, f.callee.terminated) } if f.caller.terminateReason != tc.want || f.callee.terminateReason != tc.want { t.Errorf("reason: caller=%v callee=%v, want %v", f.caller.terminateReason, f.callee.terminateReason, tc.want) } }) } } // XMPP-originated outbound: transition to Ringing only on Pending{IsReceived:true}. // Pending{IsReceived:false} (server-created, peer not notified) must not transition, // or goes out before the peer is alerting. func TestTGPending_XMPPOriginated_DrivesBridgeToRinging(t *testing.T) { f := newFixture() f.buildXmppOriginated(303, 9303) f.bridge.Start() if got := f.bridge.State(); got != signaling.StateOriginating { t.Fatalf("pre-Pending state = %s, want originating", got) } // first Pending: server-created, must NOT ring f.mgr.OnUpdateCall("jid@gw", pendingUpdate(303, 9303, true, false)) if got := f.bridge.State(); got != signaling.StateOriginating { t.Fatalf("post-Pending(IsReceived=false) state = %s, want originating", got) } if f.callee.ringing { t.Error("xmpp-Callee.Ringing fired on Pending(IsReceived=false); want only on IsReceived=true") } // second Pending: peer device alerting, ring f.mgr.OnUpdateCall("jid@gw", pendingUpdate(303, 9303, true, true)) if got := f.bridge.State(); got != signaling.StateRinging { t.Fatalf("post-Pending(IsReceived=true) state = %s, want ringing", got) } if !f.callee.ringing { t.Error("xmpp-Callee.Ringing did not fire on Pending(IsReceived=true)") } // duplicate Pending(IsReceived=true) no-ops via Bridge.Ringing's state guard f.callee.ringing = false f.mgr.OnUpdateCall("jid@gw", pendingUpdate(303, 9303, true, true)) if got := f.bridge.State(); got != signaling.StateRinging { t.Fatalf("after duplicate Pending state = %s, want ringing", got) } if f.callee.ringing { t.Error("xmpp-Callee.Ringing re-fired on duplicate Pending; want once") } // ExchangingKeys drives Ringing -> Accepted. Accept is called without // re-emitting : the ringback window between the two stanzas // is the time the user spent looking at their phone, not 30ms. f.mgr.OnUpdateCall("jid@gw", &client.UpdateCall{ Call: &client.Call{ Id: 303, UserId: 9303, IsOutgoing: true, IsVideo: false, State: &client.CallStateExchangingKeys{}, }, }) if got := f.bridge.State(); got != signaling.StateAccepted { t.Fatalf("post-ExchangingKeys state = %s, want accepted", got) } if !f.callee.accepted { t.Error("xmpp-Callee.Accept did not fire on ExchangingKeys") } } // endCall must drop registrations: subsequent OnUpdateCall for the same // (jid, callID) is a no-op (no side gets re-fired). func TestTGDiscard_DropsRegistrationFromManager(t *testing.T) { f := newFixture() f.buildTGOriginated(111, 9011) f.bridge.Start() f.mgr.OnUpdateCall("jid@gw", discardUpdate(111, 9011, false, &client.CallDiscardReasonHungUp{})) if got := f.mgr.lookup("jid@gw", 111); got != nil { t.Errorf("callBase still registered after discard: %v", got) } // dropSide on the adapter if _, ok := f.adapter.lookupSideByUser(9011); ok { t.Errorf("adapter still has user 9011 after discard") } // ntgcalls Stop was called if len(f.ntg.stopCalls) != 1 || f.ntg.stopCalls[0] != 9011 { t.Errorf("ntg.Stop calls = %v, want [9011]", f.ntg.stopCalls) } }