multica/server/internal/handler/issue_metadata_test.go

266 lines
9.5 KiB
Go

package handler
import (
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"net/url"
"strings"
"testing"
)
// Round-trip: set primitives of each type, list, get them back, delete, confirm gone.
func TestIssueMetadataSetGetDelete(t *testing.T) {
issueID := createMetadataTestIssue(t, "Metadata round-trip")
cases := []struct {
key string
value string // raw JSON value
}{
{"pipeline_status", `"waiting"`},
{"pr_number", `482`},
{"is_blocked", `true`},
{"is_done", `false`},
}
for _, c := range cases {
w := httptest.NewRecorder()
req := newRequest("PUT", "/api/issues/"+issueID+"/metadata/"+c.key, json.RawMessage(`{"value":`+c.value+`}`))
req = withURLParams(req, "id", issueID, "key", c.key)
testHandler.SetIssueMetadataKey(w, req)
if w.Code != http.StatusOK {
t.Fatalf("Set %s=%s: expected 200, got %d: %s", c.key, c.value, w.Code, w.Body.String())
}
}
// List returns every key with the right value type.
w := httptest.NewRecorder()
req := newRequest("GET", "/api/issues/"+issueID+"/metadata", nil)
req = withURLParam(req, "id", issueID)
testHandler.ListIssueMetadata(w, req)
if w.Code != http.StatusOK {
t.Fatalf("List metadata: expected 200, got %d: %s", w.Code, w.Body.String())
}
var resp struct {
Metadata map[string]any `json:"metadata"`
}
if err := json.NewDecoder(w.Body).Decode(&resp); err != nil {
t.Fatalf("decode list response: %v", err)
}
if got := resp.Metadata["pipeline_status"]; got != "waiting" {
t.Errorf("pipeline_status: expected \"waiting\", got %T %v", got, got)
}
if got := resp.Metadata["pr_number"]; got != float64(482) {
t.Errorf("pr_number: expected number 482, got %T %v", got, got)
}
if got := resp.Metadata["is_blocked"]; got != true {
t.Errorf("is_blocked: expected true, got %T %v", got, got)
}
if got := resp.Metadata["is_done"]; got != false {
t.Errorf("is_done: expected false, got %T %v", got, got)
}
// Delete a key — refresh confirms it is gone, others remain.
w = httptest.NewRecorder()
req = newRequest("DELETE", "/api/issues/"+issueID+"/metadata/pipeline_status", nil)
req = withURLParams(req, "id", issueID, "key", "pipeline_status")
testHandler.DeleteIssueMetadataKey(w, req)
if w.Code != http.StatusOK {
t.Fatalf("Delete pipeline_status: expected 200, got %d: %s", w.Code, w.Body.String())
}
w = httptest.NewRecorder()
req = newRequest("GET", "/api/issues/"+issueID+"/metadata", nil)
req = withURLParam(req, "id", issueID)
testHandler.ListIssueMetadata(w, req)
// Decode into a fresh struct — json.Decode into a non-nil map merges,
// it does not replace, so reusing `resp` would keep deleted keys around.
var afterDelete struct {
Metadata map[string]any `json:"metadata"`
}
json.NewDecoder(w.Body).Decode(&afterDelete)
if _, present := afterDelete.Metadata["pipeline_status"]; present {
t.Errorf("after delete, pipeline_status should be gone; got %+v", afterDelete.Metadata)
}
if _, present := afterDelete.Metadata["pr_number"]; !present {
t.Errorf("delete removed unrelated key; got %+v", afterDelete.Metadata)
}
}
// Invalid keys / values / shapes are rejected with 400 — the regex, primitive,
// and "no null" rules must all hold.
func TestIssueMetadataValidation(t *testing.T) {
issueID := createMetadataTestIssue(t, "Metadata validation")
bad := []struct {
name string
key string
rawBody string
}{
{"key starts with digit", "1attempts", `{"value":"x"}`},
{"key has space", "foo bar", `{"value":"x"}`},
{"value is null", "k", `{"value":null}`},
{"value is array", "k", `{"value":[1,2]}`},
{"value is object", "k", `{"value":{"a":1}}`},
{"empty body", "k", ``},
}
for _, c := range bad {
t.Run(c.name, func(t *testing.T) {
w := httptest.NewRecorder()
// chi pulls the key from URL params (injected via withURLParams);
// the raw URL needs to be a valid request line, so PathEscape any
// chars (spaces, etc.) that would otherwise break httptest.NewRequest.
req := newRequest("PUT", "/api/issues/"+issueID+"/metadata/"+url.PathEscape(c.key), json.RawMessage(c.rawBody))
req = withURLParams(req, "id", issueID, "key", c.key)
testHandler.SetIssueMetadataKey(w, req)
if w.Code != http.StatusBadRequest {
t.Fatalf("expected 400, got %d: %s", w.Code, w.Body.String())
}
})
}
}
// The 8KB DB CHECK kicks in past a few hundred KV pairs of large strings; we
// blow it deliberately with one giant value to confirm the handler surfaces
// a 400 (not a generic 500).
func TestIssueMetadataSizeLimit(t *testing.T) {
issueID := createMetadataTestIssue(t, "Metadata size limit")
huge := strings.Repeat("a", 9000)
body, _ := json.Marshal(map[string]any{"value": huge})
w := httptest.NewRecorder()
req := newRequest("PUT", "/api/issues/"+issueID+"/metadata/blob", body)
req = withURLParams(req, "id", issueID, "key", "blob")
testHandler.SetIssueMetadataKey(w, req)
if w.Code != http.StatusBadRequest {
t.Fatalf("expected 400 from size CHECK, got %d: %s", w.Code, w.Body.String())
}
}
// The 50-key cap is enforced in the handler with a clear 400.
func TestIssueMetadataKeyCountCap(t *testing.T) {
issueID := createMetadataTestIssue(t, "Metadata key count cap")
for i := 0; i < maxIssueMetadataKeys; i++ {
key := fmt.Sprintf("k_%d", i)
w := httptest.NewRecorder()
req := newRequest("PUT", "/api/issues/"+issueID+"/metadata/"+key, json.RawMessage(`{"value":"v"}`))
req = withURLParams(req, "id", issueID, "key", key)
testHandler.SetIssueMetadataKey(w, req)
if w.Code != http.StatusOK {
t.Fatalf("key #%d: expected 200, got %d: %s", i, w.Code, w.Body.String())
}
}
w := httptest.NewRecorder()
req := newRequest("PUT", "/api/issues/"+issueID+"/metadata/overflow", json.RawMessage(`{"value":"v"}`))
req = withURLParams(req, "id", issueID, "key", "overflow")
testHandler.SetIssueMetadataKey(w, req)
if w.Code != http.StatusBadRequest {
t.Fatalf("overflow key: expected 400, got %d: %s", w.Code, w.Body.String())
}
// Updating an existing key past the cap is still allowed — only new keys
// are blocked.
w = httptest.NewRecorder()
req = newRequest("PUT", "/api/issues/"+issueID+"/metadata/k_0", json.RawMessage(`{"value":"v2"}`))
req = withURLParams(req, "id", issueID, "key", "k_0")
testHandler.SetIssueMetadataKey(w, req)
if w.Code != http.StatusOK {
t.Fatalf("update existing at cap: expected 200, got %d: %s", w.Code, w.Body.String())
}
}
// ListIssues with `metadata` query param does JSONB containment filtering and
// returns only matching issues — the killer use case for autopilot.
func TestListIssuesMetadataFilter(t *testing.T) {
waitingID := createMetadataTestIssue(t, "Waiting issue")
doneID := createMetadataTestIssue(t, "Done issue")
for issueID, status := range map[string]string{waitingID: "waiting_review", doneID: "deployed"} {
w := httptest.NewRecorder()
req := newRequest("PUT", "/api/issues/"+issueID+"/metadata/pipeline_status",
json.RawMessage(`{"value":"`+status+`"}`))
req = withURLParams(req, "id", issueID, "key", "pipeline_status")
testHandler.SetIssueMetadataKey(w, req)
if w.Code != http.StatusOK {
t.Fatalf("seed %s: %d %s", issueID, w.Code, w.Body.String())
}
}
w := httptest.NewRecorder()
req := newRequest("GET", `/api/issues?metadata={"pipeline_status":"waiting_review"}`, nil)
testHandler.ListIssues(w, req)
if w.Code != http.StatusOK {
t.Fatalf("List with filter: expected 200, got %d: %s", w.Code, w.Body.String())
}
var listResp struct {
Issues []IssueResponse `json:"issues"`
}
json.NewDecoder(w.Body).Decode(&listResp)
foundWaiting := false
for _, iss := range listResp.Issues {
if iss.ID == doneID {
t.Errorf("filter leaked: deployed issue %s appeared in waiting_review result set", doneID)
}
if iss.ID == waitingID {
foundWaiting = true
if got, _ := iss.Metadata["pipeline_status"].(string); got != "waiting_review" {
t.Errorf("waiting issue: pipeline_status not surfaced; got %v", iss.Metadata)
}
}
}
if !foundWaiting {
t.Errorf("waiting issue %s missing from filter result; got %d issues", waitingID, len(listResp.Issues))
}
// Malformed filter → 400.
w = httptest.NewRecorder()
req = newRequest("GET", `/api/issues?metadata={not-json}`, nil)
testHandler.ListIssues(w, req)
if w.Code != http.StatusBadRequest {
t.Fatalf("malformed metadata: expected 400, got %d", w.Code)
}
}
// New issues default to an empty metadata object — never null — so frontend
// reads like `issue.metadata[key]` never NPE.
func TestNewIssueDefaultsToEmptyMetadata(t *testing.T) {
issueID := createMetadataTestIssue(t, "Default empty metadata")
w := httptest.NewRecorder()
req := newRequest("GET", "/api/issues/"+issueID, nil)
req = withURLParam(req, "id", issueID)
testHandler.GetIssue(w, req)
if w.Code != http.StatusOK {
t.Fatalf("GetIssue: %d %s", w.Code, w.Body.String())
}
var got IssueResponse
json.NewDecoder(w.Body).Decode(&got)
if got.Metadata == nil {
t.Fatalf("Metadata is nil on a fresh issue; expected empty object")
}
if len(got.Metadata) != 0 {
t.Fatalf("Metadata: expected empty, got %v", got.Metadata)
}
}
func createMetadataTestIssue(t *testing.T, title string) string {
t.Helper()
w := httptest.NewRecorder()
req := newRequest("POST", "/api/issues?workspace_id="+testWorkspaceID, map[string]any{
"title": title,
"status": "todo",
"priority": "medium",
})
testHandler.CreateIssue(w, req)
if w.Code != http.StatusCreated {
t.Fatalf("createMetadataTestIssue: %d %s", w.Code, w.Body.String())
}
var issue IssueResponse
if err := json.NewDecoder(w.Body).Decode(&issue); err != nil {
t.Fatalf("decode issue: %v", err)
}
return issue.ID
}