Files
go-sip/internal/rpc/approved_recorded_mock_test.go
T

195 lines
8.8 KiB
Go

package rpc
import (
"bytes"
"context"
"encoding/json"
"errors"
"io"
"net/http"
"net/http/httptest"
"os"
"strings"
"sync/atomic"
"testing"
"time"
agentpb "git.ipao.vip/rogee/go-sip/gen/agent"
"git.ipao.vip/rogee/go-sip/internal/agent"
"git.ipao.vip/rogee/go-sip/internal/ai"
"git.ipao.vip/rogee/go-sip/internal/media"
"google.golang.org/grpc"
)
type recordedMockRPC struct {
agentpb.AgentControlServiceClient
targetURL string
calls []string
asset *agentpb.AssetDescriptor
result []byte
proof *agentpb.UploadObservation
}
func (f *recordedMockRPC) RequestRecordingUpload(_ context.Context, req *agentpb.RequestRecordingUploadRequest, _ ...grpc.CallOption) (*agentpb.RequestRecordingUploadResponse, error) {
f.calls = append(f.calls, "grant")
f.asset = req.Asset
return &agentpb.RequestRecordingUploadResponse{Grant: &agentpb.UploadGrant{
UploadId: req.UploadId, Bucket: "mock-bucket", ObjectKey: "approved/fixture.wav", TargetUrl: f.targetURL,
ExpiresAtUnixMs: time.Now().Add(15 * time.Minute).UnixMilli(), MaxBytes: req.Asset.SizeBytes,
RequiredChecksumSha256: req.Asset.ChecksumSha256,
}}, nil
}
func (f *recordedMockRPC) ReportCallEnded(_ context.Context, req *agentpb.ReportCallEndedRequest, _ ...grpc.CallOption) (*agentpb.ReportCallEndedResponse, error) {
f.calls = append(f.calls, "end")
return &agentpb.ReportCallEndedResponse{Receipt: &agentpb.OperationReceipt{
FactId: req.SourceEventId, Result: agentpb.ResultCode_RESULT_CODE_APPLIED,
}}, nil
}
func (f *recordedMockRPC) ReportCallResult(_ context.Context, req *agentpb.ReportCallResultRequest, _ ...grpc.CallOption) (*agentpb.ReportCallResultResponse, error) {
f.calls = append(f.calls, "result")
f.result = bytes.Clone(req.ResultPayloadJson)
f.proof = req.Upload
return &agentpb.ReportCallResultResponse{Receipt: &agentpb.OperationReceipt{
FactId: "result-fixture", Result: agentpb.ResultCode_RESULT_CODE_ACCEPTED,
}}, nil
}
func recordedMockFixture(t *testing.T, pcm []byte, maxDuration time.Duration) (*ApprovedRecordedMockCall, *recordedMockRPC, *atomic.Int32, string, ApprovedExecution) {
t.Helper()
var puts atomic.Int32
var sent []byte
localOSS := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
puts.Add(1)
body, err := io.ReadAll(io.LimitReader(r.Body, 4097))
if err != nil || r.Method != http.MethodPut || r.URL.Path != "/object" {
t.Errorf("incorrect isolated OSS PUT: method=%s path=%s err=%v", r.Method, r.URL.Path, err)
w.WriteHeader(http.StatusBadRequest)
return
}
sent = bytes.Clone(body)
w.WriteHeader(http.StatusCreated)
}))
t.Cleanup(localOSS.Close)
root := t.TempDir()
if err := os.Chmod(root, 0700); err != nil {
t.Fatal(err)
}
stub := &recordedMockRPC{targetURL: localOSS.URL + "/object"}
approved := ApprovedExecution{
DispatcherID: "c046b893-8628-4589-ae50-619d049248a6", TenantID: 42,
TaskID: "task-asr", SourceEventID: "event-fixture", CallID: "event-fixture",
Callee: "15003164745", SelectedTrunkID: "trunk-mock", MaxCallDuration: maxDuration,
DialBefore: time.Now().Add(time.Minute), AI: ai.Binding{
Mode: "asr_only", Conversation: ai.ConversationConfig{SilenceTimeout: 100 * time.Millisecond, MaxDuration: maxDuration},
},
}
delivery := &agent.RecordingDelivery{
Call: agent.RecordingClient{
Client: stub, DispatcherID: approved.DispatcherID, TenantID: approved.TenantID, SourceEventID: approved.SourceEventID,
Session: func(context.Context) (*agentpb.RequestMeta, error) {
return &agentpb.RequestMeta{AgentId: "agent-mock", CellId: "cell-mock", BootId: "boot-mock", DispatcherEpoch: "epoch-mock", SessionGeneration: 1}, nil
},
},
Recovery: &agent.RecordingRecovery{Root: root, Upload: agent.UploadClient{AllowInsecureHTTP: true}},
}
runner := &ApprovedRecordedMockCall{
InboundPCM16: pcm, Script: ai.ApprovedMockScript{Turns: []ai.ApprovedMockTurn{{Transcript: "synthetic final ASR fixture"}}},
MaxWAVBytes: 4096, ExpectedRecording: true, Outcome: "answered", ReasonMessage: "isolated Mock answered",
ReportTimeout: 5 * time.Second, Delivery: delivery,
}
// The test inspects the actual bytes accepted by the isolated OSS endpoint.
t.Cleanup(func() {
if puts.Load() > 0 && len(sent) == 0 {
t.Error("isolated OSS accepted an empty recording")
}
})
return runner, stub, &puts, root, approved
}
func TestApprovedRecordedMockCallDeliversObservedASRAndInMemoryWAV(t *testing.T) {
pcm := bytes.Repeat([]byte{1, 0}, 1600)
runner, stub, puts, root, approved := recordedMockFixture(t, pcm, time.Second)
if err := runner.Run(context.Background(), approved); err != nil {
t.Fatal(err)
}
if strings.Join(stub.calls, ",") != "end,grant,result" || puts.Load() != 1 || stub.asset == nil || stub.proof == nil {
t.Fatalf("observed media did not yield one confirmed result: calls=%v puts=%d", stub.calls, puts.Load())
}
wav, duration, err := media.EncodeMonoWAV(pcm, 4096)
if err != nil || stub.asset.SizeBytes != int64(len(wav)) || stub.asset.DurationMs != duration || stub.asset.AssetId != "recording-"+approved.SourceEventID || stub.proof.RecordingId != stub.asset.AssetId || stub.proof.UploadId != "upload-"+approved.SourceEventID {
t.Fatalf("actual recorded media/identity changed: asset=%v proof=%v err=%v", stub.asset, stub.proof, err)
}
var result struct {
Outcome string `json:"outcome"`
Transcript []struct {
Role string `json:"role"`
Text string `json:"text"`
} `json:"transcript"`
Recording struct {
Status string `json:"status"`
} `json:"recording"`
}
if err := json.Unmarshal(stub.result, &result); err != nil || result.Outcome != "answered" || len(result.Transcript) != 1 || result.Transcript[0].Role != "user" || result.Transcript[0].Text != "synthetic final ASR fixture" || result.Recording.Status != "uploaded" {
t.Fatalf("current result does not reflect actual Mock media: %+v err=%v", result, err)
}
if entries, err := os.ReadDir(root); err != nil || len(entries) != 0 {
t.Fatalf("successful direct upload wrote business files: entries=%d err=%v", len(entries), err)
}
}
func TestApprovedRecordedMockCallGenerationFailureReportsEmptyRecording(t *testing.T) {
runner, stub, puts, _, approved := recordedMockFixture(t, nil, 200*time.Millisecond)
err := runner.Run(context.Background(), approved)
if err == nil {
t.Fatal("Mock call with no observed media reported healthy completion")
}
if strings.Join(stub.calls, ",") != "end,result" || puts.Load() != 0 || stub.proof != nil {
t.Fatalf("missing media fabricated an upload or lost the terminal result: calls=%v puts=%d", stub.calls, puts.Load())
}
var result struct {
Outcome string `json:"outcome"`
ReasonMessage string `json:"reason_message"`
Recording map[string]any `json:"recording"`
}
if err := json.Unmarshal(stub.result, &result); err != nil || result.Outcome != "failed" || !strings.Contains(result.ReasonMessage, "recording generation failed") || len(result.Recording) != 0 {
t.Fatalf("no-media failure did not produce an honest unique result: %+v err=%v", result, err)
}
}
func TestApprovedRecordedMockCallRefusesMissingPerCallDelivery(t *testing.T) {
runner, stub, puts, _, approved := recordedMockFixture(t, bytes.Repeat([]byte{1, 0}, 1600), time.Second)
runner.Delivery = nil
if err := runner.Run(context.Background(), approved); err == nil || !errors.Is(err, ErrApprovedMockDeliveryUnavailable) || len(stub.calls) != 0 || puts.Load() != 0 {
t.Fatalf("missing per-call delivery was silently accepted: calls=%v err=%v", stub.calls, err)
}
}
func TestApprovedRecordedMockCallPreparationRejectsScriptBeforeDispatch(t *testing.T) {
runner, stub, puts, _, approved := recordedMockFixture(t, bytes.Repeat([]byte{1, 0}, 1600), time.Second)
runner.Script.Turns = nil // cannot cover the approved ASR-only turn
prepared, err := runner.Prepare(approved)
if err == nil || prepared != nil || len(stub.calls) != 0 || puts.Load() != 0 {
t.Fatalf("invalid immutable Mock script reached the call or delivery path: calls=%v err=%v", stub.calls, err)
}
}
func TestApprovedRecordedMockCallPreparationRequiresBoundIdentityAndPrivateRecovery(t *testing.T) {
runner, stub, puts, root, approved := recordedMockFixture(t, bytes.Repeat([]byte{1, 0}, 1600), time.Second)
runner.Delivery.Call.TenantID = approved.TenantID + 1
if prepared, err := runner.Prepare(approved); prepared != nil || !errors.Is(err, ErrApprovedMockDeliveryUnavailable) {
t.Fatalf("recording would have been sent under another tenant: %v", err)
}
runner.Delivery.Call.TenantID = approved.TenantID
if err := os.Chmod(root, 0775); err != nil {
t.Fatal(err)
}
if prepared, err := runner.Prepare(approved); prepared != nil || !errors.Is(err, ErrApprovedMockDeliveryUnavailable) {
t.Fatalf("recording started without private recovery directory: %v", err)
}
if len(stub.calls) != 0 || puts.Load() != 0 {
t.Fatal("failed preparation contacted Dispatcher or OSS")
}
}