mirror of
https://github.com/multica-ai/multica.git
synced 2026-07-26 12:35:35 +02:00
* 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>
186 lines
4.8 KiB
Go
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,
|
|
)
|
|
}
|
|
}
|