Files
multica/server/internal/handler/issue_create_position_test.go
2026-05-28 14:20:20 +08:00

197 lines
7.1 KiB
Go

package handler
import (
"context"
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"testing"
"time"
"github.com/jackc/pgx/v5/pgtype"
db "github.com/multica-ai/multica/server/pkg/db/generated"
)
// TestCreateIssuePositionTopOfColumn verifies that a newly created issue is
// placed above all existing issues in the same status column (manual sort order).
//
// Before the fix, new issues were always assigned position=0. After drag-reorder
// activity, existing issues accumulate negative positions at the top of the
// column, so a fresh issue at 0 would land in the middle of a long list.
//
// The fix queries MIN(position) for the workspace+status pair and assigns
// newPosition = minPos - 1, so the new ticket always appears first.
func TestCreateIssuePositionTopOfColumn(t *testing.T) {
// Create two issues via the API. The first lands at COALESCE(MIN,0)-1 = -1,
// the second at -2, and so on — each successive issue ends up above the
// previous one, which is exactly the desired behavior.
createIssueAndGetPosition := func(t *testing.T, title string) (string, float64) {
t.Helper()
w := httptest.NewRecorder()
req := newRequest("POST", "/api/issues?workspace_id="+testWorkspaceID, map[string]any{
"title": title,
"status": "todo",
"priority": "low",
})
testHandler.CreateIssue(w, req)
if w.Code != http.StatusCreated {
t.Fatalf("CreateIssue %q: expected 201, got %d: %s", title, w.Code, w.Body.String())
}
var issue IssueResponse
json.NewDecoder(w.Body).Decode(&issue)
return issue.ID, issue.Position
}
id1, pos1 := createIssueAndGetPosition(t, "position-test first issue")
t.Cleanup(func() { deleteTestIssue(t, id1) })
id2, pos2 := createIssueAndGetPosition(t, "position-test second issue")
t.Cleanup(func() { deleteTestIssue(t, id2) })
id3, pos3 := createIssueAndGetPosition(t, "position-test third issue")
t.Cleanup(func() { deleteTestIssue(t, id3) })
// Each new issue must have a strictly lower position than the previous one,
// ensuring it sorts to the top of the column in manual order.
if pos2 >= pos1 {
t.Errorf("second issue position (%v) should be less than first (%v)", pos2, pos1)
}
if pos3 >= pos2 {
t.Errorf("third issue position (%v) should be less than second (%v)", pos3, pos2)
}
}
// TestCreateIssuePositionBelowExplicitMinimum verifies the fix against a
// realistic drag-reordered column: after manually setting a low position
// directly in the DB (simulating drag-and-drop), a new issue created via the
// API should land below the explicit minimum, not at 0.
func TestCreateIssuePositionBelowExplicitMinimum(t *testing.T) {
// Create a seed issue via the API.
w := httptest.NewRecorder()
req := newRequest("POST", "/api/issues?workspace_id="+testWorkspaceID, map[string]any{
"title": "position-seed issue",
"status": "todo",
"priority": "low",
})
testHandler.CreateIssue(w, req)
if w.Code != http.StatusCreated {
t.Fatalf("seed CreateIssue: expected 201, got %d: %s", w.Code, w.Body.String())
}
var seed IssueResponse
json.NewDecoder(w.Body).Decode(&seed)
t.Cleanup(func() { deleteTestIssue(t, seed.ID) })
// Simulate drag-and-drop: overwrite the seed's position to a large negative
// value (-9999), as if the user dragged it to the very top of a long list.
const simulatedMinPos = -9999.0
if _, err := testPool.Exec(t.Context(),
`UPDATE issue SET position = $1 WHERE id = $2`,
simulatedMinPos, seed.ID,
); err != nil {
t.Fatalf("failed to set explicit position: %v", err)
}
// Now create a new issue. It must land below -9999, not at 0.
w2 := httptest.NewRecorder()
req2 := newRequest("POST", "/api/issues?workspace_id="+testWorkspaceID, map[string]any{
"title": "position-new issue",
"status": "todo",
"priority": "low",
})
testHandler.CreateIssue(w2, req2)
if w2.Code != http.StatusCreated {
t.Fatalf("new CreateIssue: expected 201, got %d: %s", w2.Code, w2.Body.String())
}
var newIssue IssueResponse
json.NewDecoder(w2.Body).Decode(&newIssue)
t.Cleanup(func() { deleteTestIssue(t, newIssue.ID) })
if newIssue.Position >= simulatedMinPos {
t.Errorf("new issue position (%v) should be less than simulated min (%v); got position 0 (unfixed behavior)?",
newIssue.Position, simulatedMinPos)
}
}
func TestAutopilotCreateIssuePositionBelowCurrentMinimum(t *testing.T) {
ctx := context.Background()
seedTitle := fmt.Sprintf("position-autopilot seed %d", time.Now().UnixNano())
autopilotIssueTitle := fmt.Sprintf("position-autopilot issue %d", time.Now().UnixNano())
var agentID string
if err := testPool.QueryRow(ctx, `SELECT id FROM agent WHERE workspace_id = $1 LIMIT 1`, testWorkspaceID).Scan(&agentID); err != nil {
t.Fatalf("load test agent: %v", err)
}
w := httptest.NewRecorder()
req := newRequest("POST", "/api/issues?workspace_id="+testWorkspaceID, map[string]any{
"title": seedTitle,
"status": "todo",
"priority": "low",
})
testHandler.CreateIssue(w, req)
if w.Code != http.StatusCreated {
t.Fatalf("seed CreateIssue: expected 201, got %d: %s", w.Code, w.Body.String())
}
var seed IssueResponse
json.NewDecoder(w.Body).Decode(&seed)
t.Cleanup(func() { deleteTestIssue(t, seed.ID) })
const simulatedMinPos = -9999.0
if _, err := testPool.Exec(ctx,
`UPDATE issue SET position = $1 WHERE id = $2`,
simulatedMinPos, seed.ID,
); err != nil {
t.Fatalf("failed to set explicit position: %v", err)
}
var minBefore float64
if err := testPool.QueryRow(ctx,
`SELECT MIN(position) FROM issue WHERE workspace_id = $1 AND status = 'todo'`,
testWorkspaceID,
).Scan(&minBefore); err != nil {
t.Fatalf("load min position: %v", err)
}
w = httptest.NewRecorder()
req = newRequest("POST", "/api/autopilots?workspace_id="+testWorkspaceID, map[string]any{
"title": "Position autopilot",
"assignee_id": agentID,
"execution_mode": "create_issue",
"issue_title_template": autopilotIssueTitle,
})
testHandler.CreateAutopilot(w, req)
if w.Code != http.StatusCreated {
t.Fatalf("CreateAutopilot: expected 201, got %d: %s", w.Code, w.Body.String())
}
var autopilot AutopilotResponse
if err := json.NewDecoder(w.Body).Decode(&autopilot); err != nil {
t.Fatalf("decode autopilot: %v", err)
}
t.Cleanup(func() { testPool.Exec(context.Background(), `DELETE FROM autopilot WHERE id = $1`, autopilot.ID) })
queries := db.New(testPool)
ap, err := queries.GetAutopilot(ctx, parseUUID(autopilot.ID))
if err != nil {
t.Fatalf("GetAutopilot: %v", err)
}
run, err := testHandler.AutopilotService.DispatchAutopilot(ctx, ap, pgtype.UUID{}, "manual", nil)
if err != nil {
t.Fatalf("DispatchAutopilot: %v", err)
}
if run == nil || !run.IssueID.Valid {
t.Fatalf("dispatch run = %+v, want linked issue", run)
}
issueID := uuidToString(run.IssueID)
t.Cleanup(func() { deleteTestIssue(t, issueID) })
var createdPos float64
if err := testPool.QueryRow(ctx, `SELECT position FROM issue WHERE id = $1`, issueID).Scan(&createdPos); err != nil {
t.Fatalf("load autopilot-created issue position: %v", err)
}
if createdPos >= minBefore {
t.Errorf("autopilot-created issue position (%v) should be less than current min (%v); fixed position 0 would sort in the middle",
createdPos, minBefore)
}
}