218 lines
8.8 KiB
Go
218 lines
8.8 KiB
Go
package asterisk
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"net"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/CyCoreSystems/ari/v5"
|
|
)
|
|
|
|
type testARIClient struct {
|
|
ari.Client
|
|
channels *testChannels
|
|
bridges *testBridges
|
|
bus *testBus
|
|
closed int
|
|
}
|
|
|
|
func (c *testARIClient) Channel() ari.Channel { return c.channels }
|
|
func (c *testARIClient) Bridge() ari.Bridge { return c.bridges }
|
|
func (c *testARIClient) Bus() ari.Bus { return c.bus }
|
|
func (c *testARIClient) Close() { c.closed++ }
|
|
|
|
type testBus struct {
|
|
ari.Bus
|
|
sub ari.Subscription
|
|
}
|
|
|
|
func (b *testBus) Subscribe(_ *ari.Key, _ ...string) ari.Subscription { return b.sub }
|
|
|
|
type testChannels struct {
|
|
ari.Channel
|
|
events chan ari.Event
|
|
originate ari.OriginateRequest
|
|
media ari.ExternalMediaOptions
|
|
issued int
|
|
originateErr error
|
|
externalErr error
|
|
hungup []string
|
|
mediaPeerIP string
|
|
}
|
|
|
|
func (c *testChannels) Originate(_ *ari.Key, request ari.OriginateRequest) (*ari.ChannelHandle, error) {
|
|
c.issued++
|
|
c.originate = request
|
|
if c.originateErr != nil {
|
|
return nil, c.originateErr
|
|
}
|
|
if c.events != nil {
|
|
c.events <- &ari.StasisStart{EventData: ari.EventData{Type: "StasisStart"}, Channel: ari.ChannelData{ID: request.ChannelID}}
|
|
}
|
|
return ari.NewChannelHandle(ari.NewKey(ari.ChannelKey, request.ChannelID), c, nil), nil
|
|
}
|
|
func (c *testChannels) Get(key *ari.Key) *ari.ChannelHandle { return ari.NewChannelHandle(key, c, nil) }
|
|
func (c *testChannels) ExternalMedia(key *ari.Key, options ari.ExternalMediaOptions) (*ari.ChannelHandle, error) {
|
|
c.media = options
|
|
if c.externalErr != nil {
|
|
return nil, c.externalErr
|
|
}
|
|
return ari.NewChannelHandle(key, c, nil), nil
|
|
}
|
|
func (c *testChannels) GetVariable(_ *ari.Key, name string) (string, error) {
|
|
switch name {
|
|
case "UNICASTRTP_LOCAL_ADDRESS":
|
|
return c.mediaPeerIP, nil
|
|
case "UNICASTRTP_LOCAL_PORT":
|
|
return "17000", nil
|
|
}
|
|
return "", errors.New("unexpected ARI variable")
|
|
}
|
|
func (c *testChannels) Hangup(key *ari.Key, _ string) error {
|
|
c.hungup = append(c.hungup, key.ID)
|
|
return nil
|
|
}
|
|
|
|
type testBridges struct {
|
|
ari.Bridge
|
|
attached []string
|
|
deleted int
|
|
createErr error
|
|
}
|
|
|
|
func (b *testBridges) Create(key *ari.Key, _, _ string) (*ari.BridgeHandle, error) {
|
|
if b.createErr != nil {
|
|
return nil, b.createErr
|
|
}
|
|
return ari.NewBridgeHandle(key, b, nil), nil
|
|
}
|
|
func (b *testBridges) Get(key *ari.Key) *ari.BridgeHandle { return ari.NewBridgeHandle(key, b, nil) }
|
|
func (b *testBridges) AddChannel(_ *ari.Key, channelID string) error {
|
|
b.attached = append(b.attached, channelID)
|
|
return nil
|
|
}
|
|
func (b *testBridges) Delete(_ *ari.Key) error { b.deleted++; return nil }
|
|
|
|
func TestNativeCallUsesOneARIOriginateAndActualRTPBridge(t *testing.T) {
|
|
events := make(chan ari.Event, 2)
|
|
client := &testARIClient{
|
|
channels: &testChannels{events: events, mediaPeerIP: "127.0.0.1"},
|
|
bridges: &testBridges{},
|
|
bus: &testBus{sub: answerEvents{events: events}},
|
|
}
|
|
ctx, cancel := context.WithTimeout(context.Background(), time.Second)
|
|
defer cancel()
|
|
call, err := dialWithClient(ctx, client, NativeDial{
|
|
ExecutionID: "exec-1", TrunkID: "shuqi", DialedCallee: "708915000000001", CallerID: "BD1234",
|
|
AnswerTimeout: time.Second, MediaPayloadType: 118,
|
|
})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if call.Media == nil || client.channels.issued != 1 || client.channels.originate.Endpoint != "PJSIP/708915000000001@shuqi" ||
|
|
client.channels.originate.CallerID != "BD1234" || client.channels.originate.ChannelID != "exec-1" ||
|
|
client.channels.media.Format != "slin16" || client.channels.media.App != "go-sip-agent" ||
|
|
len(client.bridges.attached) != 2 || client.bridges.attached[0] != "exec-1" {
|
|
t.Fatal("native channel/ExternalMedia was not attached exactly once to the approved bridge")
|
|
}
|
|
if host, _, err := net.SplitHostPort(client.channels.media.ExternalHost); err != nil || host != "127.0.0.1" {
|
|
t.Fatal("native RTP capture must bind the local Cell only")
|
|
}
|
|
if err := call.Close(); err != nil || len(client.channels.hungup) != 2 || client.bridges.deleted != 1 || client.closed != 1 {
|
|
t.Fatalf("native call resources must be closed exactly once: err=%v", err)
|
|
}
|
|
}
|
|
|
|
func TestNativeCallStopsMediaOnMatchingCarrierHangup(t *testing.T) {
|
|
events := make(chan ari.Event, 3)
|
|
client := &testARIClient{channels: &testChannels{events: events, mediaPeerIP: "127.0.0.1"}, bridges: &testBridges{}, bus: &testBus{sub: answerEvents{events: events}}}
|
|
ctx, cancel := context.WithTimeout(context.Background(), time.Second)
|
|
defer cancel()
|
|
call, err := dialWithClient(ctx, client, NativeDial{ExecutionID: "exec-1", TrunkID: "shuqi", DialedCallee: "708915000000001", CallerID: "BD1234", AnswerTimeout: time.Second, MediaPayloadType: 118})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
defer call.Close()
|
|
events <- &ari.ChannelDestroyed{EventData: ari.EventData{Type: "ChannelDestroyed"}, Channel: ari.ChannelData{ID: "someone-else"}, Cause: 16}
|
|
select {
|
|
case <-call.Context.Done():
|
|
t.Fatal("another caller cannot cancel this approved call")
|
|
case <-time.After(10 * time.Millisecond):
|
|
}
|
|
events <- &ari.ChannelDestroyed{EventData: ari.EventData{Type: "ChannelDestroyed"}, Channel: ari.ChannelData{ID: "exec-1"}, Cause: 16}
|
|
select {
|
|
case <-call.Context.Done():
|
|
if cause := context.Cause(call.Context); cause == nil || !strings.Contains(cause.Error(), "channel ended") {
|
|
t.Fatalf("real call must expose carrier hangup cause, got %v", cause)
|
|
}
|
|
case <-time.After(100 * time.Millisecond):
|
|
t.Fatal("carrier hangup left real AI/media running")
|
|
}
|
|
if err := call.Close(); err != nil || len(client.channels.hungup) != 1 || client.channels.hungup[0] != "exec-1-media" {
|
|
t.Fatalf("destroyed carrier channel must not be hung up again: hungup=%v err=%v", client.channels.hungup, err)
|
|
}
|
|
}
|
|
|
|
func TestNativeCallUnknownOriginationMustAttemptHangupWithoutRetry(t *testing.T) {
|
|
client := &testARIClient{
|
|
channels: &testChannels{originateErr: errors.New("connection lost during originate")},
|
|
bridges: &testBridges{},
|
|
bus: &testBus{sub: answerEvents{events: make(chan ari.Event)}},
|
|
}
|
|
ctx, cancel := context.WithTimeout(context.Background(), time.Second)
|
|
defer cancel()
|
|
if _, err := dialWithClient(ctx, client, NativeDial{ExecutionID: "exec-1", TrunkID: "shuqi", DialedCallee: "708915000000001", CallerID: "BD1234", AnswerTimeout: time.Second, MediaPayloadType: 118}); err == nil || !strings.Contains(err.Error(), "outcome unknown") {
|
|
t.Fatalf("unknown originate outcome must never be reported as a completed call: %v", err)
|
|
}
|
|
if client.channels.issued != 1 || len(client.channels.hungup) != 1 || client.channels.hungup[0] != "exec-1" || client.closed != 1 {
|
|
t.Fatal("unknown originate outcome must not dial twice and must attempt to stop the issued channel")
|
|
}
|
|
}
|
|
|
|
func TestNativeCallUnknownBridgeOrMediaCreationCleansKnownIdentities(t *testing.T) {
|
|
for _, step := range []string{"bridge", "external-media"} {
|
|
t.Run(step, func(t *testing.T) {
|
|
events := make(chan ari.Event, 1)
|
|
channels := &testChannels{events: events, mediaPeerIP: "127.0.0.1"}
|
|
bridges := &testBridges{}
|
|
if step == "bridge" {
|
|
bridges.createErr = errors.New("lost bridge response")
|
|
} else {
|
|
channels.externalErr = errors.New("lost external-media response")
|
|
}
|
|
client := &testARIClient{channels: channels, bridges: bridges, bus: &testBus{sub: answerEvents{events: events}}}
|
|
ctx, cancel := context.WithTimeout(context.Background(), time.Second)
|
|
defer cancel()
|
|
if _, err := dialWithClient(ctx, client, NativeDial{ExecutionID: "exec-1", TrunkID: "shuqi", DialedCallee: "708915000000001", CallerID: "BD1234", AnswerTimeout: time.Second, MediaPayloadType: 118}); err == nil {
|
|
t.Fatal("unknown bridge/media outcome cannot be treated as a live audio session")
|
|
}
|
|
wantHungup := 1
|
|
if step == "external-media" {
|
|
wantHungup = 2
|
|
}
|
|
if channels.issued != 1 || len(channels.hungup) != wantHungup || bridges.deleted != 1 || client.closed != 1 {
|
|
t.Fatalf("known ARI identities must be cleaned without a second dial: hungup=%v deleted=%d closed=%d", channels.hungup, bridges.deleted, client.closed)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestNativeCallAnswerTimeoutNeverCreatesFakeMedia(t *testing.T) {
|
|
client := &testARIClient{
|
|
channels: &testChannels{mediaPeerIP: "127.0.0.1"},
|
|
bridges: &testBridges{},
|
|
bus: &testBus{sub: answerEvents{events: make(chan ari.Event)}},
|
|
}
|
|
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Millisecond)
|
|
defer cancel()
|
|
if _, err := dialWithClient(ctx, client, NativeDial{ExecutionID: "exec-1", TrunkID: "shuqi", DialedCallee: "708915000000001", CallerID: "BD1234", AnswerTimeout: time.Second, MediaPayloadType: 118}); err == nil || !strings.Contains(err.Error(), "deadline") {
|
|
t.Fatalf("unanswered channel must fail rather than synthesize media: %v", err)
|
|
}
|
|
if client.channels.issued != 1 || len(client.channels.hungup) == 0 || client.closed != 1 {
|
|
t.Fatal("timed-out real channel cannot leak or be originated a second time")
|
|
}
|
|
}
|