Files
multica/server/internal/handler/property_test.go
Lambda e6726074ad fix(properties): open_only branch honors the properties filter
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>
2026-07-15 11:24:19 +08:00

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")
}
}