mirror of
https://github.com/multica-ai/multica.git
synced 2026-07-15 22:29:04 +02:00
2207 lines
73 KiB
Go
2207 lines
73 KiB
Go
package main
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"net/url"
|
|
"os"
|
|
"strconv"
|
|
"strings"
|
|
"testing"
|
|
|
|
"github.com/spf13/cobra"
|
|
|
|
"github.com/multica-ai/multica/server/internal/cli"
|
|
)
|
|
|
|
// stderrCapture redirects os.Stderr through a pipe so a test can assert on
|
|
// the human-facing strings runIssueCommentList prints alongside its JSON
|
|
// output. Read() drains the pipe and is safe to call multiple times.
|
|
type stderrCapture struct {
|
|
t *testing.T
|
|
orig *os.File
|
|
r, w *os.File
|
|
out strings.Builder
|
|
doneCh chan struct{}
|
|
stopped bool
|
|
}
|
|
|
|
func captureStderr(t *testing.T) *stderrCapture {
|
|
t.Helper()
|
|
r, w, err := os.Pipe()
|
|
if err != nil {
|
|
t.Fatalf("pipe: %v", err)
|
|
}
|
|
c := &stderrCapture{t: t, orig: os.Stderr, r: r, w: w, doneCh: make(chan struct{})}
|
|
os.Stderr = w
|
|
go func() {
|
|
buf, _ := io.ReadAll(r)
|
|
c.out.Write(buf)
|
|
close(c.doneCh)
|
|
}()
|
|
return c
|
|
}
|
|
|
|
func (c *stderrCapture) restore() {
|
|
if c.stopped {
|
|
return
|
|
}
|
|
c.stopped = true
|
|
os.Stderr = c.orig
|
|
_ = c.w.Close()
|
|
<-c.doneCh
|
|
_ = c.r.Close()
|
|
}
|
|
|
|
func (c *stderrCapture) read() string {
|
|
c.restore()
|
|
return c.out.String()
|
|
}
|
|
|
|
// pipeStdin replaces os.Stdin with a pipe seeded by the given body for the
|
|
// duration of fn, so resolveTextFlag's --content-stdin / --description-stdin
|
|
// branch can be exercised in unit tests without spawning a subprocess.
|
|
func pipeStdin(t *testing.T, body string, fn func()) {
|
|
t.Helper()
|
|
r, w, err := os.Pipe()
|
|
if err != nil {
|
|
t.Fatalf("pipe: %v", err)
|
|
}
|
|
if _, err := w.WriteString(body); err != nil {
|
|
t.Fatalf("write pipe: %v", err)
|
|
}
|
|
if err := w.Close(); err != nil {
|
|
t.Fatalf("close pipe writer: %v", err)
|
|
}
|
|
orig := os.Stdin
|
|
os.Stdin = r
|
|
defer func() {
|
|
os.Stdin = orig
|
|
_ = r.Close()
|
|
}()
|
|
fn()
|
|
}
|
|
|
|
// newFlagTestCmd builds a throwaway cobra.Command carrying the inline +
|
|
// stdin + file flag triplet that resolveTextFlag expects.
|
|
func newFlagTestCmd(name string) *cobra.Command {
|
|
c := &cobra.Command{Use: "test"}
|
|
c.Flags().String(name, "", "")
|
|
c.Flags().Bool(name+"-stdin", false, "")
|
|
c.Flags().String(name+"-file", "", "")
|
|
return c
|
|
}
|
|
|
|
func TestResolveTextFlag(t *testing.T) {
|
|
t.Run("inline value is unescaped", func(t *testing.T) {
|
|
c := newFlagTestCmd("description")
|
|
_ = c.Flags().Set("description", `para1\n\npara2`)
|
|
got, ok, err := resolveTextFlag(c, "description")
|
|
if err != nil || !ok {
|
|
t.Fatalf("unexpected: ok=%v err=%v", ok, err)
|
|
}
|
|
if got != "para1\n\npara2" {
|
|
t.Errorf("got %q, want decoded paragraphs", got)
|
|
}
|
|
})
|
|
|
|
t.Run("stdin body is preserved verbatim", func(t *testing.T) {
|
|
c := newFlagTestCmd("description")
|
|
_ = c.Flags().Set("description-stdin", "true")
|
|
body := "first line\nsecond line with a literal \\n in it\n"
|
|
pipeStdin(t, body, func() {
|
|
got, ok, err := resolveTextFlag(c, "description")
|
|
if err != nil || !ok {
|
|
t.Fatalf("unexpected: ok=%v err=%v", ok, err)
|
|
}
|
|
// strings.TrimSuffix one trailing newline like content-stdin.
|
|
want := "first line\nsecond line with a literal \\n in it"
|
|
if got != want {
|
|
t.Errorf("got %q, want %q", got, want)
|
|
}
|
|
})
|
|
})
|
|
|
|
t.Run("inline + stdin is rejected", func(t *testing.T) {
|
|
c := newFlagTestCmd("description")
|
|
_ = c.Flags().Set("description", "inline")
|
|
_ = c.Flags().Set("description-stdin", "true")
|
|
if _, _, err := resolveTextFlag(c, "description"); err == nil {
|
|
t.Fatalf("expected mutually-exclusive error")
|
|
}
|
|
})
|
|
|
|
t.Run("missing both returns hasValue=false", func(t *testing.T) {
|
|
c := newFlagTestCmd("description")
|
|
got, ok, err := resolveTextFlag(c, "description")
|
|
if err != nil {
|
|
t.Fatalf("unexpected err: %v", err)
|
|
}
|
|
if ok || got != "" {
|
|
t.Errorf("expected absent flag to yield (\"\", false), got (%q, %v)", got, ok)
|
|
}
|
|
})
|
|
|
|
// --content-file / --description-file exists for Windows agents — piping
|
|
// HEREDOC content through Windows PowerShell silently drops non-ASCII
|
|
// bytes (PS 5.1's $OutputEncoding defaults to ASCIIEncoding when piping
|
|
// to a native command), so Chinese / Cyrillic / etc. arrive as `?`.
|
|
// Reading the body straight off disk skips the shell entirely.
|
|
// See issues #2198, #2236, #2376.
|
|
t.Run("file body is preserved verbatim with non-ASCII content", func(t *testing.T) {
|
|
dir := t.TempDir()
|
|
path := dir + string(os.PathSeparator) + "desc.md"
|
|
body := "标题 / Заголовок\n\n中文段落 with `code` and \"quotes\".\n"
|
|
if err := os.WriteFile(path, []byte(body), 0o644); err != nil {
|
|
t.Fatalf("write tempfile: %v", err)
|
|
}
|
|
c := newFlagTestCmd("description")
|
|
_ = c.Flags().Set("description-file", path)
|
|
got, ok, err := resolveTextFlag(c, "description")
|
|
if err != nil || !ok {
|
|
t.Fatalf("unexpected: ok=%v err=%v", ok, err)
|
|
}
|
|
want := "标题 / Заголовок\n\n中文段落 with `code` and \"quotes\"."
|
|
if got != want {
|
|
t.Errorf("got %q, want %q", got, want)
|
|
}
|
|
})
|
|
|
|
t.Run("file path that doesn't exist surfaces a useful error", func(t *testing.T) {
|
|
c := newFlagTestCmd("content")
|
|
_ = c.Flags().Set("content-file", "/this/path/does/not/exist.txt")
|
|
_, _, err := resolveTextFlag(c, "content")
|
|
if err == nil {
|
|
t.Fatalf("expected error for missing file")
|
|
}
|
|
if !strings.Contains(err.Error(), "content-file") {
|
|
t.Errorf("error should mention --content-file, got %v", err)
|
|
}
|
|
})
|
|
|
|
t.Run("empty file is rejected", func(t *testing.T) {
|
|
dir := t.TempDir()
|
|
path := dir + string(os.PathSeparator) + "empty.md"
|
|
if err := os.WriteFile(path, []byte(""), 0o644); err != nil {
|
|
t.Fatalf("write tempfile: %v", err)
|
|
}
|
|
c := newFlagTestCmd("description")
|
|
_ = c.Flags().Set("description-file", path)
|
|
_, _, err := resolveTextFlag(c, "description")
|
|
if err == nil {
|
|
t.Fatalf("expected error for empty file")
|
|
}
|
|
})
|
|
|
|
t.Run("file plus inline is rejected", func(t *testing.T) {
|
|
dir := t.TempDir()
|
|
path := dir + string(os.PathSeparator) + "x.md"
|
|
if err := os.WriteFile(path, []byte("body"), 0o644); err != nil {
|
|
t.Fatalf("write tempfile: %v", err)
|
|
}
|
|
c := newFlagTestCmd("description")
|
|
_ = c.Flags().Set("description", "inline")
|
|
_ = c.Flags().Set("description-file", path)
|
|
if _, _, err := resolveTextFlag(c, "description"); err == nil {
|
|
t.Fatalf("expected mutually-exclusive error for inline + file")
|
|
}
|
|
})
|
|
|
|
t.Run("file plus stdin is rejected", func(t *testing.T) {
|
|
dir := t.TempDir()
|
|
path := dir + string(os.PathSeparator) + "x.md"
|
|
if err := os.WriteFile(path, []byte("body"), 0o644); err != nil {
|
|
t.Fatalf("write tempfile: %v", err)
|
|
}
|
|
c := newFlagTestCmd("description")
|
|
_ = c.Flags().Set("description-stdin", "true")
|
|
_ = c.Flags().Set("description-file", path)
|
|
if _, _, err := resolveTextFlag(c, "description"); err == nil {
|
|
t.Fatalf("expected mutually-exclusive error for stdin + file")
|
|
}
|
|
})
|
|
}
|
|
|
|
func newIssueCreateTestCmd() *cobra.Command {
|
|
cmd := &cobra.Command{Use: "create"}
|
|
cmd.Flags().String("title", "", "")
|
|
cmd.Flags().String("description", "", "")
|
|
cmd.Flags().Bool("description-stdin", false, "")
|
|
cmd.Flags().String("description-file", "", "")
|
|
cmd.Flags().String("status", "", "")
|
|
cmd.Flags().String("priority", "", "")
|
|
cmd.Flags().String("assignee", "", "")
|
|
cmd.Flags().String("assignee-id", "", "")
|
|
cmd.Flags().String("parent", "", "")
|
|
cmd.Flags().String("project", "", "")
|
|
cmd.Flags().String("due-date", "", "")
|
|
cmd.Flags().Bool("allow-duplicate", false, "")
|
|
cmd.Flags().String("output", "json", "")
|
|
cmd.Flags().StringSlice("attachment", nil, "")
|
|
cmd.Flags().StringSlice("attachment-id", nil, "")
|
|
return cmd
|
|
}
|
|
|
|
func TestRunIssueCreateSendsAllowDuplicate(t *testing.T) {
|
|
var body map[string]any
|
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
if r.URL.Path != "/api/issues" {
|
|
http.NotFound(w, r)
|
|
return
|
|
}
|
|
if r.Method != http.MethodPost {
|
|
t.Errorf("method = %s, want POST", r.Method)
|
|
}
|
|
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
|
t.Errorf("decode body: %v", err)
|
|
}
|
|
json.NewEncoder(w).Encode(map[string]any{
|
|
"id": "issue-1",
|
|
"identifier": "MUL-1",
|
|
"title": "Duplicate allowed",
|
|
"status": "todo",
|
|
"priority": "none",
|
|
})
|
|
}))
|
|
defer srv.Close()
|
|
|
|
t.Setenv("MULTICA_SERVER_URL", srv.URL)
|
|
t.Setenv("MULTICA_WORKSPACE_ID", "ws-1")
|
|
t.Setenv("MULTICA_TOKEN", "test-token")
|
|
|
|
cmd := newIssueCreateTestCmd()
|
|
_ = cmd.Flags().Set("title", "Duplicate allowed")
|
|
_ = cmd.Flags().Set("allow-duplicate", "true")
|
|
if err := runIssueCreate(cmd, nil); err != nil {
|
|
t.Fatalf("runIssueCreate: %v", err)
|
|
}
|
|
if got := body["allow_duplicate"]; got != true {
|
|
t.Fatalf("allow_duplicate = %#v, want true in request body", got)
|
|
}
|
|
}
|
|
|
|
func TestRunIssueCreateSendsExistingAttachmentIDs(t *testing.T) {
|
|
var body map[string]any
|
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
if r.URL.Path != "/api/issues" {
|
|
http.NotFound(w, r)
|
|
return
|
|
}
|
|
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
|
t.Errorf("decode body: %v", err)
|
|
}
|
|
json.NewEncoder(w).Encode(map[string]any{
|
|
"id": "issue-1",
|
|
"identifier": "MUL-1",
|
|
"title": "With attachments",
|
|
"status": "todo",
|
|
"priority": "none",
|
|
})
|
|
}))
|
|
defer srv.Close()
|
|
|
|
t.Setenv("MULTICA_SERVER_URL", srv.URL)
|
|
t.Setenv("MULTICA_WORKSPACE_ID", "ws-1")
|
|
t.Setenv("MULTICA_TOKEN", "test-token")
|
|
t.Setenv("MULTICA_QUICK_CREATE_ATTACHMENT_IDS", `["att-env","att-shared"]`)
|
|
|
|
cmd := newIssueCreateTestCmd()
|
|
_ = cmd.Flags().Set("title", "With attachments")
|
|
_ = cmd.Flags().Set("attachment-id", "att-flag")
|
|
_ = cmd.Flags().Set("attachment-id", "att-shared")
|
|
if err := runIssueCreate(cmd, nil); err != nil {
|
|
t.Fatalf("runIssueCreate: %v", err)
|
|
}
|
|
|
|
got, ok := body["attachment_ids"].([]any)
|
|
if !ok {
|
|
t.Fatalf("attachment_ids = %#v, want JSON array", body["attachment_ids"])
|
|
}
|
|
want := []string{"att-flag", "att-shared", "att-env"}
|
|
if len(got) != len(want) {
|
|
t.Fatalf("attachment_ids length = %d, want %d (%#v)", len(got), len(want), got)
|
|
}
|
|
for i, w := range want {
|
|
if got[i] != w {
|
|
t.Fatalf("attachment_ids[%d] = %#v, want %q (all=%#v)", i, got[i], w, got)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestRunIssueCreateShowsDuplicateMessage(t *testing.T) {
|
|
want := "Active duplicate issue exists: YUA-36 SH-PM-SYNTH-01 Synthesize recommendation-to-shortlist planning outputs (status: in_progress). Set allow_duplicate=true or use --allow-duplicate to create another."
|
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
if r.URL.Path != "/api/issues" {
|
|
http.NotFound(w, r)
|
|
return
|
|
}
|
|
w.WriteHeader(http.StatusConflict)
|
|
json.NewEncoder(w).Encode(map[string]any{
|
|
"code": "active_duplicate_issue",
|
|
"error": want,
|
|
"issue": map[string]any{
|
|
"id": "issue-id",
|
|
"identifier": "YUA-36",
|
|
"status": "in_progress",
|
|
},
|
|
})
|
|
}))
|
|
defer srv.Close()
|
|
|
|
t.Setenv("MULTICA_SERVER_URL", srv.URL)
|
|
t.Setenv("MULTICA_WORKSPACE_ID", "ws-1")
|
|
t.Setenv("MULTICA_TOKEN", "test-token")
|
|
|
|
cmd := newIssueCreateTestCmd()
|
|
_ = cmd.Flags().Set("title", "SH-PM-SYNTH-01 Synthesize recommendation-to-shortlist planning outputs")
|
|
err := runIssueCreate(cmd, nil)
|
|
if err == nil {
|
|
t.Fatal("runIssueCreate: expected duplicate error")
|
|
}
|
|
if got := err.Error(); got != want {
|
|
t.Fatalf("error = %q, want %q", got, want)
|
|
}
|
|
}
|
|
|
|
func newIssuePullRequestsTestCmd() *cobra.Command {
|
|
cmd := &cobra.Command{Use: "pull-requests"}
|
|
cmd.Flags().String("output", "table", "")
|
|
return cmd
|
|
}
|
|
|
|
func TestRunIssuePullRequestsListsLinkedPRsAsJSON(t *testing.T) {
|
|
var gotPaths []string
|
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
gotPaths = append(gotPaths, r.URL.Path)
|
|
switch r.URL.Path {
|
|
case "/api/issues/MUL-2818":
|
|
json.NewEncoder(w).Encode(map[string]any{
|
|
"id": "issue-uuid",
|
|
"identifier": "MUL-2818",
|
|
"title": "CLI PR lookup",
|
|
})
|
|
case "/api/issues/issue-uuid/pull-requests":
|
|
json.NewEncoder(w).Encode(map[string]any{
|
|
"pull_requests": []map[string]any{
|
|
{
|
|
"url": "https://github.com/multica-ai/multica/pull/42",
|
|
"number": float64(42),
|
|
"state": "open",
|
|
"title": "MUL-2818 add issue PR CLI",
|
|
},
|
|
},
|
|
})
|
|
default:
|
|
http.NotFound(w, r)
|
|
}
|
|
}))
|
|
defer srv.Close()
|
|
|
|
t.Setenv("MULTICA_SERVER_URL", srv.URL)
|
|
t.Setenv("MULTICA_WORKSPACE_ID", "ws-1")
|
|
t.Setenv("MULTICA_TOKEN", "test-token")
|
|
|
|
cmd := newIssuePullRequestsTestCmd()
|
|
_ = cmd.Flags().Set("output", "json")
|
|
old := os.Stdout
|
|
r, w, _ := os.Pipe()
|
|
os.Stdout = w
|
|
err := runIssuePullRequests(cmd, []string{"MUL-2818"})
|
|
_ = w.Close()
|
|
os.Stdout = old
|
|
out, _ := io.ReadAll(r)
|
|
if err != nil {
|
|
t.Fatalf("runIssuePullRequests: %v", err)
|
|
}
|
|
|
|
if want := []string{"/api/issues/MUL-2818", "/api/issues/issue-uuid/pull-requests"}; fmt.Sprint(gotPaths) != fmt.Sprint(want) {
|
|
t.Fatalf("paths = %v, want %v", gotPaths, want)
|
|
}
|
|
var payload map[string]any
|
|
if err := json.Unmarshal(out, &payload); err != nil {
|
|
t.Fatalf("decode JSON output: %v\n%s", err, string(out))
|
|
}
|
|
prs, _ := payload["pull_requests"].([]any)
|
|
if len(prs) != 1 {
|
|
t.Fatalf("pull_requests length = %d, want 1", len(prs))
|
|
}
|
|
pr, _ := prs[0].(map[string]any)
|
|
if pr["url"] != "https://github.com/multica-ai/multica/pull/42" || pr["number"] != float64(42) || pr["state"] != "open" || pr["title"] != "MUL-2818 add issue PR CLI" {
|
|
t.Fatalf("unexpected PR payload: %#v", pr)
|
|
}
|
|
}
|
|
|
|
func TestRunIssuePullRequestsTableIncludesCoreFields(t *testing.T) {
|
|
prs := []map[string]any{{
|
|
"url": "https://github.com/multica-ai/multica/pull/42",
|
|
"number": float64(42),
|
|
"state": "open",
|
|
"title": "MUL-2818 add issue PR CLI",
|
|
}}
|
|
|
|
old := os.Stdout
|
|
r, w, _ := os.Pipe()
|
|
os.Stdout = w
|
|
printIssuePullRequestsTable(prs)
|
|
_ = w.Close()
|
|
os.Stdout = old
|
|
out, _ := io.ReadAll(r)
|
|
text := string(out)
|
|
for _, want := range []string{"NUMBER", "STATE", "TITLE", "URL", "42", "open", "MUL-2818 add issue PR CLI", "https://github.com/multica-ai/multica/pull/42"} {
|
|
if !strings.Contains(text, want) {
|
|
t.Fatalf("table output missing %q:\n%s", want, text)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestTruncateID(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
id string
|
|
want string
|
|
}{
|
|
{"short", "abc", "abc"},
|
|
{"exact 8", "abcdefgh", "abcdefgh"},
|
|
{"longer than 8", "abcdefgh-1234-5678", "abcdefgh"},
|
|
{"empty", "", ""},
|
|
{"unicode", "日本語テスト文字列追加", "日本語テスト文字"},
|
|
}
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
got := truncateID(tt.id)
|
|
if got != tt.want {
|
|
t.Errorf("truncateID(%q) = %q, want %q", tt.id, got, tt.want)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestFormatAssignee(t *testing.T) {
|
|
actors := actorDisplayLookup{
|
|
state: &actorDisplayLookupState{
|
|
members: map[string]string{"abcdefgh-1234": "Alice"},
|
|
agents: map[string]string{"xyz": "CodeBot"},
|
|
squads: map[string]string{"sq-1": "Super Human"},
|
|
membersLoaded: true,
|
|
agentsLoaded: true,
|
|
squadsLoaded: true,
|
|
},
|
|
}
|
|
tests := []struct {
|
|
name string
|
|
issue map[string]any
|
|
want string
|
|
}{
|
|
{"empty", map[string]any{}, ""},
|
|
{"no type", map[string]any{"assignee_id": "abc"}, ""},
|
|
{"no id", map[string]any{"assignee_type": "member"}, ""},
|
|
{"member", map[string]any{"assignee_type": "member", "assignee_id": "abcdefgh-1234"}, "member:Alice"},
|
|
{"agent", map[string]any{"assignee_type": "agent", "assignee_id": "xyz"}, "agent:CodeBot"},
|
|
{"squad", map[string]any{"assignee_type": "squad", "assignee_id": "sq-1"}, "squad:Super Human"},
|
|
{"unknown fallback", map[string]any{"assignee_type": "agent", "assignee_id": "missing"}, "agent:missing"},
|
|
}
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
got := formatAssignee(tt.issue, actors)
|
|
if got != tt.want {
|
|
t.Errorf("formatAssignee() = %q, want %q", got, tt.want)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestActorDisplayLookupLazyLoads(t *testing.T) {
|
|
var memberCalls, agentCalls int
|
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
switch r.URL.Path {
|
|
case "/api/workspaces/ws-1/members":
|
|
memberCalls++
|
|
json.NewEncoder(w).Encode([]map[string]any{{"user_id": "user-1", "name": "Alice"}})
|
|
case "/api/agents":
|
|
agentCalls++
|
|
json.NewEncoder(w).Encode([]map[string]any{{"id": "agent-1", "name": "CodeBot"}})
|
|
default:
|
|
http.NotFound(w, r)
|
|
}
|
|
}))
|
|
defer srv.Close()
|
|
|
|
client := cli.NewAPIClient(srv.URL, "ws-1", "test-token")
|
|
lookup := loadActorDisplayLookup(context.Background(), client)
|
|
|
|
if got := lookup.agent("agent-1"); got != "CodeBot" {
|
|
t.Fatalf("agent() = %q, want CodeBot", got)
|
|
}
|
|
if memberCalls != 0 || agentCalls != 1 {
|
|
t.Fatalf("after agent lookup: memberCalls=%d agentCalls=%d, want 0/1", memberCalls, agentCalls)
|
|
}
|
|
if got := lookup.actor("member", "user-1"); got != "member:Alice" {
|
|
t.Fatalf("actor(member) = %q, want member:Alice", got)
|
|
}
|
|
if memberCalls != 1 || agentCalls != 1 {
|
|
t.Fatalf("after member lookup: memberCalls=%d agentCalls=%d, want 1/1", memberCalls, agentCalls)
|
|
}
|
|
if got := lookup.actor("member", "missing"); got != "member:missing" {
|
|
t.Fatalf("actor(missing member) = %q", got)
|
|
}
|
|
if memberCalls != 1 || agentCalls != 1 {
|
|
t.Fatalf("lookup should cache per type: memberCalls=%d agentCalls=%d", memberCalls, agentCalls)
|
|
}
|
|
}
|
|
|
|
func TestResolveIDByPrefix(t *testing.T) {
|
|
client := cli.NewAPIClient("http://example.invalid", "ws-1", "test-token")
|
|
ctx := context.Background()
|
|
fetch := func(context.Context, *cli.APIClient) ([]idCandidate, error) {
|
|
return []idCandidate{
|
|
{ID: "aaaaaaaa-1111-2222-3333-444444444444", Display: "Alpha"},
|
|
{ID: "bbbbbbbb-1111-2222-3333-444444444444", Display: "Beta"},
|
|
{ID: "aaaabbbb-1111-2222-3333-444444444444", Display: "Alpha Two"},
|
|
}, nil
|
|
}
|
|
|
|
t.Run("full UUID passes through", func(t *testing.T) {
|
|
got, err := resolveIDByPrefix(ctx, client, "thing", "bbbbbbbb-1111-2222-3333-444444444444", fetch)
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
if got.ID != "bbbbbbbb-1111-2222-3333-444444444444" {
|
|
t.Fatalf("got %q", got.ID)
|
|
}
|
|
})
|
|
|
|
t.Run("unique short prefix resolves", func(t *testing.T) {
|
|
got, err := resolveIDByPrefix(ctx, client, "thing", "bbbb", fetch)
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
if got.ID != "bbbbbbbb-1111-2222-3333-444444444444" || got.Display != "Beta" {
|
|
t.Fatalf("got %#v", got)
|
|
}
|
|
})
|
|
|
|
t.Run("short prefix can include dashes", func(t *testing.T) {
|
|
got, err := resolveIDByPrefix(ctx, client, "thing", "bbbb-b", fetch)
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
if got.ID != "bbbbbbbb-1111-2222-3333-444444444444" {
|
|
t.Fatalf("got %#v", got)
|
|
}
|
|
})
|
|
|
|
t.Run("ambiguous prefix is rejected", func(t *testing.T) {
|
|
_, err := resolveIDByPrefix(ctx, client, "thing", "aaaa", fetch)
|
|
if err == nil || !strings.Contains(err.Error(), "ambiguous") {
|
|
t.Fatalf("expected ambiguous error, got %v", err)
|
|
}
|
|
if strings.Contains(err.Error(), "Alpha") || strings.Contains(err.Error(), "Beta") {
|
|
t.Fatalf("ambiguous error exposed candidate display/detail: %v", err)
|
|
}
|
|
})
|
|
|
|
t.Run("too short prefix is rejected", func(t *testing.T) {
|
|
if _, err := resolveIDByPrefix(ctx, client, "thing", "aaa", fetch); err == nil || !strings.Contains(err.Error(), "at least 4") {
|
|
t.Fatalf("expected short prefix error, got %v", err)
|
|
}
|
|
})
|
|
}
|
|
|
|
func TestResolveIssueRef(t *testing.T) {
|
|
issue := map[string]any{
|
|
"id": "1881a167-4bb6-4602-944b-f40ce4192fe6",
|
|
"identifier": "MUL-1852",
|
|
"title": "Short ID bug",
|
|
}
|
|
|
|
t.Run("identifier is resolved before prefix lookup", func(t *testing.T) {
|
|
listCalled := false
|
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
switch r.URL.Path {
|
|
case "/api/issues/MUL-1852":
|
|
json.NewEncoder(w).Encode(issue)
|
|
case "/api/issues":
|
|
listCalled = true
|
|
http.Error(w, "should not list", http.StatusTeapot)
|
|
default:
|
|
http.NotFound(w, r)
|
|
}
|
|
}))
|
|
defer srv.Close()
|
|
|
|
client := cli.NewAPIClient(srv.URL, "ws-1", "test-token")
|
|
got, err := resolveIssueRef(context.Background(), client, "MUL-1852")
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
if listCalled {
|
|
t.Fatal("identifier path should not call issue list")
|
|
}
|
|
if got.ID != issue["id"] || got.Display != "MUL-1852" {
|
|
t.Fatalf("got %#v", got)
|
|
}
|
|
})
|
|
|
|
t.Run("short UUID prefix resolves from workspace issue list", func(t *testing.T) {
|
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
if r.URL.Path != "/api/issues" {
|
|
http.NotFound(w, r)
|
|
return
|
|
}
|
|
if got := r.URL.Query().Get("workspace_id"); got != "ws-1" {
|
|
t.Errorf("workspace_id = %q, want ws-1", got)
|
|
}
|
|
if got := r.URL.Query().Get("include_closed"); got != "true" {
|
|
t.Errorf("include_closed = %q, want true", got)
|
|
}
|
|
if got := r.URL.Query().Get("limit"); got != strconv.Itoa(resolverListPageLimit) {
|
|
t.Errorf("limit = %q, want %d", got, resolverListPageLimit)
|
|
}
|
|
json.NewEncoder(w).Encode(map[string]any{
|
|
"issues": []map[string]any{issue},
|
|
"total": 1,
|
|
})
|
|
}))
|
|
defer srv.Close()
|
|
|
|
client := cli.NewAPIClient(srv.URL, "ws-1", "test-token")
|
|
got, err := resolveIssueRef(context.Background(), client, "1881")
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
if got.ID != issue["id"] || got.Display != "MUL-1852" {
|
|
t.Fatalf("got %#v", got)
|
|
}
|
|
})
|
|
|
|
t.Run("bare issue number is not resolved as issue number", func(t *testing.T) {
|
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
if r.URL.Path != "/api/issues" {
|
|
http.NotFound(w, r)
|
|
return
|
|
}
|
|
json.NewEncoder(w).Encode(map[string]any{
|
|
"issues": []map[string]any{{
|
|
"id": "aaaaaaaa-4bb6-4602-944b-f40ce4192fe6",
|
|
"identifier": "MUL-1852",
|
|
"title": "Should not resolve by number",
|
|
}},
|
|
"total": 1,
|
|
})
|
|
}))
|
|
defer srv.Close()
|
|
|
|
client := cli.NewAPIClient(srv.URL, "ws-1", "test-token")
|
|
_, err := resolveIssueRef(context.Background(), client, "1852")
|
|
if err == nil {
|
|
t.Fatal("expected bare number to be treated only as a UUID prefix")
|
|
}
|
|
if got := err.Error(); !strings.Contains(got, "id prefix") {
|
|
t.Fatalf("expected prefix error, got: %s", got)
|
|
}
|
|
})
|
|
}
|
|
|
|
func TestFetchAutopilotCandidatesPaginates(t *testing.T) {
|
|
page1 := make([]map[string]any, 0, resolverListPageLimit)
|
|
for i := 0; i < resolverListPageLimit; i++ {
|
|
page1 = append(page1, map[string]any{
|
|
"id": fmt.Sprintf("aaaaaaaa-0000-0000-0000-%012x", i),
|
|
"title": fmt.Sprintf("autopilot-%d", i),
|
|
"status": "active",
|
|
})
|
|
}
|
|
page2 := []map[string]any{{
|
|
"id": "bbbbbbbb-0000-0000-0000-000000000000",
|
|
"title": "final autopilot",
|
|
"status": "paused",
|
|
}}
|
|
|
|
var offsets []string
|
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
if r.URL.Path != "/api/autopilots" {
|
|
http.NotFound(w, r)
|
|
return
|
|
}
|
|
if got := r.URL.Query().Get("workspace_id"); got != "ws-1" {
|
|
t.Errorf("workspace_id = %q, want ws-1", got)
|
|
}
|
|
if got := r.URL.Query().Get("limit"); got != strconv.Itoa(resolverListPageLimit) {
|
|
t.Errorf("limit = %q, want %d", got, resolverListPageLimit)
|
|
}
|
|
offset := r.URL.Query().Get("offset")
|
|
offsets = append(offsets, offset)
|
|
switch offset {
|
|
case "":
|
|
json.NewEncoder(w).Encode(map[string]any{
|
|
"autopilots": page1,
|
|
"total": resolverListPageLimit + 1,
|
|
})
|
|
case strconv.Itoa(resolverListPageLimit):
|
|
json.NewEncoder(w).Encode(map[string]any{
|
|
"autopilots": page2,
|
|
"total": resolverListPageLimit + 1,
|
|
})
|
|
default:
|
|
t.Fatalf("unexpected offset %q", offset)
|
|
}
|
|
}))
|
|
defer srv.Close()
|
|
|
|
client := cli.NewAPIClient(srv.URL, "ws-1", "test-token")
|
|
got, err := fetchAutopilotCandidates(context.Background(), client)
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
if len(got) != resolverListPageLimit+1 {
|
|
t.Fatalf("got %d candidates, want %d", len(got), resolverListPageLimit+1)
|
|
}
|
|
if len(offsets) != 2 || offsets[0] != "" || offsets[1] != strconv.Itoa(resolverListPageLimit) {
|
|
t.Fatalf("offsets = %#v, want [\"\", %q]", offsets, strconv.Itoa(resolverListPageLimit))
|
|
}
|
|
if got[len(got)-1].ID != "bbbbbbbb-0000-0000-0000-000000000000" {
|
|
t.Fatalf("last candidate = %#v", got[len(got)-1])
|
|
}
|
|
}
|
|
|
|
func TestResolveTaskRunID(t *testing.T) {
|
|
issueID := "1881a167-4bb6-4602-944b-f40ce4192fe6"
|
|
taskID := "abcd1234-0000-0000-0000-000000000000"
|
|
|
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
switch r.URL.Path {
|
|
case "/api/issues/" + issueID + "/task-runs":
|
|
json.NewEncoder(w).Encode([]map[string]any{{
|
|
"id": taskID,
|
|
"agent_id": "agent-1",
|
|
"status": "completed",
|
|
}})
|
|
default:
|
|
http.NotFound(w, r)
|
|
}
|
|
}))
|
|
defer srv.Close()
|
|
|
|
client := cli.NewAPIClient(srv.URL, "ws-1", "test-token")
|
|
got, err := resolveTaskRunID(context.Background(), client, issueID, "abcd")
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
if got.ID != taskID {
|
|
t.Fatalf("got %#v, want task id %s", got, taskID)
|
|
}
|
|
|
|
_, err = resolveTaskRunID(context.Background(), client, "", "abcd")
|
|
if err == nil || !strings.Contains(err.Error(), "--issue") {
|
|
t.Fatalf("expected missing --issue error for short prefix, got %v", err)
|
|
}
|
|
}
|
|
|
|
func TestRunIssueRunMessagesResolvesShortTaskPrefix(t *testing.T) {
|
|
issueID := "1881a167-4bb6-4602-944b-f40ce4192fe6"
|
|
taskID := "abcd1234-0000-0000-0000-000000000000"
|
|
var messagePath string
|
|
|
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
switch r.URL.Path {
|
|
case "/api/issues/MUL-1852":
|
|
json.NewEncoder(w).Encode(map[string]any{
|
|
"id": issueID,
|
|
"identifier": "MUL-1852",
|
|
})
|
|
case "/api/issues/" + issueID + "/task-runs":
|
|
json.NewEncoder(w).Encode([]map[string]any{{"id": taskID}})
|
|
case "/api/tasks/" + taskID + "/messages":
|
|
messagePath = r.URL.Path
|
|
json.NewEncoder(w).Encode([]map[string]any{{
|
|
"seq": 1,
|
|
"type": "text",
|
|
"content": "done",
|
|
}})
|
|
default:
|
|
http.NotFound(w, r)
|
|
}
|
|
}))
|
|
defer srv.Close()
|
|
|
|
t.Setenv("MULTICA_SERVER_URL", srv.URL)
|
|
t.Setenv("MULTICA_WORKSPACE_ID", "ws-1")
|
|
t.Setenv("MULTICA_TOKEN", "test-token")
|
|
|
|
cmd := &cobra.Command{Use: "run-messages"}
|
|
cmd.Flags().String("output", "json", "")
|
|
cmd.Flags().Int("since", 0, "")
|
|
cmd.Flags().String("issue", "", "")
|
|
_ = cmd.Flags().Set("issue", "MUL-1852")
|
|
if err := runIssueRunMessages(cmd, []string{"abcd"}); err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
if messagePath != "/api/tasks/"+taskID+"/messages" {
|
|
t.Fatalf("message path = %q, want user-facing task messages path", messagePath)
|
|
}
|
|
}
|
|
|
|
func TestResolveAssignee(t *testing.T) {
|
|
membersResp := []map[string]any{
|
|
{"user_id": "user-1111", "name": "Alice Smith"},
|
|
{"user_id": "user-2222", "name": "Bob Jones"},
|
|
}
|
|
agentsResp := []map[string]any{
|
|
{"id": "agent-3333", "name": "CodeBot"},
|
|
}
|
|
squadsResp := []map[string]any{
|
|
{"id": "squad-4444", "name": "Super Human"},
|
|
}
|
|
|
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
switch r.URL.Path {
|
|
case "/api/workspaces/ws-1/members":
|
|
json.NewEncoder(w).Encode(membersResp)
|
|
case "/api/agents":
|
|
json.NewEncoder(w).Encode(agentsResp)
|
|
case "/api/squads":
|
|
json.NewEncoder(w).Encode(squadsResp)
|
|
default:
|
|
http.NotFound(w, r)
|
|
}
|
|
}))
|
|
defer srv.Close()
|
|
|
|
client := cli.NewAPIClient(srv.URL, "ws-1", "test-token")
|
|
ctx := context.Background()
|
|
|
|
t.Run("exact match member", func(t *testing.T) {
|
|
aType, aID, err := resolveAssignee(ctx, client, "Alice Smith", issueAssigneeKinds)
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
if aType != "member" || aID != "user-1111" {
|
|
t.Errorf("got (%q, %q), want (member, user-1111)", aType, aID)
|
|
}
|
|
})
|
|
|
|
t.Run("case-insensitive substring", func(t *testing.T) {
|
|
aType, aID, err := resolveAssignee(ctx, client, "bob", issueAssigneeKinds)
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
if aType != "member" || aID != "user-2222" {
|
|
t.Errorf("got (%q, %q), want (member, user-2222)", aType, aID)
|
|
}
|
|
})
|
|
|
|
t.Run("match agent", func(t *testing.T) {
|
|
aType, aID, err := resolveAssignee(ctx, client, "codebot", issueAssigneeKinds)
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
if aType != "agent" || aID != "agent-3333" {
|
|
t.Errorf("got (%q, %q), want (agent, agent-3333)", aType, aID)
|
|
}
|
|
})
|
|
|
|
// MUL-2165: squad names must resolve to (squad, <id>) so the autopilot
|
|
// quick-create prompt can route work to a squad (e.g. "Super Human")
|
|
// instead of falling through to "Unrecognized assignee".
|
|
t.Run("match squad by exact name", func(t *testing.T) {
|
|
aType, aID, err := resolveAssignee(ctx, client, "Super Human", issueAssigneeKinds)
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
if aType != "squad" || aID != "squad-4444" {
|
|
t.Errorf("got (%q, %q), want (squad, squad-4444)", aType, aID)
|
|
}
|
|
})
|
|
|
|
t.Run("match squad by case-insensitive substring", func(t *testing.T) {
|
|
aType, aID, err := resolveAssignee(ctx, client, "super", issueAssigneeKinds)
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
if aType != "squad" || aID != "squad-4444" {
|
|
t.Errorf("got (%q, %q), want (squad, squad-4444)", aType, aID)
|
|
}
|
|
})
|
|
|
|
t.Run("match squad by bare @ display name", func(t *testing.T) {
|
|
aType, aID, err := resolveAssignee(ctx, client, "@Super Human", issueAssigneeKinds)
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
if aType != "squad" || aID != "squad-4444" {
|
|
t.Errorf("got (%q, %q), want (squad, squad-4444)", aType, aID)
|
|
}
|
|
})
|
|
|
|
t.Run("no match", func(t *testing.T) {
|
|
_, _, err := resolveAssignee(ctx, client, "nobody", issueAssigneeKinds)
|
|
if err == nil {
|
|
t.Fatal("expected error for no match")
|
|
}
|
|
})
|
|
|
|
t.Run("ambiguous", func(t *testing.T) {
|
|
// Both "Alice Smith" and "Bob Jones" contain a space — but let's use a broader query
|
|
// "e" matches "Alice Smith" and "Bob Jones" and "CodeBot"
|
|
_, _, err := resolveAssignee(ctx, client, "o", issueAssigneeKinds)
|
|
if err == nil {
|
|
t.Fatal("expected error for ambiguous match")
|
|
}
|
|
if got := err.Error(); !strings.Contains(got, "ambiguous") {
|
|
t.Errorf("expected ambiguous error, got: %s", got)
|
|
}
|
|
})
|
|
|
|
t.Run("missing workspace ID", func(t *testing.T) {
|
|
noWSClient := cli.NewAPIClient(srv.URL, "", "test-token")
|
|
_, _, err := resolveAssignee(ctx, noWSClient, "alice", issueAssigneeKinds)
|
|
if err == nil {
|
|
t.Fatal("expected error for missing workspace ID")
|
|
}
|
|
})
|
|
}
|
|
|
|
func TestNormalizeAssigneeLookupInput(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
in string
|
|
want string
|
|
}{
|
|
{"plain name", "Super Human", "Super Human"},
|
|
{"bare at name", "@Super Human", "Super Human"},
|
|
{"fullwidth at name", "@独立团", "独立团"},
|
|
{"spaced at name", " @ Super Human ", "Super Human"},
|
|
{
|
|
name: "mention link",
|
|
in: "[@Super Human](mention://squad/ccccccc1-2222-3333-4444-555555555555)",
|
|
want: "ccccccc1-2222-3333-4444-555555555555",
|
|
},
|
|
}
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
if got := normalizeAssigneeLookupInput(tt.in); got != tt.want {
|
|
t.Errorf("normalizeAssigneeLookupInput(%q) = %q, want %q", tt.in, got, tt.want)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestResolveAssigneeRespectsKinds covers the MUL-2165 follow-up: callers
|
|
// whose target schema is member-or-agent-only (project.lead_type DB CHECK
|
|
// at server/migrations/034_projects.up.sql:10, and the subscriber handler's
|
|
// isWorkspaceEntity switch at server/internal/handler/handler.go:414) must
|
|
// be able to opt out of squad resolution. Without this, "--lead <SquadName>"
|
|
// would return (squad, ...) and the request would 500/403 server-side
|
|
// instead of failing with a clean CLI-side resolution error.
|
|
func TestResolveAssigneeRespectsKinds(t *testing.T) {
|
|
membersResp := []map[string]any{
|
|
{"user_id": "user-1111", "name": "Alice"},
|
|
}
|
|
agentsResp := []map[string]any{
|
|
{"id": "agent-3333", "name": "CodeBot"},
|
|
}
|
|
squadsResp := []map[string]any{
|
|
{"id": "ccccccc1-2222-3333-4444-555555555555", "name": "Super Human"},
|
|
}
|
|
|
|
var squadsHits int
|
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
switch r.URL.Path {
|
|
case "/api/workspaces/ws-1/members":
|
|
json.NewEncoder(w).Encode(membersResp)
|
|
case "/api/agents":
|
|
json.NewEncoder(w).Encode(agentsResp)
|
|
case "/api/squads":
|
|
squadsHits++
|
|
json.NewEncoder(w).Encode(squadsResp)
|
|
default:
|
|
http.NotFound(w, r)
|
|
}
|
|
}))
|
|
defer srv.Close()
|
|
|
|
client := cli.NewAPIClient(srv.URL, "ws-1", "test-token")
|
|
ctx := context.Background()
|
|
|
|
t.Run("memberOrAgentKinds skips the /api/squads fetch entirely", func(t *testing.T) {
|
|
before := squadsHits
|
|
_, _, _ = resolveAssignee(ctx, client, "Alice", memberOrAgentKinds)
|
|
if squadsHits != before {
|
|
t.Errorf("expected memberOrAgentKinds to skip /api/squads, but it was called %d time(s)", squadsHits-before)
|
|
}
|
|
})
|
|
|
|
t.Run("memberOrAgentKinds rejects a squad name with a member-or-agent-only error", func(t *testing.T) {
|
|
_, _, err := resolveAssignee(ctx, client, "Super Human", memberOrAgentKinds)
|
|
if err == nil {
|
|
t.Fatal("expected resolution error for squad name under memberOrAgentKinds")
|
|
}
|
|
if !strings.Contains(err.Error(), "no member or agent") {
|
|
t.Errorf("expected member-or-agent error wording, got: %v", err)
|
|
}
|
|
if strings.Contains(err.Error(), "squad") {
|
|
t.Errorf("error must not mention squad when squads are not allowed, got: %v", err)
|
|
}
|
|
})
|
|
|
|
t.Run("memberOrAgentKinds rejects a squad UUID via the strict resolver", func(t *testing.T) {
|
|
_, _, err := resolveAssigneeByID(ctx, client, "ccccccc1-2222-3333-4444-555555555555", memberOrAgentKinds)
|
|
if err == nil {
|
|
t.Fatal("expected not-found error for squad UUID under memberOrAgentKinds")
|
|
}
|
|
if !strings.Contains(err.Error(), "no member or agent") {
|
|
t.Errorf("expected member-or-agent error wording, got: %v", err)
|
|
}
|
|
})
|
|
|
|
t.Run("issueAssigneeKinds still resolves the same squad name (control)", func(t *testing.T) {
|
|
aType, aID, err := resolveAssignee(ctx, client, "Super Human", issueAssigneeKinds)
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
if aType != "squad" || aID != "ccccccc1-2222-3333-4444-555555555555" {
|
|
t.Errorf("got (%q, %q), want (squad, ccccccc1-...)", aType, aID)
|
|
}
|
|
})
|
|
}
|
|
|
|
// TestResolveAssigneeExactMatchWins covers the substring-collision scenario from
|
|
// multica-ai/multica#1620: when one name is a substring of another (e.g.
|
|
// "reviewer" vs "peer-reviewer"), an exact match on the shorter name must
|
|
// short-circuit substring matching instead of erroring out as ambiguous.
|
|
func TestResolveAssigneeExactMatchWins(t *testing.T) {
|
|
agentsResp := []map[string]any{
|
|
{"id": "f656eab8-1111-1111-1111-111111111111", "name": "reviewer"},
|
|
{"id": "9b0ff9a2-2222-2222-2222-222222222222", "name": "peer-reviewer"},
|
|
}
|
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
switch r.URL.Path {
|
|
case "/api/workspaces/ws-1/members":
|
|
json.NewEncoder(w).Encode([]map[string]any{})
|
|
case "/api/agents":
|
|
json.NewEncoder(w).Encode(agentsResp)
|
|
case "/api/squads":
|
|
json.NewEncoder(w).Encode([]map[string]any{})
|
|
default:
|
|
http.NotFound(w, r)
|
|
}
|
|
}))
|
|
defer srv.Close()
|
|
|
|
client := cli.NewAPIClient(srv.URL, "ws-1", "test-token")
|
|
ctx := context.Background()
|
|
|
|
t.Run("exact shorter name resolves to shorter agent", func(t *testing.T) {
|
|
aType, aID, err := resolveAssignee(ctx, client, "reviewer", issueAssigneeKinds)
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
if aType != "agent" || aID != "f656eab8-1111-1111-1111-111111111111" {
|
|
t.Errorf("got (%q, %q), want (agent, f656eab8-...)", aType, aID)
|
|
}
|
|
})
|
|
|
|
t.Run("exact longer name still resolves unambiguously", func(t *testing.T) {
|
|
aType, aID, err := resolveAssignee(ctx, client, "peer-reviewer", issueAssigneeKinds)
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
if aType != "agent" || aID != "9b0ff9a2-2222-2222-2222-222222222222" {
|
|
t.Errorf("got (%q, %q), want (agent, 9b0ff9a2-...)", aType, aID)
|
|
}
|
|
})
|
|
|
|
t.Run("exact match is case-insensitive and tolerates whitespace", func(t *testing.T) {
|
|
aType, aID, err := resolveAssignee(ctx, client, " Reviewer ", issueAssigneeKinds)
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
if aType != "agent" || aID != "f656eab8-1111-1111-1111-111111111111" {
|
|
t.Errorf("got (%q, %q), want exact reviewer agent", aType, aID)
|
|
}
|
|
})
|
|
|
|
t.Run("substring-only input falls back and stays ambiguous", func(t *testing.T) {
|
|
// "review" matches both agents via substring and neither via exact name,
|
|
// so the existing ambiguity error is preserved.
|
|
_, _, err := resolveAssignee(ctx, client, "review", issueAssigneeKinds)
|
|
if err == nil {
|
|
t.Fatal("expected error for ambiguous substring match")
|
|
}
|
|
if got := err.Error(); !strings.Contains(got, "ambiguous") {
|
|
t.Errorf("expected ambiguous error, got: %s", got)
|
|
}
|
|
})
|
|
}
|
|
|
|
// TestResolveAssigneeByID covers the ID/ShortID escape hatch from
|
|
// multica-ai/multica#1620: passing a full UUID or its 8-char prefix must
|
|
// resolve directly without going through name matching.
|
|
func TestResolveAssigneeByID(t *testing.T) {
|
|
membersResp := []map[string]any{
|
|
{"user_id": "aaaaaaaa-1111-1111-1111-111111111111", "name": "Alice"},
|
|
}
|
|
agentsResp := []map[string]any{
|
|
{"id": "f656eab8-1111-1111-1111-111111111111", "name": "reviewer"},
|
|
{"id": "9b0ff9a2-2222-2222-2222-222222222222", "name": "peer-reviewer"},
|
|
}
|
|
squadsResp := []map[string]any{
|
|
{"id": "ccccccc1-2222-3333-4444-555555555555", "name": "Super Human"},
|
|
}
|
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
switch r.URL.Path {
|
|
case "/api/workspaces/ws-1/members":
|
|
json.NewEncoder(w).Encode(membersResp)
|
|
case "/api/agents":
|
|
json.NewEncoder(w).Encode(agentsResp)
|
|
case "/api/squads":
|
|
json.NewEncoder(w).Encode(squadsResp)
|
|
default:
|
|
http.NotFound(w, r)
|
|
}
|
|
}))
|
|
defer srv.Close()
|
|
|
|
client := cli.NewAPIClient(srv.URL, "ws-1", "test-token")
|
|
ctx := context.Background()
|
|
|
|
t.Run("full UUID resolves agent", func(t *testing.T) {
|
|
aType, aID, err := resolveAssignee(ctx, client, "f656eab8-1111-1111-1111-111111111111", issueAssigneeKinds)
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
if aType != "agent" || aID != "f656eab8-1111-1111-1111-111111111111" {
|
|
t.Errorf("got (%q, %q), want reviewer agent", aType, aID)
|
|
}
|
|
})
|
|
|
|
t.Run("8-char ShortID resolves agent", func(t *testing.T) {
|
|
aType, aID, err := resolveAssignee(ctx, client, "f656eab8", issueAssigneeKinds)
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
if aType != "agent" || aID != "f656eab8-1111-1111-1111-111111111111" {
|
|
t.Errorf("got (%q, %q), want reviewer agent", aType, aID)
|
|
}
|
|
})
|
|
|
|
t.Run("uppercase ShortID still resolves", func(t *testing.T) {
|
|
aType, aID, err := resolveAssignee(ctx, client, "F656EAB8", issueAssigneeKinds)
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
if aType != "agent" || aID != "f656eab8-1111-1111-1111-111111111111" {
|
|
t.Errorf("got (%q, %q), want reviewer agent", aType, aID)
|
|
}
|
|
})
|
|
|
|
t.Run("ShortID resolves a member", func(t *testing.T) {
|
|
aType, aID, err := resolveAssignee(ctx, client, "aaaaaaaa", issueAssigneeKinds)
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
if aType != "member" || aID != "aaaaaaaa-1111-1111-1111-111111111111" {
|
|
t.Errorf("got (%q, %q), want Alice", aType, aID)
|
|
}
|
|
})
|
|
}
|
|
|
|
// TestResolveAssigneeByIDStrict covers the strict UUID resolver that backs
|
|
// --assignee-id / --to-id / --user-id. Unlike resolveAssignee it must reject
|
|
// non-UUID inputs (no name fallback) and surface a clear error when the UUID
|
|
// is well-formed but not present in the workspace.
|
|
func TestResolveAssigneeByIDStrict(t *testing.T) {
|
|
membersResp := []map[string]any{
|
|
{"user_id": "aaaaaaaa-1111-1111-1111-111111111111", "name": "Alice"},
|
|
}
|
|
agentsResp := []map[string]any{
|
|
{"id": "5fb87ac7-23b5-4a7a-81fa-ed295a54545d", "name": "J"},
|
|
{"id": "192b9cca-2222-2222-2222-222222222222", "name": "Open Claw - J"},
|
|
}
|
|
squadsResp := []map[string]any{
|
|
{"id": "ccccccc1-2222-3333-4444-555555555555", "name": "Super Human"},
|
|
}
|
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
switch r.URL.Path {
|
|
case "/api/workspaces/ws-1/members":
|
|
json.NewEncoder(w).Encode(membersResp)
|
|
case "/api/agents":
|
|
json.NewEncoder(w).Encode(agentsResp)
|
|
case "/api/squads":
|
|
json.NewEncoder(w).Encode(squadsResp)
|
|
default:
|
|
http.NotFound(w, r)
|
|
}
|
|
}))
|
|
defer srv.Close()
|
|
|
|
client := cli.NewAPIClient(srv.URL, "ws-1", "test-token")
|
|
ctx := context.Background()
|
|
|
|
t.Run("full UUID resolves the right agent in a substring-collision workspace", func(t *testing.T) {
|
|
// This is the MUL-1254 scenario: agent "J" is unreachable by name
|
|
// because every other agent has "J" in it. UUID lookup must
|
|
// deterministically pick the right one.
|
|
aType, aID, err := resolveAssigneeByID(ctx, client, "5fb87ac7-23b5-4a7a-81fa-ed295a54545d", issueAssigneeKinds)
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
if aType != "agent" || aID != "5fb87ac7-23b5-4a7a-81fa-ed295a54545d" {
|
|
t.Errorf("got (%q, %q), want agent J", aType, aID)
|
|
}
|
|
})
|
|
|
|
t.Run("uppercase UUID is normalized", func(t *testing.T) {
|
|
aType, aID, err := resolveAssigneeByID(ctx, client, "5FB87AC7-23B5-4A7A-81FA-ED295A54545D", issueAssigneeKinds)
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
if aType != "agent" || aID != "5fb87ac7-23b5-4a7a-81fa-ed295a54545d" {
|
|
t.Errorf("got (%q, %q), want agent J", aType, aID)
|
|
}
|
|
})
|
|
|
|
t.Run("UUID resolves a member", func(t *testing.T) {
|
|
aType, aID, err := resolveAssigneeByID(ctx, client, "aaaaaaaa-1111-1111-1111-111111111111", issueAssigneeKinds)
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
if aType != "member" || aID != "aaaaaaaa-1111-1111-1111-111111111111" {
|
|
t.Errorf("got (%q, %q), want Alice", aType, aID)
|
|
}
|
|
})
|
|
|
|
// MUL-2165: --assignee-id <squad-uuid> must resolve to (squad, <id>) so
|
|
// scripts that read the squad list and pin its UUID can assign work to a
|
|
// squad in a single deterministic call.
|
|
t.Run("UUID resolves a squad", func(t *testing.T) {
|
|
aType, aID, err := resolveAssigneeByID(ctx, client, "ccccccc1-2222-3333-4444-555555555555", issueAssigneeKinds)
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
if aType != "squad" || aID != "ccccccc1-2222-3333-4444-555555555555" {
|
|
t.Errorf("got (%q, %q), want squad Super Human", aType, aID)
|
|
}
|
|
})
|
|
|
|
t.Run("non-UUID input is rejected without name fallback", func(t *testing.T) {
|
|
_, _, err := resolveAssigneeByID(ctx, client, "Alice", issueAssigneeKinds)
|
|
if err == nil {
|
|
t.Fatal("expected error for non-UUID input")
|
|
}
|
|
if !strings.Contains(err.Error(), "UUID") {
|
|
t.Errorf("expected UUID error, got: %v", err)
|
|
}
|
|
})
|
|
|
|
t.Run("UUID prefix (ShortID) is rejected — strict mode requires canonical form", func(t *testing.T) {
|
|
_, _, err := resolveAssigneeByID(ctx, client, "5fb87ac7", issueAssigneeKinds)
|
|
if err == nil {
|
|
t.Fatal("expected error for ShortID")
|
|
}
|
|
})
|
|
|
|
t.Run("well-formed UUID with no matching entity errors", func(t *testing.T) {
|
|
_, _, err := resolveAssigneeByID(ctx, client, "deadbeef-1111-1111-1111-111111111111", issueAssigneeKinds)
|
|
if err == nil {
|
|
t.Fatal("expected error for missing entity")
|
|
}
|
|
if !strings.Contains(err.Error(), "no member, agent, or squad") {
|
|
t.Errorf("expected not-found error, got: %v", err)
|
|
}
|
|
})
|
|
|
|
t.Run("missing workspace ID", func(t *testing.T) {
|
|
noWSClient := cli.NewAPIClient(srv.URL, "", "test-token")
|
|
_, _, err := resolveAssigneeByID(ctx, noWSClient, "5fb87ac7-23b5-4a7a-81fa-ed295a54545d", issueAssigneeKinds)
|
|
if err == nil {
|
|
t.Fatal("expected error for missing workspace ID")
|
|
}
|
|
})
|
|
}
|
|
|
|
// TestPickAssigneeFromFlags covers the flag-pair picker that backs every
|
|
// assignee-taking command. The mutual-exclusion guard is the load-bearing
|
|
// piece — silently preferring one side would let a buggy script set both
|
|
// flags and assign the wrong entity.
|
|
func TestPickAssigneeFromFlags(t *testing.T) {
|
|
membersResp := []map[string]any{
|
|
{"user_id": "aaaaaaaa-1111-1111-1111-111111111111", "name": "Alice"},
|
|
}
|
|
agentsResp := []map[string]any{
|
|
{"id": "5fb87ac7-23b5-4a7a-81fa-ed295a54545d", "name": "J"},
|
|
}
|
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
switch r.URL.Path {
|
|
case "/api/workspaces/ws-1/members":
|
|
json.NewEncoder(w).Encode(membersResp)
|
|
case "/api/agents":
|
|
json.NewEncoder(w).Encode(agentsResp)
|
|
case "/api/squads":
|
|
json.NewEncoder(w).Encode([]map[string]any{})
|
|
default:
|
|
http.NotFound(w, r)
|
|
}
|
|
}))
|
|
defer srv.Close()
|
|
|
|
client := cli.NewAPIClient(srv.URL, "ws-1", "test-token")
|
|
ctx := context.Background()
|
|
|
|
newCmd := func() *cobra.Command {
|
|
c := &cobra.Command{Use: "test"}
|
|
c.Flags().String("assignee", "", "")
|
|
c.Flags().String("assignee-id", "", "")
|
|
return c
|
|
}
|
|
|
|
t.Run("neither flag set returns hasValue=false", func(t *testing.T) {
|
|
_, _, has, err := pickAssigneeFromFlags(ctx, client, newCmd(), "assignee", "assignee-id", issueAssigneeKinds)
|
|
if err != nil {
|
|
t.Fatalf("unexpected err: %v", err)
|
|
}
|
|
if has {
|
|
t.Errorf("expected hasValue=false")
|
|
}
|
|
})
|
|
|
|
t.Run("name flag uses fuzzy resolver", func(t *testing.T) {
|
|
c := newCmd()
|
|
_ = c.Flags().Set("assignee", "Alice")
|
|
typ, id, has, err := pickAssigneeFromFlags(ctx, client, c, "assignee", "assignee-id", issueAssigneeKinds)
|
|
if err != nil || !has || typ != "member" || id != "aaaaaaaa-1111-1111-1111-111111111111" {
|
|
t.Errorf("got (%q, %q, %v, %v), want Alice", typ, id, has, err)
|
|
}
|
|
})
|
|
|
|
t.Run("id flag uses strict resolver", func(t *testing.T) {
|
|
c := newCmd()
|
|
_ = c.Flags().Set("assignee-id", "5fb87ac7-23b5-4a7a-81fa-ed295a54545d")
|
|
typ, id, has, err := pickAssigneeFromFlags(ctx, client, c, "assignee", "assignee-id", issueAssigneeKinds)
|
|
if err != nil || !has || typ != "agent" || id != "5fb87ac7-23b5-4a7a-81fa-ed295a54545d" {
|
|
t.Errorf("got (%q, %q, %v, %v), want agent J", typ, id, has, err)
|
|
}
|
|
})
|
|
|
|
t.Run("both flags set is rejected", func(t *testing.T) {
|
|
c := newCmd()
|
|
_ = c.Flags().Set("assignee", "Alice")
|
|
_ = c.Flags().Set("assignee-id", "5fb87ac7-23b5-4a7a-81fa-ed295a54545d")
|
|
_, _, _, err := pickAssigneeFromFlags(ctx, client, c, "assignee", "assignee-id", issueAssigneeKinds)
|
|
if err == nil {
|
|
t.Fatal("expected mutually-exclusive error")
|
|
}
|
|
if !strings.Contains(err.Error(), "mutually exclusive") {
|
|
t.Errorf("expected mutually-exclusive error, got: %v", err)
|
|
}
|
|
})
|
|
|
|
// Explicit-empty regression: a script that interpolates an empty env var
|
|
// into `--assignee-id "$MAYBE_UUID"` must NOT silently route through the
|
|
// "no flag set" branch — that would defeat the whole point of the strict
|
|
// UUID flag (issue list returning everything, create leaving the issue
|
|
// unassigned, subscriber add subscribing the caller). Detection is via
|
|
// Flags().Changed, so an explicit empty string surfaces as a UUID error.
|
|
t.Run("explicit empty --assignee-id surfaces as UUID error, not silent skip", func(t *testing.T) {
|
|
c := newCmd()
|
|
_ = c.Flags().Set("assignee-id", "")
|
|
_, _, has, err := pickAssigneeFromFlags(ctx, client, c, "assignee", "assignee-id", issueAssigneeKinds)
|
|
if err == nil {
|
|
t.Fatal("expected UUID error for explicit empty assignee-id")
|
|
}
|
|
if !has {
|
|
t.Errorf("expected hasValue=true so caller treats this as a real attempt, not a no-op")
|
|
}
|
|
if !strings.Contains(err.Error(), "UUID") {
|
|
t.Errorf("expected UUID-shaped error, got: %v", err)
|
|
}
|
|
})
|
|
|
|
t.Run("explicit empty --assignee surfaces as not-found, not silent skip", func(t *testing.T) {
|
|
c := newCmd()
|
|
_ = c.Flags().Set("assignee", "")
|
|
_, _, has, err := pickAssigneeFromFlags(ctx, client, c, "assignee", "assignee-id", issueAssigneeKinds)
|
|
if err == nil {
|
|
t.Fatal("expected resolver error for explicit empty assignee")
|
|
}
|
|
if !has {
|
|
t.Errorf("expected hasValue=true so caller treats this as a real attempt, not a no-op")
|
|
}
|
|
})
|
|
|
|
t.Run("explicit empty on both flags is mutually exclusive (set wins over value)", func(t *testing.T) {
|
|
c := newCmd()
|
|
_ = c.Flags().Set("assignee", "")
|
|
_ = c.Flags().Set("assignee-id", "")
|
|
_, _, _, err := pickAssigneeFromFlags(ctx, client, c, "assignee", "assignee-id", issueAssigneeKinds)
|
|
if err == nil || !strings.Contains(err.Error(), "mutually exclusive") {
|
|
t.Errorf("expected mutually-exclusive error, got: %v", err)
|
|
}
|
|
})
|
|
}
|
|
|
|
// TestPickAssigneeFromFlagsMemberOrAgentKinds is the call-site regression
|
|
// for the MUL-2165 follow-up. Subscriber add/remove and project lead pass
|
|
// memberOrAgentKinds because their target schema rejects squads
|
|
// (subscriber: server/internal/handler/handler.go:414;
|
|
// project: server/migrations/034_projects.up.sql:10). Without this gating,
|
|
// `multica issue subscriber add --user "<SquadName>"` or
|
|
// `multica project create --lead "<SquadName>"` would resolve to
|
|
// (squad, ...) and surface as a 500/403 server-side instead of a clean
|
|
// CLI-side resolution error.
|
|
func TestPickAssigneeFromFlagsMemberOrAgentKinds(t *testing.T) {
|
|
membersResp := []map[string]any{
|
|
{"user_id": "aaaaaaaa-1111-1111-1111-111111111111", "name": "Alice"},
|
|
}
|
|
agentsResp := []map[string]any{
|
|
{"id": "5fb87ac7-23b5-4a7a-81fa-ed295a54545d", "name": "J"},
|
|
}
|
|
squadsResp := []map[string]any{
|
|
{"id": "ccccccc1-2222-3333-4444-555555555555", "name": "Super Human"},
|
|
}
|
|
|
|
var squadsHits int
|
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
switch r.URL.Path {
|
|
case "/api/workspaces/ws-1/members":
|
|
json.NewEncoder(w).Encode(membersResp)
|
|
case "/api/agents":
|
|
json.NewEncoder(w).Encode(agentsResp)
|
|
case "/api/squads":
|
|
squadsHits++
|
|
json.NewEncoder(w).Encode(squadsResp)
|
|
default:
|
|
http.NotFound(w, r)
|
|
}
|
|
}))
|
|
defer srv.Close()
|
|
|
|
client := cli.NewAPIClient(srv.URL, "ws-1", "test-token")
|
|
ctx := context.Background()
|
|
|
|
newCmd := func(nameFlag, idFlag string) *cobra.Command {
|
|
c := &cobra.Command{Use: "test"}
|
|
c.Flags().String(nameFlag, "", "")
|
|
c.Flags().String(idFlag, "", "")
|
|
return c
|
|
}
|
|
|
|
t.Run("subscriber --user with a squad name is rejected without hitting /api/squads", func(t *testing.T) {
|
|
before := squadsHits
|
|
c := newCmd("user", "user-id")
|
|
_ = c.Flags().Set("user", "Super Human")
|
|
_, _, _, err := pickAssigneeFromFlags(ctx, client, c, "user", "user-id", memberOrAgentKinds)
|
|
if err == nil {
|
|
t.Fatal("expected resolution error for squad name under memberOrAgentKinds")
|
|
}
|
|
if !strings.Contains(err.Error(), "no member or agent") {
|
|
t.Errorf("expected member-or-agent error wording, got: %v", err)
|
|
}
|
|
if squadsHits != before {
|
|
t.Errorf("memberOrAgentKinds must NOT fetch /api/squads, but it was called %d time(s)", squadsHits-before)
|
|
}
|
|
})
|
|
|
|
t.Run("subscriber --user-id with a squad UUID is rejected", func(t *testing.T) {
|
|
c := newCmd("user", "user-id")
|
|
_ = c.Flags().Set("user-id", "ccccccc1-2222-3333-4444-555555555555")
|
|
_, _, _, err := pickAssigneeFromFlags(ctx, client, c, "user", "user-id", memberOrAgentKinds)
|
|
if err == nil {
|
|
t.Fatal("expected not-found error for squad UUID under memberOrAgentKinds")
|
|
}
|
|
if !strings.Contains(err.Error(), "no member or agent") {
|
|
t.Errorf("expected member-or-agent error wording, got: %v", err)
|
|
}
|
|
})
|
|
|
|
t.Run("project --lead with a member name still resolves cleanly", func(t *testing.T) {
|
|
c := newCmd("lead", "lead-id")
|
|
_ = c.Flags().Set("lead", "Alice")
|
|
typ, id, has, err := pickAssigneeFromFlags(ctx, client, c, "lead", "lead-id", memberOrAgentKinds)
|
|
if err != nil || !has || typ != "member" || id != "aaaaaaaa-1111-1111-1111-111111111111" {
|
|
t.Errorf("got (%q, %q, %v, %v), want member Alice", typ, id, has, err)
|
|
}
|
|
})
|
|
|
|
t.Run("project --lead with an agent name still resolves cleanly", func(t *testing.T) {
|
|
c := newCmd("lead", "lead-id")
|
|
_ = c.Flags().Set("lead", "J")
|
|
typ, id, has, err := pickAssigneeFromFlags(ctx, client, c, "lead", "lead-id", memberOrAgentKinds)
|
|
if err != nil || !has || typ != "agent" || id != "5fb87ac7-23b5-4a7a-81fa-ed295a54545d" {
|
|
t.Errorf("got (%q, %q, %v, %v), want agent J", typ, id, has, err)
|
|
}
|
|
})
|
|
}
|
|
|
|
func TestIssueSubscriberList(t *testing.T) {
|
|
subscribersResp := []map[string]any{
|
|
{
|
|
"issue_id": "issue-1",
|
|
"user_type": "member",
|
|
"user_id": "user-1111",
|
|
"reason": "creator",
|
|
"created_at": "2026-04-01T10:00:00Z",
|
|
},
|
|
{
|
|
"issue_id": "issue-1",
|
|
"user_type": "agent",
|
|
"user_id": "agent-3333",
|
|
"reason": "manual",
|
|
"created_at": "2026-04-01T11:00:00Z",
|
|
},
|
|
}
|
|
|
|
var gotPath string
|
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
gotPath = r.URL.Path
|
|
if r.Method != http.MethodGet {
|
|
t.Errorf("expected GET, got %s", r.Method)
|
|
}
|
|
json.NewEncoder(w).Encode(subscribersResp)
|
|
}))
|
|
defer srv.Close()
|
|
|
|
client := cli.NewAPIClient(srv.URL, "ws-1", "test-token")
|
|
ctx := context.Background()
|
|
|
|
var got []map[string]any
|
|
if err := client.GetJSON(ctx, "/api/issues/issue-1/subscribers", &got); err != nil {
|
|
t.Fatalf("GetJSON: %v", err)
|
|
}
|
|
if gotPath != "/api/issues/issue-1/subscribers" {
|
|
t.Errorf("unexpected path: %s", gotPath)
|
|
}
|
|
if len(got) != 2 {
|
|
t.Fatalf("expected 2 subscribers, got %d", len(got))
|
|
}
|
|
if got[0]["user_type"] != "member" || got[1]["user_type"] != "agent" {
|
|
t.Errorf("unexpected subscriber ordering: %+v", got)
|
|
}
|
|
}
|
|
|
|
func TestIssueSubscriberMutationBody(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
action string
|
|
user string
|
|
members []map[string]any
|
|
agents []map[string]any
|
|
wantPath string
|
|
wantBody map[string]any
|
|
}{
|
|
{
|
|
name: "subscribe caller (no user flag)",
|
|
action: "subscribe",
|
|
user: "",
|
|
wantPath: "/api/issues/issue-1/subscribe",
|
|
wantBody: map[string]any{},
|
|
},
|
|
{
|
|
name: "unsubscribe caller",
|
|
action: "unsubscribe",
|
|
user: "",
|
|
wantPath: "/api/issues/issue-1/unsubscribe",
|
|
wantBody: map[string]any{},
|
|
},
|
|
{
|
|
name: "subscribe a member by name",
|
|
action: "subscribe",
|
|
user: "alice",
|
|
members: []map[string]any{{"user_id": "user-1111", "name": "Alice Smith"}},
|
|
wantPath: "/api/issues/issue-1/subscribe",
|
|
wantBody: map[string]any{"user_type": "member", "user_id": "user-1111"},
|
|
},
|
|
{
|
|
name: "subscribe an agent by name",
|
|
action: "subscribe",
|
|
user: "codebot",
|
|
agents: []map[string]any{{"id": "agent-3333", "name": "CodeBot"}},
|
|
wantPath: "/api/issues/issue-1/subscribe",
|
|
wantBody: map[string]any{"user_type": "agent", "user_id": "agent-3333"},
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
var gotPath string
|
|
var gotBody map[string]any
|
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
switch r.URL.Path {
|
|
case "/api/workspaces/ws-1/members":
|
|
json.NewEncoder(w).Encode(tt.members)
|
|
return
|
|
case "/api/agents":
|
|
json.NewEncoder(w).Encode(tt.agents)
|
|
return
|
|
case "/api/squads":
|
|
json.NewEncoder(w).Encode([]map[string]any{})
|
|
return
|
|
}
|
|
gotPath = r.URL.Path
|
|
if r.Method != http.MethodPost {
|
|
t.Errorf("expected POST, got %s", r.Method)
|
|
}
|
|
json.NewDecoder(r.Body).Decode(&gotBody)
|
|
json.NewEncoder(w).Encode(map[string]bool{"subscribed": tt.action == "subscribe"})
|
|
}))
|
|
defer srv.Close()
|
|
|
|
client := cli.NewAPIClient(srv.URL, "ws-1", "test-token")
|
|
ctx := context.Background()
|
|
|
|
body := map[string]any{}
|
|
if tt.user != "" {
|
|
uType, uID, err := resolveAssignee(ctx, client, tt.user, issueAssigneeKinds)
|
|
if err != nil {
|
|
t.Fatalf("resolveAssignee: %v", err)
|
|
}
|
|
body["user_type"] = uType
|
|
body["user_id"] = uID
|
|
}
|
|
|
|
var result map[string]any
|
|
path := "/api/issues/issue-1/" + tt.action
|
|
if err := client.PostJSON(ctx, path, body, &result); err != nil {
|
|
t.Fatalf("PostJSON: %v", err)
|
|
}
|
|
|
|
if gotPath != tt.wantPath {
|
|
t.Errorf("path = %q, want %q", gotPath, tt.wantPath)
|
|
}
|
|
for k, want := range tt.wantBody {
|
|
if gotBody[k] != want {
|
|
t.Errorf("body[%q] = %v, want %v", k, gotBody[k], want)
|
|
}
|
|
}
|
|
if len(tt.wantBody) == 0 && len(gotBody) != 0 {
|
|
t.Errorf("expected empty body, got %+v", gotBody)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// newIssueCommentListTestCmd mirrors the flag set wired in main.init() for
|
|
// the comment list command. We replicate it here so the runIssueCommentList
|
|
// guards can be exercised in isolation — the real command tree pulls in the
|
|
// daemon init path.
|
|
func newIssueCommentListTestCmd() *cobra.Command {
|
|
cmd := &cobra.Command{Use: "list"}
|
|
cmd.Flags().String("output", "json", "")
|
|
cmd.Flags().String("since", "", "")
|
|
cmd.Flags().Bool("roots-only", false, "")
|
|
cmd.Flags().Bool("summary", false, "")
|
|
cmd.Flags().String("thread", "", "")
|
|
cmd.Flags().Int("recent", 0, "")
|
|
cmd.Flags().Int("tail", 0, "")
|
|
cmd.Flags().String("before", "", "")
|
|
cmd.Flags().String("before-id", "", "")
|
|
return cmd
|
|
}
|
|
|
|
// TestRunIssueCommentListFlagGuards locks the CLI-side flag combination
|
|
// matrix. Three behaviours matter here:
|
|
//
|
|
// - --recent 0 / --recent -3 must error rather than silently fall back to
|
|
// the default list path. Previously `recent > 0` collapsed "not passed"
|
|
// and "passed an invalid value" into the same branch; using
|
|
// Flags().Changed("recent") distinguishes them so an explicit non-
|
|
// positive value is rejected.
|
|
// - --before / --before-id without --recent must error. Before this fix
|
|
// the cursor would be sent to the server but ignored because RecentN=0,
|
|
// so callers asking for "comments before X" got the full timeline.
|
|
// - --roots-only is mutually exclusive with the thread/recent/pagination
|
|
// modes; it may only combine with --since.
|
|
//
|
|
// These cases must fail before any HTTP round-trip — verified by an
|
|
// httptest server that fatals if /api/issues/<key>/comments is hit.
|
|
func TestRunIssueCommentListFlagGuards(t *testing.T) {
|
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
// resolveIssueRef hits GET /api/issues/<ref>; everything else means
|
|
// the guard let an invalid combination through to the wire.
|
|
if r.Method == http.MethodGet && strings.HasPrefix(r.URL.Path, "/api/issues/") && !strings.Contains(r.URL.Path, "/comments") {
|
|
json.NewEncoder(w).Encode(map[string]any{
|
|
"id": "issue-1",
|
|
"identifier": "MUL-1",
|
|
})
|
|
return
|
|
}
|
|
t.Errorf("unexpected request: %s %s", r.Method, r.URL.String())
|
|
http.Error(w, "unexpected", http.StatusInternalServerError)
|
|
}))
|
|
defer srv.Close()
|
|
|
|
t.Setenv("MULTICA_SERVER_URL", srv.URL)
|
|
t.Setenv("MULTICA_WORKSPACE_ID", "ws-1")
|
|
t.Setenv("MULTICA_TOKEN", "test-token")
|
|
|
|
cases := []struct {
|
|
name string
|
|
setup func(c *cobra.Command)
|
|
wantMsg string
|
|
}{
|
|
{
|
|
name: "explicit zero recent rejected",
|
|
setup: func(c *cobra.Command) {
|
|
_ = c.Flags().Set("recent", "0")
|
|
},
|
|
wantMsg: "--recent must be a positive integer",
|
|
},
|
|
{
|
|
name: "negative recent rejected",
|
|
setup: func(c *cobra.Command) {
|
|
_ = c.Flags().Set("recent", "-3")
|
|
},
|
|
wantMsg: "--recent must be a positive integer",
|
|
},
|
|
{
|
|
name: "before + before-id without recent or thread+tail rejected",
|
|
setup: func(c *cobra.Command) {
|
|
_ = c.Flags().Set("before", "2026-01-01T00:00:00Z")
|
|
_ = c.Flags().Set("before-id", "00000000-0000-0000-0000-000000000001")
|
|
},
|
|
wantMsg: "require --recent",
|
|
},
|
|
{
|
|
name: "thread + recent still rejected when --recent explicit zero", // also covers the Changed() path
|
|
setup: func(c *cobra.Command) {
|
|
_ = c.Flags().Set("thread", "00000000-0000-0000-0000-000000000001")
|
|
_ = c.Flags().Set("recent", "5")
|
|
},
|
|
wantMsg: "--thread and --recent are mutually exclusive",
|
|
},
|
|
{
|
|
// --tail is a thread-scoped limit. Outside of --thread it
|
|
// would be silently dropped at the server, so the CLI rejects
|
|
// it locally with a clear hint.
|
|
name: "tail without thread rejected",
|
|
setup: func(c *cobra.Command) {
|
|
_ = c.Flags().Set("tail", "5")
|
|
},
|
|
wantMsg: "--tail requires --thread",
|
|
},
|
|
{
|
|
// tail=0 is allowed (root-only) but a negative value would
|
|
// round-trip to LIMIT -N on the server. Catch at the CLI so
|
|
// the user sees a useful message instead of a 400.
|
|
name: "negative tail rejected",
|
|
setup: func(c *cobra.Command) {
|
|
_ = c.Flags().Set("thread", "00000000-0000-0000-0000-000000000001")
|
|
_ = c.Flags().Set("tail", "-2")
|
|
},
|
|
wantMsg: "--tail must be a non-negative integer",
|
|
},
|
|
{
|
|
// --thread + --before without --tail used to be rejected
|
|
// outright. Now it requires --tail so the cursor's "scroll
|
|
// older replies" semantics has somewhere to land.
|
|
name: "thread + before without tail rejected",
|
|
setup: func(c *cobra.Command) {
|
|
_ = c.Flags().Set("thread", "00000000-0000-0000-0000-000000000001")
|
|
_ = c.Flags().Set("before", "2026-01-01T00:00:00Z")
|
|
_ = c.Flags().Set("before-id", "00000000-0000-0000-0000-000000000002")
|
|
},
|
|
wantMsg: "require --recent",
|
|
},
|
|
{
|
|
name: "roots-only + thread rejected",
|
|
setup: func(c *cobra.Command) {
|
|
_ = c.Flags().Set("roots-only", "true")
|
|
_ = c.Flags().Set("thread", "00000000-0000-0000-0000-000000000001")
|
|
},
|
|
wantMsg: "--roots-only and --thread are mutually exclusive",
|
|
},
|
|
{
|
|
name: "roots-only + recent rejected",
|
|
setup: func(c *cobra.Command) {
|
|
_ = c.Flags().Set("roots-only", "true")
|
|
_ = c.Flags().Set("recent", "3")
|
|
},
|
|
wantMsg: "--roots-only and --recent are mutually exclusive",
|
|
},
|
|
{
|
|
name: "roots-only + tail rejected",
|
|
setup: func(c *cobra.Command) {
|
|
_ = c.Flags().Set("roots-only", "true")
|
|
_ = c.Flags().Set("tail", "3")
|
|
},
|
|
wantMsg: "--roots-only and --tail are mutually exclusive",
|
|
},
|
|
{
|
|
name: "roots-only + before rejected",
|
|
setup: func(c *cobra.Command) {
|
|
_ = c.Flags().Set("roots-only", "true")
|
|
_ = c.Flags().Set("before", "2026-01-01T00:00:00Z")
|
|
_ = c.Flags().Set("before-id", "00000000-0000-0000-0000-000000000001")
|
|
},
|
|
wantMsg: "--roots-only does not support --before / --before-id",
|
|
},
|
|
}
|
|
for _, tc := range cases {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
cmd := newIssueCommentListTestCmd()
|
|
tc.setup(cmd)
|
|
err := runIssueCommentList(cmd, []string{"MUL-1"})
|
|
if err == nil {
|
|
t.Fatalf("expected error containing %q, got nil", tc.wantMsg)
|
|
}
|
|
if !strings.Contains(err.Error(), tc.wantMsg) {
|
|
t.Fatalf("error = %q, want substring %q", err.Error(), tc.wantMsg)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestRunIssueCommentList_RootsOnlyPassesThroughWithSince pins the CLI side
|
|
// of #3164: the flag must be forwarded as the server's roots_only query param,
|
|
// and it must still allow the existing --since incremental polling filter.
|
|
func TestRunIssueCommentList_RootsOnlyPassesThroughWithSince(t *testing.T) {
|
|
var gotQuery url.Values
|
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
if r.Method == http.MethodGet && strings.HasPrefix(r.URL.Path, "/api/issues/") && !strings.Contains(r.URL.Path, "/comments") {
|
|
json.NewEncoder(w).Encode(map[string]any{
|
|
"id": "issue-1",
|
|
"identifier": "MUL-1",
|
|
})
|
|
return
|
|
}
|
|
if r.Method == http.MethodGet && strings.HasSuffix(r.URL.Path, "/comments") {
|
|
gotQuery = r.URL.Query()
|
|
w.Write([]byte("[]"))
|
|
return
|
|
}
|
|
t.Errorf("unexpected request: %s %s", r.Method, r.URL.String())
|
|
http.Error(w, "unexpected", http.StatusInternalServerError)
|
|
}))
|
|
defer srv.Close()
|
|
|
|
t.Setenv("MULTICA_SERVER_URL", srv.URL)
|
|
t.Setenv("MULTICA_WORKSPACE_ID", "ws-1")
|
|
t.Setenv("MULTICA_TOKEN", "test-token")
|
|
|
|
cmd := newIssueCommentListTestCmd()
|
|
if err := cmd.Flags().Set("roots-only", "true"); err != nil {
|
|
t.Fatalf("set roots-only: %v", err)
|
|
}
|
|
if err := cmd.Flags().Set("since", "2026-01-01T00:00:00Z"); err != nil {
|
|
t.Fatalf("set since: %v", err)
|
|
}
|
|
if err := runIssueCommentList(cmd, []string{"MUL-1"}); err != nil {
|
|
t.Fatalf("runIssueCommentList: %v", err)
|
|
}
|
|
|
|
if got := gotQuery.Get("roots_only"); got != "true" {
|
|
t.Errorf("roots_only query = %q, want true", got)
|
|
}
|
|
if got := gotQuery.Get("since"); got != "2026-01-01T00:00:00Z" {
|
|
t.Errorf("since query = %q, want timestamp", got)
|
|
}
|
|
}
|
|
|
|
// TestRunIssueCommentList_SummaryPassesThrough pins the CLI side of the summary
|
|
// projection: --summary must forward summary=true, and it must compose with
|
|
// --roots-only (the orientation read it pairs with most often) rather than
|
|
// being rejected as an incompatible combination.
|
|
func TestRunIssueCommentList_SummaryPassesThrough(t *testing.T) {
|
|
var gotQuery url.Values
|
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
if r.Method == http.MethodGet && strings.HasPrefix(r.URL.Path, "/api/issues/") && !strings.Contains(r.URL.Path, "/comments") {
|
|
json.NewEncoder(w).Encode(map[string]any{
|
|
"id": "issue-1",
|
|
"identifier": "MUL-1",
|
|
})
|
|
return
|
|
}
|
|
if r.Method == http.MethodGet && strings.HasSuffix(r.URL.Path, "/comments") {
|
|
gotQuery = r.URL.Query()
|
|
w.Write([]byte("[]"))
|
|
return
|
|
}
|
|
t.Errorf("unexpected request: %s %s", r.Method, r.URL.String())
|
|
http.Error(w, "unexpected", http.StatusInternalServerError)
|
|
}))
|
|
defer srv.Close()
|
|
|
|
t.Setenv("MULTICA_SERVER_URL", srv.URL)
|
|
t.Setenv("MULTICA_WORKSPACE_ID", "ws-1")
|
|
t.Setenv("MULTICA_TOKEN", "test-token")
|
|
|
|
cmd := newIssueCommentListTestCmd()
|
|
if err := cmd.Flags().Set("summary", "true"); err != nil {
|
|
t.Fatalf("set summary: %v", err)
|
|
}
|
|
if err := cmd.Flags().Set("roots-only", "true"); err != nil {
|
|
t.Fatalf("set roots-only: %v", err)
|
|
}
|
|
if err := runIssueCommentList(cmd, []string{"MUL-1"}); err != nil {
|
|
t.Fatalf("runIssueCommentList: %v", err)
|
|
}
|
|
|
|
if got := gotQuery.Get("summary"); got != "true" {
|
|
t.Errorf("summary query = %q, want true", got)
|
|
}
|
|
if got := gotQuery.Get("roots_only"); got != "true" {
|
|
t.Errorf("roots_only query = %q, want true", got)
|
|
}
|
|
}
|
|
|
|
// TestRunIssueCommentList_ThreadTailPassesThroughAndPrintsReplyCursor pins
|
|
// the positive path for --thread + --tail: the CLI forwards `thread` +
|
|
// `tail` query params and, on response, prints "Next reply cursor" (not
|
|
// "Next thread cursor") so an operator can scroll older replies inside
|
|
// the same thread without guessing which cursor model the server emitted.
|
|
func TestRunIssueCommentList_ThreadTailPassesThroughAndPrintsReplyCursor(t *testing.T) {
|
|
var gotQuery url.Values
|
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
if r.Method == http.MethodGet && strings.HasPrefix(r.URL.Path, "/api/issues/") && !strings.Contains(r.URL.Path, "/comments") {
|
|
json.NewEncoder(w).Encode(map[string]any{
|
|
"id": "issue-1",
|
|
"identifier": "MUL-1",
|
|
})
|
|
return
|
|
}
|
|
if r.Method == http.MethodGet && strings.HasSuffix(r.URL.Path, "/comments") {
|
|
gotQuery = r.URL.Query()
|
|
// Emit a cursor so we can prove the CLI labels it "reply"
|
|
// when the call was a --thread + --tail combo.
|
|
w.Header().Set("X-Multica-Next-Before", "2026-01-01T00:00:00.000000001Z")
|
|
w.Header().Set("X-Multica-Next-Before-Id", "00000000-0000-0000-0000-000000000999")
|
|
w.Write([]byte("[]"))
|
|
return
|
|
}
|
|
t.Errorf("unexpected request: %s %s", r.Method, r.URL.String())
|
|
http.Error(w, "unexpected", http.StatusInternalServerError)
|
|
}))
|
|
defer srv.Close()
|
|
|
|
t.Setenv("MULTICA_SERVER_URL", srv.URL)
|
|
t.Setenv("MULTICA_WORKSPACE_ID", "ws-1")
|
|
t.Setenv("MULTICA_TOKEN", "test-token")
|
|
|
|
// Redirect stderr so we can assert on the "Next reply cursor" line —
|
|
// that's the user-visible signal that the CLI knew it was paging
|
|
// within a thread, not across threads.
|
|
stderr := captureStderr(t)
|
|
defer stderr.restore()
|
|
|
|
cmd := newIssueCommentListTestCmd()
|
|
if err := cmd.Flags().Set("thread", "00000000-0000-0000-0000-000000000001"); err != nil {
|
|
t.Fatalf("set thread: %v", err)
|
|
}
|
|
if err := cmd.Flags().Set("tail", "5"); err != nil {
|
|
t.Fatalf("set tail: %v", err)
|
|
}
|
|
if err := runIssueCommentList(cmd, []string{"MUL-1"}); err != nil {
|
|
t.Fatalf("runIssueCommentList: %v", err)
|
|
}
|
|
|
|
if got := gotQuery.Get("thread"); got != "00000000-0000-0000-0000-000000000001" {
|
|
t.Errorf("thread query = %q, want the passed anchor", got)
|
|
}
|
|
if got := gotQuery.Get("tail"); got != "5" {
|
|
t.Errorf("tail query = %q, want %q", got, "5")
|
|
}
|
|
out := stderr.read()
|
|
if !strings.Contains(out, "Next reply cursor: --before 2026-01-01T00:00:00.000000001Z --before-id 00000000-0000-0000-0000-000000000999") {
|
|
t.Errorf("stderr missing reply-cursor line, got: %q", out)
|
|
}
|
|
}
|
|
|
|
// TestRunIssueCommentList_RecentStillLabelsCursorAsThread is the negative
|
|
// counterpart: under --recent the CLI must keep printing "Next thread
|
|
// cursor". A regression that printed "reply" here would mis-signal the
|
|
// cursor semantics to anyone copy-pasting it.
|
|
func TestRunIssueCommentList_RecentStillLabelsCursorAsThread(t *testing.T) {
|
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
if r.Method == http.MethodGet && strings.HasPrefix(r.URL.Path, "/api/issues/") && !strings.Contains(r.URL.Path, "/comments") {
|
|
json.NewEncoder(w).Encode(map[string]any{
|
|
"id": "issue-1",
|
|
"identifier": "MUL-1",
|
|
})
|
|
return
|
|
}
|
|
w.Header().Set("X-Multica-Next-Before", "2026-01-01T00:00:00.000000001Z")
|
|
w.Header().Set("X-Multica-Next-Before-Id", "00000000-0000-0000-0000-000000000777")
|
|
w.Write([]byte("[]"))
|
|
}))
|
|
defer srv.Close()
|
|
|
|
t.Setenv("MULTICA_SERVER_URL", srv.URL)
|
|
t.Setenv("MULTICA_WORKSPACE_ID", "ws-1")
|
|
t.Setenv("MULTICA_TOKEN", "test-token")
|
|
|
|
stderr := captureStderr(t)
|
|
defer stderr.restore()
|
|
|
|
cmd := newIssueCommentListTestCmd()
|
|
if err := cmd.Flags().Set("recent", "3"); err != nil {
|
|
t.Fatalf("set recent: %v", err)
|
|
}
|
|
if err := runIssueCommentList(cmd, []string{"MUL-1"}); err != nil {
|
|
t.Fatalf("runIssueCommentList: %v", err)
|
|
}
|
|
|
|
out := stderr.read()
|
|
if !strings.Contains(out, "Next thread cursor:") {
|
|
t.Errorf("stderr missing thread-cursor line, got: %q", out)
|
|
}
|
|
}
|
|
|
|
// TestRunIssueCommentList_DoesNotPrintShowingPreamble locks in the removal of
|
|
// the "Showing N comments." stderr preamble. The line was the only
|
|
// `list --output json` subcommand that emitted a human-readable count, which
|
|
// polluted stdout/stderr-merged consumers (agent harnesses, CI `2>&1`).
|
|
// Tracks GitHub issue #3303.
|
|
func TestRunIssueCommentList_DoesNotPrintShowingPreamble(t *testing.T) {
|
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
if r.Method == http.MethodGet && strings.HasPrefix(r.URL.Path, "/api/issues/") && !strings.Contains(r.URL.Path, "/comments") {
|
|
json.NewEncoder(w).Encode(map[string]any{
|
|
"id": "issue-1",
|
|
"identifier": "MUL-1",
|
|
})
|
|
return
|
|
}
|
|
w.Write([]byte(`[{"id":"c1"},{"id":"c2"}]`))
|
|
}))
|
|
defer srv.Close()
|
|
|
|
t.Setenv("MULTICA_SERVER_URL", srv.URL)
|
|
t.Setenv("MULTICA_WORKSPACE_ID", "ws-1")
|
|
t.Setenv("MULTICA_TOKEN", "test-token")
|
|
|
|
stderr := captureStderr(t)
|
|
defer stderr.restore()
|
|
|
|
cmd := newIssueCommentListTestCmd()
|
|
if err := cmd.Flags().Set("output", "json"); err != nil {
|
|
t.Fatalf("set output: %v", err)
|
|
}
|
|
if err := runIssueCommentList(cmd, []string{"MUL-1"}); err != nil {
|
|
t.Fatalf("runIssueCommentList: %v", err)
|
|
}
|
|
|
|
if got := stderr.read(); strings.Contains(got, "Showing") {
|
|
t.Errorf("stderr must not contain a 'Showing ...' preamble, got: %q", got)
|
|
}
|
|
}
|
|
|
|
func TestValidIssueStatuses(t *testing.T) {
|
|
expected := map[string]bool{
|
|
"backlog": true,
|
|
"todo": true,
|
|
"in_progress": true,
|
|
"in_review": true,
|
|
"done": true,
|
|
"blocked": true,
|
|
"cancelled": true,
|
|
}
|
|
for _, s := range validIssueStatuses {
|
|
if !expected[s] {
|
|
t.Errorf("unexpected status in validIssueStatuses: %q", s)
|
|
}
|
|
}
|
|
if len(validIssueStatuses) != len(expected) {
|
|
t.Errorf("validIssueStatuses has %d entries, expected %d", len(validIssueStatuses), len(expected))
|
|
}
|
|
}
|
|
|
|
func TestValidateIssueStatus(t *testing.T) {
|
|
for _, s := range validIssueStatuses {
|
|
if err := validateIssueStatus(s); err != nil {
|
|
t.Errorf("status %q should be valid, got: %v", s, err)
|
|
}
|
|
}
|
|
err := validateIssueStatus("active")
|
|
if err == nil {
|
|
t.Fatal("status \"active\" should be rejected")
|
|
}
|
|
if !strings.Contains(err.Error(), "backlog") {
|
|
t.Errorf("error should list valid statuses, got: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestValidateIssuePriority(t *testing.T) {
|
|
expected := map[string]bool{
|
|
"urgent": true,
|
|
"high": true,
|
|
"medium": true,
|
|
"low": true,
|
|
"none": true,
|
|
}
|
|
for _, p := range validIssuePriorities {
|
|
if !expected[p] {
|
|
t.Errorf("unexpected priority in validIssuePriorities: %q", p)
|
|
}
|
|
if err := validateIssuePriority(p); err != nil {
|
|
t.Errorf("priority %q should be valid, got: %v", p, err)
|
|
}
|
|
}
|
|
if len(validIssuePriorities) != len(expected) {
|
|
t.Errorf("validIssuePriorities has %d entries, expected %d", len(validIssuePriorities), len(expected))
|
|
}
|
|
err := validateIssuePriority("P1")
|
|
if err == nil {
|
|
t.Fatal("priority \"P1\" should be rejected")
|
|
}
|
|
if !strings.Contains(err.Error(), "urgent") {
|
|
t.Errorf("error should list valid priorities, got: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestRunIssueCreateRejectsInvalidStatusBeforeRequest(t *testing.T) {
|
|
cmd := newIssueCreateTestCmd()
|
|
_ = cmd.Flags().Set("title", "Invalid status")
|
|
_ = cmd.Flags().Set("status", "active")
|
|
err := runIssueCreate(cmd, nil)
|
|
if err == nil {
|
|
t.Fatal("runIssueCreate should reject invalid status")
|
|
}
|
|
if !strings.Contains(err.Error(), "valid values") {
|
|
t.Fatalf("expected valid values error, got: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestRunIssueCreateRejectsInvalidPriorityBeforeRequest(t *testing.T) {
|
|
cmd := newIssueCreateTestCmd()
|
|
_ = cmd.Flags().Set("title", "Invalid priority")
|
|
_ = cmd.Flags().Set("priority", "P1")
|
|
err := runIssueCreate(cmd, nil)
|
|
if err == nil {
|
|
t.Fatal("runIssueCreate should reject invalid priority")
|
|
}
|
|
if !strings.Contains(err.Error(), "valid values") {
|
|
t.Fatalf("expected valid values error, got: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestRunIssueUpdateRejectsInvalidStatusBeforeRequest(t *testing.T) {
|
|
cmd := &cobra.Command{Use: "update"}
|
|
cmd.Flags().String("status", "", "")
|
|
cmd.Flags().String("priority", "", "")
|
|
_ = cmd.Flags().Set("status", "active")
|
|
err := runIssueUpdate(cmd, []string{"MUL-1"})
|
|
if err == nil {
|
|
t.Fatal("runIssueUpdate should reject invalid status")
|
|
}
|
|
if !strings.Contains(err.Error(), "valid values") {
|
|
t.Fatalf("expected valid values error, got: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestRunIssueUpdateRejectsInvalidPriorityBeforeRequest(t *testing.T) {
|
|
cmd := &cobra.Command{Use: "update"}
|
|
cmd.Flags().String("status", "", "")
|
|
cmd.Flags().String("priority", "", "")
|
|
_ = cmd.Flags().Set("priority", "P1")
|
|
err := runIssueUpdate(cmd, []string{"MUL-1"})
|
|
if err == nil {
|
|
t.Fatal("runIssueUpdate should reject invalid priority")
|
|
}
|
|
if !strings.Contains(err.Error(), "valid values") {
|
|
t.Fatalf("expected valid values error, got: %v", err)
|
|
}
|
|
}
|