221 lines
8.5 KiB
Go
221 lines
8.5 KiB
Go
package rpc
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"strings"
|
|
"sync/atomic"
|
|
"testing"
|
|
"time"
|
|
|
|
"git.ipao.vip/rogee/go-sip/internal/agent"
|
|
)
|
|
|
|
func workerTestExecution(now time.Time) ApprovedExecution {
|
|
return ApprovedExecution{DispatcherID: "c046b893-8628-4589-ae50-619d049248a6", TenantID: 42,
|
|
TaskID: "task-a", SourceEventID: "event-a", CallID: "call-a",
|
|
DialBefore: now.Add(time.Minute), MaxCallDuration: time.Minute}
|
|
}
|
|
|
|
func prepareWorkerRun(run func(context.Context, ApprovedExecution) error) func(ApprovedExecution) (func(context.Context) error, error) {
|
|
return func(call ApprovedExecution) (func(context.Context) error, error) {
|
|
return func(ctx context.Context) error { return run(ctx, call) }, nil
|
|
}
|
|
}
|
|
|
|
func TestApprovedCallWorkerOutlivesInitiatingUnaryAndWaitsForPhysicalEnd(t *testing.T) {
|
|
now := time.Date(2026, 9, 20, 10, 0, 0, 0, time.UTC)
|
|
process, stopProcess := context.WithCancel(context.Background())
|
|
defer stopProcess()
|
|
request, closeRequest := context.WithCancel(context.Background())
|
|
defer closeRequest()
|
|
calls := &agent.TaskCalls{}
|
|
started := make(chan context.Context, 1)
|
|
finish := make(chan struct{})
|
|
defer func() {
|
|
select {
|
|
case <-finish:
|
|
default:
|
|
close(finish)
|
|
}
|
|
}()
|
|
failures := make(chan error, 1)
|
|
worker := ApprovedCallWorker{Lifecycle: process, Calls: calls, Now: func() time.Time { return now },
|
|
Prepare: prepareWorkerRun(func(ctx context.Context, _ ApprovedExecution) error {
|
|
started <- ctx
|
|
<-ctx.Done()
|
|
<-finish // simulated hangup must finish before TaskCalls releases the call
|
|
return nil
|
|
}),
|
|
OnFailure: func(_ ApprovedExecution, err error) error { failures <- err; return nil },
|
|
}
|
|
approved := workerTestExecution(now)
|
|
if err := worker.Originate(request, approved); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
var callContext context.Context
|
|
select {
|
|
case callContext = <-started:
|
|
case <-time.After(2 * time.Second):
|
|
t.Fatal("accepted call did not start")
|
|
}
|
|
closeRequest() // the unary response must not cancel the active call
|
|
if callContext.Err() != nil {
|
|
t.Fatal("call inherited the completed unary request deadline")
|
|
}
|
|
task := agent.TaskIdentity{DispatcherID: approved.DispatcherID, TenantID: approved.TenantID, TaskID: approved.TaskID}
|
|
if _, err := calls.Register(task, approved.CallID, func() {}); err == nil {
|
|
t.Fatal("accepted call was not registered before the RPC returned")
|
|
}
|
|
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
|
|
defer cancel()
|
|
controlDone := make(chan error, 1)
|
|
go func() { controlDone <- calls.Apply(ctx, task, "pause", "hangup") }()
|
|
select {
|
|
case <-callContext.Done():
|
|
case <-ctx.Done():
|
|
t.Fatal("Agent control did not cancel the active call")
|
|
}
|
|
select {
|
|
case err := <-controlDone:
|
|
t.Fatalf("control was acknowledged before physical hangup finished: %v", err)
|
|
default:
|
|
}
|
|
close(finish)
|
|
if err := <-controlDone; err != nil {
|
|
t.Fatalf("control did not wait for the Agent runner: %v", err)
|
|
}
|
|
select {
|
|
case err := <-failures:
|
|
t.Fatalf("successful cancellation generated a false failure: %v", err)
|
|
default:
|
|
}
|
|
}
|
|
|
|
func TestApprovedCallWorkerRejectsExpiredOrClosedTaskBeforeIssuing(t *testing.T) {
|
|
now := time.Date(2026, 9, 20, 10, 0, 0, 0, time.UTC)
|
|
process, stopProcess := context.WithCancel(context.Background())
|
|
defer stopProcess()
|
|
calls := &agent.TaskCalls{}
|
|
var starts atomic.Int32
|
|
worker := ApprovedCallWorker{Lifecycle: process, Calls: calls, Now: func() time.Time { return now },
|
|
Prepare: prepareWorkerRun(func(context.Context, ApprovedExecution) error { starts.Add(1); return nil }),
|
|
OnFailure: func(ApprovedExecution, error) error { return nil },
|
|
}
|
|
approved := workerTestExecution(now)
|
|
approved.DialBefore = now.Add(-time.Millisecond)
|
|
if err := worker.Originate(context.Background(), approved); !errors.Is(err, ErrApprovedDialExpired) {
|
|
t.Fatalf("expired authorization was admitted: %v", err)
|
|
}
|
|
approved.DialBefore = now.Add(time.Minute)
|
|
task := agent.TaskIdentity{DispatcherID: approved.DispatcherID, TenantID: approved.TenantID, TaskID: approved.TaskID}
|
|
if err := calls.Apply(context.Background(), task, "pause", "hangup"); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := worker.Originate(context.Background(), approved); !errors.Is(err, agent.ErrTaskAdmissionClosed) {
|
|
t.Fatalf("paused task was admitted: %v", err)
|
|
}
|
|
if starts.Load() != 0 {
|
|
t.Fatal("Agent issued a call despite expired authorization or task pause")
|
|
}
|
|
worker.OnFailure = nil
|
|
if err := worker.Originate(context.Background(), approved); err == nil || !strings.Contains(err.Error(), "failure") {
|
|
t.Fatalf("missing failure observability was accepted: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestApprovedCallWorkerProcessStopCancelsAndReportsFailureOnce(t *testing.T) {
|
|
now := time.Date(2026, 9, 20, 10, 0, 0, 0, time.UTC)
|
|
process, stopProcess := context.WithCancel(context.Background())
|
|
defer stopProcess()
|
|
calls := &agent.TaskCalls{}
|
|
started := make(chan struct{})
|
|
reported := make(chan error, 1)
|
|
var attempts atomic.Int32
|
|
worker := ApprovedCallWorker{Lifecycle: process, Calls: calls, Now: func() time.Time { return now },
|
|
Prepare: prepareWorkerRun(func(ctx context.Context, _ ApprovedExecution) error {
|
|
attempts.Add(1)
|
|
close(started)
|
|
<-ctx.Done()
|
|
return ctx.Err()
|
|
}),
|
|
OnFailure: func(_ ApprovedExecution, err error) error { reported <- err; return nil },
|
|
}
|
|
approved := workerTestExecution(now)
|
|
if err := worker.Originate(context.Background(), approved); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
select {
|
|
case <-started:
|
|
case <-time.After(2 * time.Second):
|
|
t.Fatal("accepted worker did not start")
|
|
}
|
|
stopProcess()
|
|
select {
|
|
case err := <-reported:
|
|
if !errors.Is(err, context.Canceled) {
|
|
t.Fatalf("lost process-shutdown failure reason: %v", err)
|
|
}
|
|
case <-time.After(2 * time.Second):
|
|
t.Fatal("worker failed silently after process shutdown")
|
|
}
|
|
task := agent.TaskIdentity{DispatcherID: approved.DispatcherID, TenantID: approved.TenantID, TaskID: approved.TaskID}
|
|
if err := calls.Apply(context.Background(), task, "stop", "drain"); err != nil {
|
|
t.Fatalf("failed worker was not released: %v", err)
|
|
}
|
|
if attempts.Load() != 1 {
|
|
t.Fatalf("worker was replayed after an unknown outcome: %d", attempts.Load())
|
|
}
|
|
}
|
|
|
|
func TestApprovedCallWorkerRefusesExpiredAuthorizationBeforeDispatchAck(t *testing.T) {
|
|
now := time.Date(2026, 9, 20, 10, 0, 0, 0, time.UTC)
|
|
process, stopProcess := context.WithCancel(context.Background())
|
|
defer stopProcess()
|
|
calls := &agent.TaskCalls{}
|
|
var checks, starts, reports atomic.Int32
|
|
worker := ApprovedCallWorker{Lifecycle: process, Calls: calls,
|
|
Now: func() time.Time {
|
|
if checks.Add(1) == 1 {
|
|
return now // approved when entering the worker
|
|
}
|
|
return now.Add(2 * time.Minute) // expired when the scheduled worker begins
|
|
},
|
|
Prepare: prepareWorkerRun(func(context.Context, ApprovedExecution) error { starts.Add(1); return nil }),
|
|
OnFailure: func(ApprovedExecution, error) error { reports.Add(1); return nil },
|
|
}
|
|
approved := workerTestExecution(now)
|
|
if err := worker.Originate(context.Background(), approved); !errors.Is(err, ErrApprovedDialExpired) {
|
|
t.Fatalf("expired scheduled call received an execution ACK: %v", err)
|
|
}
|
|
if starts.Load() != 0 || reports.Load() != 0 {
|
|
t.Fatal("no-dial pre-dispatch expiry started work or fabricated a call result")
|
|
}
|
|
task := agent.TaskIdentity{DispatcherID: approved.DispatcherID, TenantID: approved.TenantID, TaskID: approved.TaskID}
|
|
if err := calls.Apply(context.Background(), task, "stop", "drain"); err != nil {
|
|
t.Fatalf("refused call remained registered: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestApprovedCallWorkerPreparationRejectsBeforeDispatchAck(t *testing.T) {
|
|
now := time.Date(2026, 9, 20, 10, 0, 0, 0, time.UTC)
|
|
calls := &agent.TaskCalls{}
|
|
cause := errors.New("synthetic Mock script does not match approved AI")
|
|
var reports atomic.Int32
|
|
worker := ApprovedCallWorker{Lifecycle: context.Background(), Calls: calls, Now: func() time.Time { return now },
|
|
Prepare: func(ApprovedExecution) (func(context.Context) error, error) { return nil, cause },
|
|
OnFailure: func(ApprovedExecution, error) error { reports.Add(1); return nil },
|
|
}
|
|
approved := workerTestExecution(now)
|
|
if err := worker.Originate(context.Background(), approved); !errors.Is(err, ErrApprovedCallPreparation) || !errors.Is(err, cause) {
|
|
t.Fatalf("invalid per-call Mock parameters were acknowledged: %v", err)
|
|
}
|
|
if reports.Load() != 0 {
|
|
t.Fatal("no-dial preparation error fabricated a completed call")
|
|
}
|
|
task := agent.TaskIdentity{DispatcherID: approved.DispatcherID, TenantID: approved.TenantID, TaskID: approved.TaskID}
|
|
if err := calls.Apply(context.Background(), task, "stop", "drain"); err != nil {
|
|
t.Fatalf("failed preparation registered a call: %v", err)
|
|
}
|
|
}
|