package dispatcher import ( "context" "encoding/json" "errors" "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" ) type currentFakeOriginator struct { loaded map[string]int64 calls []CurrentCallSpec err error } func (f *currentFakeOriginator) LoadedTrunks(context.Context) (map[string]int64, error) { return f.loaded, nil } func (f *currentFakeOriginator) Originate(_ context.Context, spec CurrentCallSpec) error { f.calls = append(f.calls, spec) return f.err } type currentFakePublisher struct { bodies [][]byte err error } func (f *currentFakePublisher) 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 newCurrentExecuteFixture(t *testing.T) (*CurrentExecuteController, *currentFakeOriginator, *currentFakePublisher, *store.CurrentStore) { t.Helper() s, err := store.OpenCurrent(filepath.Join(t.TempDir(), "state.db")) if err != nil { t.Fatal(err) } t.Cleanup(func() { _ = s.Close() }) snapshot := currentPolicySnapshot(t) if err := s.ApplyDiscoverySnapshot(snapshot.Task.DispatcherID, []configread.CurrentDiscoveredTask{{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 := ¤tFakeOriginator{loaded: map[string]int64{"trunk-mock": 8}} publisher := ¤tFakePublisher{} controller := &CurrentExecuteController{DispatcherID: snapshot.Task.DispatcherID, Store: s, Originator: originator, Publisher: publisher, Now: func() time.Time { return currentMonday(9, 30) }} return controller, originator, publisher, s } func currentExecuteBody(t *testing.T, eventID, callee string) []byte { t.Helper() body := string(currentConfigExample(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 TestCurrentExecuteRejectsInvalidCalleeWithoutStoppingTask(t *testing.T) { controller, originator, publisher, s := newCurrentExecuteFixture(t) if err := controller.ProcessExecute(context.Background(), currentExecuteBody(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(), currentExecuteBody(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(), currentExecuteBody(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 TestCurrentExecuteWaitsForRulesThenDispatchesOriginalIdentity(t *testing.T) { controller, originator, _, s := newCurrentExecuteFixture(t) controller.Now = func() time.Time { return currentMonday(8, 59) } body := currentExecuteBody(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 currentMonday(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 TestCurrentExecuteTimeoutAndMQFailureDoNotRedial(t *testing.T) { controller, originator, publisher, s := newCurrentExecuteFixture(t) originator.err = context.DeadlineExceeded body := currentExecuteBody(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 := currentExecuteBody(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) } }