Files
zzet--gortex/cmd/gortex/daemon_v1_parity_test.go
wehub-resource-sync a06f331eb8
CI / benchmark (push) Has been skipped
install-script / posix-syntax (push) Successful in 6m1s
CI / build-onnx (push) Failing after 6m43s
init-smoke / dry-run (push) Failing after 15m57s
security / govulncheck (push) Has been cancelled
security / trivy-fs (push) Has been cancelled
CI / test (1.26, ubuntu-latest) (push) Has been cancelled
Scorecard supply-chain security / Scorecard analysis (push) Has been cancelled
CI / test (1.26, macos-latest) (push) Has been cancelled
CI / build-windows (push) Has been cancelled
CI / lint (push) Has been cancelled
install-script / powershell-syntax (push) Has been cancelled
install-script / install (macos-14) (push) Has been cancelled
install-script / install (ubuntu-latest) (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 12:33:42 +08:00

195 lines
7.8 KiB
Go

package main
import (
"context"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
mcpserver "github.com/mark3labs/mcp-go/server"
"go.uber.org/zap"
"github.com/zzet/gortex/internal/graph"
"github.com/zzet/gortex/internal/server"
"github.com/zzet/gortex/internal/server/hub"
)
// v1ParityRoutes is the canonical set of routes the standalone server's
// registerRoutes mounts on /v1. Route parity means the daemon's composed
// /v1 handler answers every one of these (not a 404 from the mux). Each
// entry is a (method, path) the handler must recognise; the handler may
// return any non-404 status (200/400/503/etc) — we only assert the route
// is registered, never its business logic.
var v1ParityRoutes = []struct {
method string
path string
}{
{http.MethodGet, "/v1/health"},
{http.MethodGet, "/v1/tools"},
{http.MethodPost, "/v1/tools/search_symbols"},
{http.MethodGet, "/v1/stats"},
{http.MethodGet, "/v1/graph"},
{http.MethodGet, "/v1/subgraph"},
{http.MethodGet, "/v1/events"},
{http.MethodGet, "/v1/activity"},
{http.MethodGet, "/v1/caveats"},
{http.MethodGet, "/v1/dashboard"},
{http.MethodGet, "/v1/repos"},
{http.MethodGet, "/v1/processes"},
{http.MethodGet, "/v1/contracts"},
{http.MethodGet, "/v1/contracts/validate"},
{http.MethodGet, "/v1/communities"},
{http.MethodGet, "/v1/guards"},
{http.MethodGet, "/v1/workspaces/ws1/repos"},
{http.MethodPost, "/v1/overlay/sessions"},
{http.MethodDelete, "/v1/overlay/sessions/s1"},
{http.MethodPut, "/v1/overlay/sessions/s1/files"},
{http.MethodDelete, "/v1/overlay/sessions/s1/files"},
{http.MethodGet, "/v1/overlay/sessions/s1/files"},
}
// newReferenceV1Handler builds a bare server.Handler the same way the
// standalone server (the former `gortex server`) does — NewHandler plus
// registerRoutes (run inside NewHandler). This is the parity reference.
func newReferenceV1Handler(t *testing.T) *server.Handler {
t.Helper()
g := graph.New()
srv := mcpserver.NewMCPServer("gortex-test", "0.0.1-test", mcpserver.WithToolCapabilities(false))
return server.NewHandler(srv, g, "0.0.1-test", zap.NewNop())
}
// newDaemonComposedV1Handler builds the /v1 handler exactly the way the
// daemon composes it (NewHandler + SetEventHub), returning the handler and
// the hub feeding /v1/events. This mirrors the daemon's v1 composition so
// the parity test exercises the real wiring path, not a hand-rolled mux.
func newDaemonComposedV1Handler(t *testing.T) (*server.Handler, *hub.Hub) {
t.Helper()
g := graph.New()
srv := mcpserver.NewMCPServer("gortex-test", "0.0.1-test", mcpserver.WithToolCapabilities(false))
v1 := server.NewHandler(srv, g, "0.0.1-test", zap.NewNop())
// The daemon also calls SetConfigManager / SetServerID / SetOverlayManager
// / SetRouter, but those are optional capability wires that do not change
// the route set; the route-defining call is NewHandler. The daemon wires
// an event hub so /v1/events streams — replicate that here.
eventHub := hub.New()
v1.SetEventHub(eventHub)
t.Cleanup(eventHub.Stop)
return v1, eventHub
}
// routeMatched reports whether the handler's mux has a route registered for
// (method, path). It asks the ServeMux directly via Handler(req), which
// returns the matched pattern — empty when no route is registered. This is
// precise: it distinguishes "the route is not mounted" (empty pattern) from
// "the route is mounted but its handler legitimately returns 404 for this
// input" (e.g. an unknown tool name on POST /v1/tools/, or an unknown
// workspace on /v1/workspaces/{ws}/repos), which a status-code probe cannot
// tell apart.
func routeMatched(t *testing.T, h *server.Handler, method, path string) (matched bool, pattern string) {
t.Helper()
req := httptest.NewRequest(method, path, nil)
_, pattern = h.Mux().Handler(req)
return pattern != "", pattern
}
// TestDaemonV1Parity asserts the daemon's composed /v1 surface mounts the
// exact same route set the standalone server handler registers, so the
// former `gortex server` REST surface is at parity when served from the
// daemon. It builds the v1 handler the way daemon.go does and exercises
// every route, comparing mount status against the reference handler.
func TestDaemonV1Parity(t *testing.T) {
ref := newReferenceV1Handler(t)
composed, _ := newDaemonComposedV1Handler(t)
for _, rt := range v1ParityRoutes {
t.Run(rt.method+" "+rt.path, func(t *testing.T) {
refMounted, refPat := routeMatched(t, ref, rt.method, rt.path)
if !refMounted {
t.Fatalf("reference server handler has no route for %s %s — the canonical route set drifted",
rt.method, rt.path)
}
gotMounted, gotPat := routeMatched(t, composed, rt.method, rt.path)
if !gotMounted {
t.Fatalf("daemon /v1 surface is missing route %s %s — not at parity with the standalone server",
rt.method, rt.path)
}
// Both must resolve to the SAME registered pattern — proof the
// daemon mounts the identical route, not a coincidentally
// overlapping one.
if refPat != gotPat {
t.Fatalf("route %s %s resolves to %q on the reference but %q on the daemon surface",
rt.method, rt.path, refPat, gotPat)
}
})
}
// A path the mux must NOT know is the negative control: it proves the
// matched-pattern signal above is meaningful (the mux really has no
// route for an unknown path).
t.Run("unknown route is unmatched on both", func(t *testing.T) {
if m, pat := routeMatched(t, ref, http.MethodGet, "/v1/definitely-not-a-route"); m {
t.Fatalf("reference handler must not match an unknown route, got pattern %q", pat)
}
if m, pat := routeMatched(t, composed, http.MethodGet, "/v1/definitely-not-a-route"); m {
t.Fatalf("daemon handler must not match an unknown route, got pattern %q", pat)
}
})
}
// TestDaemonV1Parity_EventsHubWired asserts the daemon's composed /v1
// handler wires the event hub so /v1/events actually streams graph-change
// events rather than emitting the "watch mode not active" frame and
// closing. A handler with no hub emits that frame; the daemon-composed one
// must not — it subscribes and blocks (we cut it off with a short context).
func TestDaemonV1Parity_EventsHubWired(t *testing.T) {
const inertFrame = "watch mode not active"
// Reference handler WITHOUT a hub: /v1/events emits the inert frame.
t.Run("no hub emits the inert frame", func(t *testing.T) {
ref := newReferenceV1Handler(t)
body := eventsBody(t, ref)
if !strings.Contains(body, inertFrame) {
t.Fatalf("a handler with no event hub should emit %q, got %q", inertFrame, body)
}
})
// Daemon-composed handler WITH a hub wired: /v1/events does not emit
// the inert frame. It subscribes and streams; the short request context
// closes it after a keepalive-free interval, so the body is empty (the
// SSE keepalive ticker fires only after 15s).
t.Run("daemon hub wired does not emit the inert frame", func(t *testing.T) {
composed, eventHub := newDaemonComposedV1Handler(t)
if eventHub == nil {
t.Fatal("daemon composition must wire an event hub for /v1/events")
}
body := eventsBody(t, composed)
if strings.Contains(body, inertFrame) {
t.Fatalf("with an event hub wired, /v1/events must not fall back to %q; got %q", inertFrame, body)
}
})
}
// eventsBody drives a single /v1/events request under a short context and
// returns whatever the handler streamed before the context cancelled it.
func eventsBody(t *testing.T, h http.Handler) string {
t.Helper()
ctx, cancel := context.WithTimeout(context.Background(), 200*time.Millisecond)
defer cancel()
req := httptest.NewRequest(http.MethodGet, "/v1/events", nil).WithContext(ctx)
rec := httptest.NewRecorder()
done := make(chan struct{})
go func() {
defer close(done)
h.ServeHTTP(rec, req)
}()
select {
case <-done:
case <-time.After(3 * time.Second):
t.Fatal("/v1/events did not return after its context cancelled")
}
return rec.Body.String()
}