feat: complete MQ-only dispatcher and OSS upload flow
This commit is contained in:
@@ -0,0 +1,67 @@
|
||||
package callruntime
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.ipao.vip/rogee/go-sip/internal/ai"
|
||||
)
|
||||
|
||||
func validConfig() Config {
|
||||
return Config{ARIURL: "http://127.0.0.1:1/ari", ARIWebsocketURL: "ws://127.0.0.1:1/ari/events", ARIApplication: "test", ARIUsername: "local", ARIPassword: "isolated-test-only", Endpoint: "PJSIP/local-test", MediaBind: "127.0.0.1", MediaPort: 20000, MediaFormat: "slin16", MediaSampleRate: 16000, PayloadType: 96, Pipeline: ai.MockPipeline{}}
|
||||
}
|
||||
|
||||
func TestRuntimeRejectsInvalidConfigurationBeforeNetwork(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
change func(*Config)
|
||||
}{
|
||||
{"ARI URL", func(c *Config) { c.ARIURL = "" }},
|
||||
{"websocket", func(c *Config) { c.ARIWebsocketURL = "" }},
|
||||
{"application", func(c *Config) { c.ARIApplication = "" }},
|
||||
{"username", func(c *Config) { c.ARIUsername = "" }},
|
||||
{"password", func(c *Config) { c.ARIPassword = "" }},
|
||||
{"endpoint", func(c *Config) { c.Endpoint = "" }},
|
||||
{"media bind", func(c *Config) { c.MediaBind = "" }},
|
||||
{"low media port", func(c *Config) { c.MediaPort = 1023 }},
|
||||
{"high media port", func(c *Config) { c.MediaPort = 65536 }},
|
||||
{"media format", func(c *Config) { c.MediaFormat = "invalid" }},
|
||||
{"pipeline", func(c *Config) { c.Pipeline = nil }},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
cfg := validConfig()
|
||||
tc.change(&cfg)
|
||||
if err := cfg.validate(); err == nil {
|
||||
t.Fatal("invalid configuration accepted")
|
||||
}
|
||||
if _, err := Run(context.Background(), cfg); err == nil {
|
||||
t.Fatal("invalid configuration reached runtime")
|
||||
}
|
||||
})
|
||||
}
|
||||
if err := validConfig().validate(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRuntimeNormalizationPreservesExplicitSettings(t *testing.T) {
|
||||
cfg := validConfig()
|
||||
cfg.AnswerTimeout = time.Second
|
||||
cfg.TurnWindow = 2 * time.Second
|
||||
cfg.FirstSpeechTimeout = 3 * time.Second
|
||||
cfg.MaxTurnDuration = 4 * time.Second
|
||||
cfg.EndSilence = 5 * time.Second
|
||||
cfg.MaxCallDuration = 6 * time.Second
|
||||
cfg.VoiceThreshold = 22
|
||||
cfg.MaxTurns = 9
|
||||
cfg.OpeningPrompt = "configured opening"
|
||||
actual := cfg.normalized()
|
||||
if actual.AnswerTimeout != cfg.AnswerTimeout || actual.TurnWindow != cfg.TurnWindow || actual.FirstSpeechTimeout != cfg.FirstSpeechTimeout || actual.MaxTurnDuration != cfg.MaxTurnDuration || actual.EndSilence != cfg.EndSilence || actual.MaxCallDuration != cfg.MaxCallDuration || actual.VoiceThreshold != cfg.VoiceThreshold || actual.MaxTurns != cfg.MaxTurns || actual.OpeningPrompt != cfg.OpeningPrompt {
|
||||
t.Fatal("explicit conversation settings were overwritten")
|
||||
}
|
||||
defaults := (Config{}).normalized()
|
||||
if defaults.MediaFormat != "slin16" || defaults.MediaSampleRate != 16000 || defaults.AnswerTimeout <= 0 || defaults.TurnWindow <= 0 {
|
||||
t.Fatal("invalid normalized transport settings")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
package callruntime
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/CyCoreSystems/ari/v5"
|
||||
)
|
||||
|
||||
type eventSubscription struct{ events chan ari.Event }
|
||||
|
||||
func (s eventSubscription) Events() <-chan ari.Event { return s.events }
|
||||
func (s eventSubscription) Cancel() {}
|
||||
func events(values ...ari.Event) eventSubscription {
|
||||
s := eventSubscription{make(chan ari.Event, len(values))}
|
||||
for _, event := range values {
|
||||
s.events <- event
|
||||
}
|
||||
close(s.events)
|
||||
return s
|
||||
}
|
||||
|
||||
func TestWaitForAnswerUsesOnlyTargetChannel(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
event ari.Event
|
||||
wantError string
|
||||
}{
|
||||
{name: "stasis", event: &ari.StasisStart{EventData: ari.EventData{Type: "StasisStart"}, Channel: ari.ChannelData{ID: "call"}}},
|
||||
{name: "up", event: &ari.ChannelStateChange{EventData: ari.EventData{Type: "ChannelStateChange"}, Channel: ari.ChannelData{ID: "call", State: "Up"}}},
|
||||
{name: "hangup", event: &ari.ChannelHangupRequest{EventData: ari.EventData{Type: "ChannelHangupRequest"}, Channel: ari.ChannelData{ID: "call", State: "Down"}, Cause: 17}, wantError: "cause=17"},
|
||||
{name: "destroyed", event: &ari.ChannelDestroyed{EventData: ari.EventData{Type: "ChannelDestroyed"}, Channel: ari.ChannelData{ID: "call"}}, wantError: "ended before StasisStart"},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
other := &ari.StasisStart{EventData: ari.EventData{Type: "StasisStart"}, Channel: ari.ChannelData{ID: "another-call"}}
|
||||
err := waitForStasisStart(context.Background(), events(nil, other, tc.event), "call", time.Second)
|
||||
if tc.wantError == "" && err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if tc.wantError != "" && (err == nil || !strings.Contains(err.Error(), tc.wantError)) {
|
||||
t.Fatalf("unexpected answer result: %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
if err := waitForStasisStart(context.Background(), events(), "call", time.Second); err == nil || !strings.Contains(err.Error(), "subscription closed") {
|
||||
t.Fatal("closed subscription accepted")
|
||||
}
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
cancel()
|
||||
if err := waitForStasisStart(ctx, eventSubscription{make(chan ari.Event)}, "call", time.Second); !errors.Is(err, context.Canceled) {
|
||||
t.Fatalf("cancellation lost: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLifecycleCancellationIsScopedToChannel(t *testing.T) {
|
||||
for _, kind := range []string{"ChannelHangupRequest", "ChannelDestroyed"} {
|
||||
t.Run(kind, func(t *testing.T) {
|
||||
var event ari.Event
|
||||
if kind == "ChannelDestroyed" {
|
||||
event = &ari.ChannelDestroyed{EventData: ari.EventData{Type: kind}, Channel: ari.ChannelData{ID: "call"}}
|
||||
} else {
|
||||
event = &ari.ChannelHangupRequest{EventData: ari.EventData{Type: kind}, Channel: ari.ChannelData{ID: "call"}}
|
||||
}
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
other := &ari.ChannelDestroyed{EventData: ari.EventData{Type: "ChannelDestroyed"}, Channel: ari.ChannelData{ID: "other"}}
|
||||
watchChannelLifecycle(ctx, events(other, event), "call", cancel)
|
||||
if !errors.Is(ctx.Err(), context.Canceled) {
|
||||
t.Fatal("matching hangup failed to cancel")
|
||||
}
|
||||
})
|
||||
}
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
watchChannelLifecycle(ctx, events(&ari.ChannelDestroyed{EventData: ari.EventData{Type: "ChannelDestroyed"}, Channel: ari.ChannelData{ID: "other"}}), "call", cancel)
|
||||
if ctx.Err() != nil {
|
||||
t.Fatal("another call ended this call")
|
||||
}
|
||||
watchChannelLifecycle(ctx, events(nil), "call", cancel)
|
||||
cancel()
|
||||
watchChannelLifecycle(ctx, eventSubscription{make(chan ari.Event)}, "call", cancel)
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
package callruntime
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"crypto/sha256"
|
||||
"encoding/binary"
|
||||
"encoding/hex"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"git.ipao.vip/rogee/go-sip/internal/callflow"
|
||||
)
|
||||
|
||||
func TestConversationRecordingFactsMatchFiles(t *testing.T) {
|
||||
directory := t.TempDir()
|
||||
incoming := [][]byte{{1, 0, 2, 0}, make([]byte, 32000)}
|
||||
outgoing := [][]byte{{4, 0}, {5, 0, 6, 0}}
|
||||
in, out, err := persistConversationRecordings(directory, "channel/with:separators", callflow.Result{InboundTurns: incoming, OutboundTurns: outgoing})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(in) != 2 || len(out) != 2 {
|
||||
t.Fatal("lost recording segments")
|
||||
}
|
||||
for side, facts := range [][]RecordingFact{in, out} {
|
||||
expected := [][][]byte{incoming, outgoing}[side]
|
||||
for index, fact := range facts {
|
||||
if filepath.Dir(fact.Path) != directory {
|
||||
t.Fatal("recording escaped directory")
|
||||
}
|
||||
raw, err := os.ReadFile(fact.Path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
sum := sha256.Sum256(raw)
|
||||
if fact.SHA256 != hex.EncodeToString(sum[:]) || fact.Bytes != len(raw) || fact.DurationMS != int64(len(expected[index]))*1000/32000 {
|
||||
t.Fatal("recording fact does not match content")
|
||||
}
|
||||
if string(raw[:4]) != "RIFF" || string(raw[8:12]) != "WAVE" || string(raw[36:40]) != "data" {
|
||||
t.Fatal("invalid WAV header")
|
||||
}
|
||||
if binary.LittleEndian.Uint32(raw[24:28]) != 16000 || binary.LittleEndian.Uint32(raw[40:44]) != uint32(len(expected[index])) || !bytes.Equal(raw[44:], expected[index]) {
|
||||
t.Fatal("incorrect WAV format or payload")
|
||||
}
|
||||
info, err := os.Stat(fact.Path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if info.Mode().Perm() != 0600 {
|
||||
t.Fatal("recording is not restricted")
|
||||
}
|
||||
}
|
||||
}
|
||||
if in[0].Segment != "inbound_turn_01" || out[0].Segment != "outbound_segment_00" {
|
||||
t.Fatal("segment identities changed")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRecordingFailuresAreExplicit(t *testing.T) {
|
||||
directory := t.TempDir()
|
||||
if err := writeWAV(filepath.Join(directory, "odd.wav"), []byte{1}, 16000); err == nil {
|
||||
t.Fatal("odd PCM accepted")
|
||||
}
|
||||
blocker := filepath.Join(directory, "file")
|
||||
if err := os.WriteFile(blocker, []byte("blocked"), 0600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, _, err := persistConversationRecordings(filepath.Join(blocker, "child"), "channel", callflow.Result{InboundTurns: [][]byte{{0, 0}}}); err == nil {
|
||||
t.Fatal("directory creation error hidden")
|
||||
}
|
||||
for _, flow := range []callflow.Result{{InboundTurns: [][]byte{{1}}}, {OutboundTurns: [][]byte{{1}}}} {
|
||||
if _, _, err := persistConversationRecordings(directory, "channel", flow); err == nil {
|
||||
t.Fatal("invalid segment accepted")
|
||||
}
|
||||
}
|
||||
for _, dir := range []string{"", directory} {
|
||||
in, out, err := persistConversationRecordings(dir, "empty", callflow.Result{})
|
||||
if err != nil || len(in) != 0 || len(out) != 0 {
|
||||
t.Fatal("empty recording produced facts")
|
||||
}
|
||||
}
|
||||
if _, _, err := fileDigestAndSize(filepath.Join(directory, "missing.wav")); err == nil {
|
||||
t.Fatal("missing recording silently returned a checksum")
|
||||
}
|
||||
}
|
||||
@@ -62,10 +62,11 @@ type Config struct {
|
||||
}
|
||||
|
||||
type RecordingFact struct {
|
||||
Segment string
|
||||
Path string
|
||||
SHA256 string
|
||||
Bytes int
|
||||
Segment string
|
||||
Path string
|
||||
SHA256 string
|
||||
Bytes int // Complete file size, including the WAV header.
|
||||
DurationMS int64
|
||||
}
|
||||
|
||||
type Result struct {
|
||||
@@ -385,7 +386,11 @@ func persistConversationRecordings(directory, channelID string, flowResult callf
|
||||
if err := writeWAV(path, pcm, 16000); err != nil {
|
||||
return inbound, nil, err
|
||||
}
|
||||
inbound = append(inbound, RecordingFact{Segment: fmt.Sprintf("inbound_turn_%02d", index+1), Path: path, SHA256: fileSHA256(path), Bytes: len(pcm)})
|
||||
digest, size, err := fileDigestAndSize(path)
|
||||
if err != nil {
|
||||
return inbound, nil, fmt.Errorf("hash inbound recording: %w", err)
|
||||
}
|
||||
inbound = append(inbound, RecordingFact{Segment: fmt.Sprintf("inbound_turn_%02d", index+1), Path: path, SHA256: digest, Bytes: size, DurationMS: int64(len(pcm)) * 1000 / 32000})
|
||||
}
|
||||
outbound := make([]RecordingFact, 0, len(flowResult.OutboundTurns))
|
||||
for index, pcm := range flowResult.OutboundTurns {
|
||||
@@ -393,7 +398,11 @@ func persistConversationRecordings(directory, channelID string, flowResult callf
|
||||
if err := writeWAV(path, pcm, 16000); err != nil {
|
||||
return inbound, outbound, err
|
||||
}
|
||||
outbound = append(outbound, RecordingFact{Segment: fmt.Sprintf("outbound_segment_%02d", index), Path: path, SHA256: fileSHA256(path), Bytes: len(pcm)})
|
||||
digest, size, err := fileDigestAndSize(path)
|
||||
if err != nil {
|
||||
return inbound, outbound, fmt.Errorf("hash outbound recording: %w", err)
|
||||
}
|
||||
outbound = append(outbound, RecordingFact{Segment: fmt.Sprintf("outbound_segment_%02d", index), Path: path, SHA256: digest, Bytes: size, DurationMS: int64(len(pcm)) * 1000 / 32000})
|
||||
}
|
||||
return inbound, outbound, nil
|
||||
}
|
||||
@@ -423,11 +432,11 @@ func writeWAV(path string, pcm []byte, sampleRate int) error {
|
||||
return os.WriteFile(path, buf, 0o600)
|
||||
}
|
||||
|
||||
func fileSHA256(path string) string {
|
||||
func fileDigestAndSize(path string) (string, int, error) {
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return ""
|
||||
return "", 0, err
|
||||
}
|
||||
sum := sha256.Sum256(data)
|
||||
return hex.EncodeToString(sum[:])
|
||||
return hex.EncodeToString(sum[:]), len(data), nil
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user