package xmppsig import ( "context" "encoding/xml" "strings" "sync" "testing" "time" "dev.narayana.im/narayana/telegabber/calls/signaling" "dev.narayana.im/narayana/telegabber/xmpp/jingle" "github.com/pion/webrtc/v4" "gosrc.io/xmpp/stanza" ) // goroutine-safe; Session.Send fires from background goroutines after // JMI type recSender struct { mu sync.Mutex sent []stanza.Packet iqSent []*stanza.IQ } func (r *recSender) Send(p stanza.Packet) error { r.mu.Lock() defer r.mu.Unlock() r.sent = append(r.sent, p) if iq, ok := p.(*stanza.IQ); ok { r.iqSent = append(r.iqSent, iq) } return nil } func (r *recSender) SendIQ(_ context.Context, iq *stanza.IQ) (chan stanza.IQ, error) { ch := make(chan stanza.IQ, 1) return ch, r.Send(iq) } func (r *recSender) SendRaw(string) error { return nil } func (r *recSender) snapshot() []stanza.Packet { r.mu.Lock() defer r.mu.Unlock() out := make([]stanza.Packet, len(r.sent)) copy(out, r.sent) return out } func (r *recSender) firstMessage() *stanza.Message { for _, p := range r.snapshot() { switch m := p.(type) { case *stanza.Message: return m case stanza.Message: return &m } } return nil } // records bridge-side Terminate firings; serves as the other-side stand-in // for tests of either xmppsig.Caller or xmppsig.Callee type recBridgeSide struct { mu sync.Mutex startedOrAccept bool terminated bool terminateReason signaling.TerminationReason } func (r *recBridgeSide) Start() { r.mu.Lock(); r.startedOrAccept = true; r.mu.Unlock() } func (r *recBridgeSide) Ringing() {} func (r *recBridgeSide) Accept() { r.mu.Lock(); r.startedOrAccept = true; r.mu.Unlock() } func (r *recBridgeSide) Terminate(reason signaling.TerminationReason) { r.mu.Lock() r.terminated = true r.terminateReason = reason r.mu.Unlock() } func (r *recBridgeSide) isTerminated() bool { r.mu.Lock() defer r.mu.Unlock() return r.terminated } func (r *recBridgeSide) reason() signaling.TerminationReason { r.mu.Lock() defer r.mu.Unlock() return r.terminateReason } type noopTimers struct{} func (noopTimers) Set(signaling.TimerKind, time.Duration) {} func (noopTimers) Cancel(signaling.TimerKind) {} // real pion PC; faking webrtc is more invasive than running on loopback func pcFactory(t *testing.T) func() (*webrtc.PeerConnection, error) { t.Helper() return func() (*webrtc.PeerConnection, error) { pc, err := webrtc.NewPeerConnection(webrtc.Configuration{}) if err != nil { return nil, err } t.Cleanup(func() { _ = pc.Close() }) if _, err := pc.AddTransceiverFromKind(webrtc.RTPCodecTypeAudio, webrtc.RTPTransceiverInit{ Direction: webrtc.RTPTransceiverDirectionSendrecv, }); err != nil { _ = pc.Close() return nil, err } return pc, nil } } // OnTerminated fires bridge.Terminate on a goroutine (deferred re-entry), // so callers poll func waitUntil(t *testing.T, desc string, d time.Duration, cond func() bool) { t.Helper() deadline := time.Now().Add(d) for time.Now().Before(deadline) { if cond() { return } time.Sleep(10 * time.Millisecond) } t.Fatalf("waitUntil(%s): timed out after %v", desc, d) } // JMI must use a full JID (userID@gw/telegabber). XEP-0353 // requires it so the recipient can route / back. func TestCallerStart_SendsProposeWithFullLocalJID(t *testing.T) { send := &recSender{} mgr := &jingle.Manager{LocalJID: "gw.example", Sender: send} a := NewAdapter(AdapterConfig{ Sender: send, LocalJID: "gw.example", Manager: mgr, PCFactory: pcFactory(t), }) caller, err := a.NewCaller("alice@xmpp.example", 12345) if err != nil { t.Fatalf("NewCaller: %v", err) } br := signaling.New(signaling.Config{ Caller: caller, Callee: &recBridgeSide{}, Timers: noopTimers{}, }) caller.Bind(br) br.Start() // triggers caller.Start synchronously msg := send.firstMessage() if msg == nil { t.Fatal("no message sent - Caller.Start did not produce a JMI ") } if msg.From != "12345@gw.example/telegabber" { t.Errorf("propose from = %q, want %q (full JID with resource per XEP-0353)", msg.From, "12345@gw.example/telegabber") } if msg.To != "alice@xmpp.example" { t.Errorf("propose to = %q, want alice@xmpp.example", msg.To) } if string(msg.Type) != "chat" { t.Errorf("propose type = %q, want chat", msg.Type) } // payload: one in JMI ns with a xmlBytes, err := xml.Marshal(msg) if err != nil { t.Fatalf("marshal: %v", err) } s := string(xmlBytes) if !strings.Contains(s, `//session-terminate -> observer -> bridge.Terminate func TestCallerOnTerminated_RejectedReason_TearsDownTGCalleeWithDecline(t *testing.T) { caller, tgSide := newStartedCaller(t) caller.OnTerminated("rejected") waitUntil(t, "tg-Callee.Terminate", time.Second, tgSide.isTerminated) if tgSide.reason() != signaling.ReasonDecline { t.Errorf("tg-side reason = %v, want ReasonDecline", tgSide.reason()) } } // Caller bound to a TG-originated bridge, started so the session is in // StateProposed; returns the Caller and the tg-side stand-in func newStartedCaller(t *testing.T) (*Caller, *recBridgeSide) { t.Helper() send := &recSender{} mgr := &jingle.Manager{LocalJID: "gw.example", Sender: send} a := NewAdapter(AdapterConfig{ Sender: send, LocalJID: "gw.example", Manager: mgr, PCFactory: pcFactory(t), }) caller, err := a.NewCaller("alice@xmpp.example", 12345) if err != nil { t.Fatalf("NewCaller: %v", err) } tgSide := &recBridgeSide{} br := signaling.New(signaling.Config{ Caller: caller, Callee: tgSide, Timers: noopTimers{}, }) caller.Bind(br) br.Start() waitUntil(t, "session proposed", time.Second, func() bool { s := caller.sessionRef() return s != nil && s.State() == jingle.StateProposed }) return caller, tgSide }