telegabber/calls/signaling/tgsig/manager_test.go
2026-05-27 08:37:47 -07:00

304 lines
10 KiB
Go

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 <ringing> 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 <ringing>: 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)
}
}