1051 lines
40 KiB
Go
1051 lines
40 KiB
Go
// Copyright 2026 Alibaba Group
|
|
// Licensed under the Apache License, Version 2.0 (the "License");
|
|
|
|
package helpers
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"encoding/json"
|
|
stderrors "errors"
|
|
"io"
|
|
"os"
|
|
"path/filepath"
|
|
"reflect"
|
|
"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"
|
|
)
|
|
|
|
type recruitCapturedCall struct {
|
|
productID string
|
|
tool string
|
|
args map[string]any
|
|
}
|
|
|
|
type recruitCaptureCaller struct {
|
|
productID string
|
|
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) {
|
|
c.productID = productID
|
|
c.tool = tool
|
|
c.args = args
|
|
c.calls = append(c.calls, recruitCapturedCall{productID: productID, tool: tool, args: args})
|
|
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 c.dryRun }
|
|
func (c *recruitCaptureCaller) Fields() string { return "" }
|
|
func (c *recruitCaptureCaller) JQ() string { return "" }
|
|
|
|
func withRecruitCaller(t *testing.T) *recruitCaptureCaller {
|
|
t.Helper()
|
|
caller := &recruitCaptureCaller{}
|
|
InitDepsForTest(t, caller)
|
|
deps.Out.w = io.Discard
|
|
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 := prepareRecruitTestCommand(newRecruitJobListCommand())
|
|
cmd.SetArgs([]string{
|
|
"--keyword", "Java", "--status", "open,draft", "--creator-user-ids", "u1,u2",
|
|
"--campus=false", "--required-edu", "9", "--cursor", "10", "--size", "30",
|
|
})
|
|
if err := cmd.Execute(); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
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"] != int64(10) || caller.args["size"] != 30 {
|
|
t.Fatalf("pagination args = %#v", caller.args)
|
|
}
|
|
param, ok := caller.args["param"].(map[string]any)
|
|
if !ok {
|
|
t.Fatalf("param = %#v, want object", caller.args["param"])
|
|
}
|
|
if param["keyword"] != "Java" || param["campus"] != false || param["requiredEdu"] != 9 {
|
|
t.Fatalf("param = %#v", param)
|
|
}
|
|
statuses, ok := param["statusList"].([]int)
|
|
if !ok || len(statuses) != 2 || statuses[0] != 1 || statuses[1] != 0 {
|
|
t.Fatalf("statusList = %#v", param["statusList"])
|
|
}
|
|
creators, ok := param["creatorUserIds"].([]string)
|
|
if !ok || len(creators) != 2 || creators[0] != "u1" || creators[1] != "u2" {
|
|
t.Fatalf("creatorUserIds = %#v", param["creatorUserIds"])
|
|
}
|
|
}
|
|
|
|
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 TestRecruitJobListResponseCursorRoundTrip(t *testing.T) {
|
|
caller := withRecruitCaller(t)
|
|
caller.text = `{"success":true,"result":{"list":[],"hasMore":true,"nextCursor":9223372036854775807}}`
|
|
|
|
cmd := prepareRecruitTestCommand(newRecruitJobListCommand())
|
|
result, err := recruitResultCall(cmd, recruitListJobsTool, map[string]any{"size": 20})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
envelope, err := output.EnvelopeFromResult(result)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if envelope.Outcome != output.OutcomeSuccess || envelope.Meta == nil || envelope.Meta.Pagination == nil {
|
|
t.Fatalf("envelope = %#v", envelope)
|
|
}
|
|
meta := envelope.Meta
|
|
if got := meta.Pagination.NextToken; got != "9223372036854775807" {
|
|
t.Fatalf("next token = %q, want lossless max int64", got)
|
|
}
|
|
|
|
caller.text = `{"jobs":[],"hasMore":false}`
|
|
cmd = prepareRecruitTestCommand(newRecruitJobListCommand())
|
|
cmd.SetArgs([]string{"--cursor", meta.Pagination.NextToken})
|
|
if err := cmd.Execute(); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if got := caller.args["cursor"]; got != int64(9223372036854775807) {
|
|
t.Fatalf("replayed cursor = %#v (%T), want max int64", got, got)
|
|
}
|
|
}
|
|
|
|
func TestRecruitJobListDefaultsAndValidation(t *testing.T) {
|
|
caller := withRecruitCaller(t)
|
|
cmd := prepareRecruitTestCommand(newRecruitJobListCommand())
|
|
if err := cmd.Execute(); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if caller.args["size"] != 20 {
|
|
t.Fatalf("size = %#v, want 20", caller.args["size"])
|
|
}
|
|
if _, ok := caller.args["cursor"]; ok {
|
|
t.Fatalf("first page unexpectedly sent cursor: %#v", caller.args)
|
|
}
|
|
|
|
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 := 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)
|
|
}
|
|
|
|
for _, requiredEdu := range []string{"0", "10"} {
|
|
t.Run("required edu "+requiredEdu, func(t *testing.T) {
|
|
caller := withRecruitCaller(t)
|
|
invalid := prepareRecruitTestCommand(newRecruitJobListCommand())
|
|
invalid.SetArgs([]string{"--required-edu", requiredEdu})
|
|
if err := invalid.Execute(); err == nil || !strings.Contains(err.Error(), "1 到 9") {
|
|
t.Fatalf("required edu %s error = %v", requiredEdu, err)
|
|
}
|
|
if len(caller.calls) != 0 {
|
|
t.Fatalf("MCP calls with invalid required edu = %d, want 0", len(caller.calls))
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestRecruitJobGetDispatchesJobID(t *testing.T) {
|
|
caller := withRecruitCaller(t)
|
|
cmd := prepareRecruitTestCommand(newRecruitJobGetCommand())
|
|
cmd.SetArgs([]string{"--job-id", "job-1"})
|
|
if err := cmd.Execute(); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if caller.productID != recruitServerID || caller.tool != recruitGetJobTool || caller.args["jobId"] != "job-1" {
|
|
t.Fatalf("captured call = %s/%s %#v", caller.productID, caller.tool, caller.args)
|
|
}
|
|
}
|
|
|
|
func writeRecruitJobFixture(t *testing.T) (string, map[string]any) {
|
|
t.Helper()
|
|
path := filepath.Join(t.TempDir(), "job.json")
|
|
job := map[string]any{
|
|
"name": "Java 工程师", "description": "服务端开发", "jobNature": "FULL-TIME",
|
|
"requiredEdu": 6, "minSalary": 20000, "maxSalary": 35000,
|
|
"creatorUserId": "creator-user-id", "ownerUserIds": []string{"owner-user-id-1", "owner-user-id-2"},
|
|
"extData": map[string]any{
|
|
"headCount": 1,
|
|
"fullTimeExtData": map[string]any{"salaryMonth": 12, "minJobExperience": 1, "maxJobExperience": 3},
|
|
},
|
|
}
|
|
data, err := json.Marshal(job)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := os.WriteFile(path, data, 0o600); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
return path, job
|
|
}
|
|
|
|
func TestRecruitJobCreateRequiresConfirmationBeforeRemoteCall(t *testing.T) {
|
|
caller := withRecruitCaller(t)
|
|
path, _ := writeRecruitJobFixture(t)
|
|
cmd := prepareRecruitTestCommand(newRecruitJobCreateCommand())
|
|
cmd.Flags().Bool("yes", false, "确认创建")
|
|
cmd.SetIn(strings.NewReader(""))
|
|
cmd.SetArgs([]string{"--from", path})
|
|
err := cmd.Execute()
|
|
var appErr *apperrors.Error
|
|
if !stderrors.As(err, &appErr) || appErr.Reason != "confirmation_required" {
|
|
t.Fatalf("create without confirmation error = %T %v, want confirmation_required", err, err)
|
|
}
|
|
if len(caller.calls) != 0 {
|
|
t.Fatalf("MCP calls before confirmation = %d: %#v, want none", len(caller.calls), caller.calls)
|
|
}
|
|
}
|
|
|
|
func TestRecruitJobCreateCallsRemoteOnceWhenConfirmed(t *testing.T) {
|
|
caller := withRecruitCaller(t)
|
|
path, _ := writeRecruitJobFixture(t)
|
|
cmd := prepareRecruitTestCommand(newRecruitJobCreateCommand())
|
|
cmd.Flags().Bool("yes", false, "确认创建")
|
|
cmd.SetArgs([]string{"--from", path, "--yes"})
|
|
if err := cmd.Execute(); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if len(caller.calls) != 1 {
|
|
t.Fatalf("MCP calls after confirmation = %d: %#v, want exactly one", len(caller.calls), caller.calls)
|
|
}
|
|
call := caller.calls[0]
|
|
if call.productID != recruitServerID || call.tool != recruitCreateJobTool {
|
|
t.Fatalf("dispatch = %s/%s, want %s/%s", call.productID, call.tool, recruitServerID, recruitCreateJobTool)
|
|
}
|
|
wantArgs := map[string]any{"atsAddJobParam": map[string]any{
|
|
"name": "Java 工程师", "description": "服务端开发", "jobNature": "FULL-TIME",
|
|
"requiredEdu": float64(6), "minSalary": float64(20000), "maxSalary": float64(35000),
|
|
"creatorUserId": "creator-user-id", "ownerUserIds": []any{"owner-user-id-1", "owner-user-id-2"},
|
|
"extData": map[string]any{
|
|
"headCount": float64(1),
|
|
"fullTimeExtData": map[string]any{"salaryMonth": float64(12), "minJobExperience": float64(1), "maxJobExperience": float64(3)},
|
|
},
|
|
}}
|
|
if !reflect.DeepEqual(call.args, wantArgs) {
|
|
t.Fatalf("args = %#v, want %#v", call.args, wantArgs)
|
|
}
|
|
}
|
|
|
|
func TestRecruitCreateValidatesJobFile(t *testing.T) {
|
|
withRecruitCaller(t)
|
|
badPath := filepath.Join(t.TempDir(), "bad-job.json")
|
|
if err := os.WriteFile(badPath, []byte(`{"name":"缺字段"}`), 0o600); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
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") {
|
|
t.Fatalf("missing field error = %v", err)
|
|
}
|
|
}
|
|
|
|
func TestRecruitCreateRejectsInvalidCreatorBeforeRemoteCall(t *testing.T) {
|
|
for _, test := range []struct {
|
|
name string
|
|
creator string
|
|
}{
|
|
{name: "missing", creator: ""},
|
|
{name: "null", creator: `,"creatorUserId":null`},
|
|
{name: "blank", creator: `,"creatorUserId":" "`},
|
|
} {
|
|
t.Run(test.name, func(t *testing.T) {
|
|
caller := withRecruitCaller(t)
|
|
path := filepath.Join(t.TempDir(), "job.json")
|
|
payload := `{"name":"Java 工程师","description":"服务端开发","jobNature":"FULL-TIME","requiredEdu":6,"extData":{}` + test.creator + `}`
|
|
if err := os.WriteFile(path, []byte(payload), 0o600); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
cmd := prepareRecruitTestCommand(newRecruitJobCreateCommand())
|
|
cmd.Flags().Bool("yes", false, "确认创建")
|
|
cmd.SetArgs([]string{"--from", path, "--yes"})
|
|
if err := cmd.Execute(); err == nil || !strings.Contains(err.Error(), "creatorUserId") {
|
|
t.Fatalf("creator validation error = %v, want creatorUserId error", err)
|
|
}
|
|
if len(caller.calls) != 0 {
|
|
t.Fatalf("MCP calls with invalid creator = %d, want 0", len(caller.calls))
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestRecruitCreateAllowsOptionalOwners(t *testing.T) {
|
|
for _, test := range []struct {
|
|
name string
|
|
owner string
|
|
}{
|
|
{name: "missing"},
|
|
{name: "null", owner: `,"ownerUserIds":null`},
|
|
{name: "empty array", owner: `,"ownerUserIds":[]`},
|
|
} {
|
|
t.Run(test.name, func(t *testing.T) {
|
|
caller := withRecruitCaller(t)
|
|
path := filepath.Join(t.TempDir(), "job.json")
|
|
payload := `{"name":"Java 工程师","description":"服务端开发","jobNature":"FULL-TIME","requiredEdu":6,"extData":{},"creatorUserId":"creator-user-id"` + test.owner + `}`
|
|
if err := os.WriteFile(path, []byte(payload), 0o600); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
cmd := prepareRecruitTestCommand(newRecruitJobCreateCommand())
|
|
cmd.Flags().Bool("yes", false, "确认创建")
|
|
cmd.SetArgs([]string{"--from", path, "--yes"})
|
|
if err := cmd.Execute(); err != nil {
|
|
t.Fatalf("optional owner create error = %v, want nil", err)
|
|
}
|
|
if len(caller.calls) != 1 {
|
|
t.Fatalf("MCP calls with optional owner = %d, want 1", len(caller.calls))
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestRecruitValidationBranches(t *testing.T) {
|
|
statuses, err := transformRecruitStatuses(" open, ,open,closed ")
|
|
if err != nil || !reflect.DeepEqual(statuses, []int{1, 3}) {
|
|
t.Fatalf("statuses = %#v, err = %v", statuses, err)
|
|
}
|
|
|
|
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)
|
|
}
|
|
|
|
nullPath := filepath.Join(t.TempDir(), "null.json")
|
|
if err := os.WriteFile(nullPath, []byte("null"), 0o600); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
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",
|
|
"requiredEdu": float64(6), "minSalary": float64(20000), "maxSalary": float64(35000),
|
|
"creatorUserId": "creator-user-id", "ownerUserIds": []any{"owner-user-id"},
|
|
"extData": map[string]any{
|
|
"headCount": float64(1),
|
|
"fullTimeExtData": map[string]any{"salaryMonth": float64(12), "minJobExperience": float64(1), "maxJobExperience": float64(3)},
|
|
},
|
|
}
|
|
for _, test := range []struct {
|
|
name string
|
|
mutate func(map[string]any)
|
|
want string
|
|
}{
|
|
{name: "string type", mutate: func(job map[string]any) { job["name"] = 1 }, want: "name 必须是字符串"},
|
|
{name: "number type", mutate: func(job map[string]any) { job["requiredEdu"] = "本科" }, want: "requiredEdu 必须是数字"},
|
|
{name: "missing ext data", mutate: func(job map[string]any) { delete(job, "extData") }, want: "缺少必填字段 extData"},
|
|
{name: "missing creator", mutate: func(job map[string]any) { delete(job, "creatorUserId") }, want: "缺少必填字段 creatorUserId"},
|
|
{name: "null creator", mutate: func(job map[string]any) { job["creatorUserId"] = nil }, want: "缺少必填字段 creatorUserId"},
|
|
{name: "ext data type", mutate: func(job map[string]any) { job["extData"] = "invalid" }, want: "extData 必须是对象"},
|
|
{name: "job nature enum", mutate: func(job map[string]any) { job["jobNature"] = "FULL_TIME" }, want: "仅支持 FULL-TIME"},
|
|
{name: "education range", mutate: func(job map[string]any) { job["requiredEdu"] = float64(10) }, want: "1 到 9 的整数"},
|
|
{name: "salary range", mutate: func(job map[string]any) { job["minSalary"] = float64(40000) }, want: "minSalary 不能大于 maxSalary"},
|
|
{name: "min salary type", mutate: func(job map[string]any) { job["minSalary"] = "20000" }, want: "minSalary 必须是数字"},
|
|
{name: "salary type", mutate: func(job map[string]any) { job["maxSalary"] = "35000" }, want: "maxSalary 必须是数字"},
|
|
{name: "creator type", mutate: func(job map[string]any) { job["creatorUserId"] = " " }, want: "缺少必填字段 creatorUserId"},
|
|
{name: "creator non-string", mutate: func(job map[string]any) { job["creatorUserId"] = float64(123) }, want: "creatorUserId 必须是非空字符串"},
|
|
{name: "owners type", mutate: func(job map[string]any) { job["ownerUserIds"] = "owner-user-id" }, want: "ownerUserIds 必须是字符串数组"},
|
|
{name: "owner item", mutate: func(job map[string]any) { job["ownerUserIds"] = []any{""} }, want: "ownerUserIds 只能包含非空字符串"},
|
|
{name: "campus type", mutate: func(job map[string]any) { job["campus"] = "false" }, want: "campus 必须是布尔值"},
|
|
{name: "region type", mutate: func(job map[string]any) { job["city"] = 330100 }, want: "city 必须是字符串"},
|
|
{name: "address type", mutate: func(job map[string]any) { job["address"] = "杭州" }, want: "address 必须是对象"},
|
|
{name: "address required field", mutate: func(job map[string]any) {
|
|
job["address"] = map[string]any{"name": "亲橙Park", "detail": "杭州市余杭区", "longitude": "120.0"}
|
|
}, want: "address.latitude 必须是非空字符串"},
|
|
{name: "head count range", mutate: func(job map[string]any) {
|
|
job["extData"] = map[string]any{"headCount": float64(0)}
|
|
}, want: "headCount 必须是 1 到 999 的整数"},
|
|
{name: "source type", mutate: func(job map[string]any) {
|
|
job["extData"] = map[string]any{"source": true}
|
|
}, want: "extData.source 必须是字符串"},
|
|
{name: "full time ext data type", mutate: func(job map[string]any) {
|
|
job["extData"] = map[string]any{"fullTimeExtData": "invalid"}
|
|
}, want: "fullTimeExtData 必须是对象"},
|
|
{name: "salary month range", mutate: func(job map[string]any) {
|
|
job["extData"] = map[string]any{"fullTimeExtData": map[string]any{"salaryMonth": float64(25)}}
|
|
}, want: "salaryMonth 必须是 12 到 24 的整数"},
|
|
{name: "salary month type", mutate: func(job map[string]any) {
|
|
job["extData"] = map[string]any{"fullTimeExtData": map[string]any{"salaryMonth": "12"}}
|
|
}, want: "salaryMonth 必须是数字"},
|
|
{name: "min experience type", mutate: func(job map[string]any) {
|
|
job["extData"] = map[string]any{"fullTimeExtData": map[string]any{"minJobExperience": "1"}}
|
|
}, want: "minJobExperience 必须是数字"},
|
|
{name: "max experience type", mutate: func(job map[string]any) {
|
|
job["extData"] = map[string]any{"fullTimeExtData": map[string]any{"maxJobExperience": "3"}}
|
|
}, want: "maxJobExperience 必须是数字"},
|
|
{name: "negative min experience", mutate: func(job map[string]any) {
|
|
job["extData"] = map[string]any{"fullTimeExtData": map[string]any{"minJobExperience": float64(-1)}}
|
|
}, want: "minJobExperience 不能小于 0"},
|
|
{name: "negative max experience", mutate: func(job map[string]any) {
|
|
job["extData"] = map[string]any{"fullTimeExtData": map[string]any{"maxJobExperience": float64(-1)}}
|
|
}, want: "maxJobExperience 不能小于 0"},
|
|
{name: "experience range", mutate: func(job map[string]any) {
|
|
job["extData"] = map[string]any{"fullTimeExtData": map[string]any{"minJobExperience": float64(5), "maxJobExperience": float64(3)}}
|
|
}, want: "minJobExperience 不能大于 maxJobExperience"},
|
|
{name: "tags type", mutate: func(job map[string]any) {
|
|
job["extData"] = map[string]any{"tags": "五险一金"}
|
|
}, want: "extData.tags 必须是数组"},
|
|
{name: "tag item type", mutate: func(job map[string]any) {
|
|
job["extData"] = map[string]any{"tags": []any{"五险一金"}}
|
|
}, want: "extData.tags 只能包含对象"},
|
|
{name: "tag name", mutate: func(job map[string]any) {
|
|
job["extData"] = map[string]any{"tags": []any{map[string]any{"name": ""}}}
|
|
}, want: "tags[].name 必须是非空字符串"},
|
|
} {
|
|
t.Run(test.name, func(t *testing.T) {
|
|
job := make(map[string]any, len(base))
|
|
for key, value := range base {
|
|
job[key] = value
|
|
}
|
|
test.mutate(job)
|
|
if err := validateRecruitJob(job); err == nil || !strings.Contains(err.Error(), test.want) {
|
|
t.Fatalf("validation error = %v, want %q", err, test.want)
|
|
}
|
|
})
|
|
}
|
|
|
|
withoutSalary := make(map[string]any, len(base))
|
|
for key, value := range base {
|
|
withoutSalary[key] = value
|
|
}
|
|
delete(withoutSalary, "minSalary")
|
|
delete(withoutSalary, "maxSalary")
|
|
if err := validateRecruitJob(withoutSalary); err != nil {
|
|
t.Fatalf("optional salary validation error = %v, want nil", err)
|
|
}
|
|
|
|
completeAddress := make(map[string]any, len(base)+1)
|
|
for key, value := range base {
|
|
completeAddress[key] = value
|
|
}
|
|
completeAddress["address"] = map[string]any{
|
|
"name": "亲橙Park", "detail": "浙江省杭州市余杭区", "longitude": "120.008822", "latitude": "30.270739",
|
|
}
|
|
completeAddress["campus"] = false
|
|
completeAddress["province"] = "330000"
|
|
completeAddress["city"] = "330100"
|
|
completeAddress["district"] = "330110"
|
|
if err := validateRecruitJob(completeAddress); err != nil {
|
|
t.Fatalf("complete job validation error = %v, want nil", err)
|
|
}
|
|
}
|
|
|
|
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"},
|
|
{name: "exponent cursor", data: map[string]any{"hasMore": true, "nextCursor": json.Number("1e3")}, want: "非负十进制 int64"},
|
|
{name: "decimal cursor", data: map[string]any{"hasMore": true, "nextCursor": json.Number("1.5")}, want: "非负十进制 int64"},
|
|
{name: "overflow cursor", data: map[string]any{"hasMore": true, "nextCursor": json.Number("9223372036854775808")}, want: "非负十进制 int64"},
|
|
} {
|
|
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)
|
|
}
|
|
|
|
_, normalizedCursor, err := recruitListResultData(map[string]any{
|
|
"jobs": []any{}, "hasMore": true, "nextCursor": "00012",
|
|
})
|
|
if err != nil || normalizedCursor.Pagination.NextToken != "12" {
|
|
t.Fatalf("normalized cursor meta = %#v, err = %v", normalizedCursor, err)
|
|
}
|
|
|
|
_, 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)
|
|
}
|
|
|
|
normalized, normalizedMeta, err := recruitListResultData(map[string]any{
|
|
"list": []any{map[string]any{"jobId": "job-2", "name": "测试工程师"}},
|
|
"hasMore": false,
|
|
"nextCursor": nil,
|
|
"totalCount": float64(1),
|
|
"success": true,
|
|
"result": map[string]any{"source": "business-payload"},
|
|
})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
normalizedData := normalized.(map[string]any)
|
|
jobs, ok := normalizedData["jobs"].([]any)
|
|
if !ok || len(jobs) != 1 || normalizedData["totalCount"] != float64(1) {
|
|
t.Fatalf("normalized data = %#v", normalizedData)
|
|
}
|
|
if _, exists := normalizedData["list"]; exists {
|
|
t.Fatalf("normalized data retained Connector list field: %#v", normalizedData)
|
|
}
|
|
if normalizedData["success"] != true || !reflect.DeepEqual(
|
|
normalizedData["result"], map[string]any{"source": "business-payload"},
|
|
) {
|
|
t.Fatalf("business success/result fields were unwrapped: %#v", normalizedData)
|
|
}
|
|
if normalizedMeta.Count == nil || *normalizedMeta.Count != 1 || !normalizedMeta.Pagination.EndpointExhausted {
|
|
t.Fatalf("normalized meta = %#v", normalizedMeta)
|
|
}
|
|
}
|
|
|
|
func TestRecruitListResultDataAcceptsMinimalTerminalPage(t *testing.T) {
|
|
data, meta, err := recruitListResultData(map[string]any{"hasMore": false})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
clean, ok := data.(map[string]any)
|
|
if !ok || len(clean) != 0 {
|
|
t.Fatalf("data = %#v, want empty object", data)
|
|
}
|
|
if meta == nil || meta.Pagination == nil || !meta.Pagination.EndpointExhausted ||
|
|
meta.Pagination.NextToken != "" || meta.Pagination.Pages != 1 || meta.Pagination.Items != 0 {
|
|
t.Fatalf("meta = %#v, want exhausted terminal page", meta)
|
|
}
|
|
if meta.Count != nil {
|
|
t.Fatalf("count = %#v, want nil when jobs is absent", meta.Count)
|
|
}
|
|
|
|
caller := withRecruitCaller(t)
|
|
caller.text = `{"success":true,"result":{"hasMore":false}}`
|
|
cmd := prepareRecruitTestCommand(newRecruitJobListCommand())
|
|
result, err := recruitResultCall(cmd, recruitListJobsTool, map[string]any{"size": 20})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
envelope, err := output.EnvelopeFromResult(result)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if envelope.Outcome != output.OutcomeSuccess || envelope.Meta == nil ||
|
|
envelope.Meta.Pagination == nil || !envelope.Meta.Pagination.EndpointExhausted {
|
|
t.Fatalf("envelope = %#v, want successful exhausted terminal page", envelope)
|
|
}
|
|
}
|
|
|
|
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 TestRecruitMCPDataDecoderIsLosslessAndStrict(t *testing.T) {
|
|
for _, test := range []struct {
|
|
name string
|
|
text string
|
|
wantErr string
|
|
}{
|
|
{name: "empty", text: " "},
|
|
{name: "invalid JSON", text: "{", wantErr: "解析 list_jobs 返回失败"},
|
|
{name: "multiple values", text: `{} {}`, wantErr: "存在多个 JSON 值"},
|
|
{name: "invalid trailing value", text: `{} {`, wantErr: "解析 list_jobs 返回失败"},
|
|
} {
|
|
t.Run(test.name, func(t *testing.T) {
|
|
caller := withRecruitCaller(t)
|
|
caller.text = test.text
|
|
data, err := callRecruitMCPToolData(context.Background(), recruitListJobsTool, map[string]any{})
|
|
if test.wantErr != "" {
|
|
if err == nil || !strings.Contains(err.Error(), test.wantErr) {
|
|
t.Fatalf("error = %v, want %q", err, test.wantErr)
|
|
}
|
|
return
|
|
}
|
|
if err != nil || len(data.(map[string]any)) != 0 {
|
|
t.Fatalf("empty response data = %#v, err = %v", data, err)
|
|
}
|
|
})
|
|
}
|
|
|
|
caller := withRecruitCaller(t)
|
|
caller.text = `{"nextCursor":9223372036854775807}`
|
|
data, err := callRecruitMCPToolData(context.Background(), recruitListJobsTool, map[string]any{})
|
|
if err != nil || data.(map[string]any)["nextCursor"] != json.Number("9223372036854775807") {
|
|
t.Fatalf("lossless recruit number data = %#v, err = %v", data, err)
|
|
}
|
|
}
|
|
|
|
func TestRecruitResultCallUnwrapsConnectorEnvelopeForGetAndCreate(t *testing.T) {
|
|
for _, test := range []struct {
|
|
name string
|
|
tool string
|
|
text string
|
|
want map[string]any
|
|
}{
|
|
{
|
|
name: "get detail",
|
|
tool: recruitGetJobTool,
|
|
text: `{"success":true,"result":{"jobId":"job-detail","name":"Java 工程师","status":1}}`,
|
|
want: map[string]any{"jobId": "job-detail", "name": "Java 工程师", "status": json.Number("1")},
|
|
},
|
|
{
|
|
name: "create job",
|
|
tool: recruitCreateJobTool,
|
|
text: `{"success":true,"result":{"jobId":"job-created"}}`,
|
|
want: map[string]any{"jobId": "job-created"},
|
|
},
|
|
} {
|
|
t.Run(test.name, func(t *testing.T) {
|
|
caller := withRecruitCaller(t)
|
|
caller.text = test.text
|
|
data, err := callRecruitMCPToolData(context.Background(), test.tool, map[string]any{})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
clean, err := recruitBusinessResultData(data, test.tool)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if !reflect.DeepEqual(clean, test.want) {
|
|
t.Fatalf("normalized data = %#v, want %#v", clean, test.want)
|
|
}
|
|
if _, hasSuccess := clean["success"]; hasSuccess {
|
|
t.Fatalf("normalized data retained Connector envelope: %#v", clean)
|
|
}
|
|
if _, hasResult := clean["result"]; hasResult {
|
|
t.Fatalf("normalized data retained nested result: %#v", clean)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestRecruitCommandsPublishUnwrappedConnectorResult(t *testing.T) {
|
|
for _, test := range []struct {
|
|
name string
|
|
leaf func() *cobra.Command
|
|
args func(*testing.T) []string
|
|
text string
|
|
wantID string
|
|
}{
|
|
{
|
|
name: "get detail",
|
|
leaf: newRecruitJobGetCommand,
|
|
args: func(*testing.T) []string { return []string{"--job-id", "job-detail"} },
|
|
text: `{"success":true,"result":{"jobId":"job-detail","name":"Java 工程师","status":1}}`,
|
|
wantID: "job-detail",
|
|
},
|
|
{
|
|
name: "create job",
|
|
leaf: newRecruitJobCreateCommand,
|
|
args: func(t *testing.T) []string {
|
|
path, _ := writeRecruitJobFixture(t)
|
|
return []string{"--from", path, "--yes"}
|
|
},
|
|
text: `{"success":true,"result":{"jobId":"job-created"}}`,
|
|
wantID: "job-created",
|
|
},
|
|
} {
|
|
t.Run(test.name, func(t *testing.T) {
|
|
caller := withRecruitCaller(t)
|
|
caller.text = test.text
|
|
root := &cobra.Command{Use: "dws", SilenceErrors: true, SilenceUsage: true}
|
|
ctx, _ := output.WithResultStore(context.Background())
|
|
root.SetContext(ctx)
|
|
root.PersistentFlags().String("format", "json", "")
|
|
root.PersistentFlags().String("fields", "", "")
|
|
root.PersistentFlags().String("jq", "", "")
|
|
root.PersistentFlags().Bool("dry-run", false, "")
|
|
root.PersistentFlags().Bool("yes", false, "")
|
|
root.PersistentPostRunE = func(cmd *cobra.Command, _ []string) error {
|
|
_, _, err := output.EmitStoredResult(cmd)
|
|
return err
|
|
}
|
|
leaf := test.leaf()
|
|
root.AddCommand(leaf)
|
|
var stdout bytes.Buffer
|
|
root.SetOut(&stdout)
|
|
root.SetArgs(append([]string{leaf.Name()}, test.args(t)...))
|
|
if err := root.Execute(); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
var envelope struct {
|
|
Data map[string]any `json:"data"`
|
|
}
|
|
if err := json.Unmarshal(stdout.Bytes(), &envelope); err != nil {
|
|
t.Fatalf("decode output %q: %v", stdout.String(), err)
|
|
}
|
|
if envelope.Data["jobId"] != test.wantID {
|
|
t.Fatalf("data = %#v, want top-level jobId %q", envelope.Data, test.wantID)
|
|
}
|
|
if _, exists := envelope.Data["success"]; exists {
|
|
t.Fatalf("public data retained Connector success: %#v", envelope.Data)
|
|
}
|
|
if _, exists := envelope.Data["result"]; exists {
|
|
t.Fatalf("public data retained Connector result: %#v", envelope.Data)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestRecruitBusinessResultDataRejectsInvalidConnectorEnvelope(t *testing.T) {
|
|
for _, test := range []struct {
|
|
name string
|
|
data any
|
|
want string
|
|
}{
|
|
{name: "not object", data: []any{}, want: "必须是 JSON 对象"},
|
|
{name: "missing result", data: map[string]any{"success": true}, want: "同时包含 success 和 result"},
|
|
{name: "missing success", data: map[string]any{"result": map[string]any{}}, want: "同时包含 success 和 result"},
|
|
{name: "invalid success", data: map[string]any{"success": "true", "result": map[string]any{}}, want: "success 必须是布尔值"},
|
|
{name: "business failure", data: map[string]any{"success": false, "message": "职位不存在", "result": map[string]any{}}, want: "职位不存在"},
|
|
{name: "business failure without message", data: map[string]any{"success": false, "result": map[string]any{}}, want: "Connector 返回 success=false"},
|
|
{name: "invalid result", data: map[string]any{"success": true, "result": nil}, want: "result 必须是 JSON 对象"},
|
|
} {
|
|
t.Run(test.name, func(t *testing.T) {
|
|
_, err := recruitBusinessResultData(test.data, recruitGetJobTool)
|
|
if err == nil || !strings.Contains(err.Error(), test.want) {
|
|
t.Fatalf("error = %v, want %q", err, test.want)
|
|
}
|
|
var businessFailure *recruitBusinessFailure
|
|
isBusinessFailure := stderrors.As(err, &businessFailure)
|
|
wantBusinessFailure := strings.HasPrefix(test.name, "business failure")
|
|
if isBusinessFailure != wantBusinessFailure {
|
|
t.Fatalf("business failure classification = %t, want %t", isBusinessFailure, wantBusinessFailure)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestRecruitResultCallClassifiesBusinessAndMalformedFailures(t *testing.T) {
|
|
for _, test := range []struct {
|
|
name string
|
|
text string
|
|
wantSubtype string
|
|
wantMessage string
|
|
wantHint bool
|
|
}{
|
|
{
|
|
name: "business failure",
|
|
text: `{"success":false,"message":"职位不存在","result":{}}`,
|
|
wantMessage: "职位不存在",
|
|
},
|
|
{
|
|
name: "malformed envelope",
|
|
text: `{"success":true}`,
|
|
wantSubtype: "invalid_response",
|
|
wantMessage: "同时包含 success 和 result",
|
|
wantHint: true,
|
|
},
|
|
} {
|
|
t.Run(test.name, func(t *testing.T) {
|
|
var payload any
|
|
if err := json.Unmarshal([]byte(test.text), &payload); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
_, responseErr := recruitBusinessResultData(payload, recruitGetJobTool)
|
|
if responseErr == nil {
|
|
t.Fatal("expected response error")
|
|
}
|
|
result := recruitResponseFailure(responseErr)
|
|
envelope, err := output.EnvelopeFromResult(result)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if envelope.Outcome != output.OutcomeFailure || envelope.Error == nil {
|
|
t.Fatalf("envelope = %#v", envelope)
|
|
}
|
|
if envelope.Error.Type != "api" || envelope.Error.Subtype != test.wantSubtype ||
|
|
!strings.Contains(envelope.Error.Message, test.wantMessage) {
|
|
t.Fatalf("error = %#v", envelope.Error)
|
|
}
|
|
if (envelope.Error.Hint != "") != test.wantHint {
|
|
t.Fatalf("hint = %q, want present %t", envelope.Error.Hint, test.wantHint)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestRecruitResultCallRejectsInconsistentPagination(t *testing.T) {
|
|
for _, response := range []string{
|
|
`{"jobs":[],"hasMore":true}`,
|
|
`{"jobs":[],"hasMore":true,"nextCursor":1e3}`,
|
|
`{"jobs":[],"hasMore":true,"nextCursor":1.5}`,
|
|
`{"jobs":[],"hasMore":true,"nextCursor":9223372036854775808}`,
|
|
} {
|
|
t.Run(response, func(t *testing.T) {
|
|
caller := withRecruitCaller(t)
|
|
caller.text = response
|
|
cmd := prepareRecruitTestCommand(newRecruitJobListCommand())
|
|
result, err := recruitResultCall(cmd, recruitListJobsTool, map[string]any{"size": 20})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
envelope, err := output.EnvelopeFromResult(result)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if envelope.Outcome != output.OutcomeFailure || envelope.Error == nil ||
|
|
envelope.Error.Subtype != "invalid_response" {
|
|
t.Fatalf("envelope = %#v, want invalid_response failure", envelope)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestRecruitResultCallRejectsInvalidJobResults(t *testing.T) {
|
|
for _, test := range []struct {
|
|
name string
|
|
tool string
|
|
args map[string]any
|
|
text string
|
|
wantMessage string
|
|
}{
|
|
{
|
|
name: "create empty response",
|
|
tool: recruitCreateJobTool,
|
|
text: " ",
|
|
wantMessage: "缺少非空字符串字段 jobId",
|
|
},
|
|
{
|
|
name: "create empty object",
|
|
tool: recruitCreateJobTool,
|
|
text: `{}`,
|
|
wantMessage: "缺少非空字符串字段 jobId",
|
|
},
|
|
{
|
|
name: "create envelope missing job id",
|
|
tool: recruitCreateJobTool,
|
|
text: `{"success":true,"result":{}}`,
|
|
wantMessage: "缺少非空字符串字段 jobId",
|
|
},
|
|
{
|
|
name: "create empty job id",
|
|
tool: recruitCreateJobTool,
|
|
text: `{"success":true,"result":{"jobId":" "}}`,
|
|
wantMessage: "缺少非空字符串字段 jobId",
|
|
},
|
|
{
|
|
name: "create non-string job id",
|
|
tool: recruitCreateJobTool,
|
|
text: `{"success":true,"result":{"jobId":123}}`,
|
|
wantMessage: "缺少非空字符串字段 jobId",
|
|
},
|
|
{
|
|
name: "get missing job id",
|
|
tool: recruitGetJobTool,
|
|
args: map[string]any{"jobId": "job-requested"},
|
|
text: `{"success":true,"result":{"name":"Java 工程师"}}`,
|
|
wantMessage: "缺少非空字符串字段 jobId",
|
|
},
|
|
{
|
|
name: "get request missing job id",
|
|
tool: recruitGetJobTool,
|
|
args: map[string]any{},
|
|
text: `{"success":true,"result":{"jobId":"job-returned"}}`,
|
|
wantMessage: "请求缺少非空字符串字段 jobId",
|
|
},
|
|
{
|
|
name: "get mismatched job id",
|
|
tool: recruitGetJobTool,
|
|
args: map[string]any{"jobId": "job-requested"},
|
|
text: `{"success":true,"result":{"jobId":"job-other"}}`,
|
|
wantMessage: "与请求的 jobId",
|
|
},
|
|
} {
|
|
t.Run(test.name, func(t *testing.T) {
|
|
caller := withRecruitCaller(t)
|
|
caller.text = test.text
|
|
result, err := recruitResultCall(prepareRecruitTestCommand(&cobra.Command{}), test.tool, test.args)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
envelope, err := output.EnvelopeFromResult(result)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if envelope.Outcome != output.OutcomeFailure || envelope.Error == nil {
|
|
t.Fatalf("envelope = %#v, want failure", envelope)
|
|
}
|
|
if envelope.Error.Type != "api" || envelope.Error.Subtype != "invalid_response" ||
|
|
!strings.Contains(envelope.Error.Message, test.wantMessage) {
|
|
t.Fatalf("error = %#v, want invalid_response containing %q", envelope.Error, test.wantMessage)
|
|
}
|
|
if len(caller.calls) != 1 {
|
|
t.Fatalf("MCP calls = %d, want exactly one", len(caller.calls))
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestRecruitResultCallRejectsToolWithoutResultValidator(t *testing.T) {
|
|
caller := withRecruitCaller(t)
|
|
caller.text = `{"jobId":"job-created"}`
|
|
result, err := recruitResultCall(prepareRecruitTestCommand(&cobra.Command{}), "future_write_tool", nil)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
envelope, err := output.EnvelopeFromResult(result)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if envelope.Outcome != output.OutcomeFailure || envelope.Error == nil ||
|
|
envelope.Error.Subtype != "invalid_response" || !strings.Contains(envelope.Error.Message, "不支持校验") {
|
|
t.Fatalf("envelope = %#v, want unsupported validator failure", envelope)
|
|
}
|
|
}
|
|
|
|
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"}} {
|
|
cmd, _, err := root.Find(path)
|
|
if err != nil || cmd == root || cmd.Name() != path[len(path)-1] {
|
|
t.Fatalf("Find(%v) = %v, %v", path, cmd, err)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestRecruitResultAndPaginationContractsReachContractFinal(t *testing.T) {
|
|
root := newRecruitCommand()
|
|
tests := []struct {
|
|
path []string
|
|
canonical string
|
|
resultField string
|
|
pagination bool
|
|
}{
|
|
{path: []string{"job", "list"}, canonical: "recruit.list_jobs", resultField: "jobs", pagination: true},
|
|
{path: []string{"job", "get"}, canonical: "recruit.get_job_detail", resultField: "jobId"},
|
|
{path: []string{"job", "create"}, canonical: "recruit.create_job", resultField: "jobId"},
|
|
}
|
|
for _, test := range tests {
|
|
t.Run(test.canonical, func(t *testing.T) {
|
|
leaf, _, err := root.Find(test.path)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
final, ok := contractfinal.RuntimeContractFinal(leaf)
|
|
if !ok || final.Identity == nil || final.Identity.CanonicalPath != test.canonical {
|
|
t.Fatalf("final identity = %#v, found = %v", final.Identity, ok)
|
|
}
|
|
if final.Result == nil {
|
|
t.Fatal("final Result is nil")
|
|
}
|
|
wantOutcomes := []contract.ResultOutcome{contract.ResultOutcomeSuccess, contract.ResultOutcomeFailure}
|
|
if !reflect.DeepEqual(final.Result.Outcomes, wantOutcomes) {
|
|
t.Fatalf("outcomes = %#v, want %#v", final.Result.Outcomes, wantOutcomes)
|
|
}
|
|
var schema struct {
|
|
Type string `json:"type"`
|
|
Properties map[string]json.RawMessage `json:"properties"`
|
|
}
|
|
if err := json.Unmarshal(final.Result.DataSchema, &schema); err != nil {
|
|
t.Fatalf("unmarshal result data_schema: %v", err)
|
|
}
|
|
if schema.Type != "object" || len(schema.Properties[test.resultField]) == 0 {
|
|
t.Fatalf("result data_schema = %s, want object property %q", final.Result.DataSchema, test.resultField)
|
|
}
|
|
if test.pagination {
|
|
if final.Pagination == nil || final.Pagination.Kind != contract.PaginationKindCursor ||
|
|
final.Pagination.CursorParameter != "cursor" || final.Pagination.MetaPath != contract.PaginationMetaPath {
|
|
t.Fatalf("pagination = %#v, want cursor contract", final.Pagination)
|
|
}
|
|
} else if final.Pagination != nil {
|
|
t.Fatalf("pagination = %#v, want nil", final.Pagination)
|
|
}
|
|
})
|
|
}
|
|
}
|