package sessions import ( "crypto/sha256" "encoding/json" "fmt" "io" "net/http" "net/http/httptest" "strings" "testing" "time" ) // ── stub servers ────────────────────────────────────────────────────────────── // newGQLServer returns a test server that routes by inspecting the request body's // "query" field and records captured request bodies. // // - "upsertEntity" → mutation response // - "hybridSearch" → search response (get, patch, episodes handlers) // - "entities" → list response (sessions list handler) // - "entity" → single entity response func newGQLServer(t *testing.T, captured *[]byte) *httptest.Server { t.Helper() return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { body, _ := io.ReadAll(r.Body) if captured != nil { *captured = body } w.Header().Set("Content-Type", "application/json") bs := string(body) switch { case strings.Contains(bs, "upsertEntity"): fmt.Fprint(w, `{"data":{"upsertEntity":{"id":"test-entity-id","created":true}}}`) case strings.Contains(bs, "hybridSearch"): // get, patch, episodes handlers still use hybridSearch fmt.Fprint(w, `{"data":{"hybridSearch":{"entities":[]}}}`) case strings.Contains(bs, "entities"): // sessions list handler uses entities query fmt.Fprint(w, `{"data":{"entities":{"entities":[]}}}`) case strings.Contains(bs, "entity"): fmt.Fprint(w, `{"data":{"entity":null}}`) default: fmt.Fprint(w, `{"data":{}}`) } })) } // buildHandler wires a Handler with the supplied graphql URL. func buildHandler(gqlURL string) *Handler { return &Handler{ graphqlURL: gqlURL, client: &http.Client{Timeout: 5 * time.Second}, } } // do fires a request through the handler ServeHTTP. path sets X-Upstream-Path. func do(h *Handler, method, path, upstreamPath, body, query string) *httptest.ResponseRecorder { var bodyReader io.Reader if body != "" { bodyReader = strings.NewReader(body) } else { bodyReader = http.NoBody } url := "/" if query != "" { url = "/?" + query } req := httptest.NewRequest(method, url, bodyReader) req.Header.Set("X-Upstream-Path", upstreamPath) if body != "" { req.Header.Set("Content-Type", "application/json") } _ = path // kept for readability in callers rr := httptest.NewRecorder() h.ServeHTTP(rr, req) return rr } // jsonBody decodes response body into map. func jsonBody(t *testing.T, rr *httptest.ResponseRecorder) map[string]any { t.Helper() var out map[string]any if err := json.Unmarshal(rr.Body.Bytes(), &out); err != nil { t.Fatalf("decode body: %v — raw: %s", err, rr.Body.String()) } return out } // ── 1. handleCreate ─────────────────────────────────────────────────────────── func TestHandleCreate(t *testing.T) { t.Run("missing name → 400", func(t *testing.T) { gql := newGQLServer(t, nil) defer gql.Close() h := buildHandler(gql.URL) rr := do(h, http.MethodPost, "/v1/sessions/create", "/v1/sessions/create", `{"goal":"some goal"}`, "") if rr.Code != http.StatusBadRequest { t.Fatalf("want 400, got %d", rr.Code) } }) t.Run("bad JSON → 400", func(t *testing.T) { gql := newGQLServer(t, nil) defer gql.Close() h := buildHandler(gql.URL) rr := do(h, http.MethodPost, "/v1/sessions/create", "/v1/sessions/create", `not json`, "") if rr.Code != http.StatusBadRequest { t.Fatalf("want 400, got %d", rr.Code) } }) t.Run("upsert graphql error → 502", func(t *testing.T) { gqlErr := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") w.WriteHeader(200) fmt.Fprint(w, `{"errors":[{"message":"resolver error"}]}`) })) defer gqlErr.Close() h := buildHandler(gqlErr.URL) rr := do(h, http.MethodPost, "/v1/sessions/create", "/v1/sessions/create", `{"name":"fail-session"}`, "") if rr.Code != http.StatusBadGateway { t.Fatalf("want 502, got %d", rr.Code) } if !strings.Contains(rr.Body.String(), "upsert failed:") { t.Fatalf("want 'upsert failed:' in body, got: %s", rr.Body.String()) } }) t.Run("upsert 200 → 201 with session_id, status=active, memory_project", func(t *testing.T) { gql := newGQLServer(t, nil) defer gql.Close() h := buildHandler(gql.URL) rr := do(h, http.MethodPost, "/v1/sessions/create", "/v1/sessions/create", `{"name":"my-session","goal":"finish task"}`, "") if rr.Code != http.StatusCreated { t.Fatalf("want 201, got %d — body: %s", rr.Code, rr.Body.String()) } body := jsonBody(t, rr) if _, ok := body["session_id"]; !ok { t.Fatal("missing session_id") } if body["status"] != "active" { t.Fatalf("want status=active, got %v", body["status"]) } proj, _ := body["memory_project"].(string) if !strings.HasPrefix(proj, "session-") { t.Fatalf("memory_project must start with 'session-', got %s", proj) } }) } // ── 2. handleList ───────────────────────────────────────────────────────────── func TestHandleList(t *testing.T) { t.Run("no params → 200, gql called with query containing 'session'", func(t *testing.T) { var captured []byte gql := newGQLServer(t, &captured) defer gql.Close() h := buildHandler(gql.URL) rr := do(h, http.MethodGet, "/v1/sessions/list", "/v1/sessions/list", "", "") if rr.Code != http.StatusOK { t.Fatalf("want 200, got %d", rr.Code) } if !strings.Contains(string(captured), "session") { t.Fatalf("gql payload missing 'session': %s", captured) } }) t.Run("status=paused → gql query contains 'status:paused'", func(t *testing.T) { var captured []byte gql := newGQLServer(t, &captured) defer gql.Close() h := buildHandler(gql.URL) rr := do(h, http.MethodGet, "/v1/sessions/list", "/v1/sessions/list", "", "status=paused") if rr.Code != http.StatusOK { t.Fatalf("want 200, got %d", rr.Code) } if !strings.Contains(string(captured), "status:paused") { t.Fatalf("gql payload missing 'status:paused': %s", captured) } }) t.Run("graphql errors body → 502", func(t *testing.T) { gql2 := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(500) fmt.Fprint(w, `{"errors":[{"message":"resolver error"}]}`) })) defer gql2.Close() h2 := buildHandler(gql2.URL) rr := do(h2, http.MethodGet, "/v1/sessions/list", "/v1/sessions/list", "", "") if rr.Code != http.StatusBadGateway { t.Fatalf("want 502, got %d — body: %s", rr.Code, rr.Body.String()) } }) } // ── 3. handleGet ───────────────────────────────────────────────────────────── func TestHandleGet(t *testing.T) { t.Run("missing session_id → 400", func(t *testing.T) { gql := newGQLServer(t, nil) defer gql.Close() h := buildHandler(gql.URL) rr := do(h, http.MethodGet, "/v1/sessions/get", "/v1/sessions/get", "", "") if rr.Code != http.StatusBadRequest { t.Fatalf("want 400, got %d", rr.Code) } }) t.Run("success → 200, gql called with session-scoped project", func(t *testing.T) { var captured []byte gql := newGQLServer(t, &captured) defer gql.Close() h := buildHandler(gql.URL) sid := "abc-123" rr := do(h, http.MethodGet, "/v1/sessions/get", "/v1/sessions/get", "", "session_id="+sid) if rr.Code != http.StatusOK { t.Fatalf("want 200, got %d — body: %s", rr.Code, rr.Body.String()) } if !strings.Contains(string(captured), "session-"+sid) { t.Fatalf("gql payload missing scoped project 'session-%s': %s", sid, captured) } }) } // ── 4. handlePatch ──────────────────────────────────────────────────────────── func TestHandlePatch(t *testing.T) { t.Run("missing session_id → 400", func(t *testing.T) { gql := newGQLServer(t, nil) defer gql.Close() h := buildHandler(gql.URL) rr := do(h, http.MethodPatch, "/v1/sessions/patch", "/v1/sessions/patch", `{"status":"paused"}`, "") if rr.Code != http.StatusBadRequest { t.Fatalf("want 400, got %d", rr.Code) } }) t.Run("bad JSON → 400", func(t *testing.T) { gql := newGQLServer(t, nil) defer gql.Close() h := buildHandler(gql.URL) rr := do(h, http.MethodPatch, "/v1/sessions/patch", "/v1/sessions/patch", `not json`, "session_id=abc-123") if rr.Code != http.StatusBadRequest { t.Fatalf("want 400, got %d", rr.Code) } }) t.Run("upsert success → 200 with session_id, status=updated", func(t *testing.T) { var secondCall []byte callCount := 0 gqlServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) { body, _ := io.ReadAll(req.Body) bodyStr := string(body) w.Header().Set("Content-Type", "application/json") callCount++ if strings.Contains(bodyStr, "hybridSearch") { summary := `{"id":"sess-1","name":"abc-123","status":"active","goal":"old-goal"}` fmt.Fprintf(w, `{"data":{"hybridSearch":{"entities":[{"id":"eid","name":"abc-123","summary":%q,"tCreated":"2026-01-01T00:00:00Z"}]}}}`, summary) } else { secondCall = body fmt.Fprint(w, `{"data":{"upsertEntity":{"id":"eid","created":false}}}`) } })) defer gqlServer.Close() h := buildHandler(gqlServer.URL) sid := "abc-123" rr := do(h, http.MethodPatch, "/v1/sessions/patch", "/v1/sessions/patch", `{"status":"paused"}`, "session_id="+sid) if rr.Code != http.StatusOK { t.Fatalf("want 200, got %d — body: %s", rr.Code, rr.Body.String()) } body := jsonBody(t, rr) if body["session_id"] != sid { t.Fatalf("want session_id=%s, got %v", sid, body["session_id"]) } if body["status"] != "updated" { t.Fatalf("want status=updated, got %v", body["status"]) } if callCount != 2 { t.Fatalf("want 2 gql calls (hybridSearch+upsert), got %d", callCount) } if !strings.Contains(string(secondCall), "paused") { t.Fatalf("upsert payload missing merged status: %s", secondCall) } }) t.Run("upsert error → 502", func(t *testing.T) { gqlServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) { body, _ := io.ReadAll(req.Body) w.Header().Set("Content-Type", "application/json") if strings.Contains(string(body), "hybridSearch") { fmt.Fprint(w, `{"data":{"hybridSearch":{"entities":[]}}}`) } else { fmt.Fprint(w, `{"errors":[{"message":"write error"}]}`) } })) defer gqlServer.Close() h := buildHandler(gqlServer.URL) rr := do(h, http.MethodPatch, "/v1/sessions/patch", "/v1/sessions/patch", `{"status":"paused"}`, "session_id=abc-123") if rr.Code != http.StatusBadGateway { t.Fatalf("want 502, got %d — body: %s", rr.Code, rr.Body.String()) } }) } // ── 5. handleCreateMilestone ───────────────────────────────────────────────── func TestHandleCreateMilestone(t *testing.T) { t.Run("missing session_id → 400", func(t *testing.T) { gql := newGQLServer(t, nil) defer gql.Close() h := buildHandler(gql.URL) rr := do(h, http.MethodPost, "/v1/sessions/milestones/create", "/v1/sessions/milestones/create", `{"title":"step 1"}`, "") if rr.Code != http.StatusBadRequest { t.Fatalf("want 400, got %d", rr.Code) } }) t.Run("missing title → 400", func(t *testing.T) { gql := newGQLServer(t, nil) defer gql.Close() h := buildHandler(gql.URL) rr := do(h, http.MethodPost, "/v1/sessions/milestones/create", "/v1/sessions/milestones/create", `{"order_idx":1}`, "session_id=abc-123") if rr.Code != http.StatusBadRequest { t.Fatalf("want 400, got %d", rr.Code) } }) t.Run("upsert error → 502", func(t *testing.T) { gqlErr := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") fmt.Fprint(w, `{"errors":[{"message":"write error"}]}`) })) defer gqlErr.Close() h := buildHandler(gqlErr.URL) rr := do(h, http.MethodPost, "/v1/sessions/milestones/create", "/v1/sessions/milestones/create", `{"title":"step 1","order_idx":0}`, "session_id=abc-123") if rr.Code != http.StatusBadGateway { t.Fatalf("want 502, got %d — body: %s", rr.Code, rr.Body.String()) } }) t.Run("success → 201 with milestone_id, session_id, status=pending", func(t *testing.T) { gql := newGQLServer(t, nil) defer gql.Close() h := buildHandler(gql.URL) sid := "abc-123" rr := do(h, http.MethodPost, "/v1/sessions/milestones/create", "/v1/sessions/milestones/create", `{"title":"phase 1","order_idx":0}`, "session_id="+sid) if rr.Code != http.StatusCreated { t.Fatalf("want 201, got %d — body: %s", rr.Code, rr.Body.String()) } body := jsonBody(t, rr) if _, ok := body["milestone_id"]; !ok { t.Fatal("missing milestone_id") } if body["session_id"] != sid { t.Fatalf("want session_id=%s, got %v", sid, body["session_id"]) } if body["status"] != "pending" { t.Fatalf("want status=pending, got %v", body["status"]) } }) } // ── 6. handleListEpisodes ───────────────────────────────────────────────────── func TestHandleListEpisodes(t *testing.T) { t.Run("missing session_id → 400", func(t *testing.T) { gql := newGQLServer(t, nil) defer gql.Close() h := buildHandler(gql.URL) rr := do(h, http.MethodGet, "/v1/sessions/episodes/list", "/v1/sessions/episodes/list", "", "") if rr.Code != http.StatusBadRequest { t.Fatalf("want 400, got %d", rr.Code) } }) t.Run("limit > 200 clamped → gql called with limit=200", func(t *testing.T) { var captured []byte gql := newGQLServer(t, &captured) defer gql.Close() h := buildHandler(gql.URL) rr := do(h, http.MethodGet, "/v1/sessions/episodes/list", "/v1/sessions/episodes/list", "", "session_id=abc-123&limit=999") if rr.Code != http.StatusOK { t.Fatalf("want 200, got %d — body: %s", rr.Code, rr.Body.String()) } // Verify GQL was called (body non-empty). if len(captured) == 0 { t.Fatal("gql not called") } // GQL was called; handler clamps limit to 200. }) t.Run("success → 200", func(t *testing.T) { gql := newGQLServer(t, nil) defer gql.Close() h := buildHandler(gql.URL) rr := do(h, http.MethodGet, "/v1/sessions/episodes/list", "/v1/sessions/episodes/list", "", "session_id=abc-123") if rr.Code != http.StatusOK { t.Fatalf("want 200, got %d — body: %s", rr.Code, rr.Body.String()) } }) } // ── 7. handleGetEpisode ─────────────────────────────────────────────────────── func TestHandleGetEpisode(t *testing.T) { t.Run("missing session_id → 400", func(t *testing.T) { gql := newGQLServer(t, nil) defer gql.Close() h := buildHandler(gql.URL) rr := do(h, http.MethodGet, "/v1/sessions/episodes/get", "/v1/sessions/episodes/get", "", "episode_id=deadbeef") if rr.Code != http.StatusBadRequest { t.Fatalf("want 400, got %d", rr.Code) } }) t.Run("missing episode_id → 400", func(t *testing.T) { gql := newGQLServer(t, nil) defer gql.Close() h := buildHandler(gql.URL) rr := do(h, http.MethodGet, "/v1/sessions/episodes/get", "/v1/sessions/episodes/get", "", "session_id=abc-123") if rr.Code != http.StatusBadRequest { t.Fatalf("want 400, got %d", rr.Code) } }) t.Run("success → 200", func(t *testing.T) { gql := newGQLServer(t, nil) defer gql.Close() h := buildHandler(gql.URL) rr := do(h, http.MethodGet, "/v1/sessions/episodes/get", "/v1/sessions/episodes/get", "", "session_id=abc-123&episode_id=deadbeef") if rr.Code != http.StatusOK { t.Fatalf("want 200, got %d — body: %s", rr.Code, rr.Body.String()) } }) } // ── 8. EpisodeID helper ─────────────────────────────────────────────────────── func TestEpisodeID(t *testing.T) { sid := "session-abc" mid := "milestone-xyz" attempt := 1 h := sha256.New() h.Write([]byte(fmt.Sprintf("%s%s%d", sid, mid, attempt))) expected := fmt.Sprintf("%x", h.Sum(nil)) got := EpisodeID(sid, mid, attempt) if got != expected { t.Fatalf("EpisodeID mismatch: want %s, got %s", expected, got) } // Sanity: different inputs differ. if EpisodeID(sid, mid, 2) == got { t.Fatal("attempt 2 must differ from attempt 1") } } // ── 9. unknown path → 404 ──────────────────────────────────────────────────── func TestUnknownPath(t *testing.T) { gql := newGQLServer(t, nil) defer gql.Close() h := buildHandler(gql.URL) rr := do(h, http.MethodGet, "/v1/sessions/unknown", "/v1/sessions/unknown", "", "") if rr.Code != http.StatusNotFound { t.Fatalf("want 404, got %d", rr.Code) } body := jsonBody(t, rr) if body["error"] == nil { t.Fatal("missing error field in 404 response") } } // ── table-driven summary for handleCreate (extra variants) ─────────────────── func TestHandleCreate_Table(t *testing.T) { tests := []struct { name string body string wantStatus int }{ {"valid", `{"name":"s1"}`, http.StatusCreated}, {"empty name", `{"name":""}`, http.StatusBadRequest}, {"empty_name_string", `{"name":""}`, http.StatusBadRequest}, } for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { gql := newGQLServer(t, nil) defer gql.Close() h := buildHandler(gql.URL) rr := do(h, http.MethodPost, "/v1/sessions/create", "/v1/sessions/create", tc.body, "") if rr.Code != tc.wantStatus { t.Fatalf("want %d, got %d — body: %s", tc.wantStatus, rr.Code, rr.Body.String()) } }) } } // ── table-driven summary for handlePatch ───────────────────────────────────── func TestHandlePatch_Table(t *testing.T) { validSID := "session_id=abc-123" tests := []struct { name string query string body string wantStatus int }{ {"ok", validSID, `{"status":"paused"}`, http.StatusOK}, {"no session_id", "", `{"status":"paused"}`, http.StatusBadRequest}, {"bad json", validSID, `{bad}`, http.StatusBadRequest}, } for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { gql := newGQLServer(t, nil) defer gql.Close() h := buildHandler(gql.URL) rr := do(h, http.MethodPatch, "/v1/sessions/patch", "/v1/sessions/patch", tc.body, tc.query) if rr.Code != tc.wantStatus { t.Fatalf("want %d, got %d — body: %s", tc.wantStatus, rr.Code, rr.Body.String()) } }) } } // ── verify upsertEntity mutation payload ───────────────────────────────────── func TestHandleCreate_IngestPayload(t *testing.T) { var captured []byte gql := newGQLServer(t, &captured) defer gql.Close() h := buildHandler(gql.URL) rr := do(h, http.MethodPost, "/v1/sessions/create", "/v1/sessions/create", `{"name":"test-session","goal":"do stuff"}`, "") if rr.Code != http.StatusCreated { t.Fatalf("want 201, got %d", rr.Code) } bs := string(captured) if !strings.Contains(bs, "upsertEntity") { t.Fatalf("gql body missing 'upsertEntity': %s", bs) } if !strings.Contains(bs, "SESSION") { t.Fatalf("gql body missing 'SESSION': %s", bs) } }