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
195 lines
7.8 KiB
Go
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()
|
|
}
|