Files
go-sip/internal/agent/recording_delivery_test.go
T

216 lines
10 KiB
Go

package agent
import (
"bytes"
"context"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"errors"
"io"
"net/http"
"net/http/httptest"
"os"
"reflect"
"sync/atomic"
"testing"
"time"
agentpb "git.ipao.vip/rogee/go-sip/gen/agent"
"git.ipao.vip/rogee/go-sip/internal/media"
"google.golang.org/grpc"
)
type recordingDeliveryRPC struct {
agentpb.AgentControlServiceClient
calls []string
endError error
resultError error
grant *agentpb.UploadGrant
asset *agentpb.AssetDescriptor
resultBody []byte
resultProof *agentpb.UploadObservation
}
func (s *recordingDeliveryRPC) RequestRecordingUpload(_ context.Context, req *agentpb.RequestRecordingUploadRequest, _ ...grpc.CallOption) (*agentpb.RequestRecordingUploadResponse, error) {
s.calls = append(s.calls, "grant")
s.asset = req.Asset
if s.grant == nil {
return nil, errors.New("injected missing grant")
}
return &agentpb.RequestRecordingUploadResponse{Grant: s.grant}, nil
}
func (s *recordingDeliveryRPC) ReportCallEnded(_ context.Context, req *agentpb.ReportCallEndedRequest, _ ...grpc.CallOption) (*agentpb.ReportCallEndedResponse, error) {
s.calls = append(s.calls, "end")
if s.endError != nil {
return nil, s.endError
}
return &agentpb.ReportCallEndedResponse{Receipt: &agentpb.OperationReceipt{FactId: req.SourceEventId, Result: agentpb.ResultCode_RESULT_CODE_APPLIED}}, nil
}
func (s *recordingDeliveryRPC) ReportCallResult(_ context.Context, req *agentpb.ReportCallResultRequest, _ ...grpc.CallOption) (*agentpb.ReportCallResultResponse, error) {
s.calls = append(s.calls, "result")
s.resultBody = append([]byte(nil), req.ResultPayloadJson...)
s.resultProof = req.Upload
if s.resultError != nil {
return nil, s.resultError
}
return &agentpb.ReportCallResultResponse{Receipt: &agentpb.OperationReceipt{FactId: "result-original", Result: agentpb.ResultCode_RESULT_CODE_ACCEPTED}}, nil
}
func testRecordingDelivery(stub *recordingDeliveryRPC) *RecordingDelivery {
return &RecordingDelivery{Call: RecordingClient{
Client: stub, DispatcherID: "dispatcher-mock", TenantID: 1001, SourceEventID: "call-mock",
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
},
}}
}
func TestRecordingDeliveryNoRecordingReportsOnlyAfterConfirmedEnd(t *testing.T) {
stub := &recordingDeliveryRPC{}
delivery := testRecordingDelivery(stub)
payload := []byte(`{"task_id":"task-asr","caller_profile_id":"caller-mock","callee":"15003164745","trunk_id":"trunk-mock","started_at":"2026-09-21T01:30:00Z","ended_at":"2026-09-21T01:30:05Z","duration_ms":5000,"outcome":"no_answer","reason_code":480,"reason_message":"no answer","transcript":[],"opt_out":false,"recording":{}}`)
if err := delivery.Complete(context.Background(), CompletedRecording{ResultPayload: payload}); err != nil {
t.Fatal(err)
}
if !reflect.DeepEqual(stub.calls, []string{"end", "result"}) || stub.resultProof != nil {
t.Fatalf("no-recording path requested upload or reported a fake PUT: calls=%v proof=%v", stub.calls, stub.resultProof)
}
var result struct {
Recording map[string]any `json:"recording"`
}
if err := json.Unmarshal(stub.resultBody, &result); err != nil || len(result.Recording) != 0 {
t.Fatalf("no-answer result invented a recording state: recording=%v err=%v", result.Recording, err)
}
}
func TestRecordingDeliveryCannotReturnResultWhenEndNotConfirmed(t *testing.T) {
stub := &recordingDeliveryRPC{endError: errors.New("injected Dispatcher persistence failure")}
delivery := testRecordingDelivery(stub)
if err := delivery.Complete(context.Background(), CompletedRecording{ResultPayload: []byte(`{"recording":{}}`)}); err == nil {
t.Fatal("failed call-end acknowledgment was treated as a completed result")
}
if !reflect.DeepEqual(stub.calls, []string{"end"}) || len(stub.resultBody) != 0 {
t.Fatalf("unconfirmed call end escaped into a final result: calls=%v", stub.calls)
}
}
func testDirectRecordingDelivery(t *testing.T, putStatus int) (*RecordingDelivery, *recordingDeliveryRPC, CompletedRecording, *atomic.Int32, string) {
t.Helper()
wav, durationMS, err := media.EncodeMonoWAV(bytes.Repeat([]byte{1, 0}, 320), 1024)
if err != nil {
t.Fatal(err)
}
var puts atomic.Int32
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
puts.Add(1)
body, err := io.ReadAll(io.LimitReader(r.Body, int64(len(wav)+1)))
if err != nil || r.Method != http.MethodPut || !bytes.Equal(body, wav) {
t.Errorf("OSS received a different or incomplete recording: method=%s size=%d err=%v", r.Method, len(body), err)
w.WriteHeader(http.StatusBadRequest)
return
}
w.WriteHeader(putStatus)
}))
t.Cleanup(server.Close)
sum := sha256.Sum256(wav)
stub := &recordingDeliveryRPC{grant: &agentpb.UploadGrant{
UploadId: "upload-mock", Bucket: "bucket-mock", ObjectKey: "mock/recording.wav",
TargetUrl: server.URL + "/upload", ExpiresAtUnixMs: time.Now().Add(time.Minute).UnixMilli(),
MaxBytes: int64(len(wav)), RequiredChecksumSha256: hex.EncodeToString(sum[:]),
}}
delivery := testRecordingDelivery(stub)
root := t.TempDir()
if err := os.Chmod(root, 0700); err != nil {
t.Fatal(err)
}
delivery.Recovery = &RecordingRecovery{Root: root, Upload: UploadClient{AllowInsecureHTTP: true}}
completed := CompletedRecording{
ResultPayload: []byte(`{"task_id":"task-asr","caller_profile_id":"caller-mock","callee":"15003164745","trunk_id":"trunk-mock","started_at":"2026-09-21T01:30:00Z","ended_at":"2026-09-21T01:30:20Z","duration_ms":20000,"outcome":"answered","reason_code":null,"reason_message":"answered","transcript":[],"opt_out":false,"recording":{}}`),
Expected: true, RecordingID: "recording-mock", UploadID: "upload-mock", WAV: wav, DurationMS: durationMS,
}
return delivery, stub, completed, &puts, root
}
func TestRecordingDeliveryDirectPUTThenOneResultWithoutBusinessFiles(t *testing.T) {
delivery, stub, completed, puts, root := testDirectRecordingDelivery(t, http.StatusCreated)
if err := delivery.Complete(context.Background(), completed); err != nil {
t.Fatal(err)
}
if !reflect.DeepEqual(stub.calls, []string{"end", "grant", "result"}) || puts.Load() != 1 || stub.resultProof == nil || stub.resultProof.PutStatusCode != http.StatusCreated {
t.Fatalf("wrong upload/result sequence: calls=%v puts=%d proof=%+v", stub.calls, puts.Load(), stub.resultProof)
}
var result struct {
Recording struct {
Status string `json:"status"`
Bucket string `json:"bucket"`
ObjectKey string `json:"object_key"`
Format string `json:"format"`
ChecksumSHA256 string `json:"checksum_sha256"`
Channels int `json:"channels"`
SampleRateHz int `json:"sample_rate_hz"`
DurationMS int64 `json:"duration_ms"`
SizeBytes int64 `json:"size_bytes"`
} `json:"recording"`
}
if err := json.Unmarshal(stub.resultBody, &result); err != nil {
t.Fatal(err)
}
sum := sha256.Sum256(completed.WAV)
if result.Recording.Status != "uploaded" || result.Recording.Bucket != stub.grant.Bucket || result.Recording.ObjectKey != stub.grant.ObjectKey || result.Recording.Format != "wav" || result.Recording.Channels != 1 || result.Recording.SampleRateHz != 16000 || result.Recording.DurationMS != completed.DurationMS || result.Recording.SizeBytes != int64(len(completed.WAV)) || result.Recording.ChecksumSHA256 != hex.EncodeToString(sum[:]) {
t.Fatalf("final result does not describe actual OSS upload: %+v", result.Recording)
}
entries, err := os.ReadDir(root)
if err != nil || len(entries) != 0 {
t.Fatalf("normal successful upload created business files: entries=%d err=%v", len(entries), err)
}
}
func TestRecordingDeliveryFailedPUTPersistsOriginalAndNeverRetriesImplicitly(t *testing.T) {
delivery, stub, completed, puts, _ := testDirectRecordingDelivery(t, http.StatusServiceUnavailable)
var rejected *UploadHTTPError
uploadErr := delivery.Complete(context.Background(), completed)
if !errors.As(uploadErr, &rejected) || rejected.StatusCode != http.StatusServiceUnavailable {
t.Fatalf("OSS explicit failure not preserved: %v", uploadErr)
}
entry, err := delivery.Recovery.Load(stub.grant.Bucket, stub.grant.ObjectKey)
var saved struct {
Recording struct {
Status string `json:"status"`
} `json:"recording"`
}
if err != nil || entry.State != "retry_pending" || json.Unmarshal(entry.ResultPayload, &saved) != nil || saved.Recording.Status != "uploaded" || len(stub.resultBody) != 0 {
t.Fatalf("failed PUT was not privately persisted without a false report: state=%s err=%v initial=%v", entry.State, err, uploadErr)
}
if !reflect.DeepEqual(stub.calls, []string{"end", "grant"}) || puts.Load() != 1 {
t.Fatalf("failed upload was treated as a final result: calls=%v puts=%d", stub.calls, puts.Load())
}
if err := delivery.Complete(context.Background(), completed); !errors.Is(err, ErrRecordingRetryManaged) || puts.Load() != 1 {
t.Fatalf("failed PUT was automatically repeated: err=%v puts=%d", err, puts.Load())
}
}
func TestRecordingDeliveryConfirmedPUTReportRetryDoesNotRepeatPUT(t *testing.T) {
delivery, stub, completed, puts, root := testDirectRecordingDelivery(t, http.StatusOK)
stub.resultError = errors.New("injected MQ/Dispatcher result failure")
if err := delivery.Complete(context.Background(), completed); err == nil || puts.Load() != 1 {
t.Fatalf("failed result report should retain confirmed PUT in memory: err=%v puts=%d", err, puts.Load())
}
stub.resultError = nil
if err := delivery.Complete(context.Background(), completed); !errors.Is(err, ErrRecordingRetryManaged) || puts.Load() != 1 {
t.Fatalf("confirmed PUT was implicitly repeated: err=%v puts=%d", err, puts.Load())
}
if err := delivery.RetryResult(context.Background()); err != nil || puts.Load() != 1 {
t.Fatalf("result-only retry repeated OSS PUT: err=%v puts=%d", err, puts.Load())
}
if !reflect.DeepEqual(stub.calls, []string{"end", "grant", "result", "result"}) {
t.Fatalf("confirmed PUT was not retried at the original result identity: %v", stub.calls)
}
entries, err := os.ReadDir(root)
if err != nil || len(entries) != 0 {
t.Fatalf("result-only retry wrote successful upload to business files: entries=%d err=%v", len(entries), err)
}
}