CI / CI (push) Successful in 3m17s
Closes #52 ## Summary Migrates all session/milestone writes from REST `/memory/ingest` to GraphQL `upsertEntity` mutation — direct, typed, no LLM extraction lag. ## Changes - **Removed**: `memoryURL` field, `defaultMemoryURL` const, `ingest()` REST helper, `"io"` import - **Added**: `upsertEntity()` GraphQL mutation helper - **Added**: `fetchEntitySummary()` helper — reads current entity before patch for read-modify-write - **handlePatch**: now fetches current summary, merges patch fields, re-upserts full blob — no data loss on partial updates - **handleCreate / handleCreateMilestone**: `json.Marshal` error now returns 500 instead of silently ignoring - 29 tests pass, 85.4% coverage ## Mutation used ```graphql mutation { upsertEntity(input: { project: "session-<uuid>" name: "<sessionID | milestoneID>" entityType: SESSION | MILESTONE summary: "{...json blob...}" }) { id created } } ``` ## Single upstream All reads and writes now go through `graphqlURL` (poimen-graphql:8090). `MEMORY_UPSTREAM_URL` env var no longer read by this adapter. --------- Co-authored-by: Admin Bot <[email protected]> Reviewed-on: #53
590 lines
19 KiB
Go
590 lines
19 KiB
Go
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
|
|
// - "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"):
|
|
fmt.Fprint(w, `{"data":{"hybridSearch":{"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)
|
|
}
|
|
}
|