mirror of
https://github.com/multica-ai/multica.git
synced 2026-07-16 14:49:09 +02:00
ListOpenIssues takes the parsed AND-of-ORs containment groups as a single jsonb properties_filter param and unrolls them with a static double NOT EXISTS; previously the open_only path parsed the properties param and silently dropped it (clean-room review F7a). Co-authored-by: multica-agent <github@multica.ai>
564 lines
23 KiB
Go
564 lines
23 KiB
Go
package handler
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"net/url"
|
|
"strings"
|
|
"testing"
|
|
|
|
"github.com/go-chi/chi/v5"
|
|
"github.com/google/uuid"
|
|
|
|
db "github.com/multica-ai/multica/server/pkg/db/generated"
|
|
)
|
|
|
|
func makePropertyDef(propType string, options []PropertyOption) db.IssueProperty {
|
|
cfg, _ := json.Marshal(PropertyConfig{Options: options})
|
|
return db.IssueProperty{Type: propType, Config: cfg}
|
|
}
|
|
|
|
// withIssuePropertyParams sets both chi URL params in one route context —
|
|
// withURLParam builds a fresh context per call, so chaining it would drop
|
|
// the first param.
|
|
func withIssuePropertyParams(req *http.Request, issueID, propertyID string) *http.Request {
|
|
rctx := chi.NewRouteContext()
|
|
rctx.URLParams.Add("id", issueID)
|
|
rctx.URLParams.Add("propertyId", propertyID)
|
|
return req.WithContext(context.WithValue(req.Context(), chi.RouteCtxKey, rctx))
|
|
}
|
|
|
|
func createTestProperty(t *testing.T, body map[string]any) PropertyResponse {
|
|
t.Helper()
|
|
w := httptest.NewRecorder()
|
|
req := newRequest("POST", "/api/properties", body)
|
|
testHandler.CreateProperty(w, req)
|
|
if w.Code != http.StatusCreated {
|
|
t.Fatalf("CreateProperty: expected 201, got %d: %s", w.Code, w.Body.String())
|
|
}
|
|
var created PropertyResponse
|
|
json.NewDecoder(w.Body).Decode(&created)
|
|
t.Cleanup(func() { deleteTestProperty(t, created.ID) })
|
|
return created
|
|
}
|
|
|
|
// deleteTestProperty removes the row directly — the API only archives, but
|
|
// tests must not leak definitions into the shared workspace fixture (the
|
|
// 20-active cap and list assertions would couple unrelated tests).
|
|
func deleteTestProperty(t *testing.T, id string) {
|
|
t.Helper()
|
|
if _, err := testPool.Exec(context.Background(), `DELETE FROM issue_property WHERE id = $1`, id); err != nil {
|
|
t.Fatalf("cleanup property %s: %v", id, err)
|
|
}
|
|
}
|
|
|
|
func createPropertyTestIssue(t *testing.T, title string) string {
|
|
t.Helper()
|
|
var issueID string
|
|
if err := testPool.QueryRow(context.Background(), `
|
|
INSERT INTO issue (workspace_id, title, status, priority, creator_type, creator_id, number)
|
|
VALUES ($1, $2, 'todo', 'none', 'member', $3,
|
|
COALESCE((SELECT MAX(number) FROM issue WHERE workspace_id = $1), 0) + 1)
|
|
RETURNING id
|
|
`, testWorkspaceID, title, testUserID).Scan(&issueID); err != nil {
|
|
t.Fatalf("create test issue: %v", err)
|
|
}
|
|
t.Cleanup(func() {
|
|
testPool.Exec(context.Background(), `DELETE FROM issue WHERE id = $1`, issueID)
|
|
})
|
|
return issueID
|
|
}
|
|
|
|
func setIssuePropertyRaw(t *testing.T, issueID, propertyID string, value any) *httptest.ResponseRecorder {
|
|
t.Helper()
|
|
w := httptest.NewRecorder()
|
|
req := newRequest("PUT", "/api/issues/"+issueID+"/properties/"+propertyID, map[string]any{"value": value})
|
|
req = withIssuePropertyParams(req, issueID, propertyID)
|
|
testHandler.SetIssueProperty(w, req)
|
|
return w
|
|
}
|
|
|
|
func TestPropertyDefinitionCRUD(t *testing.T) {
|
|
created := createTestProperty(t, map[string]any{
|
|
"name": "Severity",
|
|
"type": "select",
|
|
"description": "How bad it is",
|
|
"config": map[string]any{"options": []map[string]any{
|
|
{"name": "Critical", "color": "EF4444"},
|
|
{"name": "Minor", "color": "#6b7280"},
|
|
}},
|
|
})
|
|
if created.Type != "select" || len(created.Config.Options) != 2 {
|
|
t.Fatalf("unexpected created property: %+v", created)
|
|
}
|
|
// Server assigns option ids and normalizes colors to lowercase #rrggbb.
|
|
if created.Config.Options[0].ID == "" {
|
|
t.Fatalf("option id not assigned: %+v", created.Config.Options[0])
|
|
}
|
|
if created.Config.Options[0].Color != "#ef4444" {
|
|
t.Fatalf("color not normalized: %q", created.Config.Options[0].Color)
|
|
}
|
|
|
|
// Duplicate name (case-insensitive) → 409.
|
|
w := httptest.NewRecorder()
|
|
req := newRequest("POST", "/api/properties", map[string]any{
|
|
"name": "severity", "type": "text",
|
|
})
|
|
testHandler.CreateProperty(w, req)
|
|
if w.Code != http.StatusConflict {
|
|
t.Fatalf("duplicate name: expected 409, got %d: %s", w.Code, w.Body.String())
|
|
}
|
|
|
|
// Rename + replace options, keeping the first option's id: values that
|
|
// reference it must survive option-list edits.
|
|
keepID := created.Config.Options[0].ID
|
|
w = httptest.NewRecorder()
|
|
req = newRequest("PATCH", "/api/properties/"+created.ID, map[string]any{
|
|
"name": "Sev",
|
|
"config": map[string]any{"options": []map[string]any{
|
|
{"id": keepID, "name": "Blocker", "color": "#ef4444"},
|
|
{"name": "Trivial", "color": "#a1a1aa"},
|
|
}},
|
|
})
|
|
req = withURLParam(req, "id", created.ID)
|
|
testHandler.UpdateProperty(w, req)
|
|
if w.Code != http.StatusOK {
|
|
t.Fatalf("UpdateProperty: expected 200, got %d: %s", w.Code, w.Body.String())
|
|
}
|
|
var updated PropertyResponse
|
|
json.NewDecoder(w.Body).Decode(&updated)
|
|
if updated.Name != "Sev" || updated.Config.Options[0].ID != keepID || updated.Config.Options[0].Name != "Blocker" {
|
|
t.Fatalf("option id not preserved on update: %+v", updated.Config.Options)
|
|
}
|
|
|
|
// Archive → default list hides it, include_archived shows it.
|
|
w = httptest.NewRecorder()
|
|
req = newRequest("PATCH", "/api/properties/"+created.ID, map[string]any{"archived": true})
|
|
req = withURLParam(req, "id", created.ID)
|
|
testHandler.UpdateProperty(w, req)
|
|
if w.Code != http.StatusOK {
|
|
t.Fatalf("archive: expected 200, got %d: %s", w.Code, w.Body.String())
|
|
}
|
|
|
|
listProperties := func(query string) []PropertyResponse {
|
|
w := httptest.NewRecorder()
|
|
testHandler.ListProperties(w, newRequest("GET", "/api/properties"+query, nil))
|
|
if w.Code != http.StatusOK {
|
|
t.Fatalf("ListProperties%s: expected 200, got %d: %s", query, w.Code, w.Body.String())
|
|
}
|
|
var resp struct {
|
|
Properties []PropertyResponse `json:"properties"`
|
|
}
|
|
json.NewDecoder(w.Body).Decode(&resp)
|
|
return resp.Properties
|
|
}
|
|
contains := func(list []PropertyResponse, id string) bool {
|
|
for _, p := range list {
|
|
if p.ID == id {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
if contains(listProperties(""), created.ID) {
|
|
t.Fatalf("archived property still in default list")
|
|
}
|
|
if !contains(listProperties("?include_archived=true"), created.ID) {
|
|
t.Fatalf("archived property missing from include_archived list")
|
|
}
|
|
}
|
|
|
|
func TestPropertyDefinitionValidation(t *testing.T) {
|
|
cases := []struct {
|
|
name string
|
|
body map[string]any
|
|
want string
|
|
}{
|
|
{"reserved name", map[string]any{"name": "Due Date", "type": "text"}, "reserved"},
|
|
{"invalid type", map[string]any{"name": "X" + uuid.NewString()[:8], "type": "formula"}, "invalid type"},
|
|
{"options on text", map[string]any{"name": "X" + uuid.NewString()[:8], "type": "text",
|
|
"config": map[string]any{"options": []map[string]any{{"name": "a", "color": "#000000"}}}}, "does not accept options"},
|
|
{"select without options", map[string]any{"name": "X" + uuid.NewString()[:8], "type": "select"}, "at least one option"},
|
|
{"duplicate option names", map[string]any{"name": "X" + uuid.NewString()[:8], "type": "select",
|
|
"config": map[string]any{"options": []map[string]any{
|
|
{"name": "One", "color": "#000000"}, {"name": "one", "color": "#111111"},
|
|
}}}, "duplicate option name"},
|
|
}
|
|
for _, tc := range cases {
|
|
w := httptest.NewRecorder()
|
|
testHandler.CreateProperty(w, newRequest("POST", "/api/properties", tc.body))
|
|
if w.Code != http.StatusBadRequest || !strings.Contains(w.Body.String(), tc.want) {
|
|
t.Fatalf("%s: expected 400 containing %q, got %d: %s", tc.name, tc.want, w.Code, w.Body.String())
|
|
}
|
|
}
|
|
}
|
|
|
|
// TestPropertyAdminGate verifies the two definition-management gates: agent
|
|
// actors are rejected outright (even though the fixture user is the workspace
|
|
// owner), while value writes from the same agent context succeed.
|
|
func TestPropertyAdminGate(t *testing.T) {
|
|
// Agent actor (task_token path is trusted directly by resolveActor).
|
|
w := httptest.NewRecorder()
|
|
req := newRequest("POST", "/api/properties", map[string]any{"name": "AgentMade", "type": "text"})
|
|
req.Header.Set("X-Actor-Source", "task_token")
|
|
req.Header.Set("X-Agent-ID", uuid.NewString())
|
|
testHandler.CreateProperty(w, req)
|
|
if w.Code != http.StatusForbidden {
|
|
t.Fatalf("agent CreateProperty: expected 403, got %d: %s", w.Code, w.Body.String())
|
|
}
|
|
|
|
property := createTestProperty(t, map[string]any{"name": "AgentWritable" + uuid.NewString()[:8], "type": "text"})
|
|
issueID := createPropertyTestIssue(t, "agent value write")
|
|
|
|
w = httptest.NewRecorder()
|
|
req = newRequest("PUT", "/api/issues/"+issueID+"/properties/"+property.ID, map[string]any{"value": "set by agent"})
|
|
req.Header.Set("X-Actor-Source", "task_token")
|
|
req.Header.Set("X-Agent-ID", uuid.NewString())
|
|
req = withIssuePropertyParams(req, issueID, property.ID)
|
|
testHandler.SetIssueProperty(w, req)
|
|
if w.Code != http.StatusOK {
|
|
t.Fatalf("agent SetIssueProperty: expected 200, got %d: %s", w.Code, w.Body.String())
|
|
}
|
|
}
|
|
|
|
func TestIssuePropertyValues(t *testing.T) {
|
|
sel := createTestProperty(t, map[string]any{
|
|
"name": "Env" + uuid.NewString()[:8], "type": "select",
|
|
"config": map[string]any{"options": []map[string]any{
|
|
{"name": "Staging", "color": "#22c55e"},
|
|
{"name": "Production", "color": "#ef4444"},
|
|
}},
|
|
})
|
|
multi := createTestProperty(t, map[string]any{
|
|
"name": "Platforms" + uuid.NewString()[:8], "type": "multi_select",
|
|
"config": map[string]any{"options": []map[string]any{
|
|
{"name": "iOS", "color": "#3b82f6"},
|
|
{"name": "Android", "color": "#22c55e"},
|
|
{"name": "Web", "color": "#f59e0b"},
|
|
}},
|
|
})
|
|
date := createTestProperty(t, map[string]any{"name": "Reviewed" + uuid.NewString()[:8], "type": "date"})
|
|
link := createTestProperty(t, map[string]any{"name": "Spec" + uuid.NewString()[:8], "type": "url"})
|
|
num := createTestProperty(t, map[string]any{"name": "Effort" + uuid.NewString()[:8], "type": "number"})
|
|
|
|
issueID := createPropertyTestIssue(t, "property value matrix")
|
|
|
|
// select: valid option id.
|
|
if w := setIssuePropertyRaw(t, issueID, sel.ID, sel.Config.Options[0].ID); w.Code != http.StatusOK {
|
|
t.Fatalf("select set: expected 200, got %d: %s", w.Code, w.Body.String())
|
|
}
|
|
// select: unknown option → 400 listing legal ids (agents self-correct on this).
|
|
if w := setIssuePropertyRaw(t, issueID, sel.ID, "nope"); w.Code != http.StatusBadRequest ||
|
|
!strings.Contains(w.Body.String(), sel.Config.Options[0].ID) {
|
|
t.Fatalf("select invalid: expected 400 listing option ids, got %d: %s", w.Code, w.Body.String())
|
|
}
|
|
|
|
// multi_select: duplicates dropped, order canonicalized to config order.
|
|
webID, iosID := multi.Config.Options[2].ID, multi.Config.Options[0].ID
|
|
w := setIssuePropertyRaw(t, issueID, multi.ID, []string{webID, iosID, webID})
|
|
if w.Code != http.StatusOK {
|
|
t.Fatalf("multi_select set: expected 200, got %d: %s", w.Code, w.Body.String())
|
|
}
|
|
var resp struct {
|
|
Properties map[string]any `json:"properties"`
|
|
}
|
|
json.NewDecoder(w.Body).Decode(&resp)
|
|
stored, _ := resp.Properties[multi.ID].([]any)
|
|
if len(stored) != 2 || stored[0] != iosID || stored[1] != webID {
|
|
t.Fatalf("multi_select not canonicalized to config order: %v", stored)
|
|
}
|
|
|
|
// date / url / number validation.
|
|
if w := setIssuePropertyRaw(t, issueID, date.ID, "13/07/2026"); w.Code != http.StatusBadRequest {
|
|
t.Fatalf("bad date: expected 400, got %d", w.Code)
|
|
}
|
|
if w := setIssuePropertyRaw(t, issueID, date.ID, "2026-07-13"); w.Code != http.StatusOK {
|
|
t.Fatalf("good date: expected 200, got %d: %s", w.Code, w.Body.String())
|
|
}
|
|
if w := setIssuePropertyRaw(t, issueID, link.ID, "javascript:alert(1)"); w.Code != http.StatusBadRequest {
|
|
t.Fatalf("bad url: expected 400, got %d", w.Code)
|
|
}
|
|
if w := setIssuePropertyRaw(t, issueID, link.ID, "https://example.com/spec"); w.Code != http.StatusOK {
|
|
t.Fatalf("good url: expected 200, got %d: %s", w.Code, w.Body.String())
|
|
}
|
|
if w := setIssuePropertyRaw(t, issueID, num.ID, "3"); w.Code != http.StatusBadRequest {
|
|
t.Fatalf("string into number: expected 400, got %d", w.Code)
|
|
}
|
|
if w := setIssuePropertyRaw(t, issueID, num.ID, 3.5); w.Code != http.StatusOK {
|
|
t.Fatalf("good number: expected 200, got %d: %s", w.Code, w.Body.String())
|
|
}
|
|
|
|
// Archived definitions reject new values but allow unset.
|
|
warch := httptest.NewRecorder()
|
|
req := newRequest("PATCH", "/api/properties/"+sel.ID, map[string]any{"archived": true})
|
|
req = withURLParam(req, "id", sel.ID)
|
|
testHandler.UpdateProperty(warch, req)
|
|
if warch.Code != http.StatusOK {
|
|
t.Fatalf("archive: expected 200, got %d: %s", warch.Code, warch.Body.String())
|
|
}
|
|
if w := setIssuePropertyRaw(t, issueID, sel.ID, sel.Config.Options[1].ID); w.Code != http.StatusBadRequest {
|
|
t.Fatalf("set on archived: expected 400, got %d: %s", w.Code, w.Body.String())
|
|
}
|
|
wdel := httptest.NewRecorder()
|
|
req = newRequest("DELETE", "/api/issues/"+issueID+"/properties/"+sel.ID, nil)
|
|
req = withIssuePropertyParams(req, issueID, sel.ID)
|
|
testHandler.DeleteIssueProperty(wdel, req)
|
|
if wdel.Code != http.StatusOK {
|
|
t.Fatalf("unset on archived: expected 200, got %d: %s", wdel.Code, wdel.Body.String())
|
|
}
|
|
// Fresh struct: json.Decode merges into a pre-populated map, which would
|
|
// leave the earlier bag contents (including sel.ID) in place.
|
|
var afterDelete struct {
|
|
Properties map[string]any `json:"properties"`
|
|
}
|
|
json.NewDecoder(wdel.Body).Decode(&afterDelete)
|
|
if _, present := afterDelete.Properties[sel.ID]; present {
|
|
t.Fatalf("value not removed: %v", afterDelete.Properties)
|
|
}
|
|
}
|
|
|
|
func TestValidatePropertyValueUnit(t *testing.T) {
|
|
textDef := makePropertyDef("text", nil)
|
|
if _, err := validatePropertyValue(textDef, json.RawMessage(`" "`)); err == nil {
|
|
t.Fatalf("blank text accepted")
|
|
}
|
|
if _, err := validatePropertyValue(textDef, json.RawMessage(`"`+strings.Repeat("x", 2001)+`"`)); err == nil {
|
|
t.Fatalf("overlong text accepted")
|
|
}
|
|
if _, err := validatePropertyValue(textDef, json.RawMessage(`null`)); err == nil {
|
|
t.Fatalf("null accepted")
|
|
}
|
|
boolDef := makePropertyDef("checkbox", nil)
|
|
if _, err := validatePropertyValue(boolDef, json.RawMessage(`"true"`)); err == nil {
|
|
t.Fatalf("string into checkbox accepted")
|
|
}
|
|
if _, err := validatePropertyValue(boolDef, json.RawMessage(`false`)); err != nil {
|
|
t.Fatalf("false rejected: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestValidatePropertyNameReserved(t *testing.T) {
|
|
for _, name := range []string{"status", "Priority", "due date", "Due_Date", "START DATE", "labels"} {
|
|
if _, err := validatePropertyName(name); err == nil {
|
|
t.Fatalf("reserved name %q accepted", name)
|
|
}
|
|
}
|
|
if _, err := validatePropertyName("Severity"); err != nil {
|
|
t.Fatalf("legit name rejected: %v", err)
|
|
}
|
|
}
|
|
|
|
// TestPropertyOptionRemovalGuard: removing a select option still referenced
|
|
// by issues is rejected with a usage census; renames (same id) and removal
|
|
// of unused options pass.
|
|
func TestPropertyOptionRemovalGuard(t *testing.T) {
|
|
property := createTestProperty(t, map[string]any{
|
|
"name": "Guard" + uuid.NewString()[:8], "type": "select",
|
|
"config": map[string]any{"options": []map[string]any{
|
|
{"name": "Used", "color": "#ef4444"},
|
|
{"name": "Unused", "color": "#6b7280"},
|
|
}},
|
|
})
|
|
usedID := property.Config.Options[0].ID
|
|
unusedID := property.Config.Options[1].ID
|
|
issueID := createPropertyTestIssue(t, "option removal guard")
|
|
if w := setIssuePropertyRaw(t, issueID, property.ID, usedID); w.Code != http.StatusOK {
|
|
t.Fatalf("seed value: expected 200, got %d: %s", w.Code, w.Body.String())
|
|
}
|
|
|
|
patchConfig := func(options []map[string]any) *httptest.ResponseRecorder {
|
|
w := httptest.NewRecorder()
|
|
req := newRequest("PATCH", "/api/properties/"+property.ID, map[string]any{
|
|
"config": map[string]any{"options": options},
|
|
})
|
|
req = withURLParam(req, "id", property.ID)
|
|
testHandler.UpdateProperty(w, req)
|
|
return w
|
|
}
|
|
|
|
// Dropping the in-use option → 409 naming it with the census.
|
|
w := patchConfig([]map[string]any{{"id": unusedID, "name": "Unused", "color": "#6b7280"}})
|
|
if w.Code != http.StatusConflict || !strings.Contains(w.Body.String(), "Used") || !strings.Contains(w.Body.String(), "1 issues") {
|
|
t.Fatalf("in-use removal: expected 409 with census, got %d: %s", w.Code, w.Body.String())
|
|
}
|
|
|
|
// Renaming the in-use option (id preserved) → 200.
|
|
w = patchConfig([]map[string]any{
|
|
{"id": usedID, "name": "Used (renamed)", "color": "#ef4444"},
|
|
{"id": unusedID, "name": "Unused", "color": "#6b7280"},
|
|
})
|
|
if w.Code != http.StatusOK {
|
|
t.Fatalf("rename: expected 200, got %d: %s", w.Code, w.Body.String())
|
|
}
|
|
|
|
// Dropping only the unused option → 200.
|
|
w = patchConfig([]map[string]any{{"id": usedID, "name": "Used (renamed)", "color": "#ef4444"}})
|
|
if w.Code != http.StatusOK {
|
|
t.Fatalf("unused removal: expected 200, got %d: %s", w.Code, w.Body.String())
|
|
}
|
|
}
|
|
|
|
// TestListIssuesPropertyFilterAndSort covers the server-side list support:
|
|
// containment filtering (select / multi_select / checkbox) beyond the first
|
|
// page window, and typed property sort expressions with missing-last
|
|
// semantics. The shared workspace fixture may hold foreign issues, so
|
|
// assertions check relative order / membership rather than exact lists.
|
|
func TestListIssuesPropertyFilterAndSort(t *testing.T) {
|
|
sel := createTestProperty(t, map[string]any{
|
|
"name": "FS" + uuid.NewString()[:8], "type": "select",
|
|
"config": map[string]any{"options": []map[string]any{
|
|
{"name": "Hit", "color": "#ef4444"},
|
|
{"name": "Miss", "color": "#6b7280"},
|
|
}},
|
|
})
|
|
hitID := sel.Config.Options[0].ID
|
|
multi := createTestProperty(t, map[string]any{
|
|
"name": "FM" + uuid.NewString()[:8], "type": "multi_select",
|
|
"config": map[string]any{"options": []map[string]any{
|
|
{"name": "A", "color": "#3b82f6"},
|
|
{"name": "B", "color": "#22c55e"},
|
|
}},
|
|
})
|
|
multiB := multi.Config.Options[1].ID
|
|
box := createTestProperty(t, map[string]any{"name": "FB" + uuid.NewString()[:8], "type": "checkbox"})
|
|
num := createTestProperty(t, map[string]any{"name": "FN" + uuid.NewString()[:8], "type": "number"})
|
|
|
|
// 54 padding issues at explicit ascending positions, then the matching
|
|
// issue at the highest position — genuinely beyond the 50-row first page
|
|
// (review round 3: without explicit positions everything ties at 0 and
|
|
// the created_at DESC tie-breaker put the target on page one).
|
|
setPosition := func(issueID string, position float64) {
|
|
t.Helper()
|
|
if _, err := testPool.Exec(context.Background(),
|
|
`UPDATE issue SET position = $1 WHERE id = $2`, position, issueID); err != nil {
|
|
t.Fatalf("set position: %v", err)
|
|
}
|
|
}
|
|
for i := 0; i < 54; i++ {
|
|
setPosition(createPropertyTestIssue(t, fmt.Sprintf("filter pad %02d", i)), float64(i))
|
|
}
|
|
target := createPropertyTestIssue(t, "filter target beyond page one")
|
|
setPosition(target, 1000)
|
|
if w := setIssuePropertyRaw(t, target, sel.ID, hitID); w.Code != http.StatusOK {
|
|
t.Fatalf("seed select: %d %s", w.Code, w.Body.String())
|
|
}
|
|
if w := setIssuePropertyRaw(t, target, multi.ID, []string{multiB}); w.Code != http.StatusOK {
|
|
t.Fatalf("seed multi: %d %s", w.Code, w.Body.String())
|
|
}
|
|
if w := setIssuePropertyRaw(t, target, box.ID, true); w.Code != http.StatusOK {
|
|
t.Fatalf("seed checkbox: %d %s", w.Code, w.Body.String())
|
|
}
|
|
|
|
numLow := createPropertyTestIssue(t, "sort low")
|
|
numHigh := createPropertyTestIssue(t, "sort high")
|
|
if w := setIssuePropertyRaw(t, numLow, num.ID, 1); w.Code != http.StatusOK {
|
|
t.Fatalf("seed low: %d %s", w.Code, w.Body.String())
|
|
}
|
|
if w := setIssuePropertyRaw(t, numHigh, num.ID, 9.5); w.Code != http.StatusOK {
|
|
t.Fatalf("seed high: %d %s", w.Code, w.Body.String())
|
|
}
|
|
|
|
listIssues := func(query string) []IssueResponse {
|
|
t.Helper()
|
|
w := httptest.NewRecorder()
|
|
testHandler.ListIssues(w, newRequest("GET", "/api/issues"+query, nil))
|
|
if w.Code != http.StatusOK {
|
|
t.Fatalf("ListIssues%s: expected 200, got %d: %s", query, w.Code, w.Body.String())
|
|
}
|
|
var resp struct {
|
|
Issues []IssueResponse `json:"issues"`
|
|
}
|
|
json.NewDecoder(w.Body).Decode(&resp)
|
|
return resp.Issues
|
|
}
|
|
ids := func(list []IssueResponse) map[string]int {
|
|
out := make(map[string]int, len(list))
|
|
for i, issue := range list {
|
|
out[issue.ID] = i
|
|
}
|
|
return out
|
|
}
|
|
filterQuery := func(defID string, values ...string) string {
|
|
buf, _ := json.Marshal(map[string][]string{defID: values})
|
|
return "?limit=50&properties=" + url.QueryEscape(string(buf))
|
|
}
|
|
|
|
// Preconditions: the UNFILTERED first page must not contain the target
|
|
// (otherwise the assertions below prove nothing about windowing).
|
|
if _, present := ids(listIssues("?limit=50&sort=position&status=todo"))[target]; present {
|
|
t.Fatalf("windowing precondition broken: target already on the unfiltered first page")
|
|
}
|
|
|
|
// Select filter finds the issue past the 50-row window.
|
|
got := listIssues(filterQuery(sel.ID, hitID))
|
|
if _, present := ids(got)[target]; !present {
|
|
t.Fatalf("select filter missed the issue beyond page one")
|
|
}
|
|
for _, issue := range got {
|
|
if issue.Properties[sel.ID] != hitID {
|
|
t.Fatalf("select filter returned non-matching issue %s", issue.ID)
|
|
}
|
|
}
|
|
|
|
// Multi and checkbox containment forms.
|
|
if _, present := ids(listIssues(filterQuery(multi.ID, multiB)))[target]; !present {
|
|
t.Fatalf("multi_select filter missed the issue")
|
|
}
|
|
if _, present := ids(listIssues(filterQuery(box.ID, "true")))[target]; !present {
|
|
t.Fatalf("checkbox filter missed the issue")
|
|
}
|
|
// AND across definitions: matching select + non-matching checkbox → empty of target.
|
|
buf, _ := json.Marshal(map[string][]string{sel.ID: {hitID}, box.ID: {"false"}})
|
|
if _, present := ids(listIssues("?limit=50&properties=" + url.QueryEscape(string(buf))))[target]; present {
|
|
t.Fatalf("AND semantics failed: target matched with contradictory checkbox filter")
|
|
}
|
|
|
|
// The open_only branch honors the same filter via the static
|
|
// properties_filter param in ListOpenIssues (clean-room review: it used
|
|
// to be parsed and then silently dropped on this path).
|
|
openGot := listIssues(filterQuery(sel.ID, hitID) + "&open_only=true")
|
|
if _, present := ids(openGot)[target]; !present {
|
|
t.Fatalf("open_only ignored the properties filter: target missing")
|
|
}
|
|
for _, issue := range openGot {
|
|
if issue.Properties[sel.ID] != hitID {
|
|
t.Fatalf("open_only properties filter returned non-matching issue %s", issue.ID)
|
|
}
|
|
}
|
|
openBuf, _ := json.Marshal(map[string][]string{sel.ID: {hitID}, box.ID: {"false"}})
|
|
if _, present := ids(listIssues("?open_only=true&properties=" + url.QueryEscape(string(openBuf))))[target]; present {
|
|
t.Fatalf("open_only AND semantics failed: target matched contradictory filter")
|
|
}
|
|
|
|
// Property sort: asc = low before high, valueless after both (missing last).
|
|
sorted := listIssues("?limit=200&sort=property:" + num.ID + "&direction=asc")
|
|
pos := ids(sorted)
|
|
lowIdx, lowOK := pos[numLow]
|
|
highIdx, highOK := pos[numHigh]
|
|
padIdx, padOK := pos[target]
|
|
if !lowOK || !highOK || !padOK {
|
|
t.Fatalf("sorted list missing seeded issues (low=%v high=%v pad=%v)", lowOK, highOK, padOK)
|
|
}
|
|
if !(lowIdx < highIdx && highIdx < padIdx) {
|
|
t.Fatalf("asc property sort order wrong: low=%d high=%d valueless=%d", lowIdx, highIdx, padIdx)
|
|
}
|
|
sorted = listIssues("?limit=200&sort=property:" + num.ID + "&direction=desc")
|
|
pos = ids(sorted)
|
|
if !(pos[numHigh] < pos[numLow] && pos[numLow] < pos[target]) {
|
|
t.Fatalf("desc property sort order wrong: high=%d low=%d valueless=%d", pos[numHigh], pos[numLow], pos[target])
|
|
}
|
|
|
|
// Malformed property sort id → 400; unknown definition → 200 position order.
|
|
w := httptest.NewRecorder()
|
|
testHandler.ListIssues(w, newRequest("GET", "/api/issues?sort=property:nope", nil))
|
|
if w.Code != http.StatusBadRequest {
|
|
t.Fatalf("malformed property sort: expected 400, got %d", w.Code)
|
|
}
|
|
if got := listIssues("?limit=5&sort=property:" + uuid.NewString()); len(got) == 0 {
|
|
t.Fatalf("unknown-definition sort should fall back to position order, got empty")
|
|
}
|
|
}
|