package dispatcher import ( "context" "fmt" "net/http" "net/http/httptest" "path/filepath" "sync/atomic" "testing" "time" "git.ipao.vip/rogee/go-sip/internal/configread" "git.ipao.vip/rogee/go-sip/internal/store" ) func TestLocalV01RuntimeDiscoveryFailureClosesAdmissionAndStopsTaskConsumers(t *testing.T) { var phase atomic.Int32 var requested []string server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") requested = append(requested, r.URL.RawQuery) if r.URL.Path != "/internal/v1/dispatcher/tasks" { t.Errorf("unexpected path: %s", r.URL.Path) w.WriteHeader(http.StatusNotFound) return } if phase.Load() == 1 { w.WriteHeader(http.StatusServiceUnavailable) _, _ = w.Write([]byte(`{"schema_version":"task-discovery.v0.4-proposal","resource":"error","error":{"code":"service_unavailable","message":"mock outage"}}`)) return } switch r.URL.RawQuery { case "mode=snapshot": fmt.Fprintf(w, `{"schema_version":"task-discovery.v0.4-proposal","mode":"snapshot","dispatcher_id":%q,"snapshot_id":"22222222-2222-4222-8222-222222222222","watermark":"42","tasks":[{"task_id":%q,"tenant_id":%q,"tenant_key":%q,"status":"running","task_revision":2}],"next_page_token":"page-2"}`, localTestDispatcherID, localTestTaskID, localTestTenantID, localTestTenantKey) case "page_token=page-2&snapshot_id=22222222-2222-4222-8222-222222222222": fmt.Fprintf(w, `{"schema_version":"task-discovery.v0.4-proposal","mode":"snapshot","dispatcher_id":%q,"snapshot_id":"22222222-2222-4222-8222-222222222222","watermark":"42","tasks":[],"next_page_token":null}`, localTestDispatcherID) case "after=42": fmt.Fprintf(w, `{"schema_version":"task-discovery.v0.4-proposal","mode":"changes","dispatcher_id":%q,"tasks":[],"next_cursor":"42"}`, localTestDispatcherID) default: t.Errorf("unexpected discovery query: %q", r.URL.RawQuery) w.WriteHeader(http.StatusBadRequest) } })) defer server.Close() st, err := store.Open(filepath.Join(t.TempDir(), "dispatcher.db")) if err != nil { t.Fatal(err) } defer st.Close() d, err := NewV3(localTestDispatcherID, st, nil, time.Now) if err != nil { t.Fatal(err) } client, err := configread.NewClient(server.URL, localTestDispatcherID, "test-secret", server.Client()) if err != nil { t.Fatal(err) } broker := &fakeQueueBroker{} queues := newTaskQueueController(d, broker, nil) runtime := &LocalV01Runtime{dispatcher: d, client: client, queues: queues, controls: &localTaskControlProcessor{}} if err := runtime.refreshDiscoveryState(context.Background()); err != nil { t.Fatal(err) } if len(requested) != 2 || requested[0] != "mode=snapshot" || requested[1] != "page_token=page-2&snapshot_id=22222222-2222-4222-8222-222222222222" { t.Fatalf("snapshot was not fully paginated before admission: %v", requested) } if err := runtime.catchUpControlBeforeTasks(context.Background()); err != nil { t.Fatal(err) } assignment, err := st.LocalTaskAssignment(localTestDispatcherID, localTestTaskID) if err != nil { t.Fatal(err) } if err := queues.StartTask(context.Background(), assignment); err != nil { t.Fatal(err) } phase.Store(1) if err := runtime.refreshDiscoveryState(context.Background()); err == nil { t.Fatal("outage accepted as fresh discovery") } var ready int if err := st.DB().QueryRow(`SELECT ready FROM local_v04_task_discovery_state WHERE dispatcher_id=?`, localTestDispatcherID).Scan(&ready); err != nil || ready != 0 { t.Fatalf("discovery gate after outage=%d err=%v", ready, err) } select { case <-broker.consumers[0].done: default: t.Fatal("stale task consumer remained active after discovery outage") } phase.Store(2) if err := runtime.refreshDiscoveryState(context.Background()); err != nil { t.Fatal(err) } if len(requested) < 5 || requested[2] != "after=42" || requested[3] != "mode=snapshot" { t.Fatalf("outage did not restart a full snapshot: %v", requested) } if err := runtime.catchUpControlBeforeTasks(context.Background()); err != nil { t.Fatal(err) } if err := st.DB().QueryRow(`SELECT ready FROM local_v04_task_discovery_state WHERE dispatcher_id=?`, localTestDispatcherID).Scan(&ready); err != nil || ready != 1 { t.Fatalf("discovery gate after new snapshot=%d err=%v", ready, err) } } func TestLocalV01RuntimeReconcilesOnlyEligibleAssignments(t *testing.T) { now := time.Date(2026, 9, 22, 10, 0, 0, 0, time.UTC) d, st, server := newLocalV01TestDispatcher(t, now) defer server.Close() defer st.Close() broker := &fakeQueueBroker{} queues := newTaskQueueController(d, broker, nil) runtime := &LocalV01Runtime{dispatcher: d, queues: queues} if err := runtime.reconcileTaskQueues(context.Background()); err != nil { t.Fatal(err) } if len(broker.started) != 1 || broker.started[0] == "" { t.Fatalf("eligible task queue subscriptions=%v", broker.started) } if err := queues.StopAll(context.Background()); err != nil { t.Fatal(err) } } func TestLocalV01RuntimeReconcileDoesNotConsumePausedAssignment(t *testing.T) { now := time.Date(2026, 9, 22, 10, 0, 0, 0, time.UTC) d, st, server := newLocalV01TestDispatcher(t, now) defer server.Close() defer st.Close() assignment, err := st.LocalTaskAssignment(localTestDispatcherID, localTestTaskID) if err != nil { t.Fatal(err) } assignment, err = st.SetLocalTaskAdmissionBarrier(assignment.DispatcherID, assignment.TaskID, assignment.TenantID, assignment.TenantKey, "paused") if err != nil { t.Fatal(err) } broker := &fakeQueueBroker{} queues := newTaskQueueController(d, broker, nil) runtime := &LocalV01Runtime{dispatcher: d, queues: queues} if err := runtime.reconcileTaskQueues(context.Background()); err != nil { t.Fatal(err) } if len(broker.started) != 0 || len(broker.drained) != 0 { t.Fatalf("paused task consumed or drained its backlog: started=%v drained=%v", broker.started, broker.drained) } } func TestTaskAssignmentCanConsumeRequiresRunningAssignedQueue(t *testing.T) { base := store.LocalTaskAssignment{ Status: "running", AdmissionState: "running", Queue: store.LocalTaskQueue{QueueName: "saas-owned-queue"}, } cases := []struct { name string assignment store.LocalTaskAssignment want bool }{ {name: "eligible", assignment: base, want: true}, {name: "paused status", assignment: withTaskStatus(base, "paused"), want: false}, {name: "closed admission", assignment: withAdmissionState(base, "paused"), want: false}, {name: "removed", assignment: withRemoved(base), want: false}, {name: "queue missing", assignment: withQueueName(base, ""), want: false}, } for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { if got := taskAssignmentCanConsume(tc.assignment); got != tc.want { t.Fatalf("taskAssignmentCanConsume()=%v, want %v", got, tc.want) } }) } } func TestNewLocalV01RuntimeRequiresDependencies(t *testing.T) { if _, err := NewLocalV01Runtime(nil, nil, nil, nil, nil); err == nil { t.Fatal("runtime accepted missing dependencies") } } func TestLocalV01RuntimeRunFailsClosedWhenUnconfigured(t *testing.T) { var runtime LocalV01Runtime if err := runtime.Run(context.Background()); err == nil { t.Fatal("unconfigured runtime started") } } func withTaskStatus(assignment store.LocalTaskAssignment, status string) store.LocalTaskAssignment { assignment.Status = status return assignment } func withAdmissionState(assignment store.LocalTaskAssignment, state string) store.LocalTaskAssignment { assignment.AdmissionState = state return assignment } func withRemoved(assignment store.LocalTaskAssignment) store.LocalTaskAssignment { assignment.Removed = true return assignment } func withQueueName(assignment store.LocalTaskAssignment, name string) store.LocalTaskAssignment { assignment.Queue.QueueName = name return assignment }