gitlink-cli/shortcuts/issue/batch_operation_test.go

378 lines
12 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

package issue
import (
"fmt"
"net/http"
"net/http/httptest"
"reflect"
"strings"
"sync/atomic"
"testing"
"github.com/gitlink-org/gitlink-cli/internal/client"
"github.com/gitlink-org/gitlink-cli/shortcuts/common"
)
// ---------------------------------------------------------------------------
// RunBatch 核心行为测试
// ---------------------------------------------------------------------------
// TestRunBatchDryRunDoesNotCallFn 验证 dry-run 模式下 fn 不被调用,
// 且 summary 正确反映所有 issue 为 succeeded/dry_run。
func TestRunBatchDryRunDoesNotCallFn(t *testing.T) {
t.Setenv("GITLINK_CONFIRM_BATCH", "") // 隔离环境变量
server := newIssueTestServer(t, func(w http.ResponseWriter, r *http.Request) {
// dry-run 不应产生任何 HTTP 请求
t.Fatalf("unexpected HTTP request in dry-run: %s %s", r.Method, r.URL.Path)
})
defer server.Close()
ctx := newBatchTestCtx(t, server, nil)
numbers := []string{"1", "2"}
fn := func(_ *common.RuntimeContext, _ string) error {
t.Fatal("fn should not be called in dry-run mode")
return nil
}
opts := BatchOptions{DryRun: true, Confirm: false}
summary, err := RunBatch(ctx, numbers, "close", opts, fn)
if err != nil {
t.Fatalf("RunBatch dry-run returned error: %v", err)
}
common.AssertEqual(t, summary.Total, 2)
common.AssertEqual(t, summary.Succeeded, 2)
common.AssertEqual(t, summary.DryRun, true)
common.AssertEqual(t, summary.Failed, 0)
for _, r := range summary.Results {
common.AssertEqual(t, r.Status, "dry_run")
}
}
// TestRunBatchRequiresConfirm 验证非 dry-run 且无 confirm 时返回确认错误。
func TestRunBatchRequiresConfirm(t *testing.T) {
t.Setenv("GITLINK_CONFIRM_BATCH", "")
server := newIssueTestServer(t, func(w http.ResponseWriter, r *http.Request) {
t.Fatalf("unexpected HTTP request: %s %s", r.Method, r.URL.Path)
})
defer server.Close()
ctx := newBatchTestCtx(t, server, nil)
fn := func(_ *common.RuntimeContext, _ string) error {
t.Fatal("fn should not be called without confirm")
return nil
}
opts := BatchOptions{DryRun: false, Confirm: false}
_, err := RunBatch(ctx, []string{"1"}, "close", opts, fn)
if err == nil {
t.Fatal("expected error when confirm is required but not provided")
}
if !strings.Contains(err.Error(), "confirm") {
t.Fatalf("error should mention 'confirm', got: %v", err)
}
}
// TestRunBatchWithConfirm 验证 confirm=true 时 fn 被正常调用。
func TestRunBatchWithConfirm(t *testing.T) {
t.Setenv("GITLINK_CONFIRM_BATCH", "")
server := newIssueTestServer(t, func(w http.ResponseWriter, r *http.Request) {
// fn 不做网络请求,不需要 mock
t.Fatalf("unexpected HTTP request: %s %s", r.Method, r.URL.Path)
})
defer server.Close()
ctx := newBatchTestCtx(t, server, nil)
numbers := []string{"1", "2", "3"}
var callCount int32
fn := func(_ *common.RuntimeContext, number string) error {
atomic.AddInt32(&callCount, 1)
return nil
}
opts := BatchOptions{DryRun: false, Confirm: true}
summary, err := RunBatch(ctx, numbers, "close", opts, fn)
if err != nil {
t.Fatalf("RunBatch with confirm returned error: %v", err)
}
common.AssertEqual(t, int(atomic.LoadInt32(&callCount)), 3)
common.AssertEqual(t, summary.Total, 3)
common.AssertEqual(t, summary.Succeeded, 3)
common.AssertEqual(t, summary.Failed, 0)
}
// TestRunBatchMaxTruncation 验证 --max 截断行为。
func TestRunBatchMaxTruncation(t *testing.T) {
t.Setenv("GITLINK_CONFIRM_BATCH", "")
server := newIssueTestServer(t, func(w http.ResponseWriter, r *http.Request) {
t.Fatalf("unexpected HTTP request: %s %s", r.Method, r.URL.Path)
})
defer server.Close()
ctx := newBatchTestCtx(t, server, nil)
numbers := []string{"1", "2", "3"}
fn := func(_ *common.RuntimeContext, _ string) error {
return nil
}
opts := BatchOptions{DryRun: true, MaxItems: 2}
summary, err := RunBatch(ctx, numbers, "close", opts, fn)
if err == nil {
t.Fatal("expected error when results are truncated")
}
if !strings.Contains(err.Error(), "truncated") {
t.Fatalf("error should mention 'truncated', got: %v", err)
}
common.AssertEqual(t, summary.Total, 2) // 截断后为 2
common.AssertEqual(t, summary.Truncated, true)
common.AssertEqual(t, summary.Succeeded, 2) // dry-run 全部 succeeded
}
// TestRunBatchRecordsFailures 验证 fn 返回错误时记录为 failed。
func TestRunBatchRecordsFailures(t *testing.T) {
t.Setenv("GITLINK_CONFIRM_BATCH", "")
server := newIssueTestServer(t, func(w http.ResponseWriter, r *http.Request) {
t.Fatalf("unexpected HTTP request: %s %s", r.Method, r.URL.Path)
})
defer server.Close()
ctx := newBatchTestCtx(t, server, nil)
numbers := []string{"1", "2"}
var callCount int32
fn := func(_ *common.RuntimeContext, number string) error {
n := atomic.AddInt32(&callCount, 1)
if n == 2 {
return fmt.Errorf("simulated failure for issue %s", number)
}
return nil
}
opts := BatchOptions{DryRun: false, Confirm: true}
summary, err := RunBatch(ctx, numbers, "close", opts, fn)
if err == nil {
t.Fatal("expected error when some issues fail")
}
common.AssertEqual(t, summary.Total, 2)
common.AssertEqual(t, summary.Succeeded, 1)
common.AssertEqual(t, summary.Failed, 1)
common.AssertEqual(t, summary.Results[0].Status, "success")
common.AssertEqual(t, summary.Results[1].Status, "failed")
if summary.Results[1].Error == "" {
t.Fatal("failed result should have an error message")
}
}
// ---------------------------------------------------------------------------
// patchIssue 测试httptest mock
// ---------------------------------------------------------------------------
// TestPatchIssueMergesExtraFields 验证 patchIssue 将 extraFields 合并到 PATCH body
// 同时保留 subject 和 description。
func TestPatchIssueMergesExtraFields(t *testing.T) {
var patchPayload map[string]interface{}
server := newIssueTestServer(t, func(w http.ResponseWriter, r *http.Request) {
switch {
case r.Method == "GET" && r.URL.Path == "/v1/owner/repo/issues/42.json":
common.WriteJSON(t, w, map[string]interface{}{
"subject": "Original Title",
"description": "Original Desc",
})
case r.Method == "PATCH" && r.URL.Path == "/v1/owner/repo/issues/42.json":
patchPayload = common.DecodeJSON(t, r)
common.WriteJSON(t, w, patchPayload)
default:
t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path)
}
})
defer server.Close()
ctx := newBatchTestCtx(t, server, nil)
err := patchIssue(ctx, "42", map[string]interface{}{"status_id": closeIssueStatusID}, "close")
if err != nil {
t.Fatalf("patchIssue failed: %v", err)
}
common.AssertEqual(t, patchPayload["subject"], "Original Title")
common.AssertEqual(t, patchPayload["description"], "Original Desc")
common.AssertEqual(t, patchPayload["status_id"], float64(5))
}
// ---------------------------------------------------------------------------
// 纯函数单元测试
// ---------------------------------------------------------------------------
// TestMergeLabelIDs 验证 mergeLabelIDs 去重合并逻辑。
func TestMergeLabelIDs(t *testing.T) {
tests := []struct {
name string
a, b []int
want []int
}{
{"去重合并", []int{1, 2}, []int{2, 3}, []int{1, 2, 3}},
{"existing 为 nil", nil, []int{1}, []int{1}},
{"new 为 nil", []int{1}, nil, []int{1}},
{"两者都为 nil", nil, nil, nil},
{"完全重复", []int{1, 2}, []int{1, 2}, []int{1, 2}},
{"existing 为空", []int{}, []int{1, 2}, []int{1, 2}},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := mergeLabelIDs(tt.a, tt.b)
if !reflect.DeepEqual(got, tt.want) {
t.Fatalf("mergeLabelIDs(%v, %v) = %v, want %v", tt.a, tt.b, got, tt.want)
}
})
}
}
// TestRemoveLabelIDs 验证 removeLabelIDs 移除逻辑。
func TestRemoveLabelIDs(t *testing.T) {
tests := []struct {
name string
existing []int
remove []int
want []int
}{
{"移除中间元素", []int{1, 2, 3}, []int{2}, []int{1, 3}},
{"移除不存在的忽略", []int{1, 2}, []int{3}, []int{1, 2}},
{"全部移除", []int{1, 2}, []int{1, 2}, []int{}},
{"existing 为空", []int{}, []int{1}, []int{}},
{"remove 为空", []int{1, 2}, []int{}, []int{1, 2}},
{"两者都为空", []int{}, []int{}, []int{}},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := removeLabelIDs(tt.existing, tt.remove)
if !reflect.DeepEqual(got, tt.want) {
t.Fatalf("removeLabelIDs(%v, %v) = %v, want %v", tt.existing, tt.remove, got, tt.want)
}
})
}
}
// TestNormalizeIssueStatus 验证 normalizeIssueStatus 状态映射。
func TestNormalizeIssueStatus(t *testing.T) {
tests := []struct {
input string
want interface{}
err bool
}{
{"open", 1, false},
{"closed", 5, false},
{"OPEN", 1, false},
{"Closed", 5, false},
{"1", 1, false}, // 数字字符串
{"5", 5, false}, // 数字字符串
{"invalid", nil, true}, // 无效输入应返回错误
{"", nil, true}, // 空字符串应返回错误
}
for _, tt := range tests {
t.Run(tt.input, func(t *testing.T) {
got, err := normalizeIssueStatus(tt.input)
if tt.err {
if err == nil {
t.Fatalf("normalizeIssueStatus(%q) expected error, got nil", tt.input)
}
return
}
if err != nil {
t.Fatalf("normalizeIssueStatus(%q) unexpected error: %v", tt.input, err)
}
if got != tt.want {
t.Fatalf("normalizeIssueStatus(%q) = %v, want %v", tt.input, got, tt.want)
}
})
}
}
// ---------------------------------------------------------------------------
// parseBatchOptions 测试
// ---------------------------------------------------------------------------
// TestParseBatchOptions 验证 parseBatchOptions 从 ctx.Args 正确解析各选项。
func TestParseBatchOptions(t *testing.T) {
t.Run("完整参数解析", func(t *testing.T) {
ctx := &common.RuntimeContext{
Args: map[string]string{
"dry-run": "true",
"confirm": "true",
"max": "5",
"delay": "100",
},
}
opts := parseBatchOptions(ctx)
common.AssertEqual(t, opts.DryRun, true)
common.AssertEqual(t, opts.Confirm, true)
common.AssertEqual(t, opts.MaxItems, 5)
common.AssertEqual(t, opts.DelayMs, 100)
})
t.Run("空参数使用默认值", func(t *testing.T) {
ctx := &common.RuntimeContext{
Args: map[string]string{},
}
opts := parseBatchOptions(ctx)
common.AssertEqual(t, opts.DryRun, false)
common.AssertEqual(t, opts.Confirm, false)
common.AssertEqual(t, opts.MaxItems, defaultBatchMaxItems)
common.AssertEqual(t, opts.DelayMs, defaultBatchDelayMs)
})
t.Run("无效 max 值使用默认值", func(t *testing.T) {
ctx := &common.RuntimeContext{
Args: map[string]string{
"max": "not-a-number",
},
}
opts := parseBatchOptions(ctx)
common.AssertEqual(t, opts.MaxItems, defaultBatchMaxItems)
})
t.Run("无效 delay 值使用默认值", func(t *testing.T) {
ctx := &common.RuntimeContext{
Args: map[string]string{
"delay": "abc",
},
}
opts := parseBatchOptions(ctx)
common.AssertEqual(t, opts.DelayMs, defaultBatchDelayMs)
})
}
// ---------------------------------------------------------------------------
// 辅助函数
// ---------------------------------------------------------------------------
// newBatchTestCtx 构造用于 batch 测试的 RuntimeContext。
// Args 如果为 nil则使用空 map。
func newBatchTestCtx(t *testing.T, server *httptest.Server, args map[string]string) *common.RuntimeContext {
t.Helper()
if args == nil {
args = map[string]string{}
}
return &common.RuntimeContext{
Client: &client.Client{
HTTP: server.Client(),
BaseURL: server.URL,
},
Owner: "owner",
Repo: "repo",
Format: "json",
Args: args,
}
}