159 lines
7.9 KiB
Go
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)
|
|
}
|
|
}
|