Files
multica/server/internal/handler/issue_move_test.go
Multica Eve 40f9ecdd56 MUL-5202: unify Issue Query across List, Board, and Swimlane (#5820)
* MUL-5202: migrate status issue surfaces to table query

Co-authored-by: multica-agent <github@multica.ai>

* MUL-5202: unify grouped issue surfaces

Co-authored-by: multica-agent <github@multica.ai>

* MUL-5202: cover move safety boundaries

Co-authored-by: multica-agent <github@multica.ai>

* MUL-5202: preserve server swimlane semantics

Co-authored-by: multica-agent <github@multica.ai>

* MUL-5202: keep grouped surface facets exact

Co-authored-by: multica-agent <github@multica.ai>

---------

Co-authored-by: Eve <eve@multica-ai.local>
Co-authored-by: multica-agent <github@multica.ai>
2026-07-23 15:58:25 +08:00

186 lines
4.8 KiB
Go

package handler
import (
"context"
"fmt"
"math"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
)
func TestIssueMovePositionUsesRelativeAnchors(t *testing.T) {
before := 10.0
after := 20.0
position, err := issueMovePosition(3, &before, &after)
if err != nil {
t.Fatalf("issueMovePosition: %v", err)
}
if position != 15 {
t.Fatalf("position = %v, want 15", position)
}
}
func TestIssueMovePositionRejectsStaleAnchors(t *testing.T) {
before := 20.0
after := 10.0
if _, err := issueMovePosition(3, &before, &after); err == nil {
t.Fatal("out-of-order anchors were accepted")
}
}
func TestIssueMovePositionRejectsExhaustedGap(t *testing.T) {
before := 1.0
after := math.Nextafter(before, math.Inf(1))
if _, err := issueMovePosition(3, &before, &after); err == nil {
t.Fatal("gap with no representable midpoint was accepted")
}
}
func TestIssueMovePositionKeepsPositionWithoutAnchors(t *testing.T) {
position, err := issueMovePosition(7, nil, nil)
if err != nil {
t.Fatalf("issueMovePosition: %v", err)
}
if position != 7 {
t.Fatalf("position = %v, want 7", position)
}
}
func TestMoveIssueRejectsUnsafeInputs(t *testing.T) {
ctx := context.Background()
suffix := fmt.Sprintf("%d", time.Now().UnixNano())
var movedIssueID string
if err := testPool.QueryRow(ctx, `
INSERT INTO issue (
workspace_id, title, status, priority, creator_type, creator_id,
number, position
)
VALUES (
$1, $2, 'todo', 'none', 'member', $3,
(SELECT COALESCE(MAX(number), 0) + 1 FROM issue WHERE workspace_id = $1),
100
)
RETURNING id
`, testWorkspaceID, "Move boundary test "+suffix, testUserID).Scan(&movedIssueID); err != nil {
t.Fatalf("insert moved issue: %v", err)
}
t.Cleanup(func() {
testPool.Exec(context.Background(), `DELETE FROM issue WHERE id = $1`, movedIssueID)
})
var foreignWorkspaceID string
if err := testPool.QueryRow(ctx, `
INSERT INTO workspace (name, slug, description, issue_prefix)
VALUES ($1, $2, '', 'MOV')
RETURNING id
`, "Move boundary foreign workspace "+suffix, "move-boundary-"+suffix).Scan(&foreignWorkspaceID); err != nil {
t.Fatalf("insert foreign workspace: %v", err)
}
t.Cleanup(func() {
testPool.Exec(context.Background(), `DELETE FROM workspace WHERE id = $1`, foreignWorkspaceID)
})
var foreignAnchorID string
if err := testPool.QueryRow(ctx, `
INSERT INTO issue (
workspace_id, title, status, priority, creator_type, creator_id,
number, position
)
VALUES ($1, $2, 'todo', 'none', 'member', $3, 1, 200)
RETURNING id
`, foreignWorkspaceID, "Foreign move anchor "+suffix, testUserID).Scan(&foreignAnchorID); err != nil {
t.Fatalf("insert foreign anchor: %v", err)
}
var foreignProjectID string
if err := testPool.QueryRow(ctx, `
INSERT INTO project (workspace_id, title)
VALUES ($1, $2)
RETURNING id
`, foreignWorkspaceID, "Foreign move project "+suffix).Scan(&foreignProjectID); err != nil {
t.Fatalf("insert foreign project: %v", err)
}
tests := []struct {
name string
body map[string]any
wantError string
}{
{
name: "cross-workspace anchor",
body: map[string]any{
"before_id": foreignAnchorID,
"after_id": nil,
},
wantError: "move anchor not found in this workspace",
},
{
name: "cross-workspace project",
body: map[string]any{
"project_id": foreignProjectID,
"before_id": nil,
"after_id": nil,
},
wantError: "project not found in this workspace",
},
{
name: "canonical position bypass",
body: map[string]any{
"position": 123,
"before_id": nil,
"after_id": nil,
},
wantError: "unsupported move field: position",
},
{
name: "self anchor",
body: map[string]any{
"before_id": movedIssueID,
"after_id": nil,
},
wantError: "before_id cannot be the moved issue",
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
w := httptest.NewRecorder()
req := newRequest(
http.MethodPost,
"/api/issues/"+movedIssueID+"/move",
tc.body,
)
req = withURLParam(req, "id", movedIssueID)
testHandler.MoveIssue(w, req)
if w.Code != http.StatusBadRequest {
t.Fatalf("MoveIssue: expected 400, got %d: %s", w.Code, w.Body.String())
}
if !strings.Contains(w.Body.String(), tc.wantError) {
t.Fatalf("MoveIssue: expected error %q, got %s", tc.wantError, w.Body.String())
}
})
}
var title string
var position float64
var projectID *string
if err := testPool.QueryRow(ctx, `
SELECT title, position, project_id::text
FROM issue
WHERE id = $1
`, movedIssueID).Scan(&title, &position, &projectID); err != nil {
t.Fatalf("reload moved issue: %v", err)
}
if title != "Move boundary test "+suffix || position != 100 || projectID != nil {
t.Fatalf(
"rejected moves changed issue: title=%q position=%v project_id=%v",
title, position, projectID,
)
}
}