mirror of
https://github.com/larksuite/cli.git
synced 2026-09-14 18:42:53 +08:00
1722 lines
60 KiB
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)
|
|
}
|
|
}
|