mirror of
https://gh-proxy.org/https://github.com/klinsmaya/multica.git
synced 2026-11-09 01:46:46 +08:00
Before: the issue detail subscribed to the workspace-wide child-progress map (every parent in the workspace) just to draw nested progress rings on its own sub-issue rows, a large response in big workspaces for a tiny need. After: the detail asks for progress of exactly the loaded sub-issue ids, so the response is bounded by the visible rows. Board, list and swimlane keep the workspace-wide query unchanged, and the endpoint without parent_ids behaves as before. How: GET /api/issues/child-progress accepts an optional parent_ids filter (comma-separated uuids, at most 100, malformed or oversized input is a 400) backed by the new ChildIssueProgressForParents query, which stays workspace-scoped. In core, childIssueProgressForParentsOptions sorts, dedupes and chunks ids at 100 and nests its key under issueKeys.childProgress so existing realtime and mutation invalidations still reach it. Co-Authored-By: Claude Fable 5.1 <[email protected]> Claude-Session: https://claude.ai/code/session_017deksZDSzZBA4QSqZZoBip
120 lines
4.4 KiB
Go
120 lines
4.4 KiB
Go
package handler
|
|
|
|
import (
|
|
"net/http"
|
|
"strings"
|
|
"testing"
|
|
|
|
"github.com/google/uuid"
|
|
"github.com/multica-ai/multica/server/internal/testutil"
|
|
)
|
|
|
|
type childProgressBody struct {
|
|
Progress []struct {
|
|
ParentIssueID string `json:"parent_issue_id"`
|
|
Total int64 `json:"total"`
|
|
Done int64 `json:"done"`
|
|
} `json:"progress"`
|
|
}
|
|
|
|
// byParent maps parent id to [done, total].
|
|
func (b childProgressBody) byParent() map[string][2]int64 {
|
|
out := make(map[string][2]int64, len(b.Progress))
|
|
for _, p := range b.Progress {
|
|
out[p.ParentIssueID] = [2]int64{p.Done, p.Total}
|
|
}
|
|
return out
|
|
}
|
|
|
|
func getChildProgress(t *testing.T, query string) childProgressBody {
|
|
t.Helper()
|
|
var out childProgressBody
|
|
testutil.Call(t, testHandler.ChildIssueProgress,
|
|
newRequest("GET", "/api/issues/child-progress"+query, nil)).
|
|
Want(http.StatusOK).JSON(&out)
|
|
return out
|
|
}
|
|
|
|
func TestChildIssueProgress_ParentIDsNarrowsResponse(t *testing.T) {
|
|
parentA := dbfx.Issue(t, "progress parent A")
|
|
parentB := dbfx.Issue(t, "progress parent B")
|
|
parentC := dbfx.Issue(t, "progress parent C")
|
|
dbfx.Issue(t, "a done", testutil.Cols{"parent_issue_id": parentA, "status": "done"})
|
|
dbfx.Issue(t, "a open", testutil.Cols{"parent_issue_id": parentA, "status": "todo"})
|
|
dbfx.Issue(t, "b open", testutil.Cols{"parent_issue_id": parentB, "status": "todo"})
|
|
dbfx.Issue(t, "c done", testutil.Cols{"parent_issue_id": parentC, "status": "done"})
|
|
|
|
got := getChildProgress(t, "?parent_ids="+parentA+","+parentB).byParent()
|
|
if len(got) != 2 {
|
|
t.Fatalf("expected exactly the 2 named parents, got %v", got)
|
|
}
|
|
if got[parentA] != [2]int64{1, 2} {
|
|
t.Errorf("parent A done/total = %v, want [1 2]", got[parentA])
|
|
}
|
|
if got[parentB] != [2]int64{0, 1} {
|
|
t.Errorf("parent B done/total = %v, want [0 1]", got[parentB])
|
|
}
|
|
if _, ok := got[parentC]; ok {
|
|
t.Errorf("parent C was not requested but is in the response")
|
|
}
|
|
}
|
|
|
|
func TestChildIssueProgress_ParentWithoutChildrenYieldsNoRow(t *testing.T) {
|
|
childless := dbfx.Issue(t, "progress childless parent")
|
|
if got := getChildProgress(t, "?parent_ids="+childless).Progress; len(got) != 0 {
|
|
t.Fatalf("expected no rows for a parent without children, got %v", got)
|
|
}
|
|
}
|
|
|
|
func TestChildIssueProgress_ForeignWorkspaceParentIsAbsent(t *testing.T) {
|
|
foreignWorkspaceID := dbfx.Workspace(t, "Child progress foreign", "child-progress-"+uuid.NewString())
|
|
foreignParent := dbfx.Issue(t, "foreign progress parent", testutil.Cols{"workspace_id": foreignWorkspaceID})
|
|
dbfx.Issue(t, "foreign progress child", testutil.Cols{
|
|
"workspace_id": foreignWorkspaceID,
|
|
"parent_issue_id": foreignParent,
|
|
"status": "done",
|
|
})
|
|
localParent := dbfx.Issue(t, "local progress parent")
|
|
dbfx.Issue(t, "local progress child", testutil.Cols{"parent_issue_id": localParent, "status": "done"})
|
|
|
|
got := getChildProgress(t, "?parent_ids="+foreignParent+","+localParent).byParent()
|
|
if _, ok := got[foreignParent]; ok {
|
|
t.Errorf("foreign workspace parent leaked into the response: %v", got)
|
|
}
|
|
if got[localParent] != [2]int64{1, 1} {
|
|
t.Errorf("local parent done/total = %v, want [1 1]", got[localParent])
|
|
}
|
|
}
|
|
|
|
func TestChildIssueProgress_RejectsTooManyParentIDs(t *testing.T) {
|
|
ids := make([]string, childIssueProgressParentsLimit+1)
|
|
for i := range ids {
|
|
ids[i] = uuid.NewString()
|
|
}
|
|
testutil.Call(t, testHandler.ChildIssueProgress,
|
|
newRequest("GET", "/api/issues/child-progress?parent_ids="+strings.Join(ids, ","), nil)).
|
|
Want(http.StatusBadRequest)
|
|
|
|
// Exactly at the cap is accepted.
|
|
getChildProgress(t, "?parent_ids="+strings.Join(ids[:childIssueProgressParentsLimit], ","))
|
|
}
|
|
|
|
func TestChildIssueProgress_RejectsMalformedParentID(t *testing.T) {
|
|
parent := dbfx.Issue(t, "progress malformed parent")
|
|
testutil.Call(t, testHandler.ChildIssueProgress,
|
|
newRequest("GET", "/api/issues/child-progress?parent_ids="+parent+",not-a-uuid", nil)).
|
|
Want(http.StatusBadRequest)
|
|
}
|
|
|
|
func TestChildIssueProgress_WithoutParentIDsReturnsWorkspaceMap(t *testing.T) {
|
|
parentA := dbfx.Issue(t, "progress map parent A")
|
|
parentB := dbfx.Issue(t, "progress map parent B")
|
|
dbfx.Issue(t, "map a done", testutil.Cols{"parent_issue_id": parentA, "status": "done"})
|
|
dbfx.Issue(t, "map b open", testutil.Cols{"parent_issue_id": parentB, "status": "todo"})
|
|
|
|
got := getChildProgress(t, "").byParent()
|
|
if got[parentA] != [2]int64{1, 1} || got[parentB] != [2]int64{0, 1} {
|
|
t.Fatalf("workspace map missing the seeded parents: A=%v B=%v", got[parentA], got[parentB])
|
|
}
|
|
}
|