diff --git a/internal/app/schema_recruit_contract_test.go b/internal/app/schema_recruit_contract_test.go new file mode 100644 index 00000000..6c6c4a2c --- /dev/null +++ b/internal/app/schema_recruit_contract_test.go @@ -0,0 +1,62 @@ +// Copyright 2026 Alibaba Group +// Licensed under the Apache License, Version 2.0 (the "License"); + +package app + +import ( + "bytes" + "encoding/json" + "reflect" + "testing" +) + +func recruitSchemaLeaf(t *testing.T, canonical string, compact bool) map[string]any { + t.Helper() + root := NewRootCommand() + var stdout, stderr bytes.Buffer + root.SetOut(&stdout) + root.SetErr(&stderr) + args := []string{"schema", canonical, "--format", "json"} + if compact { + args = append(args, "--compact") + } + root.SetArgs(args) + if err := root.Execute(); err != nil { + t.Fatalf("execute schema %s compact=%v: %v; stderr=%s", canonical, compact, err, stderr.String()) + } + var payload map[string]any + if err := json.Unmarshal(stdout.Bytes(), &payload); err != nil { + t.Fatalf("decode schema %s compact=%v: %v", canonical, compact, err) + } + return payload +} + +func TestRecruitDeliveredSchemaPublishesResultAndPagination(t *testing.T) { + for _, canonical := range []string{"recruit.list_jobs", "recruit.get_job_detail", "recruit.create_job"} { + t.Run(canonical, func(t *testing.T) { + full := recruitSchemaLeaf(t, canonical, false) + compact := recruitSchemaLeaf(t, canonical, true) + if full["result"] == nil || compact["result"] == nil { + t.Fatalf("result missing: full=%#v compact=%#v", full["result"], compact["result"]) + } + if !reflect.DeepEqual(full["result"], compact["result"]) { + t.Fatalf("full/compact result mismatch\nfull=%#v\ncompact=%#v", full["result"], compact["result"]) + } + if canonical == "recruit.list_jobs" { + if full["pagination"] == nil || compact["pagination"] == nil { + t.Fatalf("list pagination missing: full=%#v compact=%#v", full["pagination"], compact["pagination"]) + } + if !reflect.DeepEqual(full["pagination"], compact["pagination"]) { + t.Fatalf("full/compact pagination mismatch\nfull=%#v\ncompact=%#v", full["pagination"], compact["pagination"]) + } + parameters, _ := full["parameters"].(map[string]any) + cursor, _ := parameters["cursor"].(map[string]any) + if cursor["type"] != "string" || cursor["interface_type"] != "number" { + t.Fatalf("cursor contract = %#v, want CLI string converted to MCP number", cursor) + } + } else if full["pagination"] != nil || compact["pagination"] != nil { + t.Fatalf("non-list pagination must be absent: full=%#v compact=%#v", full["pagination"], compact["pagination"]) + } + }) + } +} diff --git a/internal/helpers/recruit.go b/internal/helpers/recruit.go index c2756305..73997371 100644 --- a/internal/helpers/recruit.go +++ b/internal/helpers/recruit.go @@ -7,12 +7,14 @@ import ( "encoding/json" "fmt" "os" + "strconv" "strings" "github.com/spf13/cobra" "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contract" apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/output" ) const ( @@ -30,7 +32,7 @@ var recruitDryRun = &contract.DryRunSpec{ var ( recruitListResult = &contract.ResultSpec{ Outcomes: []contract.ResultOutcome{contract.ResultOutcomeSuccess, contract.ResultOutcomeFailure}, - DataSchema: json.RawMessage(`{"type":"object","description":"当前页招聘职位查询结果","properties":{"jobs":{"type":"array","description":"当前页职位记录","items":{"type":"object","description":"招聘职位摘要","properties":{"jobId":{"type":"string","description":"职位 ID"},"name":{"type":"string","description":"职位名称"},"status":{"type":"number","description":"职位状态枚举值"}},"additionalProperties":true}},"hasMore":{"type":"boolean","description":"服务端是否还有下一页"},"nextCursor":{"description":"下一页游标;仅在 hasMore 为 true 时用于续查","oneOf":[{"type":"number"},{"type":"string"}]}},"additionalProperties":true}`), + DataSchema: json.RawMessage(`{"type":"object","description":"当前页招聘职位查询结果;续页信息位于 meta.pagination","properties":{"jobs":{"type":"array","description":"当前页职位记录","items":{"type":"object","description":"招聘职位摘要","properties":{"jobId":{"type":"string","description":"职位 ID"},"name":{"type":"string","description":"职位名称"},"status":{"type":"number","description":"职位状态枚举值"}},"additionalProperties":true}}},"additionalProperties":true}`), } recruitJobDetailResult = &contract.ResultSpec{ Outcomes: []contract.ResultOutcome{contract.ResultOutcomeSuccess, contract.ResultOutcomeFailure}, @@ -78,13 +80,14 @@ func newRecruitCommand() *cobra.Command { func newRecruitJobListCommand() *cobra.Command { return NewLeafCommand(LeafSpec{ - Use: "list", - Short: "查询招聘职位列表", - Long: "按职位 ID、关键词、状态、创建人、职位性质等条件分页查询招聘职位。", - Example: " dws recruit job list --keyword Java --status open --size 20 --format json\n dws recruit job list --job-ids JOB_ID_1,JOB_ID_2 --format json", - Server: recruitServerID, - Tool: recruitListJobsTool, - Safety: recruitSafetyRead(), + Use: "list", + Short: "查询招聘职位列表", + Long: "按职位 ID、关键词、状态、创建人、职位性质等条件分页查询招聘职位。", + Example: " dws recruit job list --keyword Java --status open --size 20 --format json\n dws recruit job list --job-ids JOB_ID_1,JOB_ID_2 --format json", + Server: recruitServerID, + Tool: recruitListJobsTool, + Safety: recruitSafetyRead(), + OutputRollout: output.RolloutUnifiedActive, Flags: []LeafFlag{ {Name: "job-ids", Usage: "职位 ID,多个值用逗号分隔", Kind: LeafStringSlice, Bind: "jobIds"}, {Name: "required-edu", Usage: "学历要求枚举值", Kind: LeafInt, Bind: "requiredEdu"}, @@ -96,11 +99,11 @@ func newRecruitJobListCommand() *cobra.Command { {Name: "creator-user-ids", Usage: "创建人 userId,多个值用逗号分隔", Kind: LeafStringSlice, Bind: "creatorUserIds"}, {Name: "keyword", Usage: "职位搜索关键词", Bind: "keyword", Trim: true, OmitEmpty: true}, {Name: "category", Usage: "职位分类", Bind: "category", Trim: true, OmitEmpty: true}, - {Name: "cursor", Usage: "分页游标;首次查询不传,翻页时传返回的 nextCursor", Kind: LeafInt, Bind: "cursor"}, + {Name: "cursor", Usage: "分页游标;首次查询不传,翻页时原样回填返回的 nextCursor", Bind: "cursor", Trim: true, OmitEmpty: true, Transform: transformRecruitCursor}, {Name: "size", Usage: "分页大小,默认 20", Kind: LeafInt, Bind: "size", ArgDefault: "20"}, }, - Validate: validateRecruitList, - Call: runRecruitJobList, + Validate: validateRecruitList, + ResultCall: recruitResultCall, Contract: LeafContract{ Identity: contract.ToolIdentitySpec{ProductID: "recruit", Name: recruitListJobsTool, CanonicalPath: "recruit.list_jobs", CLIPath: "recruit job list", PrimaryCLIPath: "recruit job list"}, Description: "按条件分页查询招聘职位", @@ -119,7 +122,7 @@ func newRecruitJobListCommand() *cobra.Command { }) } -func runRecruitJobList(_ *cobra.Command, tool string, args map[string]any) error { +func recruitListToolArgs(args map[string]any) map[string]any { params := map[string]any{"param": map[string]any{}, "size": args["size"]} query := params["param"].(map[string]any) for key, value := range args { @@ -131,18 +134,88 @@ func runRecruitJobList(_ *cobra.Command, tool string, args map[string]any) error query[key] = value } } - return callMCPToolOnServer(recruitServerID, tool, params) + return params +} + +func recruitResultCall(cmd *cobra.Command, tool string, args map[string]any) (output.CommandResult, error) { + toolArgs := args + if tool == recruitListJobsTool { + toolArgs = recruitListToolArgs(args) + } + if deps.Caller.DryRun() { + return output.Success(map[string]any{ + "tool": tool, "arguments": toolArgs, "executed": false, + }, output.WithDryRun()), nil + } + data, err := CallMCPToolDataOnServer(cmd.Context(), recruitServerID, tool, toolArgs) + if err != nil { + return nil, err + } + if tool != recruitListJobsTool { + return output.Success(data), nil + } + clean, meta, err := recruitListResultData(data) + if err != nil { + return output.Failure(&output.ErrorInfo{ + Type: "api", Subtype: "pagination_inconsistent", Message: err.Error(), + Hint: "保留原始响应并停止翻页;不要把当前页当作完整结果。", + }), nil + } + return output.Success(clean, output.WithMeta(meta)), nil +} + +func recruitListResultData(data any) (any, *output.Meta, error) { + object, ok := data.(map[string]any) + if !ok { + return nil, nil, fmt.Errorf("list_jobs 返回值必须是 JSON 对象") + } + hasMore, ok := object["hasMore"].(bool) + if !ok { + return nil, nil, fmt.Errorf("list_jobs 返回值缺少布尔字段 hasMore") + } + nextToken := "" + if raw, exists := object["nextCursor"]; exists && raw != nil { + switch value := raw.(type) { + case string: + nextToken = strings.TrimSpace(value) + case float64: + nextToken = strconv.FormatFloat(value, 'f', -1, 64) + default: + return nil, nil, fmt.Errorf("list_jobs 的 nextCursor 必须是字符串或数字") + } + } + if hasMore && nextToken == "" { + return nil, nil, fmt.Errorf("list_jobs 返回 hasMore=true 但缺少 nextCursor") + } + if !hasMore { + nextToken = "" + } + clean := make(map[string]any, len(object)-2) + for key, value := range object { + if key != "hasMore" && key != "nextCursor" { + clean[key] = value + } + } + pagination := &output.Pagination{EndpointExhausted: !hasMore, NextToken: nextToken, Pages: 1} + meta := &output.Meta{Pagination: pagination} + if jobs, exists := clean["jobs"].([]any); exists { + meta.Count = output.NewCount(len(jobs)) + pagination.Items = len(jobs) + } + return clean, meta, nil } func newRecruitJobGetCommand() *cobra.Command { return NewLeafCommand(LeafSpec{ - Use: "get", - Short: "查询招聘职位详情", - Long: "根据职位 ID 查询招聘职位详情。", - Example: " dws recruit job get --job-id JOB_ID --format json", - Server: recruitServerID, - Tool: recruitGetJobTool, - Safety: recruitSafetyRead(), + Use: "get", + Short: "查询招聘职位详情", + Long: "根据职位 ID 查询招聘职位详情。", + Example: " dws recruit job get --job-id JOB_ID --format json", + Server: recruitServerID, + Tool: recruitGetJobTool, + Safety: recruitSafetyRead(), + OutputRollout: output.RolloutUnifiedActive, + ResultCall: recruitResultCall, Flags: []LeafFlag{{ Name: "job-id", Usage: "职位 ID(必填)", Bind: "jobId", Trim: true, Required: true, RequiredHint: "--job-id 为必填", @@ -166,13 +239,15 @@ func newRecruitJobGetCommand() *cobra.Command { func newRecruitJobCreateCommand() *cobra.Command { return NewLeafCommand(LeafSpec{ - Use: "create", - Short: "创建招聘职位", - Long: "从 JSON 文件读取职位信息并创建招聘职位。该操作会写入远端招聘系统,执行前必须确认。", - Example: " dws recruit job create --from ./job.json --dry-run --format json\n dws recruit job create --from ./job.json --format json", - Server: recruitServerID, - Tool: recruitCreateJobTool, - Safety: recruitSafetyCreate(), + Use: "create", + Short: "创建招聘职位", + Long: "从 JSON 文件读取职位信息并创建招聘职位。该操作会写入远端招聘系统,执行前必须确认。", + Example: " dws recruit job create --from ./job.json --dry-run --format json\n dws recruit job create --from ./job.json --format json", + Server: recruitServerID, + Tool: recruitCreateJobTool, + Safety: recruitSafetyCreate(), + OutputRollout: output.RolloutUnifiedActive, + ResultCall: recruitResultCall, Flags: []LeafFlag{{ Name: "from", Usage: "职位 JSON 文件路径(必填)", Bind: "atsAddJobParam", Trim: true, Required: true, RequiredHint: "--from 为必填", Transform: loadRecruitJobFile, @@ -249,10 +324,16 @@ func transformRecruitStatuses(raw string) (any, error) { return statuses, nil } -func validateRecruitList(cmd *cobra.Command, _ []string) error { - if cursor, _ := cmd.Flags().GetInt("cursor"); cursor < 0 { - return apperrors.NewValidation("--cursor 必须大于或等于 0") +func transformRecruitCursor(raw string) (any, error) { + value := strings.TrimSpace(raw) + cursor, err := strconv.ParseInt(value, 10, 64) + if err != nil || cursor < 0 { + return nil, apperrors.NewValidation("--cursor 必须是大于或等于 0 的整数") } + return cursor, nil +} + +func validateRecruitList(cmd *cobra.Command, _ []string) error { size, _ := cmd.Flags().GetInt("size") if cmd.Flags().Changed("size") && (size < 1 || size > 100) { return apperrors.NewValidation("--size 必须在 1 到 100 之间") diff --git a/internal/helpers/recruit_test.go b/internal/helpers/recruit_test.go index 77f319ba..bfcb3936 100644 --- a/internal/helpers/recruit_test.go +++ b/internal/helpers/recruit_test.go @@ -14,9 +14,12 @@ import ( "strings" "testing" + "github.com/spf13/cobra" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contract" "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contractfinal" apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/output" "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition" ) @@ -31,6 +34,9 @@ type recruitCaptureCaller struct { tool string args map[string]any calls []recruitCapturedCall + err error + text string + dryRun bool } func (c *recruitCaptureCaller) CallTool(_ context.Context, productID, tool string, args map[string]any) (*edition.ToolResult, error) { @@ -38,11 +44,24 @@ func (c *recruitCaptureCaller) CallTool(_ context.Context, productID, tool strin c.tool = tool c.args = args c.calls = append(c.calls, recruitCapturedCall{productID: productID, tool: tool, args: args}) - return &edition.ToolResult{Content: []edition.ContentBlock{{Type: "text", Text: `{}`}}}, nil + if c.err != nil { + return nil, c.err + } + text := c.text + if text == "" { + text = `{}` + switch tool { + case recruitListJobsTool: + text = `{"jobs":[],"hasMore":false}` + case recruitCreateJobTool: + text = `{"jobId":"created-job"}` + } + } + return &edition.ToolResult{Content: []edition.ContentBlock{{Type: "text", Text: text}}}, nil } func (c *recruitCaptureCaller) Format() string { return "json" } -func (c *recruitCaptureCaller) DryRun() bool { return false } +func (c *recruitCaptureCaller) DryRun() bool { return c.dryRun } func (c *recruitCaptureCaller) Fields() string { return "" } func (c *recruitCaptureCaller) JQ() string { return "" } @@ -54,9 +73,15 @@ func withRecruitCaller(t *testing.T) *recruitCaptureCaller { return caller } +func prepareRecruitTestCommand(cmd *cobra.Command) *cobra.Command { + ctx, _ := output.WithResultStore(context.Background()) + cmd.SetContext(ctx) + return cmd +} + func TestRecruitJobListWrapsQueryParameters(t *testing.T) { caller := withRecruitCaller(t) - cmd := newRecruitJobListCommand() + cmd := prepareRecruitTestCommand(newRecruitJobListCommand()) cmd.SetArgs([]string{ "--keyword", "Java", "--status", "open,draft", "--creator-user-ids", "u1,u2", "--campus=false", "--cursor", "10", "--size", "30", @@ -67,7 +92,7 @@ func TestRecruitJobListWrapsQueryParameters(t *testing.T) { if caller.productID != recruitServerID || caller.tool != recruitListJobsTool { t.Fatalf("dispatch = %s/%s, want %s/%s", caller.productID, caller.tool, recruitServerID, recruitListJobsTool) } - if caller.args["cursor"] != 10 || caller.args["size"] != 30 { + if caller.args["cursor"] != int64(10) || caller.args["size"] != 30 { t.Fatalf("pagination args = %#v", caller.args) } param, ok := caller.args["param"].(map[string]any) @@ -87,9 +112,29 @@ func TestRecruitJobListWrapsQueryParameters(t *testing.T) { } } +func TestRecruitJobListCursorRoundTrip(t *testing.T) { + caller := withRecruitCaller(t) + cmd := prepareRecruitTestCommand(newRecruitJobListCommand()) + cmd.SetArgs([]string{"--cursor", "9223372036854775807"}) + if err := cmd.Execute(); err != nil { + t.Fatal(err) + } + if got := caller.args["cursor"]; got != int64(9223372036854775807) { + t.Fatalf("cursor = %#v (%T), want max int64", got, got) + } + + for _, cursor := range []string{"not-a-cursor", "9223372036854775808"} { + invalid := prepareRecruitTestCommand(newRecruitJobListCommand()) + invalid.SetArgs([]string{"--cursor", cursor}) + if err := invalid.Execute(); err == nil || !strings.Contains(err.Error(), "大于或等于 0 的整数") { + t.Fatalf("cursor %q error = %v", cursor, err) + } + } +} + func TestRecruitJobListDefaultsAndValidation(t *testing.T) { caller := withRecruitCaller(t) - cmd := newRecruitJobListCommand() + cmd := prepareRecruitTestCommand(newRecruitJobListCommand()) if err := cmd.Execute(); err != nil { t.Fatal(err) } @@ -100,13 +145,13 @@ func TestRecruitJobListDefaultsAndValidation(t *testing.T) { t.Fatalf("first page unexpectedly sent cursor: %#v", caller.args) } - badStatus := newRecruitJobListCommand() + badStatus := prepareRecruitTestCommand(newRecruitJobListCommand()) badStatus.SetArgs([]string{"--status", "unknown"}) if err := badStatus.Execute(); err == nil || !strings.Contains(err.Error(), "draft/open/invalid/closed") { t.Fatalf("invalid status error = %v", err) } - badSize := newRecruitJobListCommand() + badSize := prepareRecruitTestCommand(newRecruitJobListCommand()) badSize.SetArgs([]string{"--size", "101"}) if err := badSize.Execute(); err == nil || !strings.Contains(err.Error(), "1 到 100") { t.Fatalf("invalid size error = %v", err) @@ -115,7 +160,7 @@ func TestRecruitJobListDefaultsAndValidation(t *testing.T) { func TestRecruitJobGetDispatchesJobID(t *testing.T) { caller := withRecruitCaller(t) - cmd := newRecruitJobGetCommand() + cmd := prepareRecruitTestCommand(newRecruitJobGetCommand()) cmd.SetArgs([]string{"--job-id", "job-1"}) if err := cmd.Execute(); err != nil { t.Fatal(err) @@ -145,7 +190,7 @@ func writeRecruitJobFixture(t *testing.T) (string, map[string]any) { func TestRecruitJobCreateRequiresConfirmationBeforeRemoteCall(t *testing.T) { caller := withRecruitCaller(t) path, _ := writeRecruitJobFixture(t) - cmd := newRecruitJobCreateCommand() + cmd := prepareRecruitTestCommand(newRecruitJobCreateCommand()) cmd.Flags().Bool("yes", false, "确认创建") cmd.SetIn(strings.NewReader("")) cmd.SetArgs([]string{"--from", path}) @@ -162,7 +207,7 @@ func TestRecruitJobCreateRequiresConfirmationBeforeRemoteCall(t *testing.T) { func TestRecruitJobCreateCallsRemoteOnceWhenConfirmed(t *testing.T) { caller := withRecruitCaller(t) path, _ := writeRecruitJobFixture(t) - cmd := newRecruitJobCreateCommand() + cmd := prepareRecruitTestCommand(newRecruitJobCreateCommand()) cmd.Flags().Bool("yes", false, "确认创建") cmd.SetArgs([]string{"--from", path, "--yes"}) if err := cmd.Execute(); err != nil { @@ -190,7 +235,7 @@ func TestRecruitCreateValidatesJobFile(t *testing.T) { if err := os.WriteFile(badPath, []byte(`{"name":"缺字段"}`), 0o600); err != nil { t.Fatal(err) } - bad := newRecruitJobCreateCommand() + bad := prepareRecruitTestCommand(newRecruitJobCreateCommand()) bad.Flags().Bool("yes", false, "确认创建") bad.SetArgs([]string{"--from", badPath, "--yes"}) if err := bad.Execute(); err == nil || !strings.Contains(err.Error(), "description") { @@ -204,7 +249,7 @@ func TestRecruitValidationBranches(t *testing.T) { t.Fatalf("statuses = %#v, err = %v", statuses, err) } - negativeCursor := newRecruitJobListCommand() + negativeCursor := prepareRecruitTestCommand(newRecruitJobListCommand()) negativeCursor.SetArgs([]string{"--cursor", "-1"}) if err := negativeCursor.Execute(); err == nil || !strings.Contains(err.Error(), "大于或等于 0") { t.Fatalf("negative cursor error = %v", err) @@ -217,6 +262,16 @@ func TestRecruitValidationBranches(t *testing.T) { if _, err := loadRecruitJobFile(nullPath); err == nil || !strings.Contains(err.Error(), "顶层必须是对象") { t.Fatalf("null job error = %v", err) } + if _, err := loadRecruitJobFile(filepath.Join(t.TempDir(), "missing.json")); err == nil || !strings.Contains(err.Error(), "读取职位 JSON 失败") { + t.Fatalf("missing job file error = %v", err) + } + invalidPath := filepath.Join(t.TempDir(), "invalid.json") + if err := os.WriteFile(invalidPath, []byte("{"), 0o600); err != nil { + t.Fatal(err) + } + if _, err := loadRecruitJobFile(invalidPath); err == nil || !strings.Contains(err.Error(), "不是有效的 JSON 对象") { + t.Fatalf("invalid job JSON error = %v", err) + } base := map[string]any{ "name": "Java 工程师", "description": "服务端开发", "jobNature": "FULL_TIME", @@ -244,6 +299,80 @@ func TestRecruitValidationBranches(t *testing.T) { } } +func TestRecruitListResultData(t *testing.T) { + for _, test := range []struct { + name string + data any + want string + }{ + {name: "not object", data: []any{}, want: "必须是 JSON 对象"}, + {name: "missing hasMore", data: map[string]any{}, want: "缺少布尔字段 hasMore"}, + {name: "invalid cursor", data: map[string]any{"hasMore": true, "nextCursor": true}, want: "必须是字符串或数字"}, + {name: "missing cursor", data: map[string]any{"hasMore": true}, want: "缺少 nextCursor"}, + } { + t.Run(test.name, func(t *testing.T) { + if _, _, err := recruitListResultData(test.data); err == nil || !strings.Contains(err.Error(), test.want) { + t.Fatalf("error = %v, want %q", err, test.want) + } + }) + } + + data, meta, err := recruitListResultData(map[string]any{ + "jobs": []any{map[string]any{"jobId": "job-1"}}, "hasMore": true, "nextCursor": float64(12), + }) + if err != nil { + t.Fatal(err) + } + if meta.Count == nil || *meta.Count != 1 || meta.Pagination.EndpointExhausted || + meta.Pagination.NextToken != "12" || meta.Pagination.Pages != 1 || meta.Pagination.Items != 1 { + t.Fatalf("meta = %#v", meta) + } + clean := data.(map[string]any) + if _, exists := clean["hasMore"]; exists || clean["jobs"] == nil { + t.Fatalf("clean data = %#v", clean) + } + + _, terminal, err := recruitListResultData(map[string]any{"jobs": []any{}, "hasMore": false, "nextCursor": "ignored"}) + if err != nil || !terminal.Pagination.EndpointExhausted || terminal.Pagination.NextToken != "" { + t.Fatalf("terminal meta = %#v, err = %v", terminal, err) + } +} + +func TestRecruitResultCallPropagatesMCPError(t *testing.T) { + caller := withRecruitCaller(t) + caller.err = stderrors.New("transport failed") + cmd := prepareRecruitTestCommand(newRecruitJobGetCommand()) + if _, err := recruitResultCall(cmd, recruitGetJobTool, map[string]any{"jobId": "job-1"}); err == nil { + t.Fatal("expected MCP error") + } +} + +func TestRecruitResultCallRejectsInconsistentPagination(t *testing.T) { + caller := withRecruitCaller(t) + caller.text = `{"jobs":[],"hasMore":true}` + cmd := prepareRecruitTestCommand(newRecruitJobListCommand()) + result, err := recruitResultCall(cmd, recruitListJobsTool, map[string]any{"size": 20}) + if err != nil { + t.Fatal(err) + } + if result.Outcome() != output.OutcomeFailure { + t.Fatalf("outcome = %s, want failure", result.Outcome()) + } +} + +func TestRecruitResultCallDryRun(t *testing.T) { + caller := withRecruitCaller(t) + caller.dryRun = true + cmd := prepareRecruitTestCommand(newRecruitJobGetCommand()) + result, err := recruitResultCall(cmd, recruitGetJobTool, map[string]any{"jobId": "job-1"}) + if err != nil { + t.Fatal(err) + } + if result.Outcome() != output.OutcomeSuccess || len(caller.calls) != 0 { + t.Fatalf("outcome = %s, calls = %d", result.Outcome(), len(caller.calls)) + } +} + func TestRecruitCommandTree(t *testing.T) { root := newRecruitCommand() for _, path := range [][]string{{"job", "list"}, {"job", "get"}, {"job", "create"}} {