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

159 lines
7.9 KiB
Go

package agent
import (
"bytes"
"context"
"encoding/json"
"errors"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"reflect"
"strings"
"sync/atomic"
"testing"
"time"
"git.ipao.vip/rogee/go-sip/internal/media"
)
func TestRecordingDeliveryCaptureFailureDescribesFailureWithoutInventingUpload(t *testing.T) {
for _, tc := range []struct {
name string
cause error
}{
{name: "bounded_capture_failed", cause: media.ErrWAVTooLarge},
{name: "empty_expected_recording"},
} {
t.Run(tc.name, func(t *testing.T) {
delivery, stub, completed, puts, root := testDirectRecordingDelivery(t, http.StatusOK)
completed.WAV = nil
completed.CaptureError = tc.cause
if err := delivery.Complete(context.Background(), completed); err != nil {
t.Fatal(err)
}
var result struct {
Outcome string `json:"outcome"`
ReasonMessage string `json:"reason_message"`
Recording map[string]any `json:"recording"`
}
if err := json.Unmarshal(stub.resultBody, &result); err != nil {
t.Fatal(err)
}
if !reflect.DeepEqual(stub.calls, []string{"end", "result"}) || puts.Load() != 0 || stub.resultProof != nil || result.Outcome != "answered" || !strings.Contains(result.ReasonMessage, "recording generation failed") || len(result.Recording) != 0 {
t.Fatalf("failed capture invented upload or changed the actual call outcome: calls=%v puts=%d result=%+v", stub.calls, puts.Load(), result)
}
entries, err := os.ReadDir(root)
if err != nil || len(entries) != 0 {
t.Fatalf("no generated audio created a business file: entries=%d err=%v", len(entries), err)
}
})
}
}
func TestRecordingDeliveryRejectsInvalidWAVBeforeGrantOrPUT(t *testing.T) {
delivery, stub, completed, puts, _ := testDirectRecordingDelivery(t, http.StatusOK)
completed.WAV = bytes.Clone(completed.WAV)
completed.WAV[0] = 'X'
if err := delivery.Complete(context.Background(), completed); err == nil {
t.Fatal("corrupt WAV was reported as a valid upload")
}
if !reflect.DeepEqual(stub.calls, []string{"end"}) || stub.asset != nil || puts.Load() != 0 {
t.Fatalf("invalid media reached upload: calls=%v asset=%v puts=%d", stub.calls, stub.asset, puts.Load())
}
}
func TestRecordingDeliveryRecoveryDiskFailureNeverReportsUploaded(t *testing.T) {
delivery, stub, completed, puts, root := testDirectRecordingDelivery(t, http.StatusServiceUnavailable)
delivery.Recovery.writeState = func(string, any) error { return errors.New("injected disk full") }
err := delivery.Complete(context.Background(), completed)
var rejected *UploadHTTPError
if !errors.As(err, &rejected) || !strings.Contains(err.Error(), "injected disk full") || puts.Load() != 1 {
t.Fatalf("OSS and recovery-write failures were not both visible: err=%v puts=%d", err, puts.Load())
}
original, readErr := os.ReadFile(filepath.Join(root, stub.grant.Bucket, filepath.FromSlash(stub.grant.ObjectKey), "recording.wav"))
if readErr != nil || !bytes.Equal(original, completed.WAV) || len(stub.resultBody) != 0 {
t.Fatalf("disk failure lost the original audio or forged a result: read=%v result_size=%d", readErr, len(stub.resultBody))
}
if err := delivery.Complete(context.Background(), completed); !errors.Is(err, ErrRecordingRetryManaged) || puts.Load() != 1 {
t.Fatalf("disk failure triggered an implicit second PUT: err=%v puts=%d", err, puts.Load())
}
}
type recordingFailingTransport func(*http.Request) (*http.Response, error)
func (f recordingFailingTransport) RoundTrip(req *http.Request) (*http.Response, error) {
return f(req)
}
func TestRecordingDeliveryUnknownPUTKeepsManualBarrierAcrossRestart(t *testing.T) {
delivery, stub, completed, _, root := testDirectRecordingDelivery(t, http.StatusOK)
var attempts atomic.Int32
delivery.Recovery.Upload.HTTPClient = &http.Client{Transport: recordingFailingTransport(func(*http.Request) (*http.Response, error) {
attempts.Add(1)
return nil, errors.New("injected lost OSS response")
})}
if err := delivery.Complete(context.Background(), completed); !errors.Is(err, ErrUploadOutcomeUnknown) || attempts.Load() != 1 {
t.Fatalf("unknown PUT was not retained as unknown: err=%v attempts=%d", err, attempts.Load())
}
entry, err := delivery.Recovery.Load(stub.grant.Bucket, stub.grant.ObjectKey)
if err != nil || entry.State != "outcome_unknown" || len(stub.resultBody) != 0 {
t.Fatalf("unknown PUT fabricated an uploaded result: state=%s err=%v", entry.State, err)
}
restarted := testRecordingDelivery(stub)
restarted.Recovery = &RecordingRecovery{Root: root, Upload: delivery.Recovery.Upload}
_, err = restarted.RetryRecovered(context.Background(), entry.Bucket, entry.ObjectKey)
if !errors.Is(err, ErrUploadOutcomeUnknown) || attempts.Load() != 1 || !reflect.DeepEqual(stub.calls, []string{"end", "grant"}) {
t.Fatalf("restart retried unknown PUT or reported a final result: err=%v attempts=%d calls=%v", err, attempts.Load(), stub.calls)
}
}
func TestRecordingDeliveryRestartRetriesOriginalAndReportsConfirmedPUTOnly(t *testing.T) {
delivery, stub, completed, originalPUTs, root := testDirectRecordingDelivery(t, http.StatusServiceUnavailable)
now := time.Now()
delivery.Recovery.Now = func() time.Time { return now }
delivery.Recovery.Upload.Now = func() time.Time { return now }
if err := delivery.Complete(context.Background(), completed); err == nil || originalPUTs.Load() != 1 {
t.Fatalf("original failed PUT not detected: err=%v puts=%d", err, originalPUTs.Load())
}
original, err := delivery.Recovery.Load(stub.grant.Bucket, stub.grant.ObjectKey)
if err != nil || original.State != "retry_pending" {
t.Fatalf("original upload identity not persisted: state=%s err=%v", original.State, err)
}
now = now.Add(time.Minute)
var recoveredPUTs atomic.Int32
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
recoveredPUTs.Add(1)
if r.Method != http.MethodPut || r.URL.Path != "/fresh-token" {
http.Error(w, "changed request", http.StatusBadRequest)
return
}
w.WriteHeader(http.StatusOK)
}))
defer server.Close()
stub.grant.TargetUrl = server.URL + "/fresh-token"
stub.grant.ExpiresAtUnixMs = now.Add(15 * time.Minute).UnixMilli()
stub.resultError = errors.New("injected Dispatcher result failure after confirmed PUT")
freshRecovery := func() *RecordingRecovery {
return &RecordingRecovery{Root: root, Now: func() time.Time { return now }, Upload: UploadClient{AllowInsecureHTTP: true, Now: func() time.Time { return now }}}
}
restarted := testRecordingDelivery(stub)
restarted.Recovery = freshRecovery()
_, err = restarted.RetryRecovered(context.Background(), original.Bucket, original.ObjectKey)
if err == nil || recoveredPUTs.Load() != 1 || originalPUTs.Load() != 1 {
t.Fatalf("first restarted retry did not preserve original grant/result boundary: err=%v recovered=%d initial=%d", err, recoveredPUTs.Load(), originalPUTs.Load())
}
pending, err := restarted.Recovery.Load(original.Bucket, original.ObjectKey)
if err != nil || pending.State != "uploaded_unreported" || stub.asset == nil || stub.asset.CallId != original.CallID || stub.asset.ExecutionId != original.SourceEventID || stub.asset.ChecksumSha256 != original.SHA256 || stub.asset.SizeBytes != original.SizeBytes || stub.asset.DurationMs != completed.DurationMS || !bytes.Equal(stub.resultBody, original.ResultPayload) || stub.resultProof == nil {
t.Fatalf("confirmed PUT was not preserved before result delivery: state=%s asset=%+v proof=%+v err=%v", pending.State, stub.asset, stub.resultProof, err)
}
stub.resultError = nil
again := testRecordingDelivery(stub)
again.Recovery = freshRecovery()
finished, err := again.RetryRecovered(context.Background(), original.Bucket, original.ObjectKey)
if err != nil || finished.State != "delivered" || recoveredPUTs.Load() != 1 || !reflect.DeepEqual(stub.calls, []string{"end", "grant", "grant", "result", "result"}) {
t.Fatalf("restart repeated confirmed PUT or lost result: state=%s calls=%v puts=%d err=%v", finished.State, stub.calls, recoveredPUTs.Load(), err)
}
}