Files
multica/server/internal/handler/issue_create_properties_test.go
T
90e0bdf830 MUL-7586 Atomically create issues with custom properties (#8689)
* feat(issues): create with properties atomically

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

* fix(issues): recover unavailable properties in create drafts

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

* test(issues): isolate commit failure task assertions

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

---------

Co-authored-by: Forge-Boy <[email protected]>
Co-authored-by: multica-agent <[email protected]>
2026-09-22 16:44:14 +08:00

314 lines
13 KiB
Go

package handler
import (
"context"
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"reflect"
"strings"
"testing"
"github.com/google/uuid"
"github.com/multica-ai/multica/server/internal/testutil"
)
func createIssueWithPropertyBody(t *testing.T, body map[string]any) IssueResponse {
t.Helper()
response := testutil.Call(t, testHandler.CreateIssue,
newRequest(http.MethodPost, "/api/issues?workspace_id="+testWorkspaceID, body),
).Want(http.StatusCreated)
var issue IssueResponse
if err := json.NewDecoder(response.Body).Decode(&issue); err != nil {
t.Fatalf("decode create response: %v", err)
}
t.Cleanup(func() {
_, _ = testPool.Exec(context.Background(), `DELETE FROM issue WHERE id = $1`, issue.ID)
})
return issue
}
func countIssuesWithAtomicPropertyTitle(t *testing.T, title string) int {
t.Helper()
var count int
if err := testPool.QueryRow(context.Background(), `SELECT COUNT(*) FROM issue WHERE workspace_id = $1 AND title = $2`, testWorkspaceID, title).Scan(&count); err != nil {
t.Fatalf("count issues: %v", err)
}
return count
}
func TestCreateIssueWithPropertiesCanonicalizesEveryType(t *testing.T) {
suffix := uuid.NewString()[:8]
text := createTestProperty(t, map[string]any{"name": "CreateText" + suffix, "type": "text"})
number := createTestProperty(t, map[string]any{"name": "CreateNumber" + suffix, "type": "number"})
selectOne := createTestProperty(t, map[string]any{
"name": "CreateSelect" + suffix, "type": "select",
"config": map[string]any{"options": []map[string]any{
{"name": "First", "color": "#3b82f6"},
{"name": "Second", "color": "#22c55e"},
}},
})
multiSelect := createTestProperty(t, map[string]any{
"name": "CreateMulti" + suffix, "type": "multi_select",
"config": map[string]any{"options": []map[string]any{
{"name": "One", "color": "#3b82f6"},
{"name": "Two", "color": "#22c55e"},
}},
})
date := createTestProperty(t, map[string]any{"name": "CreateDate" + suffix, "type": "date"})
checkbox := createTestProperty(t, map[string]any{"name": "CreateCheck" + suffix, "type": "checkbox"})
urlProperty := createTestProperty(t, map[string]any{"name": "CreateURL" + suffix, "type": "url"})
actor := createTestProperty(t, map[string]any{"name": "CreateActor" + suffix, "type": "actor"})
multiActor := createTestProperty(t, map[string]any{"name": "CreateActors" + suffix, "type": "multi_actor"})
memberRef := "member:" + testUserID
issue := createIssueWithPropertyBody(t, map[string]any{
"title": "atomic all property types " + suffix,
"properties": map[string]any{
text.ID: "value",
number.ID: 3.5,
selectOne.ID: selectOne.Config.Options[1].ID,
multiSelect.ID: []string{multiSelect.Config.Options[1].ID, multiSelect.Config.Options[0].ID, multiSelect.Config.Options[1].ID},
date.ID: "2026-09-22",
checkbox.ID: true,
urlProperty.ID: " https://example.com/spec ",
actor.ID: memberRef,
multiActor.ID: []string{memberRef, memberRef},
},
})
wantMulti := []any{multiSelect.Config.Options[0].ID, multiSelect.Config.Options[1].ID}
if got := issue.Properties[multiSelect.ID]; !reflect.DeepEqual(got, wantMulti) {
t.Fatalf("multi_select = %#v, want canonical config order %#v", got, wantMulti)
}
if got := issue.Properties[multiActor.ID]; !reflect.DeepEqual(got, []any{memberRef}) {
t.Fatalf("multi_actor = %#v, want first-occurrence dedupe", got)
}
if got := issue.Properties[urlProperty.ID]; got != "https://example.com/spec" {
t.Fatalf("url = %#v, want trimmed canonical value", got)
}
var stored []byte
if err := testPool.QueryRow(context.Background(), `SELECT properties FROM issue WHERE id = $1`, issue.ID).Scan(&stored); err != nil {
t.Fatalf("load stored properties: %v", err)
}
var bag map[string]any
if err := json.Unmarshal(stored, &bag); err != nil {
t.Fatalf("decode stored properties: %v", err)
}
if !reflect.DeepEqual(bag, issue.Properties) {
t.Fatalf("response properties differ from stored snapshot: response=%#v stored=%#v", issue.Properties, bag)
}
if len(issue.Properties) != 9 {
t.Fatalf("response property count = %d, want all 9 supported types", len(issue.Properties))
}
}
func TestCreateIssuePropertiesInvalidSecondValueRollsBack(t *testing.T) {
suffix := uuid.NewString()[:8]
first := createTestProperty(t, map[string]any{
"name": "RollbackFirst" + suffix, "type": "select",
"config": map[string]any{"options": []map[string]any{{"name": "Valid", "color": "#3b82f6"}}},
})
second := createTestProperty(t, map[string]any{
"name": "RollbackSecond" + suffix, "type": "select",
"config": map[string]any{"options": []map[string]any{{"name": "Valid", "color": "#3b82f6"}}},
})
if first.ID > second.ID {
first, second = second, first
}
title := "atomic property rollback " + suffix
response := testutil.Call(t, testHandler.CreateIssue,
newRequest(http.MethodPost, "/api/issues?workspace_id="+testWorkspaceID, map[string]any{
"title": title,
"properties": map[string]any{first.ID: first.Config.Options[0].ID, second.ID: uuid.NewString()},
}),
).Want(http.StatusBadRequest)
var body map[string]any
_ = json.NewDecoder(response.Body).Decode(&body)
if body["code"] != "invalid_issue_property" || body["property_id"] != second.ID {
t.Fatalf("unexpected error body: %#v", body)
}
if got := countIssuesWithAtomicPropertyTitle(t, title); got != 0 {
t.Fatalf("invalid atomic create persisted %d issue(s)", got)
}
}
func TestCreateIssuePropertiesRejectDefinitionAndActorBoundaries(t *testing.T) {
suffix := uuid.NewString()[:8]
archived := createTestProperty(t, map[string]any{"name": "ArchivedCreate" + suffix, "type": "text"})
archive := testutil.Call(t, testHandler.UpdateProperty,
withURLParam(newRequest(http.MethodPatch, "/api/properties/"+archived.ID, map[string]any{"archived": true}), "id", archived.ID),
).Want(http.StatusOK)
_ = archive
actor := createTestProperty(t, map[string]any{"name": "ForeignActor" + suffix, "type": "actor"})
foreignWorkspaceID := uuid.NewString()
foreignPropertyID := uuid.NewString()
if _, err := testPool.Exec(context.Background(), `INSERT INTO workspace (id, name, slug, issue_prefix) VALUES ($1, $2, $3, 'F')`, foreignWorkspaceID, "Foreign create test", "foreign-create-"+suffix); err != nil {
t.Fatalf("create foreign workspace: %v", err)
}
t.Cleanup(func() {
_, _ = testPool.Exec(context.Background(), `DELETE FROM issue_property WHERE id = $1`, foreignPropertyID)
_, _ = testPool.Exec(context.Background(), `DELETE FROM workspace WHERE id = $1`, foreignWorkspaceID)
})
if _, err := testPool.Exec(context.Background(), `INSERT INTO issue_property (id, workspace_id, name, type, config) VALUES ($1, $2, 'Foreign', 'text', '{}')`, foreignPropertyID, foreignWorkspaceID); err != nil {
t.Fatalf("create foreign property: %v", err)
}
tests := []struct {
name string
propertyID string
value any
}{
{name: "unknown", propertyID: uuid.NewString(), value: "x"},
{name: "foreign", propertyID: foreignPropertyID, value: "x"},
{name: "archived", propertyID: archived.ID, value: "x"},
{name: "foreign actor", propertyID: actor.ID, value: "member:" + uuid.NewString()},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
title := "reject " + test.name + " " + uuid.NewString()
response := testutil.Call(t, testHandler.CreateIssue,
newRequest(http.MethodPost, "/api/issues?workspace_id="+testWorkspaceID, map[string]any{
"title": title, "properties": map[string]any{test.propertyID: test.value},
}),
).Want(http.StatusBadRequest)
var body map[string]any
_ = json.NewDecoder(response.Body).Decode(&body)
if body["code"] != "invalid_issue_property" || body["property_id"] != test.propertyID {
t.Fatalf("unexpected error body: %#v", body)
}
if countIssuesWithAtomicPropertyTitle(t, title) != 0 {
t.Fatal("rejected create persisted an issue")
}
})
}
}
func TestCreateIssuePropertiesRejectsDuplicateJSONKey(t *testing.T) {
property := createTestProperty(t, map[string]any{"name": "DuplicateCreate" + uuid.NewString()[:8], "type": "text"})
title := "duplicate property JSON " + uuid.NewString()
body := fmt.Sprintf(`{"title":%q,"properties":{"%s":"first","%s":"second"}}`, title, property.ID, property.ID)
req := httptest.NewRequest(http.MethodPost, "/api/issues?workspace_id="+testWorkspaceID, strings.NewReader(body))
req.Header.Set("Content-Type", "application/json")
req.Header.Set("X-User-ID", testUserID)
req.Header.Set("X-Workspace-ID", testWorkspaceID)
testutil.Call(t, testHandler.CreateIssue, req).Want(http.StatusBadRequest)
if countIssuesWithAtomicPropertyTitle(t, title) != 0 {
t.Fatal("duplicate-key create persisted an issue")
}
}
func TestCreateIssuePropertiesRejectsDuplicateCanonicalPropertyID(t *testing.T) {
property := createTestProperty(t, map[string]any{"name": "DuplicateCanonical" + uuid.NewString()[:8], "type": "text"})
title := "duplicate canonical property " + uuid.NewString()
body := fmt.Sprintf(
`{"title":%q,"properties":{"%s":"first","%s":"second"}}`,
title,
property.ID,
strings.ReplaceAll(property.ID, "-", ""),
)
req := httptest.NewRequest(http.MethodPost, "/api/issues?workspace_id="+testWorkspaceID, strings.NewReader(body))
req.Header.Set("Content-Type", "application/json")
req.Header.Set("X-User-ID", testUserID)
req.Header.Set("X-Workspace-ID", testWorkspaceID)
response := testutil.Call(t, testHandler.CreateIssue, req).Want(http.StatusBadRequest)
var errorBody map[string]any
_ = json.NewDecoder(response.Body).Decode(&errorBody)
if errorBody["code"] != "invalid_issue_property" {
t.Fatalf("unexpected error body: %#v", errorBody)
}
if countIssuesWithAtomicPropertyTitle(t, title) != 0 {
t.Fatal("duplicate canonical property create persisted an issue")
}
}
func TestCreateIssuePropertiesRejectsEmptyMultiValue(t *testing.T) {
property := createTestProperty(t, map[string]any{
"name": "EmptyMultiCreate" + uuid.NewString()[:8],
"type": "multi_select",
"config": map[string]any{"options": []map[string]any{{
"name": "Only", "color": "#3b82f6",
}}},
})
title := "empty multi property " + uuid.NewString()
response := testutil.Call(t, testHandler.CreateIssue,
newRequest(http.MethodPost, "/api/issues?workspace_id="+testWorkspaceID, map[string]any{
"title": title, "properties": map[string]any{property.ID: []string{}},
}),
).Want(http.StatusBadRequest)
var body map[string]any
_ = json.NewDecoder(response.Body).Decode(&body)
if body["code"] != "invalid_issue_property" || body["property_id"] != property.ID {
t.Fatalf("unexpected error body: %#v", body)
}
if countIssuesWithAtomicPropertyTitle(t, title) != 0 {
t.Fatal("empty multi-value create persisted an issue")
}
}
func TestCreateIssuePropertiesAllowsPlainWorkspaceMember(t *testing.T) {
property := createTestProperty(t, map[string]any{
"name": "MemberCreate" + uuid.NewString()[:8], "type": "text",
})
memberID := createPropertyTestMember(t)
response := testutil.Call(t, testHandler.CreateIssue,
newRequestAs(memberID, http.MethodPost, "/api/issues?workspace_id="+testWorkspaceID, map[string]any{
"title": "plain member property create " + uuid.NewString(),
"properties": map[string]any{property.ID: "allowed"},
}),
).Want(http.StatusCreated)
var issue IssueResponse
if err := json.NewDecoder(response.Body).Decode(&issue); err != nil {
t.Fatalf("decode create response: %v", err)
}
t.Cleanup(func() { _, _ = testPool.Exec(context.Background(), `DELETE FROM issue WHERE id = $1`, issue.ID) })
if issue.Properties[property.ID] != "allowed" {
t.Fatalf("plain member property snapshot = %#v", issue.Properties)
}
}
func TestCreateIssuePropertiesPreservesAssignmentPermissionBoundary(t *testing.T) {
property := createTestProperty(t, map[string]any{
"name": "PermissionCreate" + uuid.NewString()[:8], "type": "text",
})
privateAgentID, _, plainMemberID := privateAgentTestFixture(t)
title := "property create permission " + uuid.NewString()
testutil.Call(t, testHandler.CreateIssue,
newRequestAs(plainMemberID, http.MethodPost, "/api/issues?workspace_id="+testWorkspaceID, map[string]any{
"title": title, "assignee_type": "agent", "assignee_id": privateAgentID,
"properties": map[string]any{property.ID: "must roll back"},
}),
).Want(http.StatusForbidden)
if countIssuesWithAtomicPropertyTitle(t, title) != 0 {
t.Fatal("permission-denied property create persisted an issue")
}
}
func TestCreateIssuePropertiesEnforcesSharedBagSizeLimit(t *testing.T) {
properties := make(map[string]any)
for index := 0; index < 9; index++ {
property := createTestProperty(t, map[string]any{
"name": fmt.Sprintf("LargeCreate%d%s", index, uuid.NewString()[:8]), "type": "text",
})
properties[property.ID] = strings.Repeat("x", 2000)
}
title := "large property bag " + uuid.NewString()
response := testutil.Call(t, testHandler.CreateIssue,
newRequest(http.MethodPost, "/api/issues?workspace_id="+testWorkspaceID, map[string]any{
"title": title, "properties": properties,
}),
).Want(http.StatusBadRequest)
var body map[string]any
_ = json.NewDecoder(response.Body).Decode(&body)
if body["code"] != "issue_properties_too_large" {
t.Fatalf("unexpected error body: %#v", body)
}
if countIssuesWithAtomicPropertyTitle(t, title) != 0 {
t.Fatal("oversized property-bag create persisted an issue")
}
}