Files
larksuite__cli/shortcuts/contact/contact_search_user_test.go

1722 lines
60 KiB
Go

// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package contact
import (
"bytes"
"context"
"encoding/json"
"fmt"
"net/http"
"strings"
"sync/atomic"
"testing"
"time"
"unicode/utf8"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/httpmock"
"github.com/larksuite/cli/shortcuts/common"
"github.com/spf13/cobra"
)
func newSearchUserTestCommand() *cobra.Command {
cmd := &cobra.Command{Use: "test"}
cmd.Flags().String("query", "", "")
cmd.Flags().String("user-ids", "", "")
cmd.Flags().Bool("left-organization", false, "")
cmd.Flags().Bool("has-chatted", false, "")
cmd.Flags().Bool("exclude-external-users", false, "")
cmd.Flags().Bool("has-enterprise-email", false, "")
cmd.Flags().String("lang", "", "")
cmd.Flags().Int("page-size", 20, "")
return cmd
}
func searchUserDefaultConfig() *core.CliConfig {
return &core.CliConfig{
AppID: "test", AppSecret: "test", Brand: core.BrandFeishu,
UserOpenId: "ou_self",
}
}
func TestPickName_ExplicitLang_Hit(t *testing.T) {
i18n := map[string]string{"zh_cn": "张三", "en_us": "Zhangsan"}
got := pickName(i18n, "en-US", core.BrandFeishu, "ou_x")
if got != "Zhangsan" {
t.Errorf("got %q, want Zhangsan", got)
}
}
func TestPickName_ExplicitLang_MissFallsToBrand(t *testing.T) {
i18n := map[string]string{"zh_cn": "张三"}
got := pickName(i18n, "ja-JP", core.BrandFeishu, "ou_x")
if got != "张三" {
t.Errorf("got %q, want 张三 (brand fallback)", got)
}
}
func TestPickName_BrandFeishu_PicksZh(t *testing.T) {
i18n := map[string]string{"zh_cn": "张三", "en_us": "Zhangsan"}
got := pickName(i18n, "", core.BrandFeishu, "ou_x")
if got != "张三" {
t.Errorf("got %q, want 张三", got)
}
}
func TestPickName_BrandLark_PicksEn(t *testing.T) {
i18n := map[string]string{"zh_cn": "张三", "en_us": "Zhangsan"}
got := pickName(i18n, "", core.BrandLark, "ou_x")
if got != "Zhangsan" {
t.Errorf("got %q, want Zhangsan", got)
}
}
func TestPickName_FixedLocaleList_HitJaJp(t *testing.T) {
i18n := map[string]string{"ja_jp": "Yamada"}
got := pickName(i18n, "", core.BrandFeishu, "ou_x")
if got != "Yamada" {
t.Errorf("got %q, want Yamada (fixed locale list fallback)", got)
}
}
func TestPickName_DictOrderFallback(t *testing.T) {
i18n := map[string]string{"xx_yy": "Foo", "aa_bb": "Bar"}
got := pickName(i18n, "", core.BrandFeishu, "ou_x")
if got != "Bar" {
t.Errorf("got %q, want Bar (alphabetical tie-break, first non-empty is 'aa_bb')", got)
}
}
func TestPickName_AllEmpty_FallsToOpenID(t *testing.T) {
got := pickName(map[string]string{}, "", core.BrandFeishu, "ou_x")
if got != "ou_x" {
t.Errorf("got %q, want ou_x", got)
}
}
func TestPickName_Determinism(t *testing.T) {
i18n := map[string]string{"xx_yy": "Foo", "aa_bb": "Bar", "mm_nn": "Baz"}
first := pickName(i18n, "", core.BrandFeishu, "ou_x")
for i := 0; i < 50; i++ {
got := pickName(i18n, "", core.BrandFeishu, "ou_x")
if got != first {
t.Fatalf("non-deterministic: iter %d got %q, expected %q (map iteration leaked)", i, got, first)
}
}
}
func TestParseDisplayInfo_FullShape(t *testing.T) {
raw := "<h>李海峰</h>\nLark Office Engineering-Intelligence-Search\n\n[Contacted 2 days ago]"
segments, dept, recency := parseDisplayInfo(raw)
if len(segments) != 1 || segments[0] != "李海峰" {
t.Errorf("segments: got %v, want [李海峰]", segments)
}
if dept != "Lark Office Engineering-Intelligence-Search" {
t.Errorf("department: got %q", dept)
}
if recency != "Contacted 2 days ago" {
t.Errorf("chat_recency_hint: got %q", recency)
}
}
func TestParseDisplayInfo_NoRecency(t *testing.T) {
raw := "<h>张三</h>\nMarketing\n"
segments, dept, recency := parseDisplayInfo(raw)
if len(segments) != 1 || segments[0] != "张三" {
t.Errorf("segments: got %v", segments)
}
if dept != "Marketing" {
t.Errorf("department: got %q, want Marketing", dept)
}
if recency != "" {
t.Errorf("chat_recency_hint: got %q, want empty", recency)
}
}
func TestParseDisplayInfo_EmptyDept(t *testing.T) {
raw := "<h>李海峰</h>\n\n"
segments, dept, recency := parseDisplayInfo(raw)
if len(segments) != 1 || segments[0] != "李海峰" {
t.Errorf("segments: got %v", segments)
}
if dept != "" {
t.Errorf("department: got %q, want empty", dept)
}
if recency != "" {
t.Errorf("chat_recency_hint: got %q, want empty", recency)
}
}
func TestParseDisplayInfo_MultipleHighlights(t *testing.T) {
raw := "<h>ali</h>ce <h>wang</h>\nEng\n"
segments, _, _ := parseDisplayInfo(raw)
if len(segments) != 2 || segments[0] != "ali" || segments[1] != "wang" {
t.Errorf("segments: got %v, want [ali wang]", segments)
}
}
func TestParseDisplayInfo_Empty(t *testing.T) {
segments, dept, recency := parseDisplayInfo("")
if segments == nil {
t.Errorf("segments: got nil, want empty (non-nil) slice")
}
if len(segments) != 0 {
t.Errorf("segments: got %v, want empty", segments)
}
if dept != "" || recency != "" {
t.Errorf("dept/recency: got %q / %q, want empty", dept, recency)
}
}
func TestRowFromItem_FullMapping(t *testing.T) {
item := &searchUserAPIItem{
ID: "ou_a",
DisplayInfo: "<h>张三</h>\nMarketing\n\n[Contacted 2 days ago]",
MetaData: searchUserAPIMeta{
I18nNames: map[string]string{"zh_cn": "张三", "en_us": "Z"},
MailAddress: "z@example.com",
EnterpriseMailAddress: "z@corp.example.com",
IsRegistered: true,
ChatID: "oc_abc",
IsCrossTenant: false,
Description: "Coffee fanatic ☕",
},
}
got := rowFromItem(item, "", core.BrandFeishu)
if got.OpenID != "ou_a" {
t.Errorf("OpenID: got %q, want ou_a", got.OpenID)
}
if got.LocalizedName != "张三" {
t.Errorf("LocalizedName: got %q, want 张三", got.LocalizedName)
}
if got.Email != "z@example.com" {
t.Errorf("Email: got %q", got.Email)
}
if got.EnterpriseEmail != "z@corp.example.com" {
t.Errorf("EnterpriseEmail: got %q", got.EnterpriseEmail)
}
if !got.IsActivated {
t.Errorf("IsActivated: got false, want true")
}
if got.P2PChatID != "oc_abc" {
t.Errorf("P2PChatID: got %q", got.P2PChatID)
}
if !got.HasChatted {
t.Errorf("HasChatted: got false, want true")
}
if got.IsCrossTenant {
t.Errorf("IsCrossTenant: got true, want false")
}
if got.Department != "Marketing" {
t.Errorf("Department: got %q", got.Department)
}
if got.Signature != "Coffee fanatic ☕" {
t.Errorf("Signature: got %q (must come from meta.description)", got.Signature)
}
if got.ChatRecencyHint != "Contacted 2 days ago" {
t.Errorf("ChatRecencyHint: got %q", got.ChatRecencyHint)
}
if len(got.MatchSegments) != 1 || got.MatchSegments[0] != "张三" {
t.Errorf("MatchSegments: got %v", got.MatchSegments)
}
}
func TestRowFromItem_HasChattedFalseWhenChatIDEmpty(t *testing.T) {
item := &searchUserAPIItem{ID: "ou_a"}
got := rowFromItem(item, "", core.BrandFeishu)
if got.HasChatted {
t.Errorf("HasChatted: got true, want false")
}
if got.P2PChatID != "" {
t.Errorf("P2PChatID: got %q, want empty", got.P2PChatID)
}
}
func TestRowFromItem_CrossTenantEmptyEmailNoPanic(t *testing.T) {
item := &searchUserAPIItem{
ID: "ou_outer",
MetaData: searchUserAPIMeta{
IsCrossTenant: true,
},
}
got := rowFromItem(item, "", core.BrandFeishu)
if got.Email != "" {
t.Errorf("Email: expected empty, got %q", got.Email)
}
if got.EnterpriseEmail != "" {
t.Errorf("EnterpriseEmail: expected empty, got %q", got.EnterpriseEmail)
}
}
func TestProjectUsers_NilData(t *testing.T) {
users, hasMore := projectUsers(nil, "", core.BrandFeishu)
if users == nil {
t.Fatalf("users should be an empty slice, not nil")
}
if len(users) != 0 || hasMore {
t.Fatalf("projectUsers(nil): got users=%v hasMore=%v", users, hasMore)
}
}
func TestValidateSearchUser_AllEmpty_Errors(t *testing.T) {
cmd := newSearchUserTestCommand()
rt := common.TestNewRuntimeContext(cmd, searchUserDefaultConfig())
err := validateSearchUser(rt)
if err == nil || !strings.Contains(err.Error(), "specify at least one of") {
t.Fatalf("expected AtLeastOne error, got %v", err)
}
// Error message must list the new flag names so agents see the right hints.
for _, want := range []string{"--query", "--user-ids", "--has-chatted", "--exclude-external-users", "--left-organization"} {
if !strings.Contains(err.Error(), want) {
t.Errorf("error message missing %q; got %v", want, err)
}
}
}
func TestValidateSearchUser_QueryOnly_OK(t *testing.T) {
cmd := newSearchUserTestCommand()
_ = cmd.Flags().Set("query", "hello")
rt := common.TestNewRuntimeContext(cmd, searchUserDefaultConfig())
if err := validateSearchUser(rt); err != nil {
t.Fatalf("unexpected error: %v", err)
}
}
func TestValidateSearchUser_FilterOnly_OK(t *testing.T) {
cmd := newSearchUserTestCommand()
_ = cmd.Flags().Set("has-chatted", "true")
rt := common.TestNewRuntimeContext(cmd, searchUserDefaultConfig())
if err := validateSearchUser(rt); err != nil {
t.Fatalf("unexpected error: %v", err)
}
}
func TestValidateSearchUser_QueryTooLong_Errors(t *testing.T) {
cmd := newSearchUserTestCommand()
_ = cmd.Flags().Set("query", strings.Repeat("a", 51))
rt := common.TestNewRuntimeContext(cmd, searchUserDefaultConfig())
err := validateSearchUser(rt)
if err == nil || !strings.Contains(err.Error(), "50") {
t.Fatalf("expected length error mentioning 50, got %v", err)
}
}
func TestValidateSearchUser_Query50Chars_OK(t *testing.T) {
cmd := newSearchUserTestCommand()
q := strings.Repeat("中", 25) + strings.Repeat("a", 25) // 50 runes, >50 bytes
if utf8.RuneCountInString(q) != 50 {
t.Fatalf("test string is %d runes, expected 50", utf8.RuneCountInString(q))
}
_ = cmd.Flags().Set("query", q)
rt := common.TestNewRuntimeContext(cmd, searchUserDefaultConfig())
if err := validateSearchUser(rt); err != nil {
t.Fatalf("unexpected error: %v", err)
}
}
// Regression: prior versions accepted "--user-ids ',,,'" because SplitCSV
// drops empty segments and validation only capped the upper bound, sending
// an empty body to the API.
func TestValidateSearchUser_UserIDsAllSeparators_Errors(t *testing.T) {
for _, raw := range []string{",,,", " , , ", ","} {
cmd := newSearchUserTestCommand()
_ = cmd.Flags().Set("user-ids", raw)
rt := common.TestNewRuntimeContext(cmd, searchUserDefaultConfig())
err := validateSearchUser(rt)
if err == nil || !strings.Contains(err.Error(), "--user-ids") {
t.Fatalf("raw=%q: expected --user-ids error, got %v", raw, err)
}
}
}
func TestValidateSearchUser_UserIDsOver100_Errors(t *testing.T) {
cmd := newSearchUserTestCommand()
ids := make([]string, 101)
for i := range ids {
ids[i] = fmt.Sprintf("ou_%05d", i)
}
_ = cmd.Flags().Set("user-ids", strings.Join(ids, ","))
rt := common.TestNewRuntimeContext(cmd, searchUserDefaultConfig())
err := validateSearchUser(rt)
if err == nil || !strings.Contains(err.Error(), "100") {
t.Fatalf("expected 100-cap error, got %v", err)
}
}
func TestValidateSearchUser_UserIDsBadPrefix_Errors(t *testing.T) {
cmd := newSearchUserTestCommand()
_ = cmd.Flags().Set("user-ids", "foo")
rt := common.TestNewRuntimeContext(cmd, searchUserDefaultConfig())
err := validateSearchUser(rt)
if err == nil || !strings.Contains(err.Error(), "ou_") {
t.Fatalf("expected ou_ prefix error, got %v", err)
}
}
func TestValidateSearchUser_MeWithoutLogin_Errors(t *testing.T) {
cmd := newSearchUserTestCommand()
_ = cmd.Flags().Set("user-ids", "me")
cfg := searchUserDefaultConfig()
cfg.UserOpenId = ""
rt := common.TestNewRuntimeContext(cmd, cfg)
err := validateSearchUser(rt)
if err == nil || !strings.Contains(err.Error(), "me") {
t.Fatalf("expected 'me without login' error, got %v", err)
}
}
// =false on any bool filter must fail validation. Agents passing =false almost
// always mean "do not filter", but the API treats it as "must NOT match";
// silent acceptance produces wrong results. Hard reject up front.
func TestValidateSearchUser_BoolFalse_Rejected(t *testing.T) {
cases := []string{"has-chatted", "has-enterprise-email", "exclude-external-users", "left-organization"}
for _, flag := range cases {
t.Run(flag, func(t *testing.T) {
cmd := newSearchUserTestCommand()
_ = cmd.Flags().Set("query", "x")
_ = cmd.Flags().Set(flag, "false")
rt := common.TestNewRuntimeContext(cmd, searchUserDefaultConfig())
err := validateSearchUser(rt)
if err == nil || !strings.Contains(err.Error(), "--"+flag) {
t.Fatalf("expected rejection mentioning --%s, got %v", flag, err)
}
if !strings.Contains(err.Error(), "=false is rejected") {
t.Errorf("error should explain why =false is rejected; got %v", err)
}
})
}
}
func TestBuildBody_QueryOnly(t *testing.T) {
cmd := newSearchUserTestCommand()
_ = cmd.Flags().Set("query", "hello")
rt := common.TestNewRuntimeContext(cmd, searchUserDefaultConfig())
body, err := buildSearchUserBody(rt)
if err != nil {
t.Fatalf("unexpected: %v", err)
}
if body.Query != "hello" {
t.Errorf("Query: got %q, want hello", body.Query)
}
if body.Filter != nil {
t.Errorf("Filter: should be nil when no filter set, got %+v", body.Filter)
}
}
func TestBuildBody_BoolNotSet_Omitted(t *testing.T) {
cmd := newSearchUserTestCommand()
_ = cmd.Flags().Set("query", "x")
rt := common.TestNewRuntimeContext(cmd, searchUserDefaultConfig())
body, _ := buildSearchUserBody(rt)
if body.Filter != nil {
t.Errorf("Filter: should be nil when no bool changed, got %+v", body.Filter)
}
}
func TestBuildBody_BoolTrue_MapsToAPI(t *testing.T) {
cmd := newSearchUserTestCommand()
_ = cmd.Flags().Set("query", "x")
_ = cmd.Flags().Set("has-chatted", "true")
rt := common.TestNewRuntimeContext(cmd, searchUserDefaultConfig())
body, _ := buildSearchUserBody(rt)
if body.Filter == nil {
t.Fatalf("Filter: expected non-nil")
}
// Flag --has-chatted must map to API field has_contact (the rename
// rationale lives in searchUserBoolFilters).
if !body.Filter.HasContact {
t.Errorf("Filter.HasContact: got false, want true")
}
}
func TestBuildBody_BoolFalse_NotInBody(t *testing.T) {
// Validate rejects =false up front, but buildSearchUserBody must also
// defensively skip it (in case a code path bypasses Validate).
cmd := newSearchUserTestCommand()
_ = cmd.Flags().Set("query", "x")
_ = cmd.Flags().Set("has-chatted", "false")
rt := common.TestNewRuntimeContext(cmd, searchUserDefaultConfig())
body, _ := buildSearchUserBody(rt)
if body.Filter != nil && body.Filter.HasContact {
t.Errorf("Filter.HasContact: must stay false when flag is =false")
}
}
func TestBuildBody_RenamedFlagsMapToAPI(t *testing.T) {
// Each renamed CLI flag must still hit its original API field. Using a
// getter closure keeps the assertion compile-time typed against the
// searchUserAPIFilter struct.
cases := []struct {
flag string
get func(*searchUserAPIFilter) bool
}{
{"has-chatted", func(f *searchUserAPIFilter) bool { return f.HasContact }},
{"exclude-external-users", func(f *searchUserAPIFilter) bool { return f.ExcludeOuterContact }},
{"left-organization", func(f *searchUserAPIFilter) bool { return f.IsResigned }},
{"has-enterprise-email", func(f *searchUserAPIFilter) bool { return f.HasEnterpriseEmail }},
}
for _, c := range cases {
t.Run(c.flag, func(t *testing.T) {
cmd := newSearchUserTestCommand()
_ = cmd.Flags().Set("query", "x")
_ = cmd.Flags().Set(c.flag, "true")
rt := common.TestNewRuntimeContext(cmd, searchUserDefaultConfig())
body, _ := buildSearchUserBody(rt)
if body.Filter == nil || !c.get(body.Filter) {
t.Errorf("--%s did not set the corresponding filter field; body.Filter=%+v", c.flag, body.Filter)
}
})
}
}
func TestBuildBody_UserIDsResolveAndDedup(t *testing.T) {
cmd := newSearchUserTestCommand()
_ = cmd.Flags().Set("user-ids", "me,ou_a,me,ou_a")
rt := common.TestNewRuntimeContext(cmd, searchUserDefaultConfig())
body, _ := buildSearchUserBody(rt)
if body.Filter == nil {
t.Fatalf("Filter: expected non-nil")
}
ids := body.Filter.UserIDs
if len(ids) != 2 || ids[0] != "ou_self" || ids[1] != "ou_a" {
t.Errorf("UserIDs: got %v, want [ou_self ou_a]", ids)
}
}
func TestBuildBody_UserIDsMeWithoutLoginReturnsTypedError(t *testing.T) {
cmd := newSearchUserTestCommand()
_ = cmd.Flags().Set("user-ids", "me")
cfg := searchUserDefaultConfig()
cfg.UserOpenId = ""
rt := common.TestNewRuntimeContext(cmd, cfg)
body, err := buildSearchUserBody(rt)
if err == nil {
t.Fatalf("expected error, got body %+v", body)
}
p, ok := errs.ProblemOf(err)
if !ok {
t.Fatalf("expected typed problem, got %T: %v", err, err)
}
if p.Category != errs.CategoryValidation {
t.Fatalf("category: got %q, want %q", p.Category, errs.CategoryValidation)
}
}
func TestValidateSearchUser_PageSizeOutOfRange_Errors(t *testing.T) {
for _, n := range []int{0, 31} {
cmd := newSearchUserTestCommand()
_ = cmd.Flags().Set("query", "x")
_ = cmd.Flags().Set("page-size", fmt.Sprintf("%d", n))
rt := common.TestNewRuntimeContext(cmd, searchUserDefaultConfig())
err := validateSearchUser(rt)
if err == nil || !strings.Contains(err.Error(), "page-size") {
t.Errorf("page-size=%d: expected range error, got %v", n, err)
}
}
}
func TestValidateSearchUser_PageSizeBoundaries_OK(t *testing.T) {
for _, n := range []int{1, 30} {
cmd := newSearchUserTestCommand()
_ = cmd.Flags().Set("query", "x")
_ = cmd.Flags().Set("page-size", fmt.Sprintf("%d", n))
rt := common.TestNewRuntimeContext(cmd, searchUserDefaultConfig())
if err := validateSearchUser(rt); err != nil {
t.Errorf("page-size=%d: unexpected error %v", n, err)
}
}
}
func TestDecodeSearchUserAPIData_MarshalFailureTyped(t *testing.T) {
_, err := decodeSearchUserAPIData(map[string]interface{}{"bad": func() {}})
if err == nil {
t.Fatalf("expected marshal failure")
}
p, ok := errs.ProblemOf(err)
if !ok {
t.Fatalf("expected typed problem, got %T: %v", err, err)
}
if p.Category != errs.CategoryInternal || p.Subtype != errs.SubtypeInvalidResponse {
t.Fatalf("problem type: got %s/%s", p.Category, p.Subtype)
}
}
// mountAndRun mounts the shortcut under a parent cobra command and runs it
// with the given args. Mirrors the pattern used in other shortcut packages.
func mountAndRun(t *testing.T, s common.Shortcut, args []string, f *cmdutil.Factory, stdout *bytes.Buffer) error {
t.Helper()
return mountAndRunContext(t, context.Background(), s, args, f, stdout)
}
// mountAndRunContext is mountAndRun with a caller-supplied context, so a test
// can cancel the run the shortcut actually sees (runShortcut reads cmd.Context).
func mountAndRunContext(t *testing.T, ctx context.Context, s common.Shortcut, args []string, f *cmdutil.Factory, stdout *bytes.Buffer) error {
t.Helper()
parent := &cobra.Command{Use: "contact"}
s.Mount(parent, f)
parent.SetArgs(args)
parent.SilenceErrors = true
parent.SilenceUsage = true
if stdout != nil {
stdout.Reset()
}
return parent.ExecuteContext(ctx)
}
// searchUserStub returns a representative user search response with a notice.
func searchUserStub() *httpmock.Stub {
return &httpmock.Stub{
Method: "POST",
URL: "/open-apis/contact/v3/users/search",
Body: map[string]interface{}{
"code": 0, "msg": "ok",
"data": map[string]interface{}{
"notice": "The query is too long and has been truncated to the first 50 characters for search.",
"items": []interface{}{
map[string]interface{}{
"id": "ou_a",
"meta_data": map[string]interface{}{
"i18n_names": map[string]interface{}{"zh_cn": "张三"},
"mail_address": "z@x.com",
"is_registered": true,
"chat_id": "oc_abc",
"is_cross_tenant": false,
"description": "Coffee fanatic ☕",
},
"display_info": "<h>张三</h>\nMarketing\n\n[Contacted 2 days ago]",
},
},
"has_more": false,
"page_token": "",
},
},
}
}
// TestSearchUser_Integration_PrettyRendersExpectedColumns verifies human output columns.
func TestSearchUser_Integration_PrettyRendersExpectedColumns(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, searchUserDefaultConfig())
reg.Register(searchUserStub())
err := mountAndRun(t, ContactSearchUser, []string{"+search-user", "--query", "张三", "--format", "pretty", "--as", "user"}, f, stdout)
if err != nil {
t.Fatalf("execute: %v", err)
}
out := stdout.String()
for _, col := range []string{"localized_name", "department", "open_id", "enterprise_email", "has_chatted", "chat_recency_hint"} {
if !strings.Contains(out, col) {
t.Errorf("pretty output missing column %q; got=%q", col, out)
}
}
// department is the disambiguation field — must reach the rendered cell.
if !strings.Contains(out, "Marketing") {
t.Errorf("expected department 'Marketing' in pretty output, got=%q", out)
}
// Legacy column must be gone.
if strings.Contains(out, "display_info ") || strings.Contains(out, "| display_info") {
t.Errorf("legacy 'display_info' column must not appear; got=%q", out)
}
}
// TestSearchUser_Integration_JSONStructuredFields verifies normalized JSON and notices.
func TestSearchUser_Integration_JSONStructuredFields(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, searchUserDefaultConfig())
reg.Register(searchUserStub())
err := mountAndRun(t, ContactSearchUser, []string{"+search-user", "--query", "张三", "--format", "json", "--as", "user"}, f, stdout)
if err != nil {
t.Fatalf("execute: %v", err)
}
var got map[string]interface{}
if err := json.Unmarshal(stdout.Bytes(), &got); err != nil {
t.Fatalf("json: %v\noutput=%s", err, stdout.String())
}
data, ok := got["data"].(map[string]interface{})
if !ok {
t.Fatalf("envelope.data: expected object, got %v\nraw=%s", got["data"], stdout.String())
}
if data["notice"] != "The query is too long and has been truncated to the first 50 characters for search." {
t.Fatalf("data.notice = %v", data["notice"])
}
users, _ := data["users"].([]interface{})
if len(users) != 1 {
t.Fatalf("users: expected 1, got %d (output=%s)", len(users), stdout.String())
}
u, _ := users[0].(map[string]interface{})
// New schema keys must all be present.
for _, k := range []string{
"open_id", "localized_name", "email", "enterprise_email",
"is_activated", "is_cross_tenant",
"p2p_chat_id", "has_chatted",
"department", "signature", "chat_recency_hint", "match_segments",
} {
if _, ok := u[k]; !ok {
t.Errorf("missing JSON key %q in user object", k)
}
}
// Legacy keys must be gone.
for _, k := range []string{"name", "chat_id", "is_registered", "description", "display_info", "display_info_raw", "match_rank", "tenant_id", "i18n_names"} {
if _, ok := u[k]; ok {
t.Errorf("legacy JSON key %q must not appear", k)
}
}
// Spot-check a few values that prove structured parsing works end-to-end.
if u["department"] != "Marketing" {
t.Errorf("department: got %v, want Marketing", u["department"])
}
if u["chat_recency_hint"] != "Contacted 2 days ago" {
t.Errorf("chat_recency_hint: got %v", u["chat_recency_hint"])
}
// Signature must come through from raw meta.description, under the
// agent-friendly key "signature" (not "description").
if u["signature"] != "Coffee fanatic ☕" {
t.Errorf("signature: got %v, want %q", u["signature"], "Coffee fanatic ☕")
}
}
// Most users have no signature; the field is omitempty so an empty value
// must not appear at all in the JSON, not as "" — agents shouldn't have to
// distinguish "absent" from "empty string".
func TestSearchUser_Integration_EmptySignatureOmitted(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, searchUserDefaultConfig())
reg.Register(&httpmock.Stub{
Method: "POST", URL: "/open-apis/contact/v3/users/search",
Body: map[string]interface{}{
"code": 0, "msg": "ok",
"data": map[string]interface{}{
"items": []interface{}{
map[string]interface{}{
"id": "ou_a",
"meta_data": map[string]interface{}{
"i18n_names": map[string]interface{}{"zh_cn": "无签名用户"},
"mail_address": "x@example.com",
"description": "",
},
},
},
"has_more": false,
},
},
})
err := mountAndRun(t, ContactSearchUser, []string{"+search-user", "--query", "x", "--format", "json", "--as", "user"}, f, stdout)
if err != nil {
t.Fatalf("execute: %v", err)
}
var got map[string]interface{}
if err := json.Unmarshal(stdout.Bytes(), &got); err != nil {
t.Fatalf("json: %v\nstdout=%s", err, stdout.String())
}
users := got["data"].(map[string]interface{})["users"].([]interface{})
u := users[0].(map[string]interface{})
if _, present := u["signature"]; present {
t.Errorf(`signature must be absent (not "") when empty; got %v`, u["signature"])
}
}
func TestSearchUser_Integration_NDJSONHasNoRefineHint(t *testing.T) {
f, stdout, stderr, reg := cmdutil.TestFactory(t, searchUserDefaultConfig())
reg.Register(&httpmock.Stub{
Method: "POST", URL: "/open-apis/contact/v3/users/search",
Body: map[string]interface{}{
"code": 0, "msg": "ok",
"data": map[string]interface{}{
"items": []interface{}{map[string]interface{}{"id": "ou_a"}},
"has_more": true,
"page_token": "tok_next",
},
},
})
err := mountAndRun(t, ContactSearchUser, []string{"+search-user", "--query", "x", "--format", "ndjson", "--as", "user"}, f, stdout)
if err != nil {
t.Fatalf("execute: %v", err)
}
if strings.Contains(stdout.String(), "refine") {
t.Errorf("ndjson stdout must not contain the refine hint (would corrupt the stream); got=%q", stdout.String())
}
if strings.Contains(stderr.String(), "refine") {
t.Errorf("ndjson stderr must not contain the refine hint either (non-human format opts out entirely); got=%q", stderr.String())
}
}
func TestSearchUser_Integration_PrettyRefineHintGoesToStderr(t *testing.T) {
f, stdout, stderr, reg := cmdutil.TestFactory(t, searchUserDefaultConfig())
reg.Register(&httpmock.Stub{
Method: "POST", URL: "/open-apis/contact/v3/users/search",
Body: map[string]interface{}{
"code": 0, "msg": "ok",
"data": map[string]interface{}{
"items": []interface{}{map[string]interface{}{"id": "ou_a"}},
"has_more": true,
"page_token": "tok_next",
},
},
})
err := mountAndRun(t, ContactSearchUser, []string{"+search-user", "--query", "x", "--format", "pretty", "--as", "user"}, f, stdout)
if err != nil {
t.Fatalf("execute: %v", err)
}
if strings.Contains(stdout.String(), "refine") {
t.Errorf("pretty stdout must not carry the hint (informational text belongs on stderr); got=%q", stdout.String())
}
if !strings.Contains(stderr.String(), "refine") {
t.Errorf("pretty stderr should suggest refining the query when has_more=true; got=%q", stderr.String())
}
// The hint must explicitly NOT recommend pagination — by design there
// is no auto-pagination and agents must refine instead.
if strings.Contains(stderr.String(), "--page-all") || strings.Contains(stderr.String(), "auto-paginate") {
t.Errorf("hint must not mention pagination flags; got=%q", stderr.String())
}
}
func TestSearchUser_Integration_EmptyResult(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, searchUserDefaultConfig())
reg.Register(&httpmock.Stub{
Method: "POST", URL: "/open-apis/contact/v3/users/search",
Body: map[string]interface{}{
"code": 0, "msg": "ok",
"data": map[string]interface{}{"items": []interface{}{}, "has_more": false, "page_token": ""},
},
})
err := mountAndRun(t, ContactSearchUser, []string{"+search-user", "--query", "nope", "--format", "pretty", "--as", "user"}, f, stdout)
if err != nil {
t.Fatalf("execute: %v", err)
}
if !strings.Contains(stdout.String(), "No users found.") {
t.Errorf("expected 'No users found.' in output, got %q", stdout.String())
}
}
func TestSearchUser_Integration_EmptyResult_JSONArray(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, searchUserDefaultConfig())
reg.Register(&httpmock.Stub{
Method: "POST", URL: "/open-apis/contact/v3/users/search",
Body: map[string]interface{}{
"code": 0, "msg": "ok",
"data": map[string]interface{}{"items": []interface{}{}, "has_more": false, "page_token": ""},
},
})
err := mountAndRun(t, ContactSearchUser, []string{"+search-user", "--query", "nope", "--format", "json", "--as", "user"}, f, stdout)
if err != nil {
t.Fatalf("execute: %v", err)
}
var got map[string]interface{}
if err := json.Unmarshal(stdout.Bytes(), &got); err != nil {
t.Fatalf("json: %v\noutput=%s", err, stdout.String())
}
data, ok := got["data"].(map[string]interface{})
if !ok {
t.Fatalf("envelope.data: expected object, got %v\nraw=%s", got["data"], stdout.String())
}
usersRaw, exists := data["users"]
if !exists {
t.Fatalf("data.users key missing\nraw=%s", stdout.String())
}
if usersRaw == nil {
t.Fatalf("data.users serialized as null; expected [] for empty result\nraw=%s", stdout.String())
}
users, ok := usersRaw.([]interface{})
if !ok {
t.Fatalf("data.users: expected []interface{}, got %T\nraw=%s", usersRaw, stdout.String())
}
if len(users) != 0 {
t.Errorf("data.users: expected empty array, got %d entries", len(users))
}
}
func TestSearchUser_DryRun(t *testing.T) {
f, stdout, _, _ := cmdutil.TestFactory(t, searchUserDefaultConfig())
err := mountAndRun(t, ContactSearchUser, []string{"+search-user", "--query", "x", "--has-chatted=true", "--dry-run", "--as", "user"}, f, stdout)
if err != nil {
t.Fatalf("execute: %v", err)
}
out := stdout.String()
for _, want := range []string{"POST", "/contact/v3/users/search", "has_contact"} {
if !strings.Contains(out, want) {
t.Errorf("dry-run missing %q in output: %q", want, out)
}
}
}
func TestSearchUser_Integration_RequestShape(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, searchUserDefaultConfig())
stub := searchUserStub()
reg.Register(stub)
err := mountAndRun(t, ContactSearchUser, []string{"+search-user", "--query", "x", "--has-chatted=true", "--user-ids", "me", "--as", "user"}, f, stdout)
if err != nil {
t.Fatalf("execute: %v", err)
}
var body map[string]interface{}
if err := json.Unmarshal(stub.CapturedBody, &body); err != nil {
t.Fatalf("unmarshal request body: %v\nraw=%s", err, string(stub.CapturedBody))
}
if body["query"] != "x" {
t.Errorf("body.query: got %v, want x", body["query"])
}
filter, _ := body["filter"].(map[string]interface{})
if filter == nil {
t.Fatalf("body.filter: expected object, got %v", body["filter"])
}
if filter["has_contact"] != true {
t.Errorf("filter.has_contact: got %v, want true", filter["has_contact"])
}
uids, _ := filter["user_ids"].([]interface{})
if len(uids) != 1 || uids[0] != "ou_self" {
t.Errorf("filter.user_ids: got %v, want [ou_self]", filter["user_ids"])
}
// Unset bool filters must not appear in the body.
for _, k := range []string{"is_resigned", "exclude_outer_contact", "has_enterprise_email"} {
if _, ok := filter[k]; ok {
t.Errorf("filter.%s: should be omitted (not Changed), got %v", k, filter[k])
}
}
}
// Guards against the int/string flag mismatch: the stub URL match requires
// page_size=25 to appear in the query string, which only happens if --page-size
// actually reaches DoAPI's QueryParams. A regression that silently defaults
// to 20 would fail the stub match.
func TestSearchUser_Integration_PageSizeFlowsToQuery(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, searchUserDefaultConfig())
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/contact/v3/users/search?page_size=25",
Body: map[string]interface{}{
"code": 0, "msg": "ok",
"data": map[string]interface{}{"items": []interface{}{}, "has_more": false, "page_token": ""},
},
})
err := mountAndRun(t, ContactSearchUser, []string{"+search-user", "--query", "x", "--page-size", "25", "--as", "user"}, f, stdout)
if err != nil {
t.Fatalf("execute: %v (likely page-size did not reach the request)", err)
}
reg.Verify(t)
}
func newSearchUserTestCommandWithQueries() *cobra.Command {
cmd := newSearchUserTestCommand()
cmd.Flags().String("queries", "", "")
return cmd
}
func TestValidateQueries_QueryAndQueriesMutex(t *testing.T) {
cmd := newSearchUserTestCommandWithQueries()
_ = cmd.Flags().Set("query", "alice")
_ = cmd.Flags().Set("queries", "bob,carol")
rt := common.TestNewRuntimeContext(cmd, searchUserDefaultConfig())
err := validateSearchUser(rt)
if err == nil || !strings.Contains(err.Error(), "--query and --queries are mutually exclusive") {
t.Fatalf("expected mutex error, got %v", err)
}
}
func TestValidateQueries_UserIDsAndQueriesMutex(t *testing.T) {
cmd := newSearchUserTestCommandWithQueries()
_ = cmd.Flags().Set("user-ids", "ou_a")
_ = cmd.Flags().Set("queries", "bob")
rt := common.TestNewRuntimeContext(cmd, searchUserDefaultConfig())
err := validateSearchUser(rt)
if err == nil || !strings.Contains(err.Error(), "--user-ids and --queries are mutually exclusive") {
t.Fatalf("expected mutex error, got %v", err)
}
}
func TestValidateQueries_AllSeparators_Errors(t *testing.T) {
for _, raw := range []string{",,,", " , , ", ","} {
cmd := newSearchUserTestCommandWithQueries()
_ = cmd.Flags().Set("queries", raw)
rt := common.TestNewRuntimeContext(cmd, searchUserDefaultConfig())
err := validateSearchUser(rt)
if err == nil || !strings.Contains(err.Error(), "no valid query parsed") {
t.Fatalf("raw=%q: expected 'no valid query parsed' error, got %v", raw, err)
}
}
}
func TestValidateQueries_OverLength_Errors(t *testing.T) {
cmd := newSearchUserTestCommandWithQueries()
long := strings.Repeat("a", 51)
_ = cmd.Flags().Set("queries", "short,"+long)
rt := common.TestNewRuntimeContext(cmd, searchUserDefaultConfig())
err := validateSearchUser(rt)
if err == nil || !strings.Contains(err.Error(), "exceeds 50 characters") {
t.Fatalf("expected length error mentioning 50, got %v", err)
}
}
func TestValidateQueries_Over20_Errors(t *testing.T) {
cmd := newSearchUserTestCommandWithQueries()
parts := make([]string, 21)
for i := range parts {
parts[i] = fmt.Sprintf("q%02d", i)
}
_ = cmd.Flags().Set("queries", strings.Join(parts, ","))
rt := common.TestNewRuntimeContext(cmd, searchUserDefaultConfig())
err := validateSearchUser(rt)
if err == nil || !strings.Contains(err.Error(), "must be at most 20 entries") {
t.Fatalf("expected 20-cap error, got %v", err)
}
}
func TestParseQueries_TrimAndSkipEmpty(t *testing.T) {
got := parseAndDedupQueries("a, ,b ,")
want := []string{"a", "b"}
if len(got) != len(want) || got[0] != want[0] || got[1] != want[1] {
t.Errorf("parseAndDedupQueries: got %v, want %v", got, want)
}
}
func TestParseQueries_DedupCaseSensitive(t *testing.T) {
got := parseAndDedupQueries("alice,Alice,alice")
want := []string{"alice", "Alice"}
if len(got) != 2 || got[0] != want[0] || got[1] != want[1] {
t.Errorf("got %v, want %v (case-sensitive dedup keeps first-occurrence order)", got, want)
}
}
func TestExecuteSingleQuery_OutputUnchanged(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, searchUserDefaultConfig())
reg.Register(searchUserStub())
err := mountAndRun(t, ContactSearchUser, []string{"+search-user", "--query", "张三", "--format", "json", "--as", "user"}, f, stdout)
if err != nil {
t.Fatalf("execute: %v", err)
}
var got map[string]interface{}
if err := json.Unmarshal(stdout.Bytes(), &got); err != nil {
t.Fatalf("json: %v", err)
}
data, _ := got["data"].(map[string]interface{})
if _, hasQueries := data["queries"]; hasQueries {
t.Errorf("single-query mode must NOT emit data.queries; got=%v", data)
}
users, _ := data["users"].([]interface{})
if len(users) != 1 {
t.Fatalf("users len = %d, want 1", len(users))
}
u, _ := users[0].(map[string]interface{})
if _, hasMatched := u["matched_query"]; hasMatched {
t.Errorf("single-query mode users[] must NOT carry matched_query; got=%v", u)
}
if _, hasTopHasMore := data["has_more"]; !hasTopHasMore {
t.Errorf("single-query mode must keep top-level data.has_more; data=%v", data)
}
}
// runOneQueryRuntime wires a Factory-backed RuntimeContext bound to the test
// command's flag set, so runOneQuery can be exercised directly without going
// through the cobra dispatcher. Mirrors what mountAndRun would build, minus
// the parent-command plumbing the worker doesn't need.
func runOneQueryRuntime(t *testing.T) (*common.RuntimeContext, *httpmock.Registry) {
t.Helper()
f, _, _, reg := cmdutil.TestFactory(t, searchUserDefaultConfig())
cmd := newSearchUserTestCommand()
rt := common.TestNewRuntimeContextForAPI(context.Background(), cmd, searchUserDefaultConfig(), f, core.AsUser)
return rt, reg
}
func TestRunOneQuery_Success(t *testing.T) {
rt, reg := runOneQueryRuntime(t)
reg.Register(searchUserStub())
got := runOneQuery(context.Background(), rt, 0, "张三", nil)
if got.ErrMsg != "" {
t.Fatalf("unexpected ErrMsg: %q", got.ErrMsg)
}
if got.Index != 0 || got.Query != "张三" {
t.Errorf("Index/Query mismatch: %+v", got)
}
if len(got.Users) != 1 || got.Users[0].OpenID != "ou_a" {
t.Errorf("Users mismatch: %+v", got.Users)
}
if got.HasMore {
t.Errorf("HasMore should be false")
}
}
func TestRunOneQuery_APINonZeroCode(t *testing.T) {
rt, reg := runOneQueryRuntime(t)
reg.Register(&httpmock.Stub{
Method: "POST",
URL: searchUserURL,
Body: map[string]interface{}{"code": 99991663, "msg": "rate limited"},
})
got := runOneQuery(context.Background(), rt, 3, "alice", nil)
if got.Index != 3 || got.Query != "alice" {
t.Errorf("Index/Query mismatch: %+v", got)
}
if got.ErrMsg != "API 99991663: rate limited" {
t.Errorf("ErrMsg = %q, want 'API 99991663: rate limited'", got.ErrMsg)
}
p, ok := errs.ProblemOf(got.Err)
if !ok {
t.Fatalf("expected typed problem on fanout result, got %T", got.Err)
}
if p.Code != 99991663 {
t.Errorf("problem code: got %d, want 99991663", p.Code)
}
if got.Users != nil || got.HasMore {
t.Errorf("on error, Users/HasMore must be zero values; got %+v", got)
}
}
func TestRunOneQuery_HTTPNon200(t *testing.T) {
rt, reg := runOneQueryRuntime(t)
reg.Register(&httpmock.Stub{
Method: "POST",
URL: searchUserURL,
Status: 503,
Body: map[string]interface{}{"reason": "upstream_unavailable"},
})
got := runOneQuery(context.Background(), rt, 1, "bob", nil)
if !strings.HasPrefix(got.ErrMsg, "HTTP 503 Service Unavailable: ") {
t.Errorf("ErrMsg should start with status line; got %q", got.ErrMsg)
}
if !strings.Contains(got.ErrMsg, "upstream_unavailable") {
t.Errorf("ErrMsg should include response body for diagnosis; got %q", got.ErrMsg)
}
p, ok := errs.ProblemOf(got.Err)
if !ok {
t.Fatalf("expected typed problem on fanout result, got %T", got.Err)
}
if p.Code != 503 {
t.Errorf("problem code: got %d, want 503", p.Code)
}
if p.Category != errs.CategoryNetwork {
t.Errorf("problem category: got %q, want %q", p.Category, errs.CategoryNetwork)
}
}
func TestRunOneQuery_HTTPNon200_BodyTruncated(t *testing.T) {
rt, reg := runOneQueryRuntime(t)
long := strings.Repeat("x", 1000)
reg.Register(&httpmock.Stub{
Method: "POST",
URL: searchUserURL,
Status: 500,
Body: map[string]interface{}{"detail": long},
})
got := runOneQuery(context.Background(), rt, 0, "alice", nil)
if !strings.HasSuffix(got.ErrMsg, "...") {
t.Errorf("oversized body should be truncated with '...' suffix; got %q", got.ErrMsg)
}
if len(got.ErrMsg) > 300 {
t.Errorf("ErrMsg %d chars exceeds reasonable budget; got %q", len(got.ErrMsg), got.ErrMsg)
}
}
// SDK-level transport / envelope-unmarshal failures arrive as Go errors from
// runtime.DoAPI; the worker converts them by calling err.Error() rather than
// adding its own prefix, so the assertion here is "ErrMsg is non-empty and
// preserves the underlying message" — the exact text comes from the SDK.
func TestRunOneQuery_TransportError(t *testing.T) {
rt, reg := runOneQueryRuntime(t)
reg.Register(&httpmock.Stub{
Method: "POST",
URL: searchUserURL,
RawBody: []byte("{not-json"),
})
got := runOneQuery(context.Background(), rt, 2, "carol", nil)
if got.ErrMsg == "" {
t.Fatalf("expected non-empty ErrMsg for malformed body")
}
if got.Index != 2 || got.Query != "carol" {
t.Errorf("Index/Query mismatch: %+v", got)
}
if got.Users != nil || got.HasMore {
t.Errorf("on error, Users/HasMore must be zero values; got %+v", got)
}
}
func TestFanoutErrorResult_NilErrorIsSuccess(t *testing.T) {
got := fanoutErrorResult(4, "alice", nil)
if got.Index != 4 || got.Query != "alice" {
t.Fatalf("Index/Query mismatch: %+v", got)
}
if got.ErrMsg != "" || got.Err != nil {
t.Fatalf("nil error should produce a success result, got %+v", got)
}
}
func TestFanoutAssemble_OrderAndShape(t *testing.T) {
results := []fanoutResult{
{Index: 1, Query: "bob", Users: []searchUser{{OpenID: "ou_b"}}, HasMore: true},
{Index: 0, Query: "alice", Users: []searchUser{{OpenID: "ou_a1"}, {OpenID: "ou_a2"}}, HasMore: false},
{Index: 2, Query: "carol", ErrMsg: "API 1: nope"},
}
resp, err := buildFanoutResponse([]string{"alice", "bob", "carol"}, results)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if len(resp.Users) != 3 {
t.Fatalf("Users length: got %d, want 3 (carol failed → 0 users)", len(resp.Users))
}
if resp.Users[0].OpenID != "ou_a1" || resp.Users[0].MatchedQuery != "alice" {
t.Errorf("Users[0]: got %+v", resp.Users[0])
}
if resp.Users[1].OpenID != "ou_a2" || resp.Users[1].MatchedQuery != "alice" {
t.Errorf("Users[1]: got %+v", resp.Users[1])
}
if resp.Users[2].OpenID != "ou_b" || resp.Users[2].MatchedQuery != "bob" {
t.Errorf("Users[2]: got %+v", resp.Users[2])
}
if len(resp.Queries) != 3 {
t.Fatalf("Queries length: got %d, want 3 (full enumeration)", len(resp.Queries))
}
want := []querySummary{
{Query: "alice", Error: "", HasMore: false},
{Query: "bob", Error: "", HasMore: true},
{Query: "carol", Error: "API 1: nope", HasMore: false},
}
for i, w := range want {
if resp.Queries[i] != w {
t.Errorf("Queries[%d]: got %+v, want %+v", i, resp.Queries[i], w)
}
}
}
func TestFanoutAssemble_AllFailed_ReturnsError(t *testing.T) {
results := []fanoutResult{
{Index: 0, Query: "alice", ErrMsg: "API 99991663: rate limit"},
{Index: 1, Query: "bob", ErrMsg: "HTTP 500 Internal Server Error"},
}
_, err := buildFanoutResponse([]string{"alice", "bob"}, results)
if err == nil {
t.Fatalf("expected error when all queries failed")
}
if !strings.Contains(err.Error(), "rate limit") {
t.Errorf("expected first error (rate limit) to be returned; got %v", err)
}
// Document the count is part of the message — agents grep for it.
if !strings.Contains(err.Error(), "all 2 queries failed") {
t.Errorf("expected 'all 2 queries failed' substring; got %v", err)
}
}
// When all queries fail with no structured Lark API code (transport, parse,
// panic, ctx-canceled), the returned typed error must carry an actionable
// hint so the calling agent has a next step to try instead of giving up.
func TestFanoutAssemble_AllFailed_NoCode_HasActionableHint(t *testing.T) {
results := []fanoutResult{
{Index: 0, Query: "alice", ErrMsg: "transport: connection refused"},
{Index: 1, Query: "bob", ErrMsg: "transport: timeout"},
}
_, err := buildFanoutResponse([]string{"alice", "bob"}, results)
if err == nil {
t.Fatalf("expected error when all queries failed")
}
p, ok := errs.ProblemOf(err)
if !ok {
t.Fatalf("expected typed problem, got %T", err)
}
if p.Category != errs.CategoryInternal {
t.Fatalf("category: got %q, want %q", p.Category, errs.CategoryInternal)
}
if p.Hint == "" {
t.Errorf("expected non-empty Hint so agents have a next step; got empty")
}
if !strings.Contains(p.Hint, "retry") {
t.Errorf("hint should suggest retry as the first action; got %q", p.Hint)
}
}
// Codes from the first failure must propagate through typed problem fields so
// the CLI's exit-code classifier sees the real signal (e.g., 99991663 rate limit)
// instead of 0, which would mean "success" in the Lark protocol.
func TestFanoutAssemble_AllFailed_PropagatesFirstCode(t *testing.T) {
results := []fanoutResult{
{
Index: 0,
Query: "alice",
ErrMsg: "API 99991663: rate limit",
Err: errs.NewAPIError(errs.SubtypeRateLimit, "rate limit").WithCode(99991663),
},
{
Index: 1,
Query: "bob",
ErrMsg: "HTTP 500",
Err: errs.NewNetworkError(errs.SubtypeNetworkServer, "HTTP 500").WithCode(500),
},
}
_, err := buildFanoutResponse([]string{"alice", "bob"}, results)
if err == nil {
t.Fatalf("expected error")
}
if !strings.Contains(err.Error(), "rate limit") {
t.Errorf("error should contain first ErrMsg; got %v", err)
}
p, ok := errs.ProblemOf(err)
if !ok {
t.Fatalf("expected typed problem, got %T", err)
}
if p.Code != 99991663 {
t.Errorf("problem code: got %d, want 99991663", p.Code)
}
if p.Subtype != errs.SubtypeRateLimit {
t.Errorf("problem subtype: got %q, want %q", p.Subtype, errs.SubtypeRateLimit)
}
}
func TestFanoutAssemble_PartialFailureOK(t *testing.T) {
results := []fanoutResult{
{Index: 0, Query: "alice", Users: []searchUser{{OpenID: "ou_a"}}},
{Index: 1, Query: "bob", ErrMsg: "API 5: not found"},
}
resp, err := buildFanoutResponse([]string{"alice", "bob"}, results)
if err != nil {
t.Fatalf("partial failure must NOT be a hard error; got %v", err)
}
if len(resp.Users) != 1 {
t.Errorf("Users: got %d, want 1", len(resp.Users))
}
if resp.Queries[1].Error != "API 5: not found" {
t.Errorf("Queries[1].Error: got %q", resp.Queries[1].Error)
}
}
func TestFanoutAssemble_NoTopLevelHasMore(t *testing.T) {
results := []fanoutResult{
{Index: 0, Query: "alice", HasMore: true},
}
resp, err := buildFanoutResponse([]string{"alice"}, results)
if err != nil {
t.Fatalf("unexpected: %v", err)
}
raw, _ := json.Marshal(resp)
var asMap map[string]interface{}
if err := json.Unmarshal(raw, &asMap); err != nil {
t.Fatalf("unmarshal: %v", err)
}
if _, ok := asMap["has_more"]; ok {
t.Errorf("fanoutResponse must not have top-level has_more; got %v", asMap)
}
if _, ok := asMap["users"]; !ok {
t.Errorf("fanoutResponse missing users")
}
if _, ok := asMap["queries"]; !ok {
t.Errorf("fanoutResponse missing queries")
}
}
func TestPrettyFanoutUserRows(t *testing.T) {
rows := prettyFanoutUserRows([]fanoutUser{
{
searchUser: searchUser{
OpenID: "ou_a",
LocalizedName: "Alice",
Department: strings.Repeat("d", 80),
EnterpriseEmail: "alice@example.com",
HasChatted: true,
ChatRecencyHint: "Contacted yesterday",
},
MatchedQuery: "alice",
},
})
if len(rows) != 1 {
t.Fatalf("rows: got %d, want 1", len(rows))
}
row := rows[0]
for _, key := range []string{"matched_query", "localized_name", "department", "enterprise_email", "has_chatted", "chat_recency_hint", "open_id"} {
if _, ok := row[key]; !ok {
t.Fatalf("row missing key %q: %+v", key, row)
}
}
if row["matched_query"] != "alice" || row["open_id"] != "ou_a" {
t.Fatalf("row identity fields: %+v", row)
}
if len(row["department"].(string)) >= 80 {
t.Fatalf("department should be truncated for table display, got %q", row["department"])
}
}
// Verifies that with the auto-pagination flags removed, --page-all / --page-limit
// are no longer accepted. cobra must reject the unknown flag at parse time —
// no stub is registered because the command should never reach the API.
func TestSearchUser_Integration_NoAutoPaginationFlags(t *testing.T) {
for _, removed := range []string{"--page-all", "--page-limit"} {
t.Run(removed, func(t *testing.T) {
f, stdout, _, _ := cmdutil.TestFactory(t, searchUserDefaultConfig())
args := []string{"+search-user", "--query", "x", removed}
if removed == "--page-limit" {
args = append(args, "5")
}
args = append(args, "--as", "user")
err := mountAndRun(t, ContactSearchUser, args, f, stdout)
if err == nil {
t.Errorf("%s should be rejected (unknown flag), but command succeeded", removed)
}
})
}
}
// TestFanout_FilterAppliedToEachQuery verifies shared fanout filters reach every request.
func TestFanout_FilterAppliedToEachQuery(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, searchUserDefaultConfig())
stub := &httpmock.Stub{
Method: "POST",
URL: "/open-apis/contact/v3/users/search",
Reusable: true,
Body: map[string]interface{}{"code": 0, "msg": "ok",
"data": map[string]interface{}{"items": []interface{}{}, "has_more": false}},
}
reg.Register(stub)
err := mountAndRun(t, ContactSearchUser, []string{
"+search-user", "--queries", "alice,bob", "--has-chatted",
"--format", "json", "--as", "user",
}, f, stdout)
if err != nil {
t.Fatalf("execute: %v", err)
}
if len(stub.CapturedBodies) < 2 {
t.Fatalf("expected ≥2 captured request bodies, got %d", len(stub.CapturedBodies))
}
bodyByQuery := map[string]map[string]interface{}{}
for i, raw := range stub.CapturedBodies {
var body map[string]interface{}
if err := json.Unmarshal(raw, &body); err != nil {
t.Fatalf("unmarshal req %d: %v", i, err)
}
bodyByQuery[body["query"].(string)] = body
filter, _ := body["filter"].(map[string]interface{})
if filter == nil || filter["has_contact"] != true {
t.Errorf("req %d (query=%v): expected filter.has_contact=true; got body=%v", i, body["query"], body)
}
}
if _, ok := bodyByQuery["alice"]; !ok {
t.Errorf("missing request for query=alice; captured=%v", bodyByQuery)
}
if _, ok := bodyByQuery["bob"]; !ok {
t.Errorf("missing request for query=bob; captured=%v", bodyByQuery)
}
}
// TestFanout_PartialFailure_ExitZero verifies partial fanout failures keep notices.
func TestFanout_PartialFailure_ExitZero(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, searchUserDefaultConfig())
reg.Register(&httpmock.Stub{
Method: "POST", URL: "/open-apis/contact/v3/users/search",
BodyFilter: func(b []byte) bool { return strings.Contains(string(b), `"alice"`) },
Body: map[string]interface{}{"code": 0, "msg": "ok",
"data": map[string]interface{}{
"notice": "The query is too long and has been truncated to the first 50 characters for search.",
"items": []interface{}{map[string]interface{}{"id": "ou_a"}},
"has_more": false,
}},
})
reg.Register(&httpmock.Stub{
Method: "POST", URL: "/open-apis/contact/v3/users/search",
BodyFilter: func(b []byte) bool { return strings.Contains(string(b), `"bob"`) },
Status: 500,
Body: map[string]interface{}{},
})
err := mountAndRun(t, ContactSearchUser, []string{
"+search-user", "--queries", "alice,bob", "--format", "json", "--as", "user",
}, f, stdout)
if err != nil {
t.Fatalf("partial failure should NOT propagate as error; got %v", err)
}
var got map[string]interface{}
if err := json.Unmarshal(stdout.Bytes(), &got); err != nil {
t.Fatalf("json: %v\nstdout=%s", err, stdout.String())
}
data := got["data"].(map[string]interface{})
users := data["users"].([]interface{})
if len(users) != 1 {
t.Errorf("users: expected 1 (alice), got %d; stdout=%s", len(users), stdout.String())
}
if data["notice"] != "The query is too long and has been truncated to the first 50 characters for search." {
t.Fatalf("data.notice = %v", data["notice"])
}
queries := data["queries"].([]interface{})
if len(queries) != 2 {
t.Fatalf("queries: expected 2, got %d", len(queries))
}
q0 := queries[0].(map[string]interface{})
if q0["notice"] != "The query is too long and has been truncated to the first 50 characters for search." {
t.Fatalf("queries[0].notice = %v", q0["notice"])
}
q1 := queries[1].(map[string]interface{})
if !strings.HasPrefix(q1["error"].(string), "HTTP 500") {
t.Errorf("queries[1].error: got %q", q1["error"])
}
}
func TestFanout_AllFailed_ExitNonZero(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, searchUserDefaultConfig())
reg.Register(&httpmock.Stub{
Method: "POST", URL: "/open-apis/contact/v3/users/search",
Reusable: true,
Status: 500, Body: map[string]interface{}{"reason": "boom"},
})
err := mountAndRun(t, ContactSearchUser, []string{
"+search-user", "--queries", "alice,bob", "--format", "json", "--as", "user",
}, f, stdout)
if err == nil {
t.Fatalf("expected error when all queries failed")
}
// First failure's HTTP code (500) and a digestible reason must propagate
// so agents can classify (vs. a generic ExitInternal masking the upstream).
msg := err.Error()
if !strings.Contains(msg, "500") {
t.Errorf("error must propagate first failure's HTTP 500 code; got %q", msg)
}
if !strings.Contains(msg, "all 2 queries failed") {
t.Errorf("error must indicate the all-failed mode; got %q", msg)
}
}
func TestFanout_ConcurrencyLimitFive(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, searchUserDefaultConfig())
var inFlight, peak int32
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/contact/v3/users/search",
Reusable: true,
OnMatch: func(req *http.Request) {
cur := atomic.AddInt32(&inFlight, 1)
defer atomic.AddInt32(&inFlight, -1)
for {
p := atomic.LoadInt32(&peak)
if cur <= p || atomic.CompareAndSwapInt32(&peak, p, cur) {
break
}
}
time.Sleep(50 * time.Millisecond)
},
Body: map[string]interface{}{"code": 0, "msg": "ok",
"data": map[string]interface{}{"items": []interface{}{}, "has_more": false}},
})
queries := []string{"a", "b", "c", "d", "e", "f", "g", "h", "i", "j"}
err := mountAndRun(t, ContactSearchUser, []string{
"+search-user", "--queries", strings.Join(queries, ","),
"--format", "json", "--as", "user",
}, f, stdout)
if err != nil {
t.Fatalf("execute: %v", err)
}
if peak > 5 {
t.Errorf("concurrency peak = %d, want ≤ 5", peak)
}
if peak < 2 {
t.Errorf("concurrency peak = %d, want ≥ 2 (test should observe parallelism)", peak)
}
}
func TestFanout_PanicRecovery(t *testing.T) {
f, stdout, stderr, reg := cmdutil.TestFactory(t, searchUserDefaultConfig())
reg.Register(&httpmock.Stub{
Method: "POST", URL: "/open-apis/contact/v3/users/search",
BodyFilter: func(b []byte) bool { return strings.Contains(string(b), `"boom"`) },
OnMatch: func(req *http.Request) {
panic("synthetic test panic")
},
Body: map[string]interface{}{},
})
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/contact/v3/users/search",
Reusable: true,
Body: map[string]interface{}{"code": 0, "msg": "ok",
"data": map[string]interface{}{"items": []interface{}{}, "has_more": false}},
})
err := mountAndRun(t, ContactSearchUser, []string{
"+search-user", "--queries", "ok,boom,fine", "--format", "json", "--as", "user",
}, f, stdout)
if err != nil {
t.Fatalf("partial panic must not bubble; got %v", err)
}
var got map[string]interface{}
_ = json.Unmarshal(stdout.Bytes(), &got)
queries := got["data"].(map[string]interface{})["queries"].([]interface{})
q1 := queries[1].(map[string]interface{})
if !strings.HasPrefix(q1["error"].(string), "internal error:") {
t.Errorf("queries[1].error: expected 'internal error:' prefix, got %q", q1["error"])
}
for _, marker := range []string{"goroutine ", ".go:", "runtime."} {
if strings.Contains(stderr.String(), marker) {
t.Errorf("stderr leaked stack-trace marker %q; got=%s", marker, stderr.String())
}
}
}
func TestFanout_MatchedQueryFidelity(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, searchUserDefaultConfig())
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/contact/v3/users/search",
Reusable: true,
Body: map[string]interface{}{"code": 0, "msg": "ok",
"data": map[string]interface{}{
"items": []interface{}{map[string]interface{}{"id": "ou_x"}},
"has_more": false,
}},
})
err := mountAndRun(t, ContactSearchUser, []string{
"+search-user", "--queries", "张三,Alice 王", "--format", "json", "--as", "user",
}, f, stdout)
if err != nil {
t.Fatalf("execute: %v", err)
}
var got map[string]interface{}
_ = json.Unmarshal(stdout.Bytes(), &got)
users := got["data"].(map[string]interface{})["users"].([]interface{})
if len(users) != 2 {
t.Fatalf("users: got %d, want 2", len(users))
}
want := []string{"张三", "Alice 王"}
for i, w := range want {
mq := users[i].(map[string]interface{})["matched_query"]
if mq != w {
t.Errorf("users[%d].matched_query: got %v, want %q (must be original input verbatim)", i, mq, w)
}
}
}
func TestFanout_NDJSONStdoutClean(t *testing.T) {
f, stdout, stderr, reg := cmdutil.TestFactory(t, searchUserDefaultConfig())
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/contact/v3/users/search",
Reusable: true,
Body: map[string]interface{}{"code": 0, "msg": "ok",
"data": map[string]interface{}{
"items": []interface{}{map[string]interface{}{"id": "ou_a"}},
"has_more": false,
}},
})
err := mountAndRun(t, ContactSearchUser, []string{
"+search-user", "--queries", "a,a,b", "--format", "ndjson", "--as", "user",
}, f, stdout)
if err != nil {
t.Fatalf("execute: %v", err)
}
for _, marker := range []string{"queries,", "total users", "with has_more"} {
if strings.Contains(stdout.String(), marker) {
t.Errorf("ndjson stdout must not contain %q; got=%q", marker, stdout.String())
}
}
_ = stderr
}
func TestFanout_CSVHasMatchedQueryColumn(t *testing.T) {
f, stdout, stderr, reg := cmdutil.TestFactory(t, searchUserDefaultConfig())
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/contact/v3/users/search",
Reusable: true,
Body: map[string]interface{}{"code": 0, "msg": "ok",
"data": map[string]interface{}{
"items": []interface{}{map[string]interface{}{"id": "ou_a"}},
"has_more": false,
}},
})
err := mountAndRun(t, ContactSearchUser, []string{
"+search-user", "--queries", "alice,bob", "--format", "csv", "--as", "user",
}, f, stdout)
if err != nil {
t.Fatalf("execute: %v", err)
}
if !strings.Contains(stdout.String(), "matched_query") {
t.Errorf("csv stdout must include matched_query column; got=%q", stdout.String())
}
if !strings.Contains(stderr.String(), "queries") || !strings.Contains(stderr.String(), "total users") {
t.Errorf("csv summary should land on stderr; got=%q", stderr.String())
}
}
func TestFanout_DryRun(t *testing.T) {
f, stdout, _, _ := cmdutil.TestFactory(t, searchUserDefaultConfig())
err := mountAndRun(t, ContactSearchUser, []string{
"+search-user", "--queries", "alice,bob", "--has-chatted", "--dry-run", "--as", "user",
}, f, stdout)
if err != nil {
t.Fatalf("execute: %v", err)
}
out := stdout.String()
for _, want := range []string{"alice", "bob", "POST", "/contact/v3/users/search", "has_contact"} {
if !strings.Contains(out, want) {
t.Errorf("dry-run output missing %q; got=%q", want, out)
}
}
// One DryRunAPI description per query.
if strings.Count(out, "/contact/v3/users/search") < 2 {
t.Errorf("dry-run should describe ≥2 API calls (one per query); got=%q", out)
}
}
// Spec §7 promises single-query --query mode is "零变化". The fanout summary
// hint was broadened to csv (good — stderr can carry it without corrupting
// the csv stream on stdout); the single-query refine hint must NOT inherit
// that broadening, since pre-fanout it only fired on pretty/table.
func TestSearchUser_Integration_CSVSingleQueryNoRefineHint(t *testing.T) {
f, stdout, stderr, reg := cmdutil.TestFactory(t, searchUserDefaultConfig())
reg.Register(&httpmock.Stub{
Method: "POST", URL: "/open-apis/contact/v3/users/search",
Body: map[string]interface{}{
"code": 0, "msg": "ok",
"data": map[string]interface{}{
"items": []interface{}{map[string]interface{}{"id": "ou_a"}},
"has_more": true,
"page_token": "tok_next",
},
},
})
err := mountAndRun(t, ContactSearchUser, []string{"+search-user", "--query", "x", "--format", "csv", "--as", "user"}, f, stdout)
if err != nil {
t.Fatalf("execute: %v", err)
}
if strings.Contains(stderr.String(), "refine") {
t.Errorf("single-query --format csv must NOT emit the refine hint; got stderr=%q", stderr.String())
}
}
// A pre-canceled ctx must be observed by runOneQuery before it dispatches the
// HTTP call. The error string is exactly "context canceled" because that's
// what context.Context.Err().Error() returns — agents may grep for it.
func TestRunOneQuery_CtxCanceledEarly(t *testing.T) {
rt, _ := runOneQueryRuntime(t)
// Deliberately register no stub: runOneQuery must short-circuit before
// touching the transport, so the absence of a stub is the assertion.
ctx, cancel := context.WithCancel(context.Background())
cancel()
got := runOneQuery(ctx, rt, 0, "alice", nil)
if got.ErrMsg != "context canceled" {
t.Errorf("ErrMsg: got %q, want %q", got.ErrMsg, "context canceled")
}
if got.Index != 0 || got.Query != "alice" {
t.Errorf("Index/Query mismatch: %+v", got)
}
}