Files
Bohan Jiangandmultica-agent fe23bd1161 MUL-8392: feat(properties): support up to 200 select options (#9712)
* feat(properties): support up to 200 select options

Co-authored-by: multica-agent <[email protected]>

* fix(properties): cap filter values and streamline picker tests

Co-authored-by: multica-agent <[email protected]>

---------

Co-authored-by: multica-agent <[email protected]>
2026-10-30 11:56:41 +08:00

2013 lines
83 KiB
Go

package handler
import (
"context"
"encoding/json"
"fmt"
"math"
"net/http"
"net/http/httptest"
"net/url"
"sort"
"strings"
"testing"
"github.com/go-chi/chi/v5"
"github.com/google/uuid"
"github.com/multica-ai/multica/server/internal/issueproperty"
"github.com/multica-ai/multica/server/internal/testutil"
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",
"icon": "flag",
"config": map[string]any{"options": []map[string]any{
{"name": "Critical", "color": "EF4444"},
{"name": "Minor", "color": "#6b7280"},
}},
})
if created.Type != "select" || created.Icon != "flag" || 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",
"icon": "shield",
"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.Icon != "shield" || updated.Config.Options[0].ID != keepID || updated.Config.Options[0].Name != "Blocker" {
t.Fatalf("option id not preserved on update: %+v", updated.Config.Options)
}
// Empty string clears the optional icon without changing the definition.
w = httptest.NewRecorder()
req = newRequest("PATCH", "/api/properties/"+created.ID, map[string]any{"icon": ""})
req = withURLParam(req, "id", created.ID)
testHandler.UpdateProperty(w, req)
if w.Code != http.StatusOK {
t.Fatalf("clear icon: expected 200, got %d: %s", w.Code, w.Body.String())
}
json.NewDecoder(w.Body).Decode(&updated)
if updated.Icon != "" {
t.Fatalf("icon not cleared: %q", updated.Icon)
}
// 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"},
{"icon too long", map[string]any{"name": "X" + uuid.NewString()[:8], "type": "text", "icon": strings.Repeat("x", maxPropertyIconLen+1)}, "icon must be"},
{"icon control character", map[string]any{"name": "X" + uuid.NewString()[:8], "type": "text", "icon": "\t"}, "icon cannot contain"},
{"emoji icon", map[string]any{"name": "X" + uuid.NewString()[:8], "type": "text", "icon": "🚨"}, "supported icon key"},
{"unknown icon", map[string]any{"name": "X" + uuid.NewString()[:8], "type": "text", "icon": "not-an-icon"}, "supported icon key"},
{"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())
}
}
}
func TestPropertySelectOptionLimit(t *testing.T) {
for _, propertyType := range []string{"select", "multi_select"} {
t.Run(propertyType, func(t *testing.T) {
options := make([]PropertyOption, 201)
for i := range options {
options[i] = PropertyOption{Name: fmt.Sprintf("Service %03d", i), Color: "#3b82f6"}
}
body := map[string]any{
"name": "Services" + uuid.NewString()[:8], "type": propertyType,
"config": PropertyConfig{Options: options[:51]},
}
created := createTestProperty(t, body)
if len(created.Config.Options) != 51 {
t.Fatalf("created option count = %d, want 51", len(created.Config.Options))
}
copy(options, created.Config.Options)
var updated PropertyResponse
testutil.Call(t, testHandler.UpdateProperty,
withURLParam(newRequest(http.MethodPatch, "/api/properties/"+created.ID, map[string]any{
"config": PropertyConfig{Options: options[:200]},
}), "id", created.ID),
).Want(http.StatusOK).JSON(&updated)
if len(updated.Config.Options) != 200 || updated.Config.Options[0].ID != created.Config.Options[0].ID {
t.Fatalf("expanding to 200 did not preserve options: %+v", updated.Config)
}
body["name"] = "OverLimit" + uuid.NewString()[:8]
body["config"] = PropertyConfig{Options: options}
response := testutil.Call(t, testHandler.CreateProperty,
newRequest(http.MethodPost, "/api/properties", body),
).Want(http.StatusBadRequest)
if !strings.Contains(response.Body.String(), "more than 200 options") {
t.Fatalf("unexpected create limit error: %s", response.Body.String())
}
response = testutil.Call(t, testHandler.UpdateProperty,
withURLParam(newRequest(http.MethodPatch, "/api/properties/"+created.ID, map[string]any{
"config": PropertyConfig{Options: options},
}), "id", created.ID),
).Want(http.StatusBadRequest)
if !strings.Contains(response.Body.String(), "more than 200 options") {
t.Fatalf("unexpected update limit error: %s", response.Body.String())
}
issueID := dbfx.Issue(t, "large select option list")
lastID := updated.Config.Options[199].ID
var value any = lastID
if propertyType == "multi_select" {
value = []string{lastID}
}
if response := setIssuePropertyRaw(t, issueID, created.ID, value); response.Code != http.StatusOK {
t.Fatalf("selecting option 200: %d: %s", response.Code, response.Body.String())
}
filter, _ := json.Marshal(map[string][]string{created.ID: {lastID}})
var listed struct {
Issues []IssueResponse `json:"issues"`
}
testutil.Call(t, testHandler.ListIssues,
newRequest(http.MethodGet, "/api/issues?properties="+url.QueryEscape(string(filter)), nil),
).Want(http.StatusOK).JSON(&listed)
if len(listed.Issues) != 1 || listed.Issues[0].ID != issueID {
t.Fatalf("filtering by option 200 did not return the issue: %+v", listed.Issues)
}
})
}
}
func TestIssuePropertyMultiSelectValueLimit(t *testing.T) {
options := make([]PropertyOption, 200)
for i := range options {
options[i] = PropertyOption{Name: fmt.Sprintf("Service %03d", i), Color: "#3b82f6"}
}
property := createTestProperty(t, map[string]any{
"name": "SelectionLimit" + uuid.NewString()[:8], "type": "multi_select",
"config": PropertyConfig{Options: options},
})
ids := make([]string, 51)
for i := range ids {
ids[i] = property.Config.Options[149+i].ID
}
title := "selection limit " + uuid.NewString()
body := map[string]any{"title": title, "properties": map[string]any{property.ID: ids}}
response := testutil.Call(t, testHandler.CreateIssue,
newRequest(http.MethodPost, "/api/issues", body),
).Want(http.StatusBadRequest)
if !strings.Contains(response.Body.String(), "more than 50 options") || countIssuesWithAtomicPropertyTitle(t, title) != 0 {
t.Fatalf("over-limit create should fail atomically: %s", response.Body.String())
}
body["properties"] = map[string]any{property.ID: ids[:50]}
created := createIssueWithPropertyBody(t, body)
if values, ok := created.Properties[property.ID].([]any); !ok || len(values) != 50 {
t.Fatalf("50 selections not preserved on create: %#v", created.Properties)
}
if response := setIssuePropertyRaw(t, created.ID, property.ID, ids[:50]); response.Code != http.StatusOK {
t.Fatalf("50 selections rejected: %d: %s", response.Code, response.Body.String())
}
if response := setIssuePropertyRaw(t, created.ID, property.ID, ids); response.Code != http.StatusBadRequest || !strings.Contains(response.Body.String(), "more than 50 options") {
t.Fatalf("51 selections should fail: %d: %s", response.Code, response.Body.String())
}
var stored []byte
if err := testPool.QueryRow(context.Background(), `SELECT properties -> $2 FROM issue WHERE id = $1`, created.ID, property.ID).Scan(&stored); err != nil {
t.Fatal(err)
}
var values []string
if err := json.Unmarshal(stored, &values); err != nil || len(values) != 50 {
t.Fatalf("rejected write changed saved values: %s, %v", stored, err)
}
}
// 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())
}
// multi_text: duplicates dropped, caller order kept.
listText := createTestProperty(t, map[string]any{"name": "Aliases" + uuid.NewString()[:8], "type": "multi_text"})
wl := setIssuePropertyRaw(t, issueID, listText.ID, []string{"beta", "alpha", "beta"})
if wl.Code != http.StatusOK {
t.Fatalf("multi_text set: expected 200, got %d: %s", wl.Code, wl.Body.String())
}
var listResp struct {
Properties map[string]any `json:"properties"`
}
json.NewDecoder(wl.Body).Decode(&listResp)
storedList, _ := listResp.Properties[listText.ID].([]any)
if len(storedList) != 2 || storedList[0] != "beta" || storedList[1] != "alpha" {
t.Fatalf("multi_text not stored in caller order: %v", storedList)
}
// multi_url: entry-level http(s) validation.
listURL := createTestProperty(t, map[string]any{"name": "Links" + uuid.NewString()[:8], "type": "multi_url"})
if w := setIssuePropertyRaw(t, issueID, listURL.ID, []string{"https://example.com/a", "https://example.com/b"}); w.Code != http.StatusOK {
t.Fatalf("good multi_url: expected 200, got %d: %s", w.Code, w.Body.String())
}
if w := setIssuePropertyRaw(t, issueID, listURL.ID, []string{"https://example.com/a", "ftp://example.com"}); w.Code != http.StatusBadRequest {
t.Fatalf("non-http(s) multi_url entry: expected 400, got %d", w.Code)
}
// 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 TestValidatePropertyListValuesUnit(t *testing.T) {
validate := func(def db.IssueProperty, raw string) ([]byte, error) {
return issueproperty.ValidateValue(def, json.RawMessage(raw))
}
textList := makePropertyDef("multi_text", nil)
if _, err := validate(textList, `[]`); err == nil {
t.Fatalf("empty multi_text array accepted")
}
if _, err := validate(textList, `"alpha"`); err == nil {
t.Fatalf("bare string into multi_text accepted")
}
if _, err := validate(textList, `["alpha", 3]`); err == nil {
t.Fatalf("non-string multi_text entry accepted")
}
if _, err := validate(textList, `["alpha", " "]`); err == nil {
t.Fatalf("blank multi_text entry accepted")
}
stored, err := validate(textList, `["alpha", "beta", "alpha"]`)
if err != nil {
t.Fatalf("valid multi_text rejected: %v", err)
}
if string(stored) != `["alpha","beta"]` {
t.Fatalf("multi_text not deduped in caller order: %s", stored)
}
over := make([]string, issueproperty.MaxListValues+1)
for i := range over {
over[i] = fmt.Sprintf("v%d", i)
}
overRaw, _ := json.Marshal(over)
if _, err := validate(textList, string(overRaw)); err == nil {
t.Fatalf("over-cap multi_text accepted")
}
urlList := makePropertyDef("multi_url", nil)
if _, err := validate(urlList, `["https://a.example", "javascript:alert(1)"]`); err == nil {
t.Fatalf("non-http(s) multi_url entry accepted")
}
stored, err = validate(urlList, `[" https://a.example/x ", "https://b.example", "https://a.example/x"]`)
if err != nil {
t.Fatalf("valid multi_url rejected: %v", err)
}
if string(stored) != `["https://a.example/x","https://b.example"]` {
t.Fatalf("multi_url not trimmed and deduped: %s", stored)
}
}
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")
}
// "No value" filter (the "__none__" sentinel): issues without the property
// match, the one explicitly set to `true` does not. limit=200 so the ~55
// matching issues (54 pad + numLow/numHigh, all without the checkbox) fit
// on one page — the 50-row default would hide numLow behind windowing.
noValueQuery := func(values ...string) string {
buf, _ := json.Marshal(map[string][]string{box.ID: values})
return "?limit=200&properties=" + url.QueryEscape(string(buf))
}
noneGot := ids(listIssues(noValueQuery("__none__")))
if _, present := noneGot[target]; present {
t.Fatalf("no-value filter included the issue with the property set")
}
if _, present := noneGot[numLow]; !present {
t.Fatalf("no-value filter missed an issue without the property")
}
// OR within a definition: "true" plus "no value" covers set and unset.
both := ids(listIssues(noValueQuery("true", "__none__")))
if _, present := both[target]; !present {
t.Fatalf("OR no-value filter missed the set issue")
}
if _, present := both[numLow]; !present {
t.Fatalf("OR no-value filter missed the unset issue")
}
// AND across definitions: matching select + no-value checkbox must exclude
// the target (it has the checkbox set, so it cannot match the unset branch).
andBuf, _ := json.Marshal(map[string][]string{sel.ID: {hitID}, box.ID: {"__none__"}})
andGot := ids(listIssues("?limit=50&properties=" + url.QueryEscape(string(andBuf))))
if _, present := andGot[target]; present {
t.Fatalf("AND no-value semantics failed: target matched contradictory filter")
}
// The open_only branch unrolls the same compiled filter through the static
// ListOpenIssues query, so the marker must be honored there too.
openNone := ids(listIssues(filterQuery(box.ID, "__none__") + "&open_only=true"))
if _, present := openNone[target]; present {
t.Fatalf("open_only no-value filter included the set issue")
}
if len(openNone) == 0 {
t.Fatalf("open_only no-value filter returned nothing")
}
// A number property matches a numeric filter value (the stored jsonb number,
// not the "3.5" string form).
numLowGot := ids(listIssues(filterQuery(num.ID, "1")))
if _, present := numLowGot[numLow]; !present {
t.Fatalf("number filter missed the issue with value 1")
}
if _, present := numLowGot[numHigh]; present {
t.Fatalf("number filter matched the wrong numeric value")
}
numHighGot := ids(listIssues(filterQuery(num.ID, "9.5")))
if _, present := numHighGot[numHigh]; !present {
t.Fatalf("number filter missed the issue with value 9.5")
}
}
// TestListIssuesSelectPropertySortOptionOrder pins the ordinal semantic of a
// select property sort: issues order by the OPTION ORDER of the definition
// (Low < Medium < High), not by the stored option-id string. The explicit
// option ids are chosen so their lexical order is the exact reverse of the
// option order — sorting by the raw stored value (the pre-fix behavior) puts
// High first and fails deterministically. The first id is deliberately
// uppercase: config validation stores an explicit id's original spelling, so
// the CASE arms must embed that spelling, not a re-serialized canonical form.
func TestListIssuesSelectPropertySortOptionOrder(t *testing.T) {
const (
lowID = "EEEEEEEE-EEEE-4EEE-8EEE-EEEEEEEEEEEE" // lexically last
mediumID = "99999999-9999-4999-8999-999999999999"
highID = "11111111-1111-4111-8111-111111111111" // lexically first
)
sel := createTestProperty(t, map[string]any{
"name": "SO" + uuid.NewString()[:8], "type": "select",
"config": map[string]any{"options": []map[string]any{
{"id": lowID, "name": "Low", "color": "#6b7280"},
{"id": mediumID, "name": "Medium", "color": "#f59e0b"},
{"id": highID, "name": "High", "color": "#ef4444"},
}},
})
low := dbfx.Issue(t, "select sort low")
medium := dbfx.Issue(t, "select sort medium")
high := dbfx.Issue(t, "select sort high")
unset := dbfx.Issue(t, "select sort unset")
for issueID, optionID := range map[string]string{low: lowID, medium: mediumID, high: highID} {
if w := setIssuePropertyRaw(t, issueID, sel.ID, optionID); w.Code != http.StatusOK {
t.Fatalf("seed select value: %d %s", w.Code, w.Body.String())
}
}
listIssues := func(query string) map[string]int {
t.Helper()
var resp struct {
Issues []IssueResponse `json:"issues"`
}
testutil.Call(t, testHandler.ListIssues, newRequest("GET", "/api/issues"+query, nil)).
Want(http.StatusOK).JSON(&resp)
out := make(map[string]int, len(resp.Issues))
for i, issue := range resp.Issues {
out[issue.ID] = i
}
return out
}
pos := listIssues("?limit=200&sort=property:" + sel.ID + "&direction=asc")
for _, id := range []string{low, medium, high, unset} {
if _, ok := pos[id]; !ok {
t.Fatalf("asc sorted list missing seeded issue %s", id)
}
}
if !(pos[low] < pos[medium] && pos[medium] < pos[high] && pos[high] < pos[unset]) {
t.Fatalf("asc select sort not in option order: low=%d medium=%d high=%d unset=%d",
pos[low], pos[medium], pos[high], pos[unset])
}
pos = listIssues("?limit=200&sort=property:" + sel.ID + "&direction=desc")
if !(pos[high] < pos[medium] && pos[medium] < pos[low] && pos[low] < pos[unset]) {
t.Fatalf("desc select sort not in reverse option order: high=%d medium=%d low=%d unset=%d",
pos[high], pos[medium], pos[low], pos[unset])
}
}
func TestParsePropertiesFilterNoValueUnit(t *testing.T) {
defID := uuid.NewString()
w := httptest.NewRecorder()
var args []any
addArg := func(v any) string {
args = append(args, v)
return fmt.Sprintf("$%d", len(args))
}
// "__none__" compiles to the marker object, and the predicate turns that
// marker into a key-absence check rather than a containment pattern.
groups, ok := parsePropertiesFilterParam(w, fmt.Sprintf(`{"%s":["__none__"]}`, defID))
if !ok {
t.Fatalf("no-value parse failed: %s", w.Body.String())
}
if len(groups) != 1 || len(groups[0]) != 1 {
t.Fatalf("expected one group with one alternative, got %d / %d", len(groups), len(groups[0]))
}
if parsed, isMarker := parseNoPropertyValuePattern(groups[0][0]); !isMarker || parsed != defID {
t.Fatalf("expected no-value marker for %s, got %q isMarker=%v", defID, parsed, isMarker)
}
sql := propertiesFilterPredicate(groups, addArg)
if !strings.Contains(sql, "NOT (i.properties ? $1)") {
t.Fatalf("no-value predicate wrong: %s", sql)
}
// A normal containment alternative is never mistaken for the marker.
groups, ok = parsePropertiesFilterParam(w, fmt.Sprintf(`{"%s":["true"]}`, defID))
if !ok {
t.Fatalf("containment parse failed: %s", w.Body.String())
}
if _, isMarker := parseNoPropertyValuePattern(groups[0][0]); isMarker {
t.Fatalf("checkbox true alternative misdetected as the marker")
}
// Mixed true + no value: containment OR key-absence.
groups, ok = parsePropertiesFilterParam(w, fmt.Sprintf(`{"%s":["true","__none__"]}`, defID))
if !ok {
t.Fatalf("mixed parse failed: %s", w.Body.String())
}
args = nil
sql = propertiesFilterPredicate(groups, addArg)
if !strings.Contains(sql, "@> $1") || !strings.Contains(sql, "NOT (i.properties ? ") {
t.Fatalf("mixed predicate wrong: %s", sql)
}
// Duplicate sentinels collapse to a single marker.
groups, ok = parsePropertiesFilterParam(w, fmt.Sprintf(`{"%s":["__none__","__none__"]}`, defID))
if !ok {
t.Fatalf("duplicate parse failed: %s", w.Body.String())
}
if len(groups[0]) != 1 {
t.Fatalf("duplicate sentinel produced %d alternatives, want 1", len(groups[0]))
}
// A numeric filter value also emits the stored jsonb number form, so a
// number property matches the scalar instead of only the "3.5" string.
groups, ok = parsePropertiesFilterParam(w, fmt.Sprintf(`{"%s":["3.5"]}`, defID))
if !ok {
t.Fatalf("numeric parse failed: %s", w.Body.String())
}
hasNumber := false
for _, alt := range groups[0] {
var m map[string]json.RawMessage
if err := json.Unmarshal(alt, &m); err != nil {
continue
}
var num float64
if err := json.Unmarshal(m[defID], &num); err == nil && num == 3.5 {
hasNumber = true
}
}
if !hasNumber {
t.Fatalf("numeric filter value did not emit a jsonb number containment form: %v", groups[0])
}
// A date value must NOT be misread as a number.
groups, ok = parsePropertiesFilterParam(w, fmt.Sprintf(`{"%s":["2026-08-19"]}`, defID))
if !ok {
t.Fatalf("date parse failed: %s", w.Body.String())
}
for _, alt := range groups[0] {
var m map[string]json.RawMessage
if err := json.Unmarshal(alt, &m); err != nil {
continue
}
var num float64
if err := json.Unmarshal(m[defID], &num); err == nil {
t.Fatalf("date value misread as a number: %v", alt)
}
}
// NaN / Infinity parse as floats but are not valid JSON — they must be
// skipped, not marshaled into a 400.
for _, bad := range []string{"NaN", "Infinity", "-Infinity"} {
groups, ok = parsePropertiesFilterParam(w, fmt.Sprintf(`{"%s":[%q]}`, defID, bad))
if !ok {
t.Fatalf("non-finite parse of %q failed: %s", bad, w.Body.String())
}
for _, alt := range groups[0] {
var m map[string]json.RawMessage
if err := json.Unmarshal(alt, &m); err != nil {
continue
}
var num float64
if err := json.Unmarshal(m[defID], &num); err == nil && (math.IsNaN(num) || math.IsInf(num, 0)) {
t.Fatalf("non-finite value %q leaked a number containment form: %v", bad, alt)
}
}
}
}
// ---------------------------------------------------------------------------
// Actor property types (MUL-6286)
// ---------------------------------------------------------------------------
// decodePropertiesBag reads the `{"properties": {...}}` envelope the value
// endpoints return. A fresh struct per call matters: json.Decode merges into a
// pre-populated map, so reusing one would keep keys from an earlier response.
func decodePropertiesBag(t *testing.T, w *httptest.ResponseRecorder) map[string]any {
t.Helper()
var resp struct {
Properties map[string]any `json:"properties"`
}
if err := json.NewDecoder(w.Body).Decode(&resp); err != nil {
t.Fatalf("decode properties bag: %v", err)
}
return resp.Properties
}
func TestParseActorRefUnit(t *testing.T) {
memberID := uuid.NewString()
ref, err := parseActorRef("member:" + memberID)
if err != nil {
t.Fatalf("member reference rejected: %v", err)
}
if ref.Kind != "member" || ref.ID != memberID {
t.Fatalf("unexpected parse: %+v", ref)
}
// String() is the canonical storage form, so it must round-trip exactly.
if ref.String() != "member:"+memberID {
t.Fatalf("String() round-trip broken: %q", ref.String())
}
// uuid.Parse also accepts uppercase, braces and the urn: form. Every
// consumer compares reference strings exactly, so anything not stored in
// canonical form would render as Unknown and never match a filter.
canonical := "6ba7b810-9dad-11d1-80b4-00c04fd430c8"
for _, variant := range []string{
strings.ToUpper(canonical),
"{" + canonical + "}",
"urn:uuid:" + canonical,
strings.ReplaceAll(canonical, "-", ""),
} {
got, err := parseActorRef("member:" + variant)
if err != nil {
t.Fatalf("uuid variant %q rejected: %v", variant, err)
}
if got.String() != "member:"+canonical {
t.Fatalf("uuid variant %q not canonicalized: %q", variant, got.String())
}
}
cases := []struct {
name string
value string
want string
}{
{"no colon", uuid.NewString(), `"<kind>:<uuid>"`},
{"empty string", "", `"<kind>:<uuid>"`},
// "agent" and "squad" are assignee kinds deliberately left out of the
// V1 value range; both must read as unknown, not silently accepted.
{"agent kind", "agent:" + uuid.NewString(), "unknown actor kind"},
{"squad kind", "squad:" + uuid.NewString(), "unknown actor kind"},
{"user kind", "user:" + uuid.NewString(), "unknown actor kind"},
{"empty kind", ":" + uuid.NewString(), "unknown actor kind"},
{"kind is case-sensitive", "Member:" + uuid.NewString(), "unknown actor kind"},
{"non-uuid id", "member:not-a-uuid", "must be a UUID"},
{"empty id", "member:", "must be a UUID"},
{"nested kind", "member:agent:" + uuid.NewString(), "must be a UUID"},
}
for _, tc := range cases {
_, err := parseActorRef(tc.value)
if err == nil || !strings.Contains(err.Error(), tc.want) {
t.Fatalf("%s: expected error containing %q, got %v", tc.name, tc.want, err)
}
}
}
// TestParseActorRefListUnit pins the multi_actor list contract, including the
// one place it deliberately differs from multi_select: the caller's order
// survives instead of being canonicalized.
func TestParseActorRefListUnit(t *testing.T) {
// Fixed ids so the insertion order below is neither ascending nor
// descending — any sort applied to the result would reorder it.
first := "member:11111111-1111-4111-8111-111111111111"
second := "member:22222222-2222-4222-8222-222222222222"
third := "member:00000000-0000-4000-8000-000000000000"
if _, err := parseActorRefList(nil); err == nil {
t.Fatalf("nil list accepted")
}
if _, err := parseActorRefList([]any{}); err == nil {
t.Fatalf("empty array accepted")
}
refs, err := parseActorRefList([]any{first, second, third, second})
if err != nil {
t.Fatalf("valid list rejected: %v", err)
}
got := make([]string, len(refs))
for i, ref := range refs {
got[i] = ref.String()
}
if len(got) != 3 {
t.Fatalf("duplicate not dropped: %v", got)
}
if got[0] != first || got[1] != second || got[2] != third {
t.Fatalf("insertion order not preserved: %v", got)
}
// Explicit: unlike multi_select there is no canonical order to sort to.
if sort.StringsAreSorted(got) {
t.Fatalf("list was canonicalized to sorted order: %v", got)
}
over := make([]any, 0, maxPropertyActorValues+1)
for i := 0; i < maxPropertyActorValues+1; i++ {
over = append(over, "member:"+uuid.NewString())
}
_, err = parseActorRefList(over)
if err == nil || !strings.Contains(err.Error(), fmt.Sprintf("more than %d", maxPropertyActorValues)) {
t.Fatalf("over-cap list: expected cap error, got %v", err)
}
if refs, err := parseActorRefList(over[:maxPropertyActorValues]); err != nil || len(refs) != maxPropertyActorValues {
t.Fatalf("list at the cap rejected: %d refs, %v", len(refs), err)
}
if _, err := parseActorRefList([]any{first, 42}); err == nil {
t.Fatalf("non-string element accepted")
}
if _, err := parseActorRefList([]any{first, "squad:" + uuid.NewString()}); err == nil {
t.Fatalf("unknown kind inside a list accepted")
}
}
func TestValidatePropertyValueActorUnit(t *testing.T) {
actorDef := makePropertyDef("actor", nil)
multiDef := makePropertyDef("multi_actor", nil)
// Fixed ids: memberRef sorts AFTER secondRef, so the insertion order
// asserted below is proof that no canonicalizing sort ran.
memberRef := "member:99999999-9999-4999-8999-999999999999"
secondRef := "member:00000000-0000-4000-8000-000000000000"
stored, err := validatePropertyValue(actorDef, json.RawMessage(`"`+memberRef+`"`))
if err != nil {
t.Fatalf("actor value rejected: %v", err)
}
if string(stored) != `"`+memberRef+`"` {
t.Fatalf("actor value not stored as a plain string: %s", stored)
}
// actor is a single reference: no array, no number, no object, no bare id.
for _, raw := range []string{
`["` + memberRef + `"]`,
`3`,
`true`,
`{"kind":"member","id":"` + uuid.NewString() + `"}`,
`null`,
`"` + uuid.NewString() + `"`,
`"agent:` + uuid.NewString() + `"`,
`"squad:` + uuid.NewString() + `"`,
} {
if _, err := validatePropertyValue(actorDef, json.RawMessage(raw)); err == nil {
t.Fatalf("actor accepted %s", raw)
}
}
// multi_actor is always an array, even for a single reference.
for _, raw := range []string{
`"` + memberRef + `"`,
`[]`,
`3`,
`[3]`,
`null`,
} {
if _, err := validatePropertyValue(multiDef, json.RawMessage(raw)); err == nil {
t.Fatalf("multi_actor accepted %s", raw)
}
}
// Duplicates collapse and the caller's order survives.
stored, err = validatePropertyValue(multiDef, json.RawMessage(
`["`+memberRef+`","`+secondRef+`","`+memberRef+`"]`))
if err != nil {
t.Fatalf("multi_actor value rejected: %v", err)
}
if string(stored) != `["`+memberRef+`","`+secondRef+`"]` {
t.Fatalf("multi_actor not deduped in insertion order: %s", stored)
}
}
// TestPropertyActorDefinitionRejectsOptions: actor types are resolved against
// the workspace, not against a config option list, so a definition carrying
// options is a modelling mistake and must be refused.
func TestPropertyActorDefinitionRejectsOptions(t *testing.T) {
for _, propType := range []string{"actor", "multi_actor"} {
_, err := validatePropertyConfig(propType, &PropertyConfig{
Options: []PropertyOption{{Name: "Anyone", Color: "#ef4444"}},
})
if err == nil || !strings.Contains(err.Error(), "does not accept options") {
t.Fatalf("%s definition with options: expected rejection, got %v", propType, err)
}
cfg, err := validatePropertyConfig(propType, nil)
if err != nil || string(cfg) != `{}` {
t.Fatalf("%s definition without config: expected {}, got %s (%v)", propType, cfg, err)
}
}
w := httptest.NewRecorder()
testHandler.CreateProperty(w, newRequest("POST", "/api/properties", map[string]any{
"name": "Owner" + uuid.NewString()[:8],
"type": "actor",
"config": map[string]any{"options": []map[string]any{
{"name": "Anyone", "color": "#ef4444"},
}},
}))
if w.Code != http.StatusBadRequest || !strings.Contains(w.Body.String(), "does not accept options") {
t.Fatalf("CreateProperty actor+options: expected 400, got %d: %s", w.Code, w.Body.String())
}
}
// TestIssueActorPropertyValues drives the actor / multi_actor pair through the
// real endpoints: a reference must resolve to something that exists in this
// workspace, and the stored bag keeps the prefixed "<kind>:<uuid>" form.
func TestIssueActorPropertyValues(t *testing.T) {
owner := createTestProperty(t, map[string]any{
"name": "Owner" + uuid.NewString()[:8], "type": "actor", "icon": "user-round",
})
if owner.Type != "actor" || len(owner.Config.Options) != 0 {
t.Fatalf("unexpected actor definition: %+v", owner)
}
reviewers := createTestProperty(t, map[string]any{
"name": "Reviewers" + uuid.NewString()[:8], "type": "multi_actor",
})
memberRef := "member:" + testUserID
secondRef := "member:" + createPropertyTestMember(t)
issueID := createPropertyTestIssue(t, "actor property values")
// A real workspace member round-trips as "member:<user_id>".
w := setIssuePropertyRaw(t, issueID, owner.ID, memberRef)
if w.Code != http.StatusOK {
t.Fatalf("actor set member: expected 200, got %d: %s", w.Code, w.Body.String())
}
if got := decodePropertiesBag(t, w)[owner.ID]; got != memberRef {
t.Fatalf("actor value not stored as %q: %v", memberRef, got)
}
// Read back through the issue endpoint, not just the write response.
wget := httptest.NewRecorder()
getReq := newRequest("GET", "/api/issues/"+issueID, nil)
getReq = withURLParam(getReq, "id", issueID)
testHandler.GetIssue(wget, getReq)
if wget.Code != http.StatusOK {
t.Fatalf("GetIssue: expected 200, got %d: %s", wget.Code, wget.Body.String())
}
var fetched IssueResponse
if err := json.NewDecoder(wget.Body).Decode(&fetched); err != nil {
t.Fatalf("decode issue: %v", err)
}
if fetched.Properties[owner.ID] != memberRef {
t.Fatalf("actor value missing from the issue bag: %v", fetched.Properties)
}
// An uppercase id resolves to the same member and must land in the bag in
// canonical form — otherwise it renders as Unknown and the "= me" filter,
// which matches the reference string exactly, silently misses it.
upper := "member:" + strings.ToUpper(testUserID)
w = setIssuePropertyRaw(t, issueID, owner.ID, upper)
if w.Code != http.StatusOK {
t.Fatalf("actor set uppercase member: expected 200, got %d: %s", w.Code, w.Body.String())
}
if got := decodePropertiesBag(t, w)[owner.ID]; got != memberRef {
t.Fatalf("uppercase actor id not canonicalized on write: %v", got)
}
// Shape is valid but the referent is not in this workspace → 400.
rejections := []struct {
name string
value any
want string
}{
{"unknown member", "member:" + uuid.NewString(), "does not refer to a member"},
// Agents and squads are assignable but not referenceable in V1: both
// must be refused at the kind check, before any workspace lookup.
{"agent kind", "agent:" + uuid.NewString(), "unknown actor kind"},
{"squad kind", "squad:" + uuid.NewString(), "unknown actor kind"},
// writeJSON HTML-escapes the angle brackets, so match the prose part.
{"bare uuid", uuid.NewString(), "value must look like"},
{"array into actor", []string{memberRef}, "actor reference string"},
{"number into actor", 7, "actor reference string"},
}
for _, tc := range rejections {
w := setIssuePropertyRaw(t, issueID, owner.ID, tc.value)
if w.Code != http.StatusBadRequest || !strings.Contains(w.Body.String(), tc.want) {
t.Fatalf("actor %s: expected 400 containing %q, got %d: %s", tc.name, tc.want, w.Code, w.Body.String())
}
}
// multi_actor: duplicates dropped, insertion order kept.
w = setIssuePropertyRaw(t, issueID, reviewers.ID, []string{memberRef, secondRef, memberRef})
if w.Code != http.StatusOK {
t.Fatalf("multi_actor set: expected 200, got %d: %s", w.Code, w.Body.String())
}
stored, ok := decodePropertiesBag(t, w)[reviewers.ID].([]any)
if !ok || len(stored) != 2 {
t.Fatalf("multi_actor not stored as a 2-element array: %v", stored)
}
if stored[0] != memberRef || stored[1] != secondRef {
t.Fatalf("multi_actor lost insertion order: %v", stored)
}
multiRejections := []struct {
name string
value any
want string
}{
{"bare string", memberRef, "array of actor reference strings"},
{"empty array", []string{}, "non-empty array"},
{"unknown member inside", []string{memberRef, "member:" + uuid.NewString()}, "does not refer to a member"},
}
for _, tc := range multiRejections {
w := setIssuePropertyRaw(t, issueID, reviewers.ID, tc.value)
if w.Code != http.StatusBadRequest || !strings.Contains(w.Body.String(), tc.want) {
t.Fatalf("multi_actor %s: expected 400 containing %q, got %d: %s", tc.name, tc.want, w.Code, w.Body.String())
}
}
// The cap is enforced on the raw array, before any workspace resolution.
over := make([]string, 0, maxPropertyActorValues+1)
for i := 0; i < maxPropertyActorValues+1; i++ {
over = append(over, "member:"+uuid.NewString())
}
if w := setIssuePropertyRaw(t, issueID, reviewers.ID, over); w.Code != http.StatusBadRequest ||
!strings.Contains(w.Body.String(), fmt.Sprintf("more than %d", maxPropertyActorValues)) {
t.Fatalf("multi_actor over cap: expected 400 cap error, got %d: %s", w.Code, w.Body.String())
}
}
// TestIssueActorPropertyFacets: actor and multi_actor values aggregate into
// table facets, which is what backs the "= me" filter in the header. Members
// are the only referenceable kind, and workspace membership is visible to
// every member, so there is no visibility gate to apply here — a plain member
// sees the same keys the owner does.
func TestIssueActorPropertyFacets(t *testing.T) {
_, _, plainMemberID := privateAgentTestFixture(t)
property := createTestProperty(t, map[string]any{
"name": "Facet Owner" + uuid.NewString()[:8], "type": "actor",
})
memberRef := "member:" + testUserID
otherRef := "member:" + plainMemberID
firstIssueID := createPropertyTestIssue(t, "actor facet first")
secondIssueID := createPropertyTestIssue(t, "actor facet second")
if w := setIssuePropertyRaw(t, firstIssueID, property.ID, memberRef); w.Code != http.StatusOK {
t.Fatalf("set first actor value: expected 200, got %d: %s", w.Code, w.Body.String())
}
if w := setIssuePropertyRaw(t, secondIssueID, property.ID, otherRef); w.Code != http.StatusOK {
t.Fatalf("set second actor value: expected 200, got %d: %s", w.Code, w.Body.String())
}
facetKeys := func(userID string) map[string]int64 {
t.Helper()
w := httptest.NewRecorder()
req := newRequestAs(userID, http.MethodPost, "/api/issues/table/facets", map[string]any{
"query": map[string]any{
"scope": map[string]any{"kind": "workspace"},
"filters": map[string]any{},
"sort": map[string]any{"field": "position", "direction": "asc"},
},
"facets": []map[string]any{{"kind": "property", "property_id": property.ID}},
"include_total": false,
})
testHandler.ListIssueTableFacets(w, req)
if w.Code != http.StatusOK {
t.Fatalf("ListIssueTableFacets: expected 200, got %d: %s", w.Code, w.Body.String())
}
var resp issueTableFacetsResponse
if err := json.NewDecoder(w.Body).Decode(&resp); err != nil {
t.Fatalf("decode facets: %v", err)
}
counts := map[string]int64{}
for _, facet := range resp.Facets {
for _, value := range facet.Values {
counts[value.Key] = value.Count
}
}
return counts
}
// Only these two issues carry this brand-new definition, so the counts are
// exact regardless of what else lives in the shared fixture workspace.
ownerCounts := facetKeys(testUserID)
if ownerCounts[memberRef] != 1 || ownerCounts[otherRef] != 1 {
t.Fatalf("owner facet counts wrong: %v", ownerCounts)
}
memberCounts := facetKeys(plainMemberID)
if memberCounts[memberRef] != 1 || memberCounts[otherRef] != 1 {
t.Fatalf("plain member sees different facet keys than the owner: %v", memberCounts)
}
}
// TestIssuePropertyFacetNoValue verifies the checkbox facet reports an unset
// "__none__" bucket alongside "true"/"false", so the filter menu's "No value"
// option carries a real count. A brand-new select marker narrows the facet to
// exactly the two issues created here, keeping the counts deterministic in the
// shared fixture workspace.
func TestIssuePropertyFacetNoValue(t *testing.T) {
marker := createTestProperty(t, map[string]any{
"name": "FM" + uuid.NewString()[:8], "type": "select",
"config": map[string]any{"options": []map[string]any{{"name": "Only", "color": "#3b82f6"}}},
})
markerOpt := marker.Config.Options[0].ID
hold := createTestProperty(t, map[string]any{"name": "FH" + uuid.NewString()[:8], "type": "checkbox"})
setID := createPropertyTestIssue(t, "hold facet set")
unsetID := createPropertyTestIssue(t, "hold facet unset")
for _, id := range []string{setID, unsetID} {
if w := setIssuePropertyRaw(t, id, marker.ID, markerOpt); w.Code != http.StatusOK {
t.Fatalf("seed marker: %d %s", w.Code, w.Body.String())
}
}
if w := setIssuePropertyRaw(t, setID, hold.ID, true); w.Code != http.StatusOK {
t.Fatalf("set hold: %d %s", w.Code, w.Body.String())
}
w := httptest.NewRecorder()
req := newRequest(http.MethodPost, "/api/issues/table/facets", map[string]any{
"query": map[string]any{
"scope": map[string]any{"kind": "workspace"},
"filters": map[string]any{"properties": map[string][]string{marker.ID: {markerOpt}}},
"sort": map[string]any{"field": "position", "direction": "asc"},
},
"facets": []map[string]any{{"kind": "property", "property_id": hold.ID}},
"include_total": false,
})
testHandler.ListIssueTableFacets(w, req)
if w.Code != http.StatusOK {
t.Fatalf("ListIssueTableFacets: expected 200, got %d: %s", w.Code, w.Body.String())
}
var resp issueTableFacetsResponse
if err := json.NewDecoder(w.Body).Decode(&resp); err != nil {
t.Fatalf("decode facets: %v", err)
}
counts := map[string]int64{}
for _, facet := range resp.Facets {
for _, value := range facet.Values {
counts[value.Key] = value.Count
}
}
if counts["true"] != 1 || counts["__none__"] != 1 {
t.Fatalf("hold facet counts wrong: %v", counts)
}
}
// TestIssuePropertyFacetScalarTypes covers the facet branches added for
// text / number / date / url, which previously fell through to the 422
// default. A marker select narrows the facet to exactly two issues.
func TestIssuePropertyFacetScalarTypes(t *testing.T) {
marker := createTestProperty(t, map[string]any{
"name": "FM" + uuid.NewString()[:8], "type": "select",
"config": map[string]any{"options": []map[string]any{{"name": "Only", "color": "#3b82f6"}}},
})
markerOpt := marker.Config.Options[0].ID
num := createTestProperty(t, map[string]any{"name": "FN" + uuid.NewString()[:8], "type": "number"})
text := createTestProperty(t, map[string]any{"name": "FT" + uuid.NewString()[:8], "type": "text"})
date := createTestProperty(t, map[string]any{"name": "FD" + uuid.NewString()[:8], "type": "date"})
url := createTestProperty(t, map[string]any{"name": "FU" + uuid.NewString()[:8], "type": "url"})
withNum := createPropertyTestIssue(t, "scalar facet num")
withText := createPropertyTestIssue(t, "scalar facet text")
for _, id := range []string{withNum, withText} {
if w := setIssuePropertyRaw(t, id, marker.ID, markerOpt); w.Code != http.StatusOK {
t.Fatalf("seed marker: %d %s", w.Code, w.Body.String())
}
}
if w := setIssuePropertyRaw(t, withNum, num.ID, 3.5); w.Code != http.StatusOK {
t.Fatalf("seed number: %d %s", w.Code, w.Body.String())
}
if w := setIssuePropertyRaw(t, withText, text.ID, "hello"); w.Code != http.StatusOK {
t.Fatalf("seed text: %d %s", w.Code, w.Body.String())
}
// A date set on neither issue keeps its facet to a single __none__ bucket.
if w := setIssuePropertyRaw(t, withNum, date.ID, "2026-08-19"); w.Code != http.StatusOK {
t.Fatalf("seed date: %d %s", w.Code, w.Body.String())
}
if w := setIssuePropertyRaw(t, withText, url.ID, "https://example.com"); w.Code != http.StatusOK {
t.Fatalf("seed url: %d %s", w.Code, w.Body.String())
}
facetCounts := func(propertyID string) map[string]int64 {
t.Helper()
w := httptest.NewRecorder()
req := newRequest(http.MethodPost, "/api/issues/table/facets", map[string]any{
"query": map[string]any{
"scope": map[string]any{"kind": "workspace"},
"filters": map[string]any{"properties": map[string][]string{marker.ID: {markerOpt}}},
"sort": map[string]any{"field": "position", "direction": "asc"},
},
"facets": []map[string]any{{"kind": "property", "property_id": propertyID}},
"include_total": false,
})
testHandler.ListIssueTableFacets(w, req)
if w.Code != http.StatusOK {
t.Fatalf("ListIssueTableFacets(%s): expected 200, got %d: %s", propertyID, w.Code, w.Body.String())
}
var resp issueTableFacetsResponse
if err := json.NewDecoder(w.Body).Decode(&resp); err != nil {
t.Fatalf("decode facets: %v", err)
}
counts := map[string]int64{}
for _, facet := range resp.Facets {
for _, value := range facet.Values {
counts[value.Key] = value.Count
}
}
return counts
}
// Scalar facets collapse to the bounded "__set__"/"__none__" buckets (the
// UI only reads the "No value" count for these types).
for _, tc := range []struct {
name string
id string
}{{"number", num.ID}, {"text", text.ID}, {"date", date.ID}, {"url", url.ID}} {
counts := facetCounts(tc.id)
if counts["__set__"] != 1 || counts["__none__"] != 1 {
t.Fatalf("%s facet counts wrong: %v", tc.name, counts)
}
}
}
// createPropertyTestMember adds a second real member to the fixture workspace
// so multi_actor ordering can be asserted with two resolvable references.
func createPropertyTestMember(t *testing.T) string {
t.Helper()
ctx := context.Background()
email := "actor-second-" + uuid.NewString()[:8] + "@multica.test"
var userID string
if err := testPool.QueryRow(ctx,
`INSERT INTO "user" (name, email) VALUES ('Actor Second Member', $1) RETURNING id`, email,
).Scan(&userID); err != nil {
t.Fatalf("create second member user: %v", err)
}
t.Cleanup(func() {
testPool.Exec(context.Background(), `DELETE FROM "user" WHERE email = $1`, email)
})
if _, err := testPool.Exec(ctx,
`INSERT INTO member (workspace_id, user_id, role) VALUES ($1, $2, 'member')`,
testWorkspaceID, userID,
); err != nil {
t.Fatalf("add second member: %v", err)
}
return userID
}
func TestParsePropertiesFilterOperatorUnit(t *testing.T) {
defID := uuid.NewString()
w := httptest.NewRecorder()
var args []any
addArg := func(v any) string {
args = append(args, v)
return fmt.Sprintf("$%d", len(args))
}
// A contains member compiles to the operator pattern (with the LIKE
// wildcards escaped) and renders as an ILIKE predicate, never as a
// containment pattern or the no-value marker.
groups, ok := parsePropertiesFilterParam(w, fmt.Sprintf(`{"%s":[{"op":"contains","value":"50%% off"}]}`, defID))
if !ok {
t.Fatalf("contains parse failed: %s", w.Body.String())
}
if len(groups) != 1 || len(groups[0]) != 1 {
t.Fatalf("expected one group with one alternative, got %d / %d", len(groups), len(groups[0]))
}
if _, isMarker := parseNoPropertyValuePattern(groups[0][0]); isMarker {
t.Fatalf("operator alternative misdetected as the no-value marker")
}
pattern, isOp := parseOperatorPattern(groups[0][0])
if !isOp || pattern.Op != "contains" || pattern.Def != defID {
t.Fatalf("expected contains operator pattern, got %+v isOp=%v", pattern, isOp)
}
if pattern.Value != `50\% off` {
t.Fatalf("contains value not LIKE-escaped: %q", pattern.Value)
}
args = nil
sql := propertiesFilterPredicate(groups, addArg)
if !strings.Contains(sql, "jsonb_typeof") || !strings.Contains(sql, "= 'string'") ||
!strings.Contains(sql, "ILIKE") || strings.Contains(sql, "@>") {
t.Fatalf("contains predicate wrong: %s", sql)
}
// Numeric ops validate their value and render the typed comparison.
groups, ok = parsePropertiesFilterParam(w, fmt.Sprintf(`{"%s":[{"op":"gte","value":"3.5"}]}`, defID))
if !ok {
t.Fatalf("gte parse failed: %s", w.Body.String())
}
args = nil
sql = propertiesFilterPredicate(groups, addArg)
if !strings.Contains(sql, "CASE WHEN") || !strings.Contains(sql, "::numeric END >= $") ||
!strings.Contains(sql, "::numeric)") {
t.Fatalf("gte predicate wrong: %s", sql)
}
// The canonical decimal stays a string bind and is explicitly cast to
// numeric, matching the static open_only path without float8 demotion.
last := args[len(args)-1]
if value, isString := last.(string); !isString || value != "3.5" {
t.Fatalf("gte bind arg must be canonical string 3.5, got %T %v", last, last)
}
// ParseFloat accepts forms Postgres ::numeric rejects (hex-float,
// underscores); the compiled pattern must store the canonical plain
// decimal or the static open_only unroll would 500 on the cast.
groups, ok = parsePropertiesFilterParam(w, fmt.Sprintf(`{"%s":[{"op":"gt","value":"0x1p4"}]}`, defID))
if !ok {
t.Fatalf("hex-float parse failed: %s", w.Body.String())
}
pattern, isOp = parseOperatorPattern(groups[0][0])
if !isOp || pattern.Value != "16" {
t.Fatalf("hex-float bound not canonicalized to 16: %+v isOp=%v", pattern, isOp)
}
// Date ops render the lexicographic string comparison.
groups, ok = parsePropertiesFilterParam(w, fmt.Sprintf(`{"%s":[{"op":"before","value":"2026-02-01"}]}`, defID))
if !ok {
t.Fatalf("before parse failed: %s", w.Body.String())
}
args = nil
sql = propertiesFilterPredicate(groups, addArg)
if !strings.Contains(sql, "= 'string' AND") || !strings.Contains(sql, "< $") {
t.Fatalf("before predicate wrong: %s", sql)
}
// An operator composes OR-style with legacy equality members and the
// no-value sentinel in one group.
groups, ok = parsePropertiesFilterParam(w, fmt.Sprintf(`{"%s":["3.5",{"op":"lt","value":"10"},"__none__"]}`, defID))
if !ok {
t.Fatalf("mixed parse failed: %s", w.Body.String())
}
// "3.5" expands to 3 containment forms, the operator is 1, the marker 1.
if len(groups[0]) != 5 {
t.Fatalf("expected 5 alternatives, got %d", len(groups[0]))
}
// Invalid members are rejected with a 400.
for name, raw := range map[string]string{
"unknown op": `{"op":"regex","value":"x"}`,
"empty value": `{"op":"contains","value":""}`,
"non-numeric": `{"op":"gt","value":"abc"}`,
"NaN": `{"op":"lt","value":"NaN"}`,
"bad date": `{"op":"before","value":"02/01/2026"}`,
"missing value": `{"op":"contains"}`,
} {
w = httptest.NewRecorder()
_, ok := parsePropertiesFilterParam(w, fmt.Sprintf(`{"%s":[%s]}`, defID, raw))
if ok {
t.Fatalf("%s: expected rejection", name)
}
if w.Code != http.StatusBadRequest {
t.Fatalf("%s: expected 400, got %d", name, w.Code)
}
}
}
func TestListIssuesPropertyFilterOperators(t *testing.T) {
text := createTestProperty(t, map[string]any{"name": "OT" + uuid.NewString()[:8], "type": "text"})
num := createTestProperty(t, map[string]any{"name": "ON" + uuid.NewString()[:8], "type": "number"})
date := createTestProperty(t, map[string]any{"name": "OD" + uuid.NewString()[:8], "type": "date"})
box := createTestProperty(t, map[string]any{"name": "OB" + uuid.NewString()[:8], "type": "checkbox"})
multi := createTestProperty(t, map[string]any{
"name": "OM" + uuid.NewString()[:8], "type": "multi_select",
"config": map[string]any{"options": []map[string]any{
{"name": "Alpha", "color": "#3b82f6"},
{"name": "Beta", "color": "#22c55e"},
}},
})
marker := uuid.NewString()[:8]
helloWorld := "Alpha " + marker + " World"
percentDone := "100% done " + marker
hasText := createPropertyTestIssue(t, "op text")
hasAll := createPropertyTestIssue(t, "op all")
empty := createPropertyTestIssue(t, "op empty")
if w := setIssuePropertyRaw(t, hasText, text.ID, percentDone); w.Code != http.StatusOK {
t.Fatalf("seed text: %d %s", w.Code, w.Body.String())
}
if w := setIssuePropertyRaw(t, hasText, date.ID, "2026-01-10"); w.Code != http.StatusOK {
t.Fatalf("seed text date: %d %s", w.Code, w.Body.String())
}
if w := setIssuePropertyRaw(t, hasAll, text.ID, helloWorld); w.Code != http.StatusOK {
t.Fatalf("seed text2: %d %s", w.Code, w.Body.String())
}
if w := setIssuePropertyRaw(t, hasAll, num.ID, 15); w.Code != http.StatusOK {
t.Fatalf("seed num: %d %s", w.Code, w.Body.String())
}
if w := setIssuePropertyRaw(t, hasAll, date.ID, "2026-03-01"); w.Code != http.StatusOK {
t.Fatalf("seed date: %d %s", w.Code, w.Body.String())
}
if w := setIssuePropertyRaw(t, hasAll, box.ID, true); w.Code != http.StatusOK {
t.Fatalf("seed checkbox: %d %s", w.Code, w.Body.String())
}
if w := setIssuePropertyRaw(t, hasAll, multi.ID, []string{multi.Config.Options[0].ID}); w.Code != http.StatusOK {
t.Fatalf("seed multi-select: %d %s", w.Code, w.Body.String())
}
listIssues := func(query string) (int, []IssueResponse) {
t.Helper()
w := httptest.NewRecorder()
testHandler.ListIssues(w, newRequest("GET", "/api/issues"+query, nil))
if w.Code != http.StatusOK {
return w.Code, nil
}
var resp struct {
Issues []IssueResponse `json:"issues"`
}
json.NewDecoder(w.Body).Decode(&resp)
return w.Code, resp.Issues
}
opQuery := func(defID string, members ...any) string {
buf, _ := json.Marshal(map[string]any{defID: members})
return "?limit=50&properties=" + url.QueryEscape(string(buf))
}
expect := func(query string, want ...string) {
t.Helper()
code, got := listIssues(query)
if code != http.StatusOK {
t.Fatalf("ListIssues%s: expected 200, got %d", query, code)
}
present := map[string]bool{}
for _, issue := range got {
present[issue.ID] = true
}
for _, id := range want {
if !present[id] {
t.Fatalf("query %s missed issue %s", query, id)
}
}
// Every returned issue must carry a matching value for the filtered
// definition (or none, when "__none__" is a member) — checked by the
// negative assertions at each call site.
return
}
notPresent := func(query, id string) {
t.Helper()
_, got := listIssues(query)
for _, issue := range got {
if issue.ID == id {
t.Fatalf("query %s unexpectedly matched issue %s", query, id)
}
}
}
// contains is case-insensitive and matches fragments.
expect(opQuery(text.ID, map[string]any{"op": "contains", "value": "world"}), hasAll)
notPresent(opQuery(text.ID, map[string]any{"op": "contains", "value": "world"}), hasText)
// The % in the needle is a literal percent, not a wildcard.
expect(opQuery(text.ID, map[string]any{"op": "contains", "value": "% done"}), hasText)
notPresent(opQuery(text.ID, map[string]any{"op": "contains", "value": "% done"}), hasAll)
// contains is string-only even when a hand-edited URL applies it to another
// property type. jsonb ->> would otherwise stringify both values and match.
containsNumber := opQuery(num.ID, map[string]any{"op": "contains", "value": "15"})
notPresent(containsNumber, hasAll)
notPresent(containsNumber+"&open_only=true", hasAll)
containsBoolean := opQuery(box.ID, map[string]any{"op": "contains", "value": "true"})
notPresent(containsBoolean, hasAll)
notPresent(containsBoolean+"&open_only=true", hasAll)
containsArray := opQuery(multi.ID, map[string]any{"op": "contains", "value": multi.Config.Options[0].ID[:8]})
notPresent(containsArray, hasAll)
notPresent(containsArray+"&open_only=true", hasAll)
// Numeric comparison matches stored jsonb numbers only.
expect(opQuery(num.ID, map[string]any{"op": "gt", "value": "10"}), hasAll)
notPresent(opQuery(num.ID, map[string]any{"op": "gt", "value": "10"}), hasText)
expect(opQuery(num.ID, map[string]any{"op": "gte", "value": "15"}), hasAll)
notPresent(opQuery(num.ID, map[string]any{"op": "lte", "value": "5"}), hasAll)
// Date comparison is chronological.
expect(opQuery(date.ID, map[string]any{"op": "before", "value": "2026-02-01"}), hasText)
notPresent(opQuery(date.ID, map[string]any{"op": "before", "value": "2026-02-01"}), hasAll)
expect(opQuery(date.ID, map[string]any{"op": "after", "value": "2026-02-01"}), hasAll)
notPresent(opQuery(date.ID, map[string]any{"op": "after", "value": "2026-02-01"}), hasText)
// An operator composes OR-style with the no-value sentinel.
noneOrAfter := opQuery(date.ID, map[string]any{"op": "after", "value": "2026-02-01"}, "__none__")
expect(noneOrAfter, hasAll, empty)
notPresent(noneOrAfter, hasText)
// AND across definitions still applies: text contains "world" AND
// number <= 5 matches nothing.
both, _ := json.Marshal(map[string]any{
text.ID: []any{map[string]any{"op": "contains", "value": "world"}},
num.ID: []any{map[string]any{"op": "lte", "value": "5"}},
})
notPresent("?limit=50&properties="+url.QueryEscape(string(both)), hasAll)
// Malformed operators are rejected outright.
for name, member := range map[string]map[string]any{
"unknown op": {"op": "regex", "value": "x"},
"empty value": {"op": "contains", "value": ""},
"bad number": {"op": "gt", "value": "15x"},
"bad date": {"op": "before", "value": "March 1"},
} {
if code, _ := listIssues(opQuery(num.ID, member)); code != http.StatusBadRequest {
t.Fatalf("%s: expected 400, got %d", name, code)
}
}
// The static ListOpenIssues unroll agrees with the dynamic predicates.
expect(opQuery(text.ID, map[string]any{"op": "contains", "value": "world"})+"&open_only=true", hasAll)
notPresent(opQuery(text.ID, map[string]any{"op": "contains", "value": "world"})+"&open_only=true", hasText)
expect(opQuery(num.ID, map[string]any{"op": "gte", "value": "15"})+"&open_only=true", hasAll)
expect(noneOrAfter+"&open_only=true", hasAll, empty)
notPresent(noneOrAfter+"&open_only=true", hasText)
// A ParseFloat-only bound form (hex-float) must behave identically on the
// dynamic and the static open_only path — an uncanonicalized raw string
// would 500 the static ::numeric cast.
expect(opQuery(num.ID, map[string]any{"op": "lt", "value": "0x1p4"}), hasAll)
expect(opQuery(num.ID, map[string]any{"op": "lt", "value": "0x1p4"})+"&open_only=true", hasAll)
notPresent(opQuery(num.ID, map[string]any{"op": "lt", "value": "0x1p4"})+"&open_only=true", hasText)
// Issues without the filtered key never match an operator on either path —
// the guards (ILIKE-NULL / jsonb_typeof / IS NOT NULL) exclude the seeded
// "empty" issue, which is open and matches the same queries pre-guard.
notPresent(opQuery(text.ID, map[string]any{"op": "contains", "value": "world"}), empty)
notPresent(opQuery(date.ID, map[string]any{"op": "before", "value": "2026-02-01"}), empty)
notPresent(opQuery(date.ID, map[string]any{"op": "after", "value": "2026-02-01"}), empty)
notPresent(opQuery(num.ID, map[string]any{"op": "gte", "value": "15"})+"&open_only=true", empty)
// The table-rows endpoint is the third serving path for the properties
// filter: members arrive as raw JSON in issueTableFiltersRequest, go
// through the byte-exact fingerprint canonicalization, and compile via the
// same parsePropertiesFilterParam the list endpoints use. Pin the path
// with operator members ANDed across two definitions.
rowsRecorder := httptest.NewRecorder()
testHandler.ListIssueTableRows(rowsRecorder, newRequest(http.MethodPost, "/api/issues/table/rows", map[string]any{
"query": map[string]any{
"scope": map[string]any{"kind": "workspace"},
"filters": map[string]any{
"properties": map[string]any{
num.ID: []any{map[string]any{"op": "gte", "value": "15"}},
text.ID: []any{map[string]any{"op": "contains", "value": "world"}},
},
},
"sort": map[string]any{"field": "position", "direction": "asc"},
},
"group": map[string]any{"kind": "none"},
"hierarchy": map[string]any{"enabled": false},
"page": map[string]any{"limit": 10},
}))
if rowsRecorder.Code != http.StatusOK {
t.Fatalf("table rows with operator members: %d %s", rowsRecorder.Code, rowsRecorder.Body.String())
}
var rowsResponse issueTableRowsResponse
if err := json.NewDecoder(rowsRecorder.Body).Decode(&rowsResponse); err != nil {
t.Fatalf("decode rows: %v", err)
}
if rowsResponse.Total != 1 || len(rowsResponse.Rows) != 1 || rowsResponse.Rows[0].Issue.ID != hasAll {
t.Fatalf("table rows operator filter wrong: total=%d rows=%d", rowsResponse.Total, len(rowsResponse.Rows))
}
}
// TestPropertyContainsPrefilterCompilationUnit pins which contains needles get
// the bigram prefilter that migration 446's index serves. The prefilter matches
// against the jsonb text form of the whole properties object, so a needle that
// jsonb escapes on serialization (", \, control characters) must compile
// without it — the alternative would silently drop matching rows.
func TestPropertyContainsPrefilterCompilationUnit(t *testing.T) {
defID := uuid.NewString()
compile := func(t *testing.T, needle string) (propertyOperatorPattern, json.RawMessage, string) {
t.Helper()
raw, err := json.Marshal(map[string]any{defID: []any{map[string]any{"op": "contains", "value": needle}}})
if err != nil {
t.Fatalf("marshal filter: %v", err)
}
w := httptest.NewRecorder()
groups, ok := parsePropertiesFilterParam(w, string(raw))
if !ok {
t.Fatalf("parse %q: %s", needle, w.Body.String())
}
pattern, isOp := parseOperatorPattern(groups[0][0])
if !isOp {
t.Fatalf("%q did not compile to an operator pattern", needle)
}
var args []any
addArg := func(v any) string {
args = append(args, v)
return fmt.Sprintf("$%d", len(args))
}
return pattern, groups[0][0], propertiesFilterPredicate(groups, addArg)
}
for _, tc := range []struct {
name string
needle string
wantPrefilter string
}{
{"plain ascii", "world", "world"},
// LIKE wildcards are escaped identically on both sides, so they stay
// prefilterable — the escape is a backslash in the pattern, not in the
// serialized value.
{"like wildcards", "50% off_now", `50\% off\_now`},
{"cjk", "中文属性", "中文属性"},
// Short needles are prefiltered too: pg_bigm indexes 1- and
// 2-character keywords, which is the capability it exists for over
// pg_trgm, and one CJK character is already a word.
{"single character", "x", "x"},
{"single cjk character", "文", "文"},
{"double quote", `say "hi"`, ""},
{"backslash", `C:\logs`, ""},
{"tab", "col\tvalue", ""},
} {
t.Run(tc.name, func(t *testing.T) {
pattern, alt, sql := compile(t, tc.needle)
if pattern.Prefilter != tc.wantPrefilter {
t.Fatalf("prefilter for %q: got %q, want %q", tc.needle, pattern.Prefilter, tc.wantPrefilter)
}
// The static open_only unroll keys off the presence of the JSON
// member, so an unsafe needle must omit it rather than carry "".
var members map[string]json.RawMessage
if err := json.Unmarshal(alt, &members); err != nil {
t.Fatalf("decode alternative: %v", err)
}
_, hasMember := members["prefilter"]
if hasMember != (tc.wantPrefilter != "") {
t.Fatalf("alternative %s: prefilter member present=%v, want %v", alt, hasMember, tc.wantPrefilter != "")
}
hasClause := strings.Contains(sql, "LOWER(i.properties::text) LIKE LOWER(")
if hasClause != (tc.wantPrefilter != "") {
t.Fatalf("predicate %s: prefilter clause present=%v, want %v", sql, hasClause, tc.wantPrefilter != "")
}
// The per-key check is authoritative on every path, prefiltered or
// not.
if !strings.Contains(sql, "ILIKE") || !strings.Contains(sql, "jsonb_typeof") {
t.Fatalf("predicate lost its per-key contains check: %s", sql)
}
})
}
}
// TestPropertyContainsPrefilterKeepsResultsExact runs the needles above against
// real rows on both serving paths. The prefilter is lossy by design, so the
// property under test is that it never changes an answer: needles jsonb escapes
// must still match, and a needle that only appears elsewhere in the object —
// under another definition, or in a definition id — must still miss.
func TestPropertyContainsPrefilterKeepsResultsExact(t *testing.T) {
text := createTestProperty(t, map[string]any{"name": "PF" + uuid.NewString()[:8], "type": "text"})
other := createTestProperty(t, map[string]any{"name": "PO" + uuid.NewString()[:8], "type": "text"})
listIDs := func(t *testing.T, defID, needle string, openOnly bool) map[string]bool {
t.Helper()
buf, err := json.Marshal(map[string]any{defID: []any{map[string]any{"op": "contains", "value": needle}}})
if err != nil {
t.Fatalf("marshal filter: %v", err)
}
query := "/api/issues?limit=50&properties=" + url.QueryEscape(string(buf))
if openOnly {
query += "&open_only=true"
}
var resp struct {
Issues []IssueResponse `json:"issues"`
}
testutil.Call(t, testHandler.ListIssues, newRequest(http.MethodGet, query, nil)).
Want(http.StatusOK).JSON(&resp)
present := make(map[string]bool, len(resp.Issues))
for _, issue := range resp.Issues {
present[issue.ID] = true
}
return present
}
for _, tc := range []struct {
name string
value string
needle string
}{
{"plain ascii", "plain value", "in val"},
{"double quote", `he said "yes" loudly`, `"yes"`},
// jsonb doubles the backslash, so the needle's literal form is absent
// from properties::text — a prefiltered lookup would find nothing.
{"backslash", `C:\bin`, `C:\bin`},
{"tab", "column\tvalue", "\tvalue"},
{"cjk", "中文属性值", "文属"},
{"like wildcards", "100% done_now", "% done_"},
{"case folded", "MiXeD Case", "mixed ca"},
} {
t.Run(tc.name, func(t *testing.T) {
hit := createPropertyTestIssue(t, "prefilter hit")
// The decoy carries the identical string under a different
// definition: it is in LOWER(properties::text) exactly like the
// hit's, so only the per-key check can tell the two apart.
decoy := createPropertyTestIssue(t, "prefilter decoy")
if w := setIssuePropertyRaw(t, hit, text.ID, tc.value); w.Code != http.StatusOK {
t.Fatalf("seed hit: %d %s", w.Code, w.Body.String())
}
if w := setIssuePropertyRaw(t, decoy, other.ID, tc.value); w.Code != http.StatusOK {
t.Fatalf("seed decoy: %d %s", w.Code, w.Body.String())
}
for _, openOnly := range []bool{false, true} {
present := listIDs(t, text.ID, tc.needle, openOnly)
if !present[hit] {
t.Fatalf("open_only=%v: contains %q missed the issue holding %q", openOnly, tc.needle, tc.value)
}
if present[decoy] {
t.Fatalf("open_only=%v: contains %q matched another definition's value", openOnly, tc.needle)
}
// A definition id is part of the object's text form but never
// part of a value, so the prefilter alone must not surface the
// row.
if byDefID := listIDs(t, text.ID, text.ID[:8], openOnly); byDefID[hit] {
t.Fatalf("open_only=%v: contains matched a definition id, not a value", openOnly)
}
}
})
}
}