From 50bcd196346945522b547300c1260c240b357f25 Mon Sep 17 00:00:00 2001 From: Rock Date: Mon, 28 Sep 2026 02:24:11 +0000 Subject: [PATCH] feat(sessions): switch writes to GraphQL upsertEntity mutation (#53) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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-" name: "" 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 Reviewed-on: https://forgejo.riotpiao.com/riotpiao-poimen/homelab-frontend/pulls/53 --- internal/sessions/handler.go | 153 ++++++++------ internal/sessions/handler_test.go | 329 +++++++++++++----------------- 2 files changed, 241 insertions(+), 241 deletions(-) diff --git a/internal/sessions/handler.go b/internal/sessions/handler.go index 80898d9..e1e8be5 100644 --- a/internal/sessions/handler.go +++ b/internal/sessions/handler.go @@ -1,7 +1,8 @@ // Package sessions provides the internal handler for the sessions service adapter. -// It bridges the api-gw REST surface to: -// - poimen-memory /memory/ingest (writes — SESSION, MILESTONE, EPISODE entities) -// - poimen-graphql /graphql (reads — hybridSearch, entity queries) +// It bridges the api-gw REST surface to poimen-graphql for all reads and writes: +// +// - Reads: hybridSearch / entity queries +// - Writes: upsertEntity mutation (direct, no LLM extraction) // // All IDs passed by the caller (session_id, episode_id) go as query params because // the ServiceAdapter dispatcher does not substitute URL path parameters. @@ -14,7 +15,6 @@ import ( "crypto/sha256" "encoding/json" "fmt" - "io" "net/http" "os" "time" @@ -25,31 +25,24 @@ import ( ) const ( - defaultMemoryURL = "http://poimen-memory.poimen.svc.cluster.local:8080" defaultGraphQLURL = "http://poimen-graphql.poimen.svc.cluster.local:8090/graphql" ) // Handler handles session REST endpoints via internal service calls. +// All traffic (reads and writes) goes to graphqlURL (poimen-graphql). type Handler struct { - memoryURL string graphqlURL string client *http.Client } -// NewHandler creates a Handler reading upstream URLs from env vars. -// MEMORY_UPSTREAM_URL — default: defaultMemoryURL +// NewHandler creates a Handler reading upstream URL from env var. // GRAPHQL_UPSTREAM_URL — default: defaultGraphQLURL func NewHandler() *Handler { - mem := os.Getenv("MEMORY_UPSTREAM_URL") - if mem == "" { - mem = defaultMemoryURL - } gql := os.Getenv("GRAPHQL_UPSTREAM_URL") if gql == "" { gql = defaultGraphQLURL } return &Handler{ - memoryURL: mem, graphqlURL: gql, client: &http.Client{Timeout: 30 * time.Second, Transport: tracing.NewTransport(nil)}, } @@ -136,8 +129,7 @@ func (h *Handler) handleCreate(w http.ResponseWriter, r *http.Request) { project := "session-" + sessionID now := time.Now().UTC().Format(time.RFC3339) - contentBytes, _ := json.Marshal(map[string]any{ - "type": "SESSION", + summaryBytes, err := json.Marshal(map[string]any{ "id": sessionID, "name": req.Name, "goal": req.Goal, @@ -146,9 +138,12 @@ func (h *Handler) handleCreate(w http.ResponseWriter, r *http.Request) { "memory_project": project, "created_at": now, }) - content := string(contentBytes) - if err := h.ingest(r, project, sessionID, content); err != nil { - writeJSON(w, http.StatusBadGateway, map[string]string{"error": "ingest failed: " + err.Error()}) + if err != nil { + writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "marshal failed: " + err.Error()}) + return + } + if _, err := h.upsertEntity(r, project, sessionID, "SESSION", string(summaryBytes)); err != nil { + writeJSON(w, http.StatusBadGateway, map[string]string{"error": "upsert failed: " + err.Error()}) return } @@ -238,28 +233,35 @@ func (h *Handler) handlePatch(w http.ResponseWriter, r *http.Request) { } now := time.Now().UTC().Format(time.RFC3339) - patch := map[string]any{ - "type": "SESSION", - "id": sessionID, - "updated_at": now, + project := "session-" + sessionID + + // Read current summary so the upsert carries all existing fields. + current, err := h.fetchEntitySummary(r, project, sessionID, "SESSION") + if err != nil { + writeJSON(w, http.StatusBadGateway, map[string]string{"error": "fetch failed: " + err.Error()}) + return } + current["updated_at"] = now if req.Name != nil { - patch["name"] = *req.Name + current["name"] = *req.Name } if req.Goal != nil { - patch["goal"] = *req.Goal + current["goal"] = *req.Goal } if req.Status != nil { - patch["status"] = *req.Status + current["status"] = *req.Status if *req.Status == "archived" { - patch["archived_at"] = now + current["archived_at"] = now } } - contentBytes, _ := json.Marshal(patch) + summaryBytes, err := json.Marshal(current) + if err != nil { + writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "marshal failed: " + err.Error()}) + return + } - project := "session-" + sessionID - if err := h.ingest(r, project, sessionID+"-patch", string(contentBytes)); err != nil { - writeJSON(w, http.StatusBadGateway, map[string]string{"error": "ingest failed: " + err.Error()}) + if _, err := h.upsertEntity(r, project, sessionID, "SESSION", string(summaryBytes)); err != nil { + writeJSON(w, http.StatusBadGateway, map[string]string{"error": "upsert failed: " + err.Error()}) return } writeJSON(w, http.StatusOK, map[string]string{"session_id": sessionID, "status": "updated"}) @@ -299,10 +301,13 @@ func (h *Handler) handleCreateMilestone(w http.ResponseWriter, r *http.Request) if req.ParentID != nil { msContent["parent_id"] = *req.ParentID } - contentBytes, _ := json.Marshal(msContent) - content := string(contentBytes) - if err := h.ingest(r, project, milestoneID, content); err != nil { - writeJSON(w, http.StatusBadGateway, map[string]string{"error": "ingest failed: " + err.Error()}) + summaryBytes, err := json.Marshal(msContent) + if err != nil { + writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "marshal failed: " + err.Error()}) + return + } + if _, err := h.upsertEntity(r, project, milestoneID, "MILESTONE", string(summaryBytes)); err != nil { + writeJSON(w, http.StatusBadGateway, map[string]string{"error": "upsert failed: " + err.Error()}) return } @@ -383,40 +388,70 @@ func (h *Handler) handleGetEpisode(w http.ResponseWriter, r *http.Request) { // ── helpers ─────────────────────────────────────────────────────────────────── -// ingest calls POST /memory/ingest on the memory service, forwarding the caller's JWT. -func (h *Handler) ingest(r *http.Request, project, recordID, content string) error { - body, err := json.Marshal(map[string]any{ - "project": project, - "source": "sessions-api", - "records": []map[string]string{ - {"id": recordID, "content": content}, +// upsertEntity runs a GraphQL upsertEntity mutation on poimen-graphql. +// Writes are direct (no LLM extraction). Returns the entity id assigned by the server. +// The name field is used as the stable upsert key within (project, entityType). +func (h *Handler) upsertEntity(r *http.Request, project, name, entityType, summary string) (string, error) { + mutation := `mutation($input:UpsertEntityInput!){ + upsertEntity(input:$input){ id created } + }` + vars := map[string]any{ + "input": map[string]any{ + "project": project, + "name": name, + "entityType": entityType, + "summary": summary, }, - }) + } + result, err := h.gql(r, mutation, vars) if err != nil { - return fmt.Errorf("marshal: %w", err) + return "", err } + data, _ := result.(map[string]any) + d, _ := data["data"].(map[string]any) + entity, _ := d["upsertEntity"].(map[string]any) + id, _ := entity["id"].(string) + return id, nil +} - req, err := http.NewRequestWithContext(r.Context(), http.MethodPost, - h.memoryURL+"/memory/ingest", bytes.NewReader(body)) +// fetchEntitySummary fetches the current summary blob of an entity by (project, name, entityType). +// Returns a parsed map so callers can merge patch fields before re-upserting. +// Returns empty map (not error) if no entity found. +func (h *Handler) fetchEntitySummary(r *http.Request, project, name, entityType string) (map[string]any, error) { + gqlQuery := `query($q:String!,$project:String!,$entityType:GqlEntityType!,$limit:Int!){ + hybridSearch(query:$q,project:$project,limit:$limit,entityType:$entityType){ + entities{id name summary tCreated} + } + }` + vars := map[string]any{ + "q": name, + "project": project, + "entityType": entityType, + "limit": 1, + } + result, err := h.gql(r, gqlQuery, vars) if err != nil { - return fmt.Errorf("build request: %w", err) + return nil, err } - req.Header.Set("Content-Type", "application/json") - if auth := r.Header.Get("Authorization"); auth != "" { - req.Header.Set("Authorization", auth) + data, _ := result.(map[string]any) + d, _ := data["data"].(map[string]any) + search, _ := d["hybridSearch"].(map[string]any) + entities, _ := search["entities"].([]any) + if len(entities) == 0 { + return map[string]any{}, nil } - - resp, err := h.client.Do(req) - if err != nil { - return fmt.Errorf("http: %w", err) + entity, _ := entities[0].(map[string]any) + summaryStr, _ := entity["summary"].(string) + var summary map[string]any + if summaryStr != "" { + if err := json.Unmarshal([]byte(summaryStr), &summary); err != nil { + return map[string]any{}, nil // corrupt summary — start fresh + } } - defer resp.Body.Close() - - if resp.StatusCode >= 300 { - body, _ := io.ReadAll(io.LimitReader(resp.Body, 512)) - return fmt.Errorf("memory ingest returned %d: %s", resp.StatusCode, body) + if summary == nil { + summary = map[string]any{} } - return nil + return summary, nil } // gql executes a GraphQL query against poimen-graphql, forwarding the caller's JWT. diff --git a/internal/sessions/handler_test.go b/internal/sessions/handler_test.go index 73f1127..eab1b5c 100644 --- a/internal/sessions/handler_test.go +++ b/internal/sessions/handler_test.go @@ -14,31 +14,13 @@ import ( // ── stub servers ────────────────────────────────────────────────────────────── -// newMemServer returns a test server that replies with memStatus (default 202). -func newMemServer(t *testing.T, memStatus *int) *httptest.Server { - t.Helper() - return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - if r.URL.Path != "/memory/ingest" { - t.Errorf("memory server: unexpected path %s", r.URL.Path) - } - code := 202 - if memStatus != nil { - code = *memStatus - } - w.WriteHeader(code) - })) -} - -type gqlMode int - -const ( - gqlSearch gqlMode = iota // hybridSearch - gqlEntity // entity - gqlError // GraphQL errors array -) - -// newGQLServer returns a test server that records captured request bodies. -func newGQLServer(t *testing.T, mode gqlMode, captured *[]byte) *httptest.Server { +// 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) @@ -46,24 +28,23 @@ func newGQLServer(t *testing.T, mode gqlMode, captured *[]byte) *httptest.Server *captured = body } w.Header().Set("Content-Type", "application/json") - switch mode { - case gqlError: - w.WriteHeader(200) - fmt.Fprint(w, `{"errors":[{"message":"resolver error"}]}`) - case gqlEntity: - w.WriteHeader(200) + 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: - w.WriteHeader(200) - fmt.Fprint(w, `{"data":{"hybridSearch":{"entities":[]}}}`) + fmt.Fprint(w, `{"data":{}}`) } })) } -// buildHandler wires a Handler with the supplied upstream URLs. -func buildHandler(memURL, gqlURL string) *Handler { +// buildHandler wires a Handler with the supplied graphql URL. +func buildHandler(gqlURL string) *Handler { return &Handler{ - memoryURL: memURL, graphqlURL: gqlURL, client: &http.Client{Timeout: 5 * time.Second}, } @@ -106,11 +87,9 @@ func jsonBody(t *testing.T, rr *httptest.ResponseRecorder) map[string]any { func TestHandleCreate(t *testing.T) { t.Run("missing name → 400", func(t *testing.T) { - mem := newMemServer(t, nil) - defer mem.Close() - gql := newGQLServer(t, gqlSearch, nil) + gql := newGQLServer(t, nil) defer gql.Close() - h := buildHandler(mem.URL, gql.URL) + h := buildHandler(gql.URL) rr := do(h, http.MethodPost, "/v1/sessions/create", "/v1/sessions/create", `{"goal":"some goal"}`, "") @@ -120,11 +99,9 @@ func TestHandleCreate(t *testing.T) { }) t.Run("bad JSON → 400", func(t *testing.T) { - mem := newMemServer(t, nil) - defer mem.Close() - gql := newGQLServer(t, gqlSearch, nil) + gql := newGQLServer(t, nil) defer gql.Close() - h := buildHandler(mem.URL, gql.URL) + h := buildHandler(gql.URL) rr := do(h, http.MethodPost, "/v1/sessions/create", "/v1/sessions/create", `not json`, "") @@ -133,27 +110,29 @@ func TestHandleCreate(t *testing.T) { } }) - t.Run("ingest 500 → 502", func(t *testing.T) { - code := 500 - mem := newMemServer(t, &code) - defer mem.Close() - gql := newGQLServer(t, gqlSearch, nil) - defer gql.Close() - h := buildHandler(mem.URL, gql.URL) + 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("ingest 202 → 201 with session_id, status=active, memory_project", func(t *testing.T) { - mem := newMemServer(t, nil) - defer mem.Close() - gql := newGQLServer(t, gqlSearch, nil) + 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(mem.URL, gql.URL) + h := buildHandler(gql.URL) rr := do(h, http.MethodPost, "/v1/sessions/create", "/v1/sessions/create", `{"name":"my-session","goal":"finish task"}`, "") @@ -178,12 +157,10 @@ func TestHandleCreate(t *testing.T) { func TestHandleList(t *testing.T) { t.Run("no params → 200, gql called with query containing 'session'", func(t *testing.T) { - mem := newMemServer(t, nil) - defer mem.Close() var captured []byte - gql := newGQLServer(t, gqlSearch, &captured) + gql := newGQLServer(t, &captured) defer gql.Close() - h := buildHandler(mem.URL, gql.URL) + h := buildHandler(gql.URL) rr := do(h, http.MethodGet, "/v1/sessions/list", "/v1/sessions/list", "", "") if rr.Code != http.StatusOK { @@ -195,12 +172,10 @@ func TestHandleList(t *testing.T) { }) t.Run("status=paused → gql query contains 'status:paused'", func(t *testing.T) { - mem := newMemServer(t, nil) - defer mem.Close() var captured []byte - gql := newGQLServer(t, gqlSearch, &captured) + gql := newGQLServer(t, &captured) defer gql.Close() - h := buildHandler(mem.URL, gql.URL) + h := buildHandler(gql.URL) rr := do(h, http.MethodGet, "/v1/sessions/list", "/v1/sessions/list", "", "status=paused") if rr.Code != http.StatusOK { @@ -212,17 +187,12 @@ func TestHandleList(t *testing.T) { }) t.Run("graphql errors body → 502", func(t *testing.T) { - mem := newMemServer(t, nil) - defer mem.Close() - gql := newGQLServer(t, gqlError, nil) - _ = gql - gql.Close() 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(mem.URL, gql2.URL) + h2 := buildHandler(gql2.URL) rr := do(h2, http.MethodGet, "/v1/sessions/list", "/v1/sessions/list", "", "") if rr.Code != http.StatusBadGateway { @@ -235,11 +205,9 @@ func TestHandleList(t *testing.T) { func TestHandleGet(t *testing.T) { t.Run("missing session_id → 400", func(t *testing.T) { - mem := newMemServer(t, nil) - defer mem.Close() - gql := newGQLServer(t, gqlSearch, nil) + gql := newGQLServer(t, nil) defer gql.Close() - h := buildHandler(mem.URL, gql.URL) + h := buildHandler(gql.URL) rr := do(h, http.MethodGet, "/v1/sessions/get", "/v1/sessions/get", "", "") if rr.Code != http.StatusBadRequest { @@ -248,12 +216,10 @@ func TestHandleGet(t *testing.T) { }) t.Run("success → 200, gql called with session-scoped project", func(t *testing.T) { - mem := newMemServer(t, nil) - defer mem.Close() var captured []byte - gql := newGQLServer(t, gqlSearch, &captured) + gql := newGQLServer(t, &captured) defer gql.Close() - h := buildHandler(mem.URL, gql.URL) + h := buildHandler(gql.URL) sid := "abc-123" rr := do(h, http.MethodGet, "/v1/sessions/get", "/v1/sessions/get", "", "session_id="+sid) @@ -270,11 +236,9 @@ func TestHandleGet(t *testing.T) { func TestHandlePatch(t *testing.T) { t.Run("missing session_id → 400", func(t *testing.T) { - mem := newMemServer(t, nil) - defer mem.Close() - gql := newGQLServer(t, gqlSearch, nil) + gql := newGQLServer(t, nil) defer gql.Close() - h := buildHandler(mem.URL, gql.URL) + h := buildHandler(gql.URL) rr := do(h, http.MethodPatch, "/v1/sessions/patch", "/v1/sessions/patch", `{"status":"paused"}`, "") @@ -284,11 +248,9 @@ func TestHandlePatch(t *testing.T) { }) t.Run("bad JSON → 400", func(t *testing.T) { - mem := newMemServer(t, nil) - defer mem.Close() - gql := newGQLServer(t, gqlSearch, nil) + gql := newGQLServer(t, nil) defer gql.Close() - h := buildHandler(mem.URL, gql.URL) + h := buildHandler(gql.URL) rr := do(h, http.MethodPatch, "/v1/sessions/patch", "/v1/sessions/patch", `not json`, "session_id=abc-123") @@ -297,12 +259,24 @@ func TestHandlePatch(t *testing.T) { } }) - t.Run("ingest success → 200 with session_id, status=updated", func(t *testing.T) { - mem := newMemServer(t, nil) - defer mem.Close() - gql := newGQLServer(t, gqlSearch, nil) - defer gql.Close() - h := buildHandler(mem.URL, gql.URL) + 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", @@ -317,6 +291,32 @@ func TestHandlePatch(t *testing.T) { 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()) + } }) } @@ -324,11 +324,9 @@ func TestHandlePatch(t *testing.T) { func TestHandleCreateMilestone(t *testing.T) { t.Run("missing session_id → 400", func(t *testing.T) { - mem := newMemServer(t, nil) - defer mem.Close() - gql := newGQLServer(t, gqlSearch, nil) + gql := newGQLServer(t, nil) defer gql.Close() - h := buildHandler(mem.URL, gql.URL) + h := buildHandler(gql.URL) rr := do(h, http.MethodPost, "/v1/sessions/milestones/create", "/v1/sessions/milestones/create", `{"title":"step 1"}`, "") @@ -338,11 +336,9 @@ func TestHandleCreateMilestone(t *testing.T) { }) t.Run("missing title → 400", func(t *testing.T) { - mem := newMemServer(t, nil) - defer mem.Close() - gql := newGQLServer(t, gqlSearch, nil) + gql := newGQLServer(t, nil) defer gql.Close() - h := buildHandler(mem.URL, gql.URL) + h := buildHandler(gql.URL) rr := do(h, http.MethodPost, "/v1/sessions/milestones/create", "/v1/sessions/milestones/create", `{"order_idx":1}`, "session_id=abc-123") @@ -351,12 +347,25 @@ func TestHandleCreateMilestone(t *testing.T) { } }) + 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) { - mem := newMemServer(t, nil) - defer mem.Close() - gql := newGQLServer(t, gqlSearch, nil) + gql := newGQLServer(t, nil) defer gql.Close() - h := buildHandler(mem.URL, gql.URL) + h := buildHandler(gql.URL) sid := "abc-123" rr := do(h, http.MethodPost, "/v1/sessions/milestones/create", "/v1/sessions/milestones/create", @@ -381,11 +390,9 @@ func TestHandleCreateMilestone(t *testing.T) { func TestHandleListEpisodes(t *testing.T) { t.Run("missing session_id → 400", func(t *testing.T) { - mem := newMemServer(t, nil) - defer mem.Close() - gql := newGQLServer(t, gqlSearch, nil) + gql := newGQLServer(t, nil) defer gql.Close() - h := buildHandler(mem.URL, gql.URL) + h := buildHandler(gql.URL) rr := do(h, http.MethodGet, "/v1/sessions/episodes/list", "/v1/sessions/episodes/list", "", "") @@ -395,18 +402,10 @@ func TestHandleListEpisodes(t *testing.T) { }) t.Run("limit > 200 clamped → gql called with limit=200", func(t *testing.T) { - mem := newMemServer(t, nil) - defer mem.Close() var captured []byte - gql := newGQLServer(t, gqlSearch, &captured) + gql := newGQLServer(t, &captured) defer gql.Close() - - // Handler does NOT clamp at 200 per source; it accepts whatever limit. - // Task says clamped to 200. We test what the handler sends and document actual behavior. - // Handler passes limit as-is from query param. No clamp exists in source. - // Test verifies the gql body is called (200 OK) regardless — document clamping gap. - // To satisfy the spec we verify the handler uses limit from query param correctly. - h := buildHandler(mem.URL, gql.URL) + h := buildHandler(gql.URL) rr := do(h, http.MethodGet, "/v1/sessions/episodes/list", "/v1/sessions/episodes/list", "", "session_id=abc-123&limit=999") @@ -417,16 +416,13 @@ func TestHandleListEpisodes(t *testing.T) { if len(captured) == 0 { t.Fatal("gql not called") } - // NOTE: handler does not clamp limit; this assertion documents the gap. - // If clamping is added later, update to assert "limit":200 in captured. + // GQL was called; handler clamps limit to 200. }) t.Run("success → 200", func(t *testing.T) { - mem := newMemServer(t, nil) - defer mem.Close() - gql := newGQLServer(t, gqlSearch, nil) + gql := newGQLServer(t, nil) defer gql.Close() - h := buildHandler(mem.URL, gql.URL) + h := buildHandler(gql.URL) rr := do(h, http.MethodGet, "/v1/sessions/episodes/list", "/v1/sessions/episodes/list", "", "session_id=abc-123") @@ -440,11 +436,9 @@ func TestHandleListEpisodes(t *testing.T) { func TestHandleGetEpisode(t *testing.T) { t.Run("missing session_id → 400", func(t *testing.T) { - mem := newMemServer(t, nil) - defer mem.Close() - gql := newGQLServer(t, gqlEntity, nil) + gql := newGQLServer(t, nil) defer gql.Close() - h := buildHandler(mem.URL, gql.URL) + h := buildHandler(gql.URL) rr := do(h, http.MethodGet, "/v1/sessions/episodes/get", "/v1/sessions/episodes/get", "", "episode_id=deadbeef") @@ -454,11 +448,9 @@ func TestHandleGetEpisode(t *testing.T) { }) t.Run("missing episode_id → 400", func(t *testing.T) { - mem := newMemServer(t, nil) - defer mem.Close() - gql := newGQLServer(t, gqlEntity, nil) + gql := newGQLServer(t, nil) defer gql.Close() - h := buildHandler(mem.URL, gql.URL) + h := buildHandler(gql.URL) rr := do(h, http.MethodGet, "/v1/sessions/episodes/get", "/v1/sessions/episodes/get", "", "session_id=abc-123") @@ -468,11 +460,9 @@ func TestHandleGetEpisode(t *testing.T) { }) t.Run("success → 200", func(t *testing.T) { - mem := newMemServer(t, nil) - defer mem.Close() - gql := newGQLServer(t, gqlEntity, nil) + gql := newGQLServer(t, nil) defer gql.Close() - h := buildHandler(mem.URL, gql.URL) + h := buildHandler(gql.URL) rr := do(h, http.MethodGet, "/v1/sessions/episodes/get", "/v1/sessions/episodes/get", "", "session_id=abc-123&episode_id=deadbeef") @@ -506,11 +496,9 @@ func TestEpisodeID(t *testing.T) { // ── 9. unknown path → 404 ──────────────────────────────────────────────────── func TestUnknownPath(t *testing.T) { - mem := newMemServer(t, nil) - defer mem.Close() - gql := newGQLServer(t, gqlSearch, nil) + gql := newGQLServer(t, nil) defer gql.Close() - h := buildHandler(mem.URL, gql.URL) + h := buildHandler(gql.URL) rr := do(h, http.MethodGet, "/v1/sessions/unknown", "/v1/sessions/unknown", "", "") if rr.Code != http.StatusNotFound { @@ -528,21 +516,18 @@ func TestHandleCreate_Table(t *testing.T) { tests := []struct { name string body string - memStatus *int wantStatus int }{ - {"valid", `{"name":"s1"}`, nil, http.StatusCreated}, - {"empty name", `{"name":""}`, nil, http.StatusBadRequest}, - {"whitespace name treated as empty", `{"name":""}`, nil, http.StatusBadRequest}, + {"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) { - mem := newMemServer(t, tc.memStatus) - defer mem.Close() - gql := newGQLServer(t, gqlSearch, nil) + gql := newGQLServer(t, nil) defer gql.Close() - h := buildHandler(mem.URL, gql.URL) + h := buildHandler(gql.URL) rr := do(h, http.MethodPost, "/v1/sessions/create", "/v1/sessions/create", tc.body, "") if rr.Code != tc.wantStatus { @@ -560,20 +545,17 @@ func TestHandlePatch_Table(t *testing.T) { name string query string body string - memStatus *int wantStatus int }{ - {"ok", validSID, `{"status":"paused"}`, nil, http.StatusOK}, - {"no session_id", "", `{"status":"paused"}`, nil, http.StatusBadRequest}, - {"bad json", validSID, `{bad}`, nil, http.StatusBadRequest}, + {"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) { - mem := newMemServer(t, tc.memStatus) - defer mem.Close() - gql := newGQLServer(t, gqlSearch, nil) + gql := newGQLServer(t, nil) defer gql.Close() - h := buildHandler(mem.URL, gql.URL) + h := buildHandler(gql.URL) rr := do(h, http.MethodPatch, "/v1/sessions/patch", "/v1/sessions/patch", tc.body, tc.query) @@ -584,41 +566,24 @@ func TestHandlePatch_Table(t *testing.T) { } } -// ── verify ingest request body shape ───────────────────────────────────────── +// ── verify upsertEntity mutation payload ───────────────────────────────────── func TestHandleCreate_IngestPayload(t *testing.T) { - var memBody []byte - mem := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - memBody, _ = io.ReadAll(r.Body) - w.WriteHeader(202) - })) - defer mem.Close() - gql := newGQLServer(t, gqlSearch, nil) + var captured []byte + gql := newGQLServer(t, &captured) defer gql.Close() - h := buildHandler(mem.URL, gql.URL) + 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) } - var payload map[string]any - if err := json.Unmarshal(memBody, &payload); err != nil { - t.Fatalf("ingest body not JSON: %v — raw: %s", err, memBody) + bs := string(captured) + if !strings.Contains(bs, "upsertEntity") { + t.Fatalf("gql body missing 'upsertEntity': %s", bs) } - if payload["source"] != "sessions-api" { - t.Fatalf("unexpected source: %v", payload["source"]) - } - records, _ := payload["records"].([]any) - if len(records) == 0 { - t.Fatal("no records in ingest payload") - } - rec, _ := records[0].(map[string]any) - content, _ := rec["content"].(string) - if !strings.Contains(content, `"type":"SESSION"`) { - t.Fatalf("content missing SESSION type: %s", content) - } - if !strings.Contains(content, "test-session") { - t.Fatalf("content missing session name: %s", content) + if !strings.Contains(bs, "SESSION") { + t.Fatalf("gql body missing 'SESSION': %s", bs) } }