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

217 lines
6 KiB
Go

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 <proceed>
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 <propose> must use a full JID (userID@gw/telegabber). XEP-0353
// requires it so the recipient can route <proceed>/<reject> 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 <propose>")
}
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 <propose> in JMI ns with a <description media="audio">
xmlBytes, err := xml.Marshal(msg)
if err != nil {
t.Fatalf("marshal: %v", err)
}
s := string(xmlBytes)
if !strings.Contains(s, `<propose xmlns="urn:xmpp:jingle-message:0"`) {
t.Errorf("propose missing JMI namespace: %s", s)
}
if !strings.Contains(s, `<description xmlns="urn:xmpp:jingle:apps:rtp:1" media="audio"`) {
t.Errorf("propose missing rtp description with media=audio: %s", s)
}
}
// cross-side teardown for TG-originated calls:
// inbound <reject>/<retract>/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
}