package agent import ( "bytes" "context" "crypto/sha256" "encoding/hex" "encoding/json" "errors" "io" "net/http" "net/http/httptest" "os" "path/filepath" "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","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","status_line":null,"raw":null,"sip_capture_error":null,"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 TestRecordingDeliveryConfirmedHangupSurvivesLostEndReport(t *testing.T) { root := t.TempDir() if err := os.Chmod(root, 0700); err != nil { t.Fatal(err) } stub := &recordingDeliveryRPC{endError: errors.New("end reply lost")} delivery := testRecordingDelivery(stub) delivery.Journal = &ResultJournal{Root: root} payload := []byte(`{"recording":{}}`) if err := delivery.Complete(context.Background(), CompletedRecording{ResultPayload: payload}); err == nil { t.Fatal("Dispatcher did not confirm the local hangup") } stub.endError = nil if err := (&ResultJournal{Root: root}).RecoverWithEnd(context.Background(), func(ctx context.Context, entry ResultEntry) error { call := delivery.Call return call.ReportEnded(ctx) }, func(ctx context.Context, entry ResultEntry) error { call := delivery.Call _, err := call.ReportFinal(ctx, entry.Payload, entry.Upload) return err }); err != nil { t.Fatal(err) } if !reflect.DeepEqual(stub.calls, []string{"end", "end", "result"}) { t.Fatalf("local hangup was not recovered under the original identity: %v", stub.calls) } } func TestRecordingFailurePreservesResultAcrossLostEndReport(t *testing.T) { root := t.TempDir() if err := os.Chmod(root, 0700); err != nil { t.Fatal(err) } stub := &recordingDeliveryRPC{endError: errors.New("end reply lost")} delivery := testRecordingDelivery(stub) delivery.Journal = &ResultJournal{Root: root} completed := CompletedRecording{Expected: true, CaptureError: errors.New("recording generation error"), ResultPayload: []byte(`{"recording":{},"reason_message":"media failed"}`)} if err := delivery.Complete(context.Background(), completed); err == nil { t.Fatal("lost end reply must block result delivery") } stub.endError = nil if err := (&ResultJournal{Root: root}).RecoverWithEnd(context.Background(), func(ctx context.Context, entry ResultEntry) error { return delivery.Call.ReportEnded(ctx) }, func(ctx context.Context, entry ResultEntry) error { _, err := delivery.Call.ReportFinal(ctx, entry.Payload, nil) return err }); err != nil { t.Fatal(err) } if !reflect.DeepEqual(stub.calls, []string{"end", "end", "result"}) || !bytes.Contains(stub.resultBody, []byte("recording generation failed")) || stub.resultProof != nil { t.Fatalf("recording failure was lost or reported as uploaded: calls=%v body=%s", stub.calls, stub.resultBody) } } func TestRecordingDeliveryUnconfirmedResultSurvivesRestart(t *testing.T) { root := t.TempDir() if err := os.Chmod(root, 0700); err != nil { t.Fatal(err) } stub := &recordingDeliveryRPC{resultError: errors.New("reply lost")} delivery := testRecordingDelivery(stub) delivery.Journal = &ResultJournal{Root: root} payload := []byte(`{"recording":{}}`) if err := delivery.Complete(context.Background(), CompletedRecording{ResultPayload: payload}); err == nil { t.Fatal("Dispatcher confirmation was not observed") } stub.resultError = nil if err := (&ResultJournal{Root: root}).Recover(context.Background(), func(ctx context.Context, entry ResultEntry) error { call := delivery.Call call.SourceEventID = entry.SourceEventID _, err := call.ReportFinal(ctx, entry.Payload, entry.Upload) return err }); err != nil { t.Fatal(err) } if !reflect.DeepEqual(stub.calls, []string{"end", "result", "result"}) || !bytes.Equal(stub.resultBody, payload) { t.Fatalf("restart changed or failed to resend the original result: %v", stub.calls) } } 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","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","status_line":null,"raw":null,"sip_capture_error":null,"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 TestRecordingDeliverySuccessfulPUTLeavesNoResultOrAudioFiles(t *testing.T) { delivery, _, completed, puts, root := testDirectRecordingDelivery(t, http.StatusCreated) delivery.Journal = &ResultJournal{Root: root} if err := delivery.Complete(context.Background(), completed); err != nil { t.Fatal(err) } if puts.Load() != 1 { t.Fatalf("successful delivery repeated PUT %d times", puts.Load()) } entries, err := os.ReadDir(filepath.Join(root, ".results")) if err != nil || len(entries) != 0 { t.Fatalf("confirmed result was not removed: entries=%v err=%v", entries, err) } rootEntries, err := os.ReadDir(root) if err != nil || len(rootEntries) != 1 || rootEntries[0].Name() != ".results" { t.Fatalf("normal success retained audio or business files: entries=%v err=%v", rootEntries, 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) } }