package dispatcher import ( "context" "encoding/json" "errors" "fmt" "path/filepath" "strings" "testing" "time" "git.ipao.vip/rogee/go-sip/internal/configread" "git.ipao.vip/rogee/go-sip/internal/contract" "git.ipao.vip/rogee/go-sip/internal/store" "google.golang.org/genproto/googleapis/rpc/errdetails" "google.golang.org/grpc/codes" "google.golang.org/grpc/status" ) type fakeOriginator struct { loaded map[string]int64 calls []CallSpec err error } func (f *fakeOriginator) LoadedTrunks(context.Context) (map[string]int64, error) { return f.loaded, nil } func (f *fakeOriginator) Originate(_ context.Context, spec CallSpec) error { f.calls = append(f.calls, spec) return f.err } type fakePublisher struct { bodies [][]byte err error } func (f *fakePublisher) Publish(_ context.Context, exchange, key string, body []byte) error { if exchange != "agent-call.saas.v1" || !strings.HasSuffix(key, ".out") { return errors.New("unexpected outbound MQ route") } if err := contract.ValidateCurrent("mq", body); err != nil { return err } f.bodies = append(f.bodies, append([]byte(nil), body...)) return f.err } func newExecuteFixture(t *testing.T) (*ExecuteController, *fakeOriginator, *fakePublisher, *store.Store) { t.Helper() s, err := store.Open(filepath.Join(t.TempDir(), "state.db")) if err != nil { t.Fatal(err) } t.Cleanup(func() { _ = s.Close() }) snapshot := policySnapshot(t) if err := s.ApplyDiscoverySnapshot(snapshot.Task.DispatcherID, []configread.DiscoveredTask{{TaskID: snapshot.Task.TaskID, TenantID: snapshot.Task.TenantID, TaskRevision: snapshot.Task.TaskRevision, Status: "running"}}); err != nil { t.Fatal(err) } if err := s.SaveSnapshot(snapshot); err != nil { t.Fatal(err) } if err := s.MarkReadyForSIP(snapshot.Task.DispatcherID, snapshot.SIP.Revision); err != nil { t.Fatal(err) } originator := &fakeOriginator{loaded: map[string]int64{"trunk-mock": 8}} publisher := &fakePublisher{} controller := &ExecuteController{DispatcherID: snapshot.Task.DispatcherID, Store: s, Originator: originator, Publisher: publisher, Now: func() time.Time { return monday(9, 30) }} return controller, originator, publisher, s } func executeBody(t *testing.T, eventID, callee string) []byte { t.Helper() body := string(configExample(t, "mq-execute")) body = strings.Replace(body, `"event_id":"call-example"`, `"event_id":"`+eventID+`"`, 1) body = strings.Replace(body, `"callee":"15003164745"`, `"callee":"`+callee+`"`, 1) return []byte(body) } func TestExecuteRejectsInvalidCalleeWithoutStoppingTask(t *testing.T) { controller, originator, publisher, s := newExecuteFixture(t) if err := controller.ProcessExecute(context.Background(), executeBody(t, "bad-1", "not-a-phone")); err != nil { t.Fatal(err) } if len(originator.calls) != 0 { t.Fatal("invalid number reached originator") } outbox, err := s.ListPendingOutbox(controller.DispatcherID) if err != nil || len(outbox) != 1 || !strings.Contains(string(outbox[0].Body), `"status":"rejected"`) { t.Fatalf("missing individual rejection: %+v %v", outbox, err) } if err := controller.ProcessExecute(context.Background(), executeBody(t, "good-1", "15003164745")); err != nil { t.Fatal(err) } if len(originator.calls) != 1 { t.Fatalf("invalid number paused entire task; originate calls=%d", len(originator.calls)) } if err := controller.ProcessExecute(context.Background(), executeBody(t, "good-1", "15003164745")); err != nil || len(originator.calls) != 1 { t.Fatalf("duplicate command reoriginated: %d %v", len(originator.calls), err) } if err := controller.FlushOutbox(context.Background()); err != nil { t.Fatal(err) } if len(publisher.bodies) != 2 { t.Fatalf("expected rejected and dispatched acknowledgments, got %d", len(publisher.bodies)) } for _, body := range publisher.bodies { var message struct { EventType string `json:"event_type"` Payload struct { Status string `json:"status"` } `json:"payload"` } if err := json.Unmarshal(body, &message); err != nil || message.EventType != "call.execute" || (message.Payload.Status != "rejected" && message.Payload.Status != "dispatched") { t.Fatalf("unexpected outbound message: %s %v", body, err) } } } func TestExecuteWaitsForRulesThenDispatchesOriginalIdentity(t *testing.T) { controller, originator, _, s := newExecuteFixture(t) controller.Now = func() time.Time { return monday(8, 59) } body := executeBody(t, "waiting-1", "15003164745") body = []byte(strings.Replace(string(body), `"issued_at":"2026-09-21T01:00:00Z"`, `"issued_at":"2026-09-20T00:00:00Z"`, 1)) if err := controller.ProcessExecute(context.Background(), body); err != nil { t.Fatal(err) } if len(originator.calls) != 0 { t.Fatal("dialed before task/trunk window") } pending, err := s.ListPendingExecute(controller.DispatcherID) if err != nil || len(pending) != 1 || pending[0].EventID != "waiting-1" { t.Fatalf("pending command was lost: %+v %v", pending, err) } controller.Now = func() time.Time { return monday(9, 30) } if err := controller.ProcessPending(context.Background()); err != nil || len(originator.calls) != 1 || originator.calls[0].EventID != "waiting-1" { t.Fatalf("original command did not resume once: calls=%+v err=%v", originator.calls, err) } } func TestAgentFailedPreconditionWithoutNoIssueProofKeepsUnknownReservation(t *testing.T) { controller, originator, _, s := newExecuteFixture(t) originator.err = status.Error(codes.FailedPrecondition, "prior Agent execution outcome is unknown; no redial") if err := controller.ProcessExecute(context.Background(), executeBody(t, "prior-agent-unknown-1", "15003164745")); err == nil { t.Fatal("prior unknown was silently treated as confirmed rejection") } occupied, err := s.TrunkOccupancy(controller.DispatcherID) if err != nil || occupied["trunk-mock"] != 1 { t.Fatalf("unproven Agent state was released: %+v %v", occupied, err) } } func TestAgentExplicitRefusalBeforeDialReleasesReservationAndKeepsUnknownBlocked(t *testing.T) { controller, originator, _, s := newExecuteFixture(t) confirmed, err := status.New(codes.FailedPrecondition, "Agent refused before issue").WithDetails(&errdetails.ErrorInfo{Reason: "GO_SIP_CALL_NOT_ISSUED", Domain: "agent.go-sip"}) if err != nil { t.Fatal(err) } originator.err = fmt.Errorf("signed Agent RPC: %w", confirmed.Err()) body := executeBody(t, "agent-refused-1", "15003164745") if err := controller.ProcessExecute(context.Background(), body); err == nil { t.Fatal("Agent refusal hidden") } outbox, err := s.ListPendingOutbox(controller.DispatcherID) if err != nil || len(outbox) != 1 || !strings.Contains(string(outbox[0].Body), `"status":"rejected"`) { t.Fatalf("definite refusal lacks durable rejection receipt: %+v %v", outbox, err) } if err := controller.ProcessExecute(context.Background(), body); err != nil || len(originator.calls) != 1 { t.Fatalf("refused command retried: %d %v", len(originator.calls), err) } originator.err = nil if err := controller.ProcessExecute(context.Background(), executeBody(t, "after-refusal-1", "15003164745")); err != nil || len(originator.calls) != 2 { t.Fatalf("definite pre-dial failure held capacity: %d %v", len(originator.calls), err) } } func TestExecuteTimeoutAndMQFailureDoNotRedial(t *testing.T) { controller, originator, publisher, s := newExecuteFixture(t) originator.err = context.DeadlineExceeded body := executeBody(t, "unknown-1", "15003164745") if err := controller.ProcessExecute(context.Background(), body); err == nil { t.Fatal("originator timeout hidden") } if err := controller.ProcessExecute(context.Background(), body); err != nil || len(originator.calls) != 1 { t.Fatalf("unknown call reoriginated: %d %v", len(originator.calls), err) } if err := controller.ProcessPending(context.Background()); err != nil || len(originator.calls) != 1 { t.Fatalf("unknown call appeared pending: %d %v", len(originator.calls), err) } originator.err = nil second := executeBody(t, "mq-loss-1", "15003164745") if err := controller.ProcessExecute(context.Background(), second); err != nil { t.Fatal(err) } publisher.err = errors.New("injected publisher confirm loss") if err := controller.FlushOutbox(context.Background()); err == nil { t.Fatal("MQ failure was hidden") } pending, err := s.ListPendingOutbox(controller.DispatcherID) if err != nil || len(pending) != 1 { t.Fatalf("outbox lost original ack: %+v %v", pending, err) } if err := controller.ProcessExecute(context.Background(), second); err != nil || len(originator.calls) != 2 { t.Fatalf("MQ redelivery reoriginated: %d %v", len(originator.calls), err) } publisher.err = nil if err := controller.FlushOutbox(context.Background()); err != nil { t.Fatal(err) } pending, err = s.ListPendingOutbox(controller.DispatcherID) if err != nil || len(pending) != 0 { t.Fatalf("confirmed event not retained correctly: %+v %v", pending, err) } }