Files
multica/server/internal/daemon/client_test.go
Multica Eve bf161f2f9c fix(tasks): preserve merged comment delivery (#5192)
Track actual claim-time delivery, support legacy daemons, and repair comment
batches across claim, retry, edit, and delete races.

MUL-4348

Co-authored-by: Eve <eve@multica-ai.local>
Co-authored-by: multica-agent <github@multica.ai>
2026-07-10 14:10:10 +08:00

271 lines
8.6 KiB
Go

package daemon
import (
"context"
"encoding/json"
"errors"
"net/http"
"net/http/httptest"
"runtime"
"strings"
"sync/atomic"
"testing"
"time"
"github.com/multica-ai/multica/server/pkg/protocol"
)
func TestClient_IdentityHeaders_PostJSON(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if got := r.Header.Get("X-Client-Platform"); got != "daemon" {
t.Errorf("expected X-Client-Platform daemon, got %q", got)
}
if got := r.Header.Get("X-Client-Version"); got != "9.9.9" {
t.Errorf("expected X-Client-Version 9.9.9, got %q", got)
}
if got := r.Header.Get("X-Client-OS"); got != normalizeGOOS(runtime.GOOS) {
t.Errorf("expected X-Client-OS %q, got %q", normalizeGOOS(runtime.GOOS), got)
}
if got := r.Header.Get("Authorization"); got != "Bearer tok" {
t.Errorf("expected Authorization Bearer tok, got %q", got)
}
capabilities := make(map[string]bool)
for _, capability := range strings.Split(r.Header.Get("X-Client-Capabilities"), ",") {
capabilities[strings.TrimSpace(capability)] = true
}
for _, want := range []string{
protocol.DaemonCapabilitySkillBundlesV1,
protocol.DaemonCapabilityCoalescedCommentsV1,
} {
if !capabilities[want] {
t.Errorf("X-Client-Capabilities missing %q: %v", want, capabilities)
}
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]string{"ok": "1"})
}))
defer srv.Close()
c := NewClient(srv.URL)
c.SetToken("tok")
c.SetVersion("9.9.9")
if err := c.postJSON(context.Background(), "/api/daemon/test", map[string]any{}, nil); err != nil {
t.Fatalf("postJSON: %v", err)
}
}
func TestClient_IdentityHeaders_GetJSON(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if got := r.Header.Get("X-Client-Platform"); got != "daemon" {
t.Errorf("expected X-Client-Platform daemon, got %q", got)
}
if got := r.Header.Get("X-Client-Version"); got != "1.2.3" {
t.Errorf("expected X-Client-Version 1.2.3, got %q", got)
}
if got := r.Header.Get("X-Client-OS"); got == "" {
t.Errorf("expected X-Client-OS to be set")
}
w.Header().Set("Content-Type", "application/json")
w.Write([]byte(`{}`))
}))
defer srv.Close()
c := NewClient(srv.URL)
c.SetToken("tok")
c.SetVersion("1.2.3")
var out map[string]any
if err := c.getJSON(context.Background(), "/api/daemon/test", &out); err != nil {
t.Fatalf("getJSON: %v", err)
}
}
func TestClient_VersionOmittedWhenUnset(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if got := r.Header.Get("X-Client-Platform"); got != "daemon" {
t.Errorf("expected X-Client-Platform daemon, got %q", got)
}
// SetVersion not called → header must be omitted (not "").
if vals := r.Header.Values("X-Client-Version"); len(vals) != 0 {
t.Errorf("expected X-Client-Version absent, got %v", vals)
}
w.WriteHeader(http.StatusNoContent)
}))
defer srv.Close()
c := NewClient(srv.URL)
if err := c.postJSON(context.Background(), "/api/daemon/test", nil, nil); err != nil {
t.Fatalf("postJSON: %v", err)
}
}
// noSleepRetry replaces retrySleep with an immediate no-op so tests don't
// actually wait the 4s/8s/16s/... backoffs. Returns a restore func.
func noSleepRetry(t *testing.T) func() {
t.Helper()
prev := retrySleep
retrySleep = func(ctx context.Context, _ time.Duration) error {
if err := ctx.Err(); err != nil {
return err
}
return nil
}
return func() { retrySleep = prev }
}
func TestIsTransientError(t *testing.T) {
cases := []struct {
name string
err error
want bool
}{
{"nil is not transient", nil, false},
{"5xx is transient", &requestError{StatusCode: http.StatusBadGateway}, true},
{"503 is transient", &requestError{StatusCode: http.StatusServiceUnavailable}, true},
{"408 is transient", &requestError{StatusCode: http.StatusRequestTimeout}, true},
{"429 is transient", &requestError{StatusCode: http.StatusTooManyRequests}, true},
{"400 is permanent", &requestError{StatusCode: http.StatusBadRequest}, false},
{"401 is permanent", &requestError{StatusCode: http.StatusUnauthorized}, false},
{"404 is permanent", &requestError{StatusCode: http.StatusNotFound}, false},
{"transport-level error is transient", errors.New("connection reset by peer"), true},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
if got := isTransientError(tc.err); got != tc.want {
t.Fatalf("isTransientError(%v) = %v, want %v", tc.err, got, tc.want)
}
})
}
}
func TestPostJSONWithRetry_TransientThenSuccess(t *testing.T) {
defer noSleepRetry(t)()
var calls atomic.Int32
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
n := calls.Add(1)
if n < 3 {
w.WriteHeader(http.StatusBadGateway)
return
}
w.WriteHeader(http.StatusOK)
}))
defer srv.Close()
c := NewClient(srv.URL)
schedule := []time.Duration{time.Nanosecond, time.Nanosecond, time.Nanosecond}
if err := c.postJSONWithRetry(context.Background(), "/x", map[string]any{}, nil, schedule); err != nil {
t.Fatalf("postJSONWithRetry: %v", err)
}
if got := calls.Load(); got != 3 {
t.Fatalf("expected 3 attempts (2 transient + 1 success), got %d", got)
}
}
func TestPostJSONWithRetry_TransientExhausts(t *testing.T) {
defer noSleepRetry(t)()
var calls atomic.Int32
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
calls.Add(1)
w.WriteHeader(http.StatusBadGateway)
}))
defer srv.Close()
c := NewClient(srv.URL)
schedule := []time.Duration{time.Nanosecond, time.Nanosecond}
err := c.postJSONWithRetry(context.Background(), "/x", map[string]any{}, nil, schedule)
if err == nil {
t.Fatal("expected error after schedule exhausted, got nil")
}
if !isTransientError(err) {
t.Fatalf("expected transient error, got %v", err)
}
if got := calls.Load(); got != int32(len(schedule)+1) {
t.Fatalf("expected %d attempts (initial + %d retries), got %d", len(schedule)+1, len(schedule), got)
}
}
func TestPostJSONWithRetry_PermanentBailsImmediately(t *testing.T) {
defer noSleepRetry(t)()
var calls atomic.Int32
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
calls.Add(1)
w.WriteHeader(http.StatusBadRequest)
}))
defer srv.Close()
c := NewClient(srv.URL)
schedule := []time.Duration{time.Nanosecond, time.Nanosecond, time.Nanosecond}
err := c.postJSONWithRetry(context.Background(), "/x", map[string]any{}, nil, schedule)
if err == nil {
t.Fatal("expected error, got nil")
}
if got := calls.Load(); got != 1 {
t.Fatalf("expected exactly 1 attempt on permanent error, got %d", got)
}
}
func TestPostJSONWithRetry_CtxCancelStopsRetries(t *testing.T) {
// Use the real sleeper here so we can observe a cancel preempting it.
var calls atomic.Int32
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
calls.Add(1)
w.WriteHeader(http.StatusBadGateway)
}))
defer srv.Close()
ctx, cancel := context.WithCancel(context.Background())
go func() {
// Cancel quickly so the first sleep is aborted long before its 1s.
time.Sleep(50 * time.Millisecond)
cancel()
}()
c := NewClient(srv.URL)
schedule := []time.Duration{time.Second, time.Second, time.Second}
start := time.Now()
err := c.postJSONWithRetry(ctx, "/x", map[string]any{}, nil, schedule)
elapsed := time.Since(start)
if err == nil {
t.Fatal("expected error after ctx cancel, got nil")
}
if elapsed > 750*time.Millisecond {
t.Fatalf("expected ctx cancel to short-circuit retry, took %s", elapsed)
}
if got := calls.Load(); got != 1 {
t.Fatalf("expected exactly 1 attempt before cancel, got %d", got)
}
}
func TestDefaultTerminalRetrySchedule_MatchesAgreedPlan(t *testing.T) {
// MUL-2780 settled on a 5-step exponential backoff (4s, 8s, 16s, 32s, 64s).
// Pin it so a future "tidy this up" refactor can't silently flatten or
// shorten the recovery window without explicit discussion.
want := []time.Duration{4 * time.Second, 8 * time.Second, 16 * time.Second, 32 * time.Second, 64 * time.Second}
if len(defaultTerminalRetrySchedule) != len(want) {
t.Fatalf("schedule length: got %d, want %d", len(defaultTerminalRetrySchedule), len(want))
}
for i, d := range want {
if defaultTerminalRetrySchedule[i] != d {
t.Errorf("schedule[%d]: got %s, want %s", i, defaultTerminalRetrySchedule[i], d)
}
}
}
func TestNormalizeGOOS(t *testing.T) {
cases := map[string]string{
"darwin": "macos",
"windows": "windows",
"linux": "linux",
"freebsd": "freebsd",
}
for in, want := range cases {
if got := normalizeGOOS(in); got != want {
t.Errorf("normalizeGOOS(%q) = %q, want %q", in, got, want)
}
}
}