package dispatcher import ( "bytes" "context" "encoding/json" "errors" "log/slog" "net/http" "net/http/httptest" "os" "path/filepath" "reflect" "strings" "testing" "git.ipao.vip/rogee/go-sip/internal/configread" "git.ipao.vip/rogee/go-sip/internal/store" ) func configExample(t *testing.T, name string) []byte { t.Helper() raw, err := os.ReadFile(filepath.Join("..", "..", "contracts", "schema", "examples", name+".json")) if err != nil { t.Fatal(err) } return raw } func TestBootstrapRequiresSIPLoadingAndControlDrain(t *testing.T) { id := "c046b893-8628-4589-ae50-619d049248a6" called := make([]string, 0) currentTaskStatus := "running" server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { called = append(called, r.URL.Path) var response []byte switch r.URL.Path { case "/internal/v1/dispatcher/sip": response = configExample(t, "config-read-sip") case "/internal/v1/dispatcher/tasks": if r.URL.Query().Get("after") == "" { response = configExample(t, "task-discovery-page") } else { response = configExample(t, "task-discovery-end") } case "/internal/v1/dispatcher/ai-providers": response = configExample(t, "config-read-providers") case "/internal/v1/dispatcher/task/task-asr": var task map[string]any if err := json.Unmarshal(configExample(t, "config-read-task-asr"), &task); err != nil { t.Fatal(err) } task["status"] = currentTaskStatus var err error response, err = json.Marshal(task) if err != nil { t.Error(err) w.WriteHeader(http.StatusInternalServerError) return } case "/internal/v1/dispatcher/tenant/1001/quota": response = configExample(t, "config-read-quota") default: t.Errorf("unapproved config path %s", r.URL.Path) w.WriteHeader(http.StatusNotFound) return } w.Header().Set("Content-Type", "application/json") _, _ = w.Write(response) })) defer server.Close() client, err := configread.NewClient(server.URL, id, "test-secret", server.Client()) if err != nil { t.Fatal(err) } db, err := store.Open(filepath.Join(t.TempDir(), "current.db")) if err != nil { t.Fatal(err) } defer db.Close() applied, verifierCalled, drained := false, false, false var stages []string var trace bytes.Buffer var cursor string var approvedSIP configread.SIP bootstrap := Bootstrap{ Client: client, Store: db, DispatcherID: id, Cursor: &cursor, SIP: &approvedSIP, Logger: slog.New(slog.NewJSONHandler(&trace, nil)), ApplySIP: func(_ context.Context, sip configread.SIP) error { stages = append(stages, "apply") applied = sip.Revision == 8 return nil }, VerifySIP: func(_ context.Context, sip configread.SIP) error { stages = append(stages, "verify") verifierCalled = true if !applied { t.Fatal("verified Agent load before applying SIP") } if sip.Revision != 8 { t.Fatalf("verified wrong SIP revision %d", sip.Revision) } return nil }, DrainControls: func(context.Context) error { drained = true if admitted, err := db.CanAdmit(id, 1001, "task-asr"); err != nil || admitted { t.Fatalf("opened before draining control queue: admitted=%v, err=%v", admitted, err) } return nil }, } if err := bootstrap.Run(context.Background()); err != nil { t.Fatal(err) } admitted, err := db.CanAdmit(id, 1001, "task-asr") if err != nil || !admitted || !verifierCalled || !drained || cursor != "opaque-end-token" || approvedSIP.Revision != 8 || len(called) < 6 || called[0] != "/internal/v1/dispatcher/sip" { t.Fatalf("bootstrap: admitted=%v, err=%v, verified=%v, drained=%v, paths=%v", admitted, err, verifierCalled, drained, called) } if !reflect.DeepEqual(stages, []string{"apply", "verify"}) { t.Fatalf("startup SIP lifecycle = %v", stages) } for _, message := range []string{"startup SIP snapshot prepared", "applying startup SIP snapshot", "startup SIP snapshot verified"} { if !strings.Contains(trace.String(), message) { t.Fatalf("missing SIP trace %q", message) } } currentTaskStatus = "paused" if err := bootstrap.Run(context.Background()); err != nil { t.Fatalf("same-revision status change/detail newer than startup list rejected: %v", err) } latest, err := db.ReadSnapshot(id, 1001, "task-asr") if err != nil || latest.Task.Status != "paused" { t.Fatalf("latest detail not adopted: %s %v", latest.Task.Status, err) } if admitted, err := db.CanAdmit(id, 1001, "task-asr"); err != nil || admitted { t.Fatalf("paused detail opened admission: %v %v", admitted, err) } currentTaskStatus = "running" bootstrap.DrainControls = func(context.Context) error { return db.NoteSIPChange(id, 9) } if err := bootstrap.Run(context.Background()); err != nil { t.Fatalf("notification delivered during startup must remain pending for runtime: %v", err) } if cursor != "opaque-end-token" || approvedSIP.Revision != 8 { t.Fatalf("pending SIP startup lost verified snapshot/cursor: revision=%d cursor=%q", approvedSIP.Revision, cursor) } if admitted, err := db.CanAdmit(id, 1001, "task-asr"); err != nil || admitted { t.Fatalf("old SIP opened during pending notification: %v %v", admitted, err) } } func TestBootstrapFailsClosedOnVerifierOrDrainError(t *testing.T) { id := "c046b893-8628-4589-ae50-619d049248a6" for _, failure := range []string{"apply", "verify", "drain"} { t.Run(failure, func(t *testing.T) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { var response []byte switch r.URL.Path { case "/internal/v1/dispatcher/sip": response = configExample(t, "config-read-sip") case "/internal/v1/dispatcher/tasks": if r.URL.Query().Get("after") == "" { response = configExample(t, "task-discovery-page") } else { response = configExample(t, "task-discovery-end") } case "/internal/v1/dispatcher/ai-providers": response = configExample(t, "config-read-providers") case "/internal/v1/dispatcher/task/task-asr": response = configExample(t, "config-read-task-asr") case "/internal/v1/dispatcher/tenant/1001/quota": response = configExample(t, "config-read-quota") default: t.Fatalf("unexpected path %s", r.URL.Path) } w.Header().Set("Content-Type", "application/json") _, _ = w.Write(response) })) defer server.Close() client, err := configread.NewClient(server.URL, id, "test-secret", server.Client()) if err != nil { t.Fatal(err) } db, err := store.Open(filepath.Join(t.TempDir(), "current.db")) if err != nil { t.Fatal(err) } defer db.Close() b := Bootstrap{ Client: client, Store: db, DispatcherID: id, ApplySIP: func(context.Context, configread.SIP) error { if failure == "apply" { return errors.New("native SIP application rejected") } return nil }, VerifySIP: func(context.Context, configread.SIP) error { if failure == "verify" { return errors.New("applied SIP revision unavailable") } return nil }, DrainControls: func(context.Context) error { if failure == "drain" { return errors.New("control queue not drained") } return nil }, } if err := b.Run(context.Background()); err == nil { t.Fatal("bootstrapped without required SIP/control gate") } if admitted, err := db.CanAdmit(id, 1001, "task-asr"); err != nil || admitted { t.Fatalf("opened admission after failed bootstrap: admitted=%v err=%v", admitted, err) } }) } }