Files
multica/server/internal/daemon/health_test.go
Bohan Jiang 97df9b90f5 refactor(daemon): rename repoCache interface, relax /health test timeout (#2270)
Two follow-up nits from PR #2211 review:

- Rename the package-local `repoCache` interface to `repoCacheBackend`
  so the field declaration `repoCache repoCacheBackend` no longer shadows
  its own type name.
- Bump the `/health`-must-respond timeout in
  `TestHealthHandlerRespondsWhileTaskRepoLookupWaits` from 200ms to 1s.
  The regression case blocks indefinitely on the old code, so a 1s
  upper bound still fail-fast detects it while leaving headroom for
  loaded CI runners.

Co-authored-by: multica-agent <github@multica.ai>
2026-05-08 17:38:06 +08:00

246 lines
6.4 KiB
Go

package daemon
import (
"context"
"encoding/json"
"log/slog"
"net/http"
"net/http/httptest"
"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)
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)
}
// 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)
}
}
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, []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")
}
}
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) CreateWorktree(repocache.WorktreeParams) (*repocache.WorktreeResult, error) {
return nil, nil
}
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)
}
}