fix(recruit): align result and cursor contracts
This commit is contained in:
@@ -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"])
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
+111
-30
@@ -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 之间")
|
||||
|
||||
@@ -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"}} {
|
||||
|
||||
Reference in New Issue
Block a user