package daemon import ( "context" "encoding/json" "log/slog" "net/http" "net/http/httptest" "runtime" "strings" "sync" "testing" "time" "github.com/multica-ai/multica/server/internal/daemon/repocache" ) func TestHealthHandlerReportsCLIVersionAndActiveTaskCount(t *testing.T) { t.Parallel() d := &Daemon{ cfg: Config{ CLIVersion: "v9.9.9", DaemonID: "daemon-test", DeviceName: "dev", ServerBaseURL: "http://localhost:8080", }, workspaces: map[string]*workspaceState{}, logger: slog.Default(), } d.activeTasks.Store(3) d.ready.Store(true) // preflight done -> status should be "running" req := httptest.NewRequest(http.MethodGet, "/health", nil) rec := httptest.NewRecorder() d.healthHandler(time.Now()).ServeHTTP(rec, req) if rec.Code != http.StatusOK { t.Fatalf("expected 200, got %d", rec.Code) } // Decode into a raw map so the test locks in the exact wire-level JSON // keys — the desktop TS client depends on snake_case (cli_version, // active_task_count), so a silent struct-tag rename must fail here. var raw map[string]any if err := json.Unmarshal(rec.Body.Bytes(), &raw); err != nil { t.Fatalf("decode raw response: %v", err) } if got, want := raw["cli_version"], "v9.9.9"; got != want { t.Errorf("cli_version key: got %v, want %q", got, want) } // JSON numbers decode to float64 through map[string]any. if got, want := raw["active_task_count"], float64(3); got != want { t.Errorf("active_task_count key: got %v, want %v", got, want) } if got, want := raw["status"], "running"; got != want { t.Errorf("status key: got %v, want %q", got, want) } // The desktop relies on the `os` key (runtime.GOOS) to detect a daemon it // can't manage (e.g. Linux-in-WSL behind a Windows desktop). A rename or // drop would silently re-break #3916, so lock both the key and its value. if got, want := raw["os"], runtime.GOOS; got != want { t.Errorf("os key: got %v, want %q", got, want) } // Also round-trip into the typed struct as a separate check that the // field values match, independent of key naming. var resp HealthResponse if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil { t.Fatalf("decode typed response: %v", err) } if resp.CLIVersion != "v9.9.9" { t.Errorf("CLIVersion: got %q, want %q", resp.CLIVersion, "v9.9.9") } if resp.ActiveTaskCount != 3 { t.Errorf("ActiveTaskCount: got %d, want 3", resp.ActiveTaskCount) } } // TestHealthHandlerReportsStartingUntilReady pins the liveness/readiness split: // the health server binds and answers before preflight finishes, but it must // report "starting" until d.ready is set, and only then "running". Otherwise a // slow or failing preflight would be misreported to `daemon start` (and the // desktop) as a fully started daemon. func TestHealthHandlerReportsStartingUntilReady(t *testing.T) { t.Parallel() d := &Daemon{ cfg: Config{CLIVersion: "v1.0.0"}, workspaces: map[string]*workspaceState{}, logger: slog.Default(), } handler := d.healthHandler(time.Now()) readStatus := func() string { rec := httptest.NewRecorder() handler.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/health", nil)) var resp HealthResponse if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil { t.Fatalf("decode response: %v", err) } return resp.Status } if got := readStatus(); got != "starting" { t.Fatalf("status before ready: got %q, want \"starting\"", got) } d.ready.Store(true) if got := readStatus(); got != "running" { t.Fatalf("status after ready: got %q, want \"running\"", got) } } func TestHealthHandlerActiveTaskCountTracksCounter(t *testing.T) { t.Parallel() d := &Daemon{ cfg: Config{CLIVersion: "v1.0.0"}, workspaces: map[string]*workspaceState{}, logger: slog.Default(), } handler := d.healthHandler(time.Now()) // Simulate the pollLoop increment/decrement protocol. d.activeTasks.Add(1) d.activeTasks.Add(1) assertActiveTaskCount(t, handler, 2) d.activeTasks.Add(-1) assertActiveTaskCount(t, handler, 1) d.activeTasks.Add(-1) assertActiveTaskCount(t, handler, 0) } func TestShutdownHandlerPostCancelsDaemonContext(t *testing.T) { t.Parallel() ctx, cancel := context.WithCancel(context.Background()) defer cancel() d := &Daemon{cancelFunc: cancel} rec := httptest.NewRecorder() req := httptest.NewRequest(http.MethodPost, "/shutdown", nil) d.shutdownHandler().ServeHTTP(rec, req) if rec.Code != http.StatusOK { t.Fatalf("expected 200, got %d", rec.Code) } select { case <-ctx.Done(): case <-time.After(time.Second): t.Fatal("daemon context was not cancelled after POST /shutdown") } } func TestShutdownHandlerRejectsNonPost(t *testing.T) { t.Parallel() cancelled := false d := &Daemon{cancelFunc: func() { cancelled = true }} rec := httptest.NewRecorder() req := httptest.NewRequest(http.MethodGet, "/shutdown", nil) d.shutdownHandler().ServeHTTP(rec, req) if rec.Code != http.StatusMethodNotAllowed { t.Fatalf("expected 405, got %d", rec.Code) } // Give the handler's deferred cancel goroutine a moment to fire // in case a bug causes it to run anyway. time.Sleep(10 * time.Millisecond) if cancelled { t.Fatal("GET request should not trigger cancellation") } } func TestHealthHandlerRespondsWhileTaskRepoLookupWaits(t *testing.T) { const workspaceID = "ws-health" const repoURL = "https://github.com/org/repo.git" cache := newBlockingLookupRepoCache("/cache/org/repo.git") d := &Daemon{ cfg: Config{CLIVersion: "v1.0.0"}, workspaces: map[string]*workspaceState{ workspaceID: { workspaceID: workspaceID, runtimeIDs: []string{"rt-1"}, allowedRepoURLs: map[string]struct{}{repoURL: {}}, taskRepoURLs: map[string]struct{}{}, }, }, repoCache: cache, logger: slog.Default(), } defer cache.release() registerDone := make(chan struct{}) go func() { d.registerTaskRepos(workspaceID, "task-health", []RepoData{{URL: repoURL}}) close(registerDone) }() cache.waitForLookup(t) rec := httptest.NewRecorder() healthDone := make(chan struct{}) go func() { d.healthHandler(time.Now()).ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/health", nil)) close(healthDone) }() select { case <-healthDone: if rec.Code != http.StatusOK { t.Fatalf("expected 200, got %d", rec.Code) } case <-time.After(time.Second): t.Fatal("/health blocked behind task repo cache lookup") } cache.release() select { case <-registerDone: case <-time.After(time.Second): t.Fatal("registerTaskRepos did not unblock after repo lookup finished") } } func TestRepoCheckoutUsesTaskScopedProjectRefByDefault(t *testing.T) { t.Parallel() const workspaceID = "ws-checkout" const repoURL = "https://github.com/org/repo.git" cache := &recordingRepoCache{lookupPath: "/cache/org/repo.git"} d := newRepoCheckoutTestDaemon(t, workspaceID, repoURL, cache) d.registerTaskRepos(workspaceID, "task-1", []RepoData{{URL: repoURL, Ref: "release/v2"}}) rec := httptest.NewRecorder() body := strings.NewReader(`{"url":"` + repoURL + `","workspace_id":"` + workspaceID + `","workdir":"/tmp/work","task_id":"task-1"}`) d.repoCheckoutHandler().ServeHTTP(rec, httptest.NewRequest(http.MethodPost, "/repo/checkout", body)) if rec.Code != http.StatusOK { t.Fatalf("expected 200, got %d: %s", rec.Code, rec.Body.String()) } if got := cache.lastCreateParams().Ref; got != "release/v2" { t.Fatalf("CreateWorktree Ref = %q, want release/v2", got) } } func TestRepoCheckoutExplicitRefOverridesProjectDefault(t *testing.T) { t.Parallel() const workspaceID = "ws-checkout" const repoURL = "https://github.com/org/repo.git" cache := &recordingRepoCache{lookupPath: "/cache/org/repo.git"} d := newRepoCheckoutTestDaemon(t, workspaceID, repoURL, cache) d.registerTaskRepos(workspaceID, "task-1", []RepoData{{URL: repoURL, Ref: "release/v2"}}) rec := httptest.NewRecorder() body := strings.NewReader(`{"url":"` + repoURL + `","workspace_id":"` + workspaceID + `","workdir":"/tmp/work","task_id":"task-1","ref":"hotfix"}`) d.repoCheckoutHandler().ServeHTTP(rec, httptest.NewRequest(http.MethodPost, "/repo/checkout", body)) if rec.Code != http.StatusOK { t.Fatalf("expected 200, got %d: %s", rec.Code, rec.Body.String()) } if got := cache.lastCreateParams().Ref; got != "hotfix" { t.Fatalf("CreateWorktree Ref = %q, want explicit hotfix", got) } } func TestRepoCheckoutForwardsIsolatedMode(t *testing.T) { t.Parallel() const workspaceID = "ws-checkout" const repoURL = "https://github.com/org/repo.git" cache := &recordingRepoCache{lookupPath: "/cache/org/repo.git"} d := newRepoCheckoutTestDaemon(t, workspaceID, repoURL, cache) rec := httptest.NewRecorder() body := strings.NewReader(`{"url":"` + repoURL + `","workspace_id":"` + workspaceID + `","workdir":"/tmp/work","task_id":"task-1","checkout_mode":"isolated"}`) d.repoCheckoutHandler().ServeHTTP(rec, httptest.NewRequest(http.MethodPost, "/repo/checkout", body)) if rec.Code != http.StatusOK { t.Fatalf("expected 200, got %d: %s", rec.Code, rec.Body.String()) } if !cache.lastCreateParams().IsolatedGitMetadata { t.Fatal("isolated checkout_mode was not forwarded to repo cache") } } func TestRepoCheckoutRejectsUnknownMode(t *testing.T) { t.Parallel() const workspaceID = "ws-checkout" const repoURL = "https://github.com/org/repo.git" cache := &recordingRepoCache{lookupPath: "/cache/org/repo.git"} d := newRepoCheckoutTestDaemon(t, workspaceID, repoURL, cache) rec := httptest.NewRecorder() body := strings.NewReader(`{"url":"` + repoURL + `","workspace_id":"` + workspaceID + `","workdir":"/tmp/work","task_id":"task-1","checkout_mode":"unsafe"}`) d.repoCheckoutHandler().ServeHTTP(rec, httptest.NewRequest(http.MethodPost, "/repo/checkout", body)) if rec.Code != http.StatusBadRequest { t.Fatalf("expected 400, got %d: %s", rec.Code, rec.Body.String()) } if got := cache.lastCreateParams(); got != (repocache.WorktreeParams{}) { t.Fatalf("invalid checkout mode reached repo cache: %+v", got) } } func newRepoCheckoutTestDaemon(t *testing.T, workspaceID, repoURL string, cache *recordingRepoCache) *Daemon { t.Helper() srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodGet || r.URL.Path != "/api/daemon/workspaces/"+workspaceID+"/repos" { http.NotFound(w, r) return } json.NewEncoder(w).Encode(WorkspaceReposResponse{ WorkspaceID: workspaceID, Repos: []RepoData{{URL: repoURL}}, ReposVersion: "v1", }) })) t.Cleanup(srv.Close) return &Daemon{ cfg: Config{CLIVersion: "v1.0.0"}, client: NewClient(srv.URL), repoCache: cache, workspaces: map[string]*workspaceState{ workspaceID: newWorkspaceState(workspaceID, nil, "", []RepoData{{URL: repoURL}}, nil), }, logger: slog.Default(), } } type blockingLookupRepoCache struct { path string lookupSeen chan struct{} releaseLookup chan struct{} releaseOnce sync.Once } func newBlockingLookupRepoCache(path string) *blockingLookupRepoCache { return &blockingLookupRepoCache{ path: path, lookupSeen: make(chan struct{}), releaseLookup: make(chan struct{}), } } func (c *blockingLookupRepoCache) Lookup(_, _ string) string { select { case <-c.lookupSeen: default: close(c.lookupSeen) } <-c.releaseLookup return c.path } func (c *blockingLookupRepoCache) Sync(string, []repocache.RepoInfo) error { return nil } func (c *blockingLookupRepoCache) WithRepoLock(_ string, fn func() error) error { return fn() } func (c *blockingLookupRepoCache) CreateWorktree(repocache.WorktreeParams) (*repocache.WorktreeResult, error) { return nil, nil } type recordingRepoCache struct { lookupPath string mu sync.Mutex params []repocache.WorktreeParams } func (c *recordingRepoCache) Lookup(_, _ string) string { return c.lookupPath } func (c *recordingRepoCache) Sync(string, []repocache.RepoInfo) error { return nil } func (c *recordingRepoCache) WithRepoLock(_ string, fn func() error) error { return fn() } func (c *recordingRepoCache) CreateWorktree(params repocache.WorktreeParams) (*repocache.WorktreeResult, error) { c.mu.Lock() defer c.mu.Unlock() c.params = append(c.params, params) return &repocache.WorktreeResult{Path: params.WorkDir, BranchName: "agent/test"}, nil } func (c *recordingRepoCache) lastCreateParams() repocache.WorktreeParams { c.mu.Lock() defer c.mu.Unlock() if len(c.params) == 0 { return repocache.WorktreeParams{} } return c.params[len(c.params)-1] } func (c *blockingLookupRepoCache) waitForLookup(t *testing.T) { t.Helper() select { case <-c.lookupSeen: case <-time.After(time.Second): t.Fatal("registerTaskRepos did not call repo lookup") } } func (c *blockingLookupRepoCache) release() { c.releaseOnce.Do(func() { close(c.releaseLookup) }) } func assertActiveTaskCount(t *testing.T, h http.HandlerFunc, want int64) { t.Helper() rec := httptest.NewRecorder() h.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/health", nil)) var resp HealthResponse if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil { t.Fatalf("decode response: %v", err) } if resp.ActiveTaskCount != want { t.Errorf("active_task_count: got %d, want %d", resp.ActiveTaskCount, want) } }