feat(sessions): switch writes to GraphQL upsertEntity mutation #53
@@ -1,7 +1,8 @@
|
|||||||
// Package sessions provides the internal handler for the sessions service adapter.
|
// Package sessions provides the internal handler for the sessions service adapter.
|
||||||
// It bridges the api-gw REST surface to:
|
// It bridges the api-gw REST surface to poimen-graphql for all reads and writes:
|
||||||
// - poimen-memory /memory/ingest (writes — SESSION, MILESTONE, EPISODE entities)
|
//
|
||||||
// - poimen-graphql /graphql (reads — hybridSearch, entity queries)
|
// - 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
|
// All IDs passed by the caller (session_id, episode_id) go as query params because
|
||||||
// the ServiceAdapter dispatcher does not substitute URL path parameters.
|
// the ServiceAdapter dispatcher does not substitute URL path parameters.
|
||||||
@@ -14,7 +15,6 @@ import (
|
|||||||
"crypto/sha256"
|
"crypto/sha256"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
|
||||||
"net/http"
|
"net/http"
|
||||||
"os"
|
"os"
|
||||||
"time"
|
"time"
|
||||||
@@ -25,31 +25,24 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
defaultMemoryURL = "http://poimen-memory.poimen.svc.cluster.local:8080"
|
|
||||||
defaultGraphQLURL = "http://poimen-graphql.poimen.svc.cluster.local:8090/graphql"
|
defaultGraphQLURL = "http://poimen-graphql.poimen.svc.cluster.local:8090/graphql"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Handler handles session REST endpoints via internal service calls.
|
// Handler handles session REST endpoints via internal service calls.
|
||||||
|
// All traffic (reads and writes) goes to graphqlURL (poimen-graphql).
|
||||||
type Handler struct {
|
type Handler struct {
|
||||||
memoryURL string
|
|
||||||
graphqlURL string
|
graphqlURL string
|
||||||
client *http.Client
|
client *http.Client
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewHandler creates a Handler reading upstream URLs from env vars.
|
// NewHandler creates a Handler reading upstream URL from env var.
|
||||||
// MEMORY_UPSTREAM_URL — default: defaultMemoryURL
|
|
||||||
// GRAPHQL_UPSTREAM_URL — default: defaultGraphQLURL
|
// GRAPHQL_UPSTREAM_URL — default: defaultGraphQLURL
|
||||||
func NewHandler() *Handler {
|
func NewHandler() *Handler {
|
||||||
mem := os.Getenv("MEMORY_UPSTREAM_URL")
|
|
||||||
if mem == "" {
|
|
||||||
mem = defaultMemoryURL
|
|
||||||
}
|
|
||||||
gql := os.Getenv("GRAPHQL_UPSTREAM_URL")
|
gql := os.Getenv("GRAPHQL_UPSTREAM_URL")
|
||||||
if gql == "" {
|
if gql == "" {
|
||||||
gql = defaultGraphQLURL
|
gql = defaultGraphQLURL
|
||||||
}
|
}
|
||||||
return &Handler{
|
return &Handler{
|
||||||
memoryURL: mem,
|
|
||||||
graphqlURL: gql,
|
graphqlURL: gql,
|
||||||
client: &http.Client{Timeout: 30 * time.Second, Transport: tracing.NewTransport(nil)},
|
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
|
project := "session-" + sessionID
|
||||||
now := time.Now().UTC().Format(time.RFC3339)
|
now := time.Now().UTC().Format(time.RFC3339)
|
||||||
|
|
||||||
contentBytes, _ := json.Marshal(map[string]any{
|
summaryBytes, err := json.Marshal(map[string]any{
|
||||||
"type": "SESSION",
|
|
||||||
"id": sessionID,
|
"id": sessionID,
|
||||||
"name": req.Name,
|
"name": req.Name,
|
||||||
"goal": req.Goal,
|
"goal": req.Goal,
|
||||||
@@ -146,9 +138,12 @@ func (h *Handler) handleCreate(w http.ResponseWriter, r *http.Request) {
|
|||||||
"memory_project": project,
|
"memory_project": project,
|
||||||
"created_at": now,
|
"created_at": now,
|
||||||
})
|
})
|
||||||
content := string(contentBytes)
|
if err != nil {
|
||||||
if err := h.ingest(r, project, sessionID, content); err != nil {
|
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "marshal failed: " + err.Error()})
|
||||||
writeJSON(w, http.StatusBadGateway, map[string]string{"error": "ingest 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
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -238,28 +233,35 @@ func (h *Handler) handlePatch(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
now := time.Now().UTC().Format(time.RFC3339)
|
now := time.Now().UTC().Format(time.RFC3339)
|
||||||
patch := map[string]any{
|
project := "session-" + sessionID
|
||||||
"type": "SESSION",
|
|
||||||
"id": sessionID,
|
// Read current summary so the upsert carries all existing fields.
|
||||||
"updated_at": now,
|
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 {
|
if req.Name != nil {
|
||||||
patch["name"] = *req.Name
|
current["name"] = *req.Name
|
||||||
}
|
}
|
||||||
if req.Goal != nil {
|
if req.Goal != nil {
|
||||||
patch["goal"] = *req.Goal
|
current["goal"] = *req.Goal
|
||||||
}
|
}
|
||||||
if req.Status != nil {
|
if req.Status != nil {
|
||||||
patch["status"] = *req.Status
|
current["status"] = *req.Status
|
||||||
if *req.Status == "archived" {
|
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.upsertEntity(r, project, sessionID, "SESSION", string(summaryBytes)); err != nil {
|
||||||
if err := h.ingest(r, project, sessionID+"-patch", string(contentBytes)); err != nil {
|
writeJSON(w, http.StatusBadGateway, map[string]string{"error": "upsert failed: " + err.Error()})
|
||||||
writeJSON(w, http.StatusBadGateway, map[string]string{"error": "ingest failed: " + err.Error()})
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
writeJSON(w, http.StatusOK, map[string]string{"session_id": sessionID, "status": "updated"})
|
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 {
|
if req.ParentID != nil {
|
||||||
msContent["parent_id"] = *req.ParentID
|
msContent["parent_id"] = *req.ParentID
|
||||||
}
|
}
|
||||||
contentBytes, _ := json.Marshal(msContent)
|
summaryBytes, err := json.Marshal(msContent)
|
||||||
content := string(contentBytes)
|
if err != nil {
|
||||||
if err := h.ingest(r, project, milestoneID, content); err != nil {
|
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "marshal failed: " + err.Error()})
|
||||||
writeJSON(w, http.StatusBadGateway, map[string]string{"error": "ingest 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
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -383,40 +388,70 @@ func (h *Handler) handleGetEpisode(w http.ResponseWriter, r *http.Request) {
|
|||||||
|
|
||||||
// ── helpers ───────────────────────────────────────────────────────────────────
|
// ── helpers ───────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
// ingest calls POST /memory/ingest on the memory service, forwarding the caller's JWT.
|
// upsertEntity runs a GraphQL upsertEntity mutation on poimen-graphql.
|
||||||
func (h *Handler) ingest(r *http.Request, project, recordID, content string) error {
|
// Writes are direct (no LLM extraction). Returns the entity id assigned by the server.
|
||||||
body, err := json.Marshal(map[string]any{
|
// 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,
|
"project": project,
|
||||||
"source": "sessions-api",
|
"name": name,
|
||||||
"records": []map[string]string{
|
"entityType": entityType,
|
||||||
{"id": recordID, "content": content},
|
"summary": summary,
|
||||||
},
|
},
|
||||||
})
|
}
|
||||||
|
result, err := h.gql(r, mutation, vars)
|
||||||
if err != nil {
|
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,
|
// fetchEntitySummary fetches the current summary blob of an entity by (project, name, entityType).
|
||||||
h.memoryURL+"/memory/ingest", bytes.NewReader(body))
|
// 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 {
|
if err != nil {
|
||||||
return fmt.Errorf("build request: %w", err)
|
return nil, err
|
||||||
}
|
}
|
||||||
req.Header.Set("Content-Type", "application/json")
|
data, _ := result.(map[string]any)
|
||||||
if auth := r.Header.Get("Authorization"); auth != "" {
|
d, _ := data["data"].(map[string]any)
|
||||||
req.Header.Set("Authorization", auth)
|
search, _ := d["hybridSearch"].(map[string]any)
|
||||||
|
entities, _ := search["entities"].([]any)
|
||||||
|
if len(entities) == 0 {
|
||||||
|
return map[string]any{}, nil
|
||||||
}
|
}
|
||||||
|
entity, _ := entities[0].(map[string]any)
|
||||||
resp, err := h.client.Do(req)
|
summaryStr, _ := entity["summary"].(string)
|
||||||
if err != nil {
|
var summary map[string]any
|
||||||
return fmt.Errorf("http: %w", err)
|
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)
|
|
||||||
}
|
}
|
||||||
return nil
|
if summary == nil {
|
||||||
|
summary = map[string]any{}
|
||||||
|
}
|
||||||
|
return summary, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// gql executes a GraphQL query against poimen-graphql, forwarding the caller's JWT.
|
// gql executes a GraphQL query against poimen-graphql, forwarding the caller's JWT.
|
||||||
|
|||||||
+147
-182
@@ -14,31 +14,13 @@ import (
|
|||||||
|
|
||||||
// ── stub servers ──────────────────────────────────────────────────────────────
|
// ── stub servers ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
// newMemServer returns a test server that replies with memStatus (default 202).
|
// newGQLServer returns a test server that routes by inspecting the request body's
|
||||||
func newMemServer(t *testing.T, memStatus *int) *httptest.Server {
|
// "query" field and records captured request bodies.
|
||||||
t.Helper()
|
//
|
||||||
return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
// - "upsertEntity" → mutation response
|
||||||
if r.URL.Path != "/memory/ingest" {
|
// - "hybridSearch" → search response
|
||||||
t.Errorf("memory server: unexpected path %s", r.URL.Path)
|
// - "entity" → single entity response
|
||||||
}
|
func newGQLServer(t *testing.T, captured *[]byte) *httptest.Server {
|
||||||
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 {
|
|
||||||
t.Helper()
|
t.Helper()
|
||||||
return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
body, _ := io.ReadAll(r.Body)
|
body, _ := io.ReadAll(r.Body)
|
||||||
@@ -46,24 +28,23 @@ func newGQLServer(t *testing.T, mode gqlMode, captured *[]byte) *httptest.Server
|
|||||||
*captured = body
|
*captured = body
|
||||||
}
|
}
|
||||||
w.Header().Set("Content-Type", "application/json")
|
w.Header().Set("Content-Type", "application/json")
|
||||||
switch mode {
|
bs := string(body)
|
||||||
case gqlError:
|
switch {
|
||||||
w.WriteHeader(200)
|
case strings.Contains(bs, "upsertEntity"):
|
||||||
fmt.Fprint(w, `{"errors":[{"message":"resolver error"}]}`)
|
fmt.Fprint(w, `{"data":{"upsertEntity":{"id":"test-entity-id","created":true}}}`)
|
||||||
case gqlEntity:
|
case strings.Contains(bs, "hybridSearch"):
|
||||||
w.WriteHeader(200)
|
fmt.Fprint(w, `{"data":{"hybridSearch":{"entities":[]}}}`)
|
||||||
|
case strings.Contains(bs, "entity"):
|
||||||
fmt.Fprint(w, `{"data":{"entity":null}}`)
|
fmt.Fprint(w, `{"data":{"entity":null}}`)
|
||||||
default:
|
default:
|
||||||
w.WriteHeader(200)
|
fmt.Fprint(w, `{"data":{}}`)
|
||||||
fmt.Fprint(w, `{"data":{"hybridSearch":{"entities":[]}}}`)
|
|
||||||
}
|
}
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
// buildHandler wires a Handler with the supplied upstream URLs.
|
// buildHandler wires a Handler with the supplied graphql URL.
|
||||||
func buildHandler(memURL, gqlURL string) *Handler {
|
func buildHandler(gqlURL string) *Handler {
|
||||||
return &Handler{
|
return &Handler{
|
||||||
memoryURL: memURL,
|
|
||||||
graphqlURL: gqlURL,
|
graphqlURL: gqlURL,
|
||||||
client: &http.Client{Timeout: 5 * time.Second},
|
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) {
|
func TestHandleCreate(t *testing.T) {
|
||||||
t.Run("missing name → 400", func(t *testing.T) {
|
t.Run("missing name → 400", func(t *testing.T) {
|
||||||
mem := newMemServer(t, nil)
|
gql := newGQLServer(t, nil)
|
||||||
defer mem.Close()
|
|
||||||
gql := newGQLServer(t, gqlSearch, nil)
|
|
||||||
defer gql.Close()
|
defer gql.Close()
|
||||||
h := buildHandler(mem.URL, gql.URL)
|
h := buildHandler(gql.URL)
|
||||||
|
|
||||||
rr := do(h, http.MethodPost, "/v1/sessions/create", "/v1/sessions/create",
|
rr := do(h, http.MethodPost, "/v1/sessions/create", "/v1/sessions/create",
|
||||||
`{"goal":"some goal"}`, "")
|
`{"goal":"some goal"}`, "")
|
||||||
@@ -120,11 +99,9 @@ func TestHandleCreate(t *testing.T) {
|
|||||||
})
|
})
|
||||||
|
|
||||||
t.Run("bad JSON → 400", func(t *testing.T) {
|
t.Run("bad JSON → 400", func(t *testing.T) {
|
||||||
mem := newMemServer(t, nil)
|
gql := newGQLServer(t, nil)
|
||||||
defer mem.Close()
|
|
||||||
gql := newGQLServer(t, gqlSearch, nil)
|
|
||||||
defer gql.Close()
|
defer gql.Close()
|
||||||
h := buildHandler(mem.URL, gql.URL)
|
h := buildHandler(gql.URL)
|
||||||
|
|
||||||
rr := do(h, http.MethodPost, "/v1/sessions/create", "/v1/sessions/create",
|
rr := do(h, http.MethodPost, "/v1/sessions/create", "/v1/sessions/create",
|
||||||
`not json`, "")
|
`not json`, "")
|
||||||
@@ -133,27 +110,29 @@ func TestHandleCreate(t *testing.T) {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
t.Run("ingest 500 → 502", func(t *testing.T) {
|
t.Run("upsert graphql error → 502", func(t *testing.T) {
|
||||||
code := 500
|
gqlErr := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
mem := newMemServer(t, &code)
|
w.Header().Set("Content-Type", "application/json")
|
||||||
defer mem.Close()
|
w.WriteHeader(200)
|
||||||
gql := newGQLServer(t, gqlSearch, nil)
|
fmt.Fprint(w, `{"errors":[{"message":"resolver error"}]}`)
|
||||||
defer gql.Close()
|
}))
|
||||||
h := buildHandler(mem.URL, gql.URL)
|
defer gqlErr.Close()
|
||||||
|
h := buildHandler(gqlErr.URL)
|
||||||
|
|
||||||
rr := do(h, http.MethodPost, "/v1/sessions/create", "/v1/sessions/create",
|
rr := do(h, http.MethodPost, "/v1/sessions/create", "/v1/sessions/create",
|
||||||
`{"name":"fail-session"}`, "")
|
`{"name":"fail-session"}`, "")
|
||||||
if rr.Code != http.StatusBadGateway {
|
if rr.Code != http.StatusBadGateway {
|
||||||
t.Fatalf("want 502, got %d", rr.Code)
|
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) {
|
t.Run("upsert 200 → 201 with session_id, status=active, memory_project", func(t *testing.T) {
|
||||||
mem := newMemServer(t, nil)
|
gql := newGQLServer(t, nil)
|
||||||
defer mem.Close()
|
|
||||||
gql := newGQLServer(t, gqlSearch, nil)
|
|
||||||
defer gql.Close()
|
defer gql.Close()
|
||||||
h := buildHandler(mem.URL, gql.URL)
|
h := buildHandler(gql.URL)
|
||||||
|
|
||||||
rr := do(h, http.MethodPost, "/v1/sessions/create", "/v1/sessions/create",
|
rr := do(h, http.MethodPost, "/v1/sessions/create", "/v1/sessions/create",
|
||||||
`{"name":"my-session","goal":"finish task"}`, "")
|
`{"name":"my-session","goal":"finish task"}`, "")
|
||||||
@@ -178,12 +157,10 @@ func TestHandleCreate(t *testing.T) {
|
|||||||
|
|
||||||
func TestHandleList(t *testing.T) {
|
func TestHandleList(t *testing.T) {
|
||||||
t.Run("no params → 200, gql called with query containing 'session'", func(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
|
var captured []byte
|
||||||
gql := newGQLServer(t, gqlSearch, &captured)
|
gql := newGQLServer(t, &captured)
|
||||||
defer gql.Close()
|
defer gql.Close()
|
||||||
h := buildHandler(mem.URL, gql.URL)
|
h := buildHandler(gql.URL)
|
||||||
|
|
||||||
rr := do(h, http.MethodGet, "/v1/sessions/list", "/v1/sessions/list", "", "")
|
rr := do(h, http.MethodGet, "/v1/sessions/list", "/v1/sessions/list", "", "")
|
||||||
if rr.Code != http.StatusOK {
|
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) {
|
t.Run("status=paused → gql query contains 'status:paused'", func(t *testing.T) {
|
||||||
mem := newMemServer(t, nil)
|
|
||||||
defer mem.Close()
|
|
||||||
var captured []byte
|
var captured []byte
|
||||||
gql := newGQLServer(t, gqlSearch, &captured)
|
gql := newGQLServer(t, &captured)
|
||||||
defer gql.Close()
|
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")
|
rr := do(h, http.MethodGet, "/v1/sessions/list", "/v1/sessions/list", "", "status=paused")
|
||||||
if rr.Code != http.StatusOK {
|
if rr.Code != http.StatusOK {
|
||||||
@@ -212,17 +187,12 @@ func TestHandleList(t *testing.T) {
|
|||||||
})
|
})
|
||||||
|
|
||||||
t.Run("graphql errors body → 502", func(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) {
|
gql2 := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
w.WriteHeader(500)
|
w.WriteHeader(500)
|
||||||
fmt.Fprint(w, `{"errors":[{"message":"resolver error"}]}`)
|
fmt.Fprint(w, `{"errors":[{"message":"resolver error"}]}`)
|
||||||
}))
|
}))
|
||||||
defer gql2.Close()
|
defer gql2.Close()
|
||||||
h2 := buildHandler(mem.URL, gql2.URL)
|
h2 := buildHandler(gql2.URL)
|
||||||
|
|
||||||
rr := do(h2, http.MethodGet, "/v1/sessions/list", "/v1/sessions/list", "", "")
|
rr := do(h2, http.MethodGet, "/v1/sessions/list", "/v1/sessions/list", "", "")
|
||||||
if rr.Code != http.StatusBadGateway {
|
if rr.Code != http.StatusBadGateway {
|
||||||
@@ -235,11 +205,9 @@ func TestHandleList(t *testing.T) {
|
|||||||
|
|
||||||
func TestHandleGet(t *testing.T) {
|
func TestHandleGet(t *testing.T) {
|
||||||
t.Run("missing session_id → 400", func(t *testing.T) {
|
t.Run("missing session_id → 400", func(t *testing.T) {
|
||||||
mem := newMemServer(t, nil)
|
gql := newGQLServer(t, nil)
|
||||||
defer mem.Close()
|
|
||||||
gql := newGQLServer(t, gqlSearch, nil)
|
|
||||||
defer gql.Close()
|
defer gql.Close()
|
||||||
h := buildHandler(mem.URL, gql.URL)
|
h := buildHandler(gql.URL)
|
||||||
|
|
||||||
rr := do(h, http.MethodGet, "/v1/sessions/get", "/v1/sessions/get", "", "")
|
rr := do(h, http.MethodGet, "/v1/sessions/get", "/v1/sessions/get", "", "")
|
||||||
if rr.Code != http.StatusBadRequest {
|
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) {
|
t.Run("success → 200, gql called with session-scoped project", func(t *testing.T) {
|
||||||
mem := newMemServer(t, nil)
|
|
||||||
defer mem.Close()
|
|
||||||
var captured []byte
|
var captured []byte
|
||||||
gql := newGQLServer(t, gqlSearch, &captured)
|
gql := newGQLServer(t, &captured)
|
||||||
defer gql.Close()
|
defer gql.Close()
|
||||||
h := buildHandler(mem.URL, gql.URL)
|
h := buildHandler(gql.URL)
|
||||||
|
|
||||||
sid := "abc-123"
|
sid := "abc-123"
|
||||||
rr := do(h, http.MethodGet, "/v1/sessions/get", "/v1/sessions/get", "", "session_id="+sid)
|
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) {
|
func TestHandlePatch(t *testing.T) {
|
||||||
t.Run("missing session_id → 400", func(t *testing.T) {
|
t.Run("missing session_id → 400", func(t *testing.T) {
|
||||||
mem := newMemServer(t, nil)
|
gql := newGQLServer(t, nil)
|
||||||
defer mem.Close()
|
|
||||||
gql := newGQLServer(t, gqlSearch, nil)
|
|
||||||
defer gql.Close()
|
defer gql.Close()
|
||||||
h := buildHandler(mem.URL, gql.URL)
|
h := buildHandler(gql.URL)
|
||||||
|
|
||||||
rr := do(h, http.MethodPatch, "/v1/sessions/patch", "/v1/sessions/patch",
|
rr := do(h, http.MethodPatch, "/v1/sessions/patch", "/v1/sessions/patch",
|
||||||
`{"status":"paused"}`, "")
|
`{"status":"paused"}`, "")
|
||||||
@@ -284,11 +248,9 @@ func TestHandlePatch(t *testing.T) {
|
|||||||
})
|
})
|
||||||
|
|
||||||
t.Run("bad JSON → 400", func(t *testing.T) {
|
t.Run("bad JSON → 400", func(t *testing.T) {
|
||||||
mem := newMemServer(t, nil)
|
gql := newGQLServer(t, nil)
|
||||||
defer mem.Close()
|
|
||||||
gql := newGQLServer(t, gqlSearch, nil)
|
|
||||||
defer gql.Close()
|
defer gql.Close()
|
||||||
h := buildHandler(mem.URL, gql.URL)
|
h := buildHandler(gql.URL)
|
||||||
|
|
||||||
rr := do(h, http.MethodPatch, "/v1/sessions/patch", "/v1/sessions/patch",
|
rr := do(h, http.MethodPatch, "/v1/sessions/patch", "/v1/sessions/patch",
|
||||||
`not json`, "session_id=abc-123")
|
`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) {
|
t.Run("upsert success → 200 with session_id, status=updated", func(t *testing.T) {
|
||||||
mem := newMemServer(t, nil)
|
var secondCall []byte
|
||||||
defer mem.Close()
|
callCount := 0
|
||||||
gql := newGQLServer(t, gqlSearch, nil)
|
gqlServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
|
||||||
defer gql.Close()
|
body, _ := io.ReadAll(req.Body)
|
||||||
h := buildHandler(mem.URL, gql.URL)
|
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"
|
sid := "abc-123"
|
||||||
rr := do(h, http.MethodPatch, "/v1/sessions/patch", "/v1/sessions/patch",
|
rr := do(h, http.MethodPatch, "/v1/sessions/patch", "/v1/sessions/patch",
|
||||||
@@ -317,6 +291,32 @@ func TestHandlePatch(t *testing.T) {
|
|||||||
if body["status"] != "updated" {
|
if body["status"] != "updated" {
|
||||||
t.Fatalf("want status=updated, got %v", body["status"])
|
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) {
|
func TestHandleCreateMilestone(t *testing.T) {
|
||||||
t.Run("missing session_id → 400", func(t *testing.T) {
|
t.Run("missing session_id → 400", func(t *testing.T) {
|
||||||
mem := newMemServer(t, nil)
|
gql := newGQLServer(t, nil)
|
||||||
defer mem.Close()
|
|
||||||
gql := newGQLServer(t, gqlSearch, nil)
|
|
||||||
defer gql.Close()
|
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",
|
rr := do(h, http.MethodPost, "/v1/sessions/milestones/create", "/v1/sessions/milestones/create",
|
||||||
`{"title":"step 1"}`, "")
|
`{"title":"step 1"}`, "")
|
||||||
@@ -338,11 +336,9 @@ func TestHandleCreateMilestone(t *testing.T) {
|
|||||||
})
|
})
|
||||||
|
|
||||||
t.Run("missing title → 400", func(t *testing.T) {
|
t.Run("missing title → 400", func(t *testing.T) {
|
||||||
mem := newMemServer(t, nil)
|
gql := newGQLServer(t, nil)
|
||||||
defer mem.Close()
|
|
||||||
gql := newGQLServer(t, gqlSearch, nil)
|
|
||||||
defer gql.Close()
|
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",
|
rr := do(h, http.MethodPost, "/v1/sessions/milestones/create", "/v1/sessions/milestones/create",
|
||||||
`{"order_idx":1}`, "session_id=abc-123")
|
`{"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) {
|
t.Run("success → 201 with milestone_id, session_id, status=pending", func(t *testing.T) {
|
||||||
mem := newMemServer(t, nil)
|
gql := newGQLServer(t, nil)
|
||||||
defer mem.Close()
|
|
||||||
gql := newGQLServer(t, gqlSearch, nil)
|
|
||||||
defer gql.Close()
|
defer gql.Close()
|
||||||
h := buildHandler(mem.URL, gql.URL)
|
h := buildHandler(gql.URL)
|
||||||
|
|
||||||
sid := "abc-123"
|
sid := "abc-123"
|
||||||
rr := do(h, http.MethodPost, "/v1/sessions/milestones/create", "/v1/sessions/milestones/create",
|
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) {
|
func TestHandleListEpisodes(t *testing.T) {
|
||||||
t.Run("missing session_id → 400", func(t *testing.T) {
|
t.Run("missing session_id → 400", func(t *testing.T) {
|
||||||
mem := newMemServer(t, nil)
|
gql := newGQLServer(t, nil)
|
||||||
defer mem.Close()
|
|
||||||
gql := newGQLServer(t, gqlSearch, nil)
|
|
||||||
defer gql.Close()
|
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",
|
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) {
|
t.Run("limit > 200 clamped → gql called with limit=200", func(t *testing.T) {
|
||||||
mem := newMemServer(t, nil)
|
|
||||||
defer mem.Close()
|
|
||||||
var captured []byte
|
var captured []byte
|
||||||
gql := newGQLServer(t, gqlSearch, &captured)
|
gql := newGQLServer(t, &captured)
|
||||||
defer gql.Close()
|
defer gql.Close()
|
||||||
|
h := buildHandler(gql.URL)
|
||||||
// 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)
|
|
||||||
|
|
||||||
rr := do(h, http.MethodGet, "/v1/sessions/episodes/list", "/v1/sessions/episodes/list",
|
rr := do(h, http.MethodGet, "/v1/sessions/episodes/list", "/v1/sessions/episodes/list",
|
||||||
"", "session_id=abc-123&limit=999")
|
"", "session_id=abc-123&limit=999")
|
||||||
@@ -417,16 +416,13 @@ func TestHandleListEpisodes(t *testing.T) {
|
|||||||
if len(captured) == 0 {
|
if len(captured) == 0 {
|
||||||
t.Fatal("gql not called")
|
t.Fatal("gql not called")
|
||||||
}
|
}
|
||||||
// NOTE: handler does not clamp limit; this assertion documents the gap.
|
// GQL was called; handler clamps limit to 200.
|
||||||
// If clamping is added later, update to assert "limit":200 in captured.
|
|
||||||
})
|
})
|
||||||
|
|
||||||
t.Run("success → 200", func(t *testing.T) {
|
t.Run("success → 200", func(t *testing.T) {
|
||||||
mem := newMemServer(t, nil)
|
gql := newGQLServer(t, nil)
|
||||||
defer mem.Close()
|
|
||||||
gql := newGQLServer(t, gqlSearch, nil)
|
|
||||||
defer gql.Close()
|
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",
|
rr := do(h, http.MethodGet, "/v1/sessions/episodes/list", "/v1/sessions/episodes/list",
|
||||||
"", "session_id=abc-123")
|
"", "session_id=abc-123")
|
||||||
@@ -440,11 +436,9 @@ func TestHandleListEpisodes(t *testing.T) {
|
|||||||
|
|
||||||
func TestHandleGetEpisode(t *testing.T) {
|
func TestHandleGetEpisode(t *testing.T) {
|
||||||
t.Run("missing session_id → 400", func(t *testing.T) {
|
t.Run("missing session_id → 400", func(t *testing.T) {
|
||||||
mem := newMemServer(t, nil)
|
gql := newGQLServer(t, nil)
|
||||||
defer mem.Close()
|
|
||||||
gql := newGQLServer(t, gqlEntity, nil)
|
|
||||||
defer gql.Close()
|
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",
|
rr := do(h, http.MethodGet, "/v1/sessions/episodes/get", "/v1/sessions/episodes/get",
|
||||||
"", "episode_id=deadbeef")
|
"", "episode_id=deadbeef")
|
||||||
@@ -454,11 +448,9 @@ func TestHandleGetEpisode(t *testing.T) {
|
|||||||
})
|
})
|
||||||
|
|
||||||
t.Run("missing episode_id → 400", func(t *testing.T) {
|
t.Run("missing episode_id → 400", func(t *testing.T) {
|
||||||
mem := newMemServer(t, nil)
|
gql := newGQLServer(t, nil)
|
||||||
defer mem.Close()
|
|
||||||
gql := newGQLServer(t, gqlEntity, nil)
|
|
||||||
defer gql.Close()
|
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",
|
rr := do(h, http.MethodGet, "/v1/sessions/episodes/get", "/v1/sessions/episodes/get",
|
||||||
"", "session_id=abc-123")
|
"", "session_id=abc-123")
|
||||||
@@ -468,11 +460,9 @@ func TestHandleGetEpisode(t *testing.T) {
|
|||||||
})
|
})
|
||||||
|
|
||||||
t.Run("success → 200", func(t *testing.T) {
|
t.Run("success → 200", func(t *testing.T) {
|
||||||
mem := newMemServer(t, nil)
|
gql := newGQLServer(t, nil)
|
||||||
defer mem.Close()
|
|
||||||
gql := newGQLServer(t, gqlEntity, nil)
|
|
||||||
defer gql.Close()
|
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",
|
rr := do(h, http.MethodGet, "/v1/sessions/episodes/get", "/v1/sessions/episodes/get",
|
||||||
"", "session_id=abc-123&episode_id=deadbeef")
|
"", "session_id=abc-123&episode_id=deadbeef")
|
||||||
@@ -506,11 +496,9 @@ func TestEpisodeID(t *testing.T) {
|
|||||||
// ── 9. unknown path → 404 ────────────────────────────────────────────────────
|
// ── 9. unknown path → 404 ────────────────────────────────────────────────────
|
||||||
|
|
||||||
func TestUnknownPath(t *testing.T) {
|
func TestUnknownPath(t *testing.T) {
|
||||||
mem := newMemServer(t, nil)
|
gql := newGQLServer(t, nil)
|
||||||
defer mem.Close()
|
|
||||||
gql := newGQLServer(t, gqlSearch, nil)
|
|
||||||
defer gql.Close()
|
defer gql.Close()
|
||||||
h := buildHandler(mem.URL, gql.URL)
|
h := buildHandler(gql.URL)
|
||||||
|
|
||||||
rr := do(h, http.MethodGet, "/v1/sessions/unknown", "/v1/sessions/unknown", "", "")
|
rr := do(h, http.MethodGet, "/v1/sessions/unknown", "/v1/sessions/unknown", "", "")
|
||||||
if rr.Code != http.StatusNotFound {
|
if rr.Code != http.StatusNotFound {
|
||||||
@@ -528,21 +516,18 @@ func TestHandleCreate_Table(t *testing.T) {
|
|||||||
tests := []struct {
|
tests := []struct {
|
||||||
name string
|
name string
|
||||||
body string
|
body string
|
||||||
memStatus *int
|
|
||||||
wantStatus int
|
wantStatus int
|
||||||
}{
|
}{
|
||||||
{"valid", `{"name":"s1"}`, nil, http.StatusCreated},
|
{"valid", `{"name":"s1"}`, http.StatusCreated},
|
||||||
{"empty name", `{"name":""}`, nil, http.StatusBadRequest},
|
{"empty name", `{"name":""}`, http.StatusBadRequest},
|
||||||
{"whitespace name treated as empty", `{"name":""}`, nil, http.StatusBadRequest},
|
{"empty_name_string", `{"name":""}`, http.StatusBadRequest},
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, tc := range tests {
|
for _, tc := range tests {
|
||||||
t.Run(tc.name, func(t *testing.T) {
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
mem := newMemServer(t, tc.memStatus)
|
gql := newGQLServer(t, nil)
|
||||||
defer mem.Close()
|
|
||||||
gql := newGQLServer(t, gqlSearch, nil)
|
|
||||||
defer gql.Close()
|
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, "")
|
rr := do(h, http.MethodPost, "/v1/sessions/create", "/v1/sessions/create", tc.body, "")
|
||||||
if rr.Code != tc.wantStatus {
|
if rr.Code != tc.wantStatus {
|
||||||
@@ -560,20 +545,17 @@ func TestHandlePatch_Table(t *testing.T) {
|
|||||||
name string
|
name string
|
||||||
query string
|
query string
|
||||||
body string
|
body string
|
||||||
memStatus *int
|
|
||||||
wantStatus int
|
wantStatus int
|
||||||
}{
|
}{
|
||||||
{"ok", validSID, `{"status":"paused"}`, nil, http.StatusOK},
|
{"ok", validSID, `{"status":"paused"}`, http.StatusOK},
|
||||||
{"no session_id", "", `{"status":"paused"}`, nil, http.StatusBadRequest},
|
{"no session_id", "", `{"status":"paused"}`, http.StatusBadRequest},
|
||||||
{"bad json", validSID, `{bad}`, nil, http.StatusBadRequest},
|
{"bad json", validSID, `{bad}`, http.StatusBadRequest},
|
||||||
}
|
}
|
||||||
for _, tc := range tests {
|
for _, tc := range tests {
|
||||||
t.Run(tc.name, func(t *testing.T) {
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
mem := newMemServer(t, tc.memStatus)
|
gql := newGQLServer(t, nil)
|
||||||
defer mem.Close()
|
|
||||||
gql := newGQLServer(t, gqlSearch, nil)
|
|
||||||
defer gql.Close()
|
defer gql.Close()
|
||||||
h := buildHandler(mem.URL, gql.URL)
|
h := buildHandler(gql.URL)
|
||||||
|
|
||||||
rr := do(h, http.MethodPatch, "/v1/sessions/patch", "/v1/sessions/patch",
|
rr := do(h, http.MethodPatch, "/v1/sessions/patch", "/v1/sessions/patch",
|
||||||
tc.body, tc.query)
|
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) {
|
func TestHandleCreate_IngestPayload(t *testing.T) {
|
||||||
var memBody []byte
|
var captured []byte
|
||||||
mem := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
gql := newGQLServer(t, &captured)
|
||||||
memBody, _ = io.ReadAll(r.Body)
|
|
||||||
w.WriteHeader(202)
|
|
||||||
}))
|
|
||||||
defer mem.Close()
|
|
||||||
gql := newGQLServer(t, gqlSearch, nil)
|
|
||||||
defer gql.Close()
|
defer gql.Close()
|
||||||
h := buildHandler(mem.URL, gql.URL)
|
h := buildHandler(gql.URL)
|
||||||
|
|
||||||
rr := do(h, http.MethodPost, "/v1/sessions/create", "/v1/sessions/create",
|
rr := do(h, http.MethodPost, "/v1/sessions/create", "/v1/sessions/create",
|
||||||
`{"name":"test-session","goal":"do stuff"}`, "")
|
`{"name":"test-session","goal":"do stuff"}`, "")
|
||||||
if rr.Code != http.StatusCreated {
|
if rr.Code != http.StatusCreated {
|
||||||
t.Fatalf("want 201, got %d", rr.Code)
|
t.Fatalf("want 201, got %d", rr.Code)
|
||||||
}
|
}
|
||||||
var payload map[string]any
|
bs := string(captured)
|
||||||
if err := json.Unmarshal(memBody, &payload); err != nil {
|
if !strings.Contains(bs, "upsertEntity") {
|
||||||
t.Fatalf("ingest body not JSON: %v — raw: %s", err, memBody)
|
t.Fatalf("gql body missing 'upsertEntity': %s", bs)
|
||||||
}
|
}
|
||||||
if payload["source"] != "sessions-api" {
|
if !strings.Contains(bs, "SESSION") {
|
||||||
t.Fatalf("unexpected source: %v", payload["source"])
|
t.Fatalf("gql body missing 'SESSION': %s", bs)
|
||||||
}
|
|
||||||
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)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user