311 lines
11 KiB
Go
311 lines
11 KiB
Go
package main
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"errors"
|
|
"git.ipao.vip/rogee/go-sip/internal/sipcall"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
)
|
|
|
|
func testLineENV(t *testing.T) string {
|
|
t.Helper()
|
|
env := filepath.Join(t.TempDir(), "line.env")
|
|
data := `SIP_ID=line
|
|
SIP_SERVER=192.0.2.1
|
|
SIP_PORT=5060
|
|
SIP_CALLER_ID=BD123
|
|
SIP_PREFIX=7089
|
|
SIP_TRANSPORT=udp
|
|
SIP_AUTH=ip
|
|
SIP_REGISTER=no
|
|
SIP_CODEC=alaw
|
|
ASTERISK_CONFIG_DIR=/tmp/ast
|
|
ASTERISK_BIN=/usr/local/sbin/asterisk
|
|
ASTERISK_LIBRARY_DIR=/usr/local/lib
|
|
OUTPUT_DIR=/tmp/results
|
|
RING_SECONDS=20
|
|
HOLD_SECONDS=3
|
|
DEBUG_INTERFACE=any
|
|
`
|
|
if err := os.WriteFile(env, []byte(data), 0600); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
return env
|
|
}
|
|
|
|
func TestCallCLIHasNoBusinessOrAIDependencies(t *testing.T) {
|
|
env := testLineENV(t)
|
|
for _, tc := range []struct {
|
|
args []string
|
|
debug bool
|
|
}{
|
|
{[]string{env, "18601010101"}, false},
|
|
{[]string{"-D", env, "18601010101"}, true},
|
|
{[]string{"--debug", env, "18601010101"}, true},
|
|
{[]string{env, "-D", "18601010101"}, true},
|
|
{[]string{env, "--debug", "18601010101"}, true},
|
|
} {
|
|
calls := 0
|
|
root := newRootCommand(func(ctx context.Context, c sipcall.Config, n string, debug bool) (*sipcall.Report, error) {
|
|
calls++
|
|
if debug != tc.debug || n != "18601010101" || c.ID != "line" {
|
|
t.Fatal("incorrect CLI inputs")
|
|
}
|
|
return &sipcall.Report{Status: "connected", Directory: "/tmp/result", Callee: n, DialedCallee: c.Prefix + n, Debug: debug}, nil
|
|
})
|
|
out := &bytes.Buffer{}
|
|
root.SetOut(out)
|
|
root.SetErr(out)
|
|
root.SetArgs(tc.args)
|
|
if err := root.Execute(); err != nil || calls != 1 {
|
|
t.Fatalf("%v %v", tc.args, err)
|
|
}
|
|
if !strings.Contains(out.String(), "result.json") {
|
|
t.Fatal("result path missing")
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestCallCLIRejectsMissingOrMalformedInputsBeforeHostAccess(t *testing.T) {
|
|
env := testLineENV(t)
|
|
for _, args := range [][]string{
|
|
{}, {"-D"}, {env}, {env, "123", "456"}, {"missing.env", "123"},
|
|
{env, "+123"}, {env, ""}, {env, strings.Repeat("1", 33)}, {env, "123"},
|
|
{"--unknown", env, "123"}, {"agent"}, {"call", "123"},
|
|
{"call", env, "123"}, {"call", "--sip", env, "123"}, {"--sip", env, "123"},
|
|
} {
|
|
root := newRootCommand(func(context.Context, sipcall.Config, string, bool) (*sipcall.Report, error) {
|
|
t.Fatal("invalid input must never access host")
|
|
return nil, nil
|
|
})
|
|
root.SetArgs(args)
|
|
root.SetOut(&bytes.Buffer{})
|
|
root.SetErr(&bytes.Buffer{})
|
|
if err := root.Execute(); err == nil {
|
|
t.Fatalf("invalid invocation accepted: %v", args)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestCallCLIHelpShowsPositionalArguments(t *testing.T) {
|
|
root := newRootCommand(func(context.Context, sipcall.Config, string, bool) (*sipcall.Report, error) {
|
|
t.Fatal("help must never access host")
|
|
return nil, nil
|
|
})
|
|
out := &bytes.Buffer{}
|
|
root.SetOut(out)
|
|
root.SetArgs([]string{"--help"})
|
|
if err := root.Execute(); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
for _, text := range []string{"sip-call <线路.env> <原始号码>", "-D, --debug"} {
|
|
if !strings.Contains(out.String(), text) {
|
|
t.Fatalf("help is missing %q: %s", text, out.String())
|
|
}
|
|
}
|
|
if strings.Contains(out.String(), "--sip") || strings.Contains(out.String(), "Available Commands:") {
|
|
t.Fatalf("help still advertises subcommands or --sip: %s", out.String())
|
|
}
|
|
}
|
|
|
|
func TestCallCLIPropagatesRunFailure(t *testing.T) {
|
|
want := errors.New("unconfirmed call")
|
|
root := newRootCommand(func(context.Context, sipcall.Config, string, bool) (*sipcall.Report, error) {
|
|
return &sipcall.Report{Status: "unknown", Directory: "/tmp/test"}, want
|
|
})
|
|
root.SetOut(&bytes.Buffer{})
|
|
root.SetArgs([]string{testLineENV(t), "18601010101"})
|
|
if err := root.Execute(); !errors.Is(err, want) {
|
|
t.Fatalf("run failure hidden: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestDebugStartupFailureDoesNotClaimCaptureDisabled(t *testing.T) {
|
|
out := &bytes.Buffer{}
|
|
err := printReport(out, &sipcall.Report{Status: "not_dialed", Directory: "/tmp/test", Debug: true, Failure: "tcpdump startup failed"})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if strings.Contains(out.String(), "抓包:未启用") || !strings.Contains(out.String(), "抓包:已请求,但未启动") {
|
|
t.Fatalf("debug failure was reported as disabled: %s", out.String())
|
|
}
|
|
}
|
|
|
|
func TestCallCLIDoesNotHideRunFailures(t *testing.T) {
|
|
errExpected := errors.New("unconfirmed call")
|
|
out := &bytes.Buffer{}
|
|
err := printReport(out, &sipcall.Report{Status: "unknown", Directory: "/tmp/test", Failure: errExpected.Error()})
|
|
if err != nil || !strings.Contains(out.String(), "状态不明") {
|
|
t.Fatal("unknown call must not look connected")
|
|
}
|
|
}
|
|
|
|
func TestAICLIRequiresExplicitOptInAndBothConfigFiles(t *testing.T) {
|
|
env := testLineENV(t)
|
|
for _, tc := range []struct {
|
|
name string
|
|
args []string
|
|
}{
|
|
{name: "task without opt-in", args: []string{"--task", "task.json", env, "18601010101"}},
|
|
{name: "provider without opt-in", args: []string{"--provider", "providers.json", env, "18601010101"}},
|
|
{name: "both files without opt-in", args: []string{"--task", "task.json", "--provider", "providers.json", env, "18601010101"}},
|
|
{name: "missing task", args: []string{"--ai", "--provider", "providers.json", env, "18601010101"}},
|
|
{name: "missing provider", args: []string{"--ai", "--task", "task.json", env, "18601010101"}},
|
|
} {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
ordinaryCalls, aiCalls := 0, 0
|
|
root := newRootCommandWithAI(
|
|
func(context.Context, sipcall.Config, string, bool) (*sipcall.Report, error) {
|
|
ordinaryCalls++
|
|
return &sipcall.Report{Status: "connected"}, nil
|
|
},
|
|
func(context.Context, sipcall.Config, string, bool, string, string) (*sipcall.AIReport, error) {
|
|
aiCalls++
|
|
return &sipcall.AIReport{}, nil
|
|
},
|
|
)
|
|
root.SetArgs(tc.args)
|
|
root.SetOut(&bytes.Buffer{})
|
|
root.SetErr(&bytes.Buffer{})
|
|
if err := root.Execute(); err == nil || ordinaryCalls != 0 || aiCalls != 0 {
|
|
t.Fatalf("invalid AI opt-in/configuration was accepted: err=%v ordinary=%d ai=%d", err, ordinaryCalls, aiCalls)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
var errConsoleWrite = errors.New("console writer failed")
|
|
|
|
type failingConsoleWriter struct{}
|
|
|
|
func (failingConsoleWriter) Write([]byte) (int, error) { return 0, errConsoleWrite }
|
|
|
|
func TestPrintAIReportReportsWhetherMediaFlowStarted(t *testing.T) {
|
|
for _, tc := range []struct {
|
|
started bool
|
|
want string
|
|
}{{false, "AI 媒体流程:未启动"}, {true, "AI 媒体流程:已启动"}} {
|
|
out := &bytes.Buffer{}
|
|
report := &sipcall.AIReport{Call: &sipcall.Report{Status: "not_dialed", Directory: "/private/result"}, Mode: "asr_only", MediaFlowStarted: tc.started}
|
|
if err := printAIReport(out, report); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if !strings.Contains(out.String(), tc.want) {
|
|
t.Fatalf("wanted %q, got %s", tc.want, out.String())
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestPrintAIReportPropagatesConsoleWriteFailure(t *testing.T) {
|
|
report := &sipcall.AIReport{Call: &sipcall.Report{Status: "connected", Directory: "/private/result"}, Mode: "asr_only"}
|
|
if err := printAIReport(failingConsoleWriter{}, report); !errors.Is(err, errConsoleWrite) {
|
|
t.Fatalf("console output failure was hidden: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestAICLIUsesEachInvocationLineENV(t *testing.T) {
|
|
firstENV := testLineENV(t)
|
|
original, err := os.ReadFile(firstENV)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
second := bytes.Replace(original, []byte("SIP_ID=line\n"), []byte("SIP_ID=line-two\n"), 1)
|
|
if bytes.Equal(second, original) {
|
|
t.Fatal("line fixture did not contain the SIP_ID to change")
|
|
}
|
|
secondENV := filepath.Join(t.TempDir(), "second-line.env")
|
|
if err := os.WriteFile(secondENV, second, 0600); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
for _, tc := range []struct{ env, wantLine string }{{firstENV, "line"}, {secondENV, "line-two"}} {
|
|
root := newRootCommandWithAI(
|
|
func(context.Context, sipcall.Config, string, bool) (*sipcall.Report, error) {
|
|
t.Fatal("AI invocation used the ordinary runner")
|
|
return nil, nil
|
|
},
|
|
func(_ context.Context, c sipcall.Config, _ string, _ bool, _, _ string) (*sipcall.AIReport, error) {
|
|
if c.ID != tc.wantLine {
|
|
t.Fatalf("AI runner used line %q, want invocation ENV line %q", c.ID, tc.wantLine)
|
|
}
|
|
return &sipcall.AIReport{Call: &sipcall.Report{Status: "connected", Directory: "/tmp/test"}, Mode: "asr_only"}, nil
|
|
},
|
|
)
|
|
root.SetArgs([]string{"--ai", "--task", "task.json", "--provider", "providers.json", tc.env, "18601010101"})
|
|
root.SetOut(&bytes.Buffer{})
|
|
root.SetErr(&bytes.Buffer{})
|
|
if err := root.Execute(); err != nil {
|
|
t.Fatalf("line %q invocation failed: %v", tc.wantLine, err)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestAICLIUsesOnlyLowercaseOptInAndPassesLocalInputs(t *testing.T) {
|
|
env := testLineENV(t)
|
|
for _, args := range [][]string{
|
|
{"--AI", "--task", "task.json", "--provider", "providers.json", env, "18601010101"},
|
|
{"--mode", "full_ai", "--ai", "--task", "task.json", "--provider", "providers.json", env, "18601010101"},
|
|
} {
|
|
root := newRootCommandWithAI(
|
|
func(context.Context, sipcall.Config, string, bool) (*sipcall.Report, error) {
|
|
t.Fatal("AI-only invocation must not use the ordinary runner")
|
|
return nil, nil
|
|
},
|
|
func(context.Context, sipcall.Config, string, bool, string, string) (*sipcall.AIReport, error) {
|
|
t.Fatal("invalid CLI alias or mode flag was accepted")
|
|
return nil, nil
|
|
},
|
|
)
|
|
root.SetArgs(args)
|
|
root.SetOut(&bytes.Buffer{})
|
|
root.SetErr(&bytes.Buffer{})
|
|
if err := root.Execute(); err == nil {
|
|
t.Fatalf("unsupported flag accepted: %v", args)
|
|
}
|
|
}
|
|
|
|
var gotTask, gotProvider string
|
|
var gotDebug bool
|
|
root := newRootCommandWithAI(
|
|
func(context.Context, sipcall.Config, string, bool) (*sipcall.Report, error) {
|
|
t.Fatal("AI invocation used the ordinary runner")
|
|
return nil, nil
|
|
},
|
|
func(_ context.Context, c sipcall.Config, number string, debug bool, taskPath, providerPath string) (*sipcall.AIReport, error) {
|
|
if c.ID != "line" || number != "18601010101" {
|
|
t.Fatalf("AI runner received wrong line/number: %+v %q", c, number)
|
|
}
|
|
gotTask, gotProvider, gotDebug = taskPath, providerPath, debug
|
|
answeredAt := time.Now().UTC()
|
|
return &sipcall.AIReport{
|
|
Call: &sipcall.Report{Status: "connected", Callee: number, DialedCallee: c.Prefix + number, Directory: "/private/result", Debug: debug, AnsweredAt: &answeredAt},
|
|
Mode: "full_ai", MediaFlowStarted: true, RecordingPath: "/private/result/recording.wav",
|
|
Turns: []sipcall.AITurn{{Transcript: "用户转写", Reply: "助手回复"}},
|
|
}, nil
|
|
},
|
|
)
|
|
out := &bytes.Buffer{}
|
|
root.SetOut(out)
|
|
root.SetErr(&bytes.Buffer{})
|
|
root.SetArgs([]string{"--ai", "--task", "/private/task.json", "--provider", "/private/providers.json", "--debug", env, "18601010101"})
|
|
if err := root.Execute(); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if gotTask != "/private/task.json" || gotProvider != "/private/providers.json" || !gotDebug {
|
|
t.Fatalf("AI config/debug flags not passed exactly: task=%q provider=%q debug=%v", gotTask, gotProvider, gotDebug)
|
|
}
|
|
for _, want := range []string{"full_ai", "recording.wav", "用户转写", "助手回复"} {
|
|
if !strings.Contains(out.String(), want) {
|
|
t.Fatalf("console result missing %q: %s", want, out.String())
|
|
}
|
|
}
|
|
if !strings.Contains(out.String(), "AI 媒体流程:已启动") || strings.Contains(out.String(), "AI 媒体流程未启动") {
|
|
t.Fatalf("AI report did not distinguish entered media flow: %s", out.String())
|
|
}
|
|
}
|