mirror of
https://github.com/larksuite/cli.git
synced 2026-07-03 14:02:43 +08:00
feat(contact +search-user): add --queries multi-name fanout (#707)
Add --queries CSV flag to lark-cli contact +search-user for parallel
multi-name fanout (up to 20 entries, partial-failure tolerant).
Output shape in fanout mode:
- data.users[] rows carry matched_query (string)
- data.queries[] sidecar lists each input with {query, error?, has_more}
- top-level data.has_more removed (per-query in queries[])
- error is omitempty; absent on success
Single --query mode is byte-for-byte unchanged (regression-guarded).
--queries is mutually exclusive with --query and --user-ids; bool
filters propagate to every sub-request.
Workers run with WaitGroup + buffered semaphore + index-slot writes;
each has defer recover() converting panics to internal error: ... in
the sidecar (no stack to stderr). Pre-canceled context returns
context canceled without making the request.
All-failed exit propagates first failure's HTTP/API code via ErrAPI;
falls back to ExitInternal for transport/parse/panic/ctx-canceled
(avoids emitting code 0, which means success in the Lark protocol).
HTTP non-200 ErrMsg now includes truncated response body for diagnosis.
Drive-by: signature field is now omitempty (mostly empty in practice).
Infrastructure:
- internal/httpmock gains BodyFilter/OnMatch/Reusable/CapturedBodies
hooks to support concurrent stub-driven tests
- internal/output adds 'users' to knownArrayFields so CSV picks the
primary array correctly
Change-Id: I3c14195fb8e094ae150002d90c36a0e4a0cc97d0
This commit is contained in:
@@ -25,10 +25,26 @@ type Stub struct {
|
||||
Headers http.Header // optional full response headers (takes precedence over ContentType)
|
||||
matched bool
|
||||
|
||||
// BodyFilter (optional): match only when the captured request body satisfies
|
||||
// this predicate. Used to disambiguate multiple stubs that share a URL.
|
||||
BodyFilter func([]byte) bool
|
||||
|
||||
// OnMatch (optional): runs synchronously after the stub matches but before
|
||||
// the response is composed. Used in tests to inject panics or count
|
||||
// in-flight goroutines.
|
||||
OnMatch func(req *http.Request)
|
||||
|
||||
// Reusable (optional): when true, the stub stays available for further
|
||||
// matches after the first hit. Each match appends to CapturedBodies.
|
||||
Reusable bool
|
||||
|
||||
// CapturedHeaders records the request headers of the matched request.
|
||||
// Populated after RoundTrip matches this stub.
|
||||
CapturedHeaders http.Header
|
||||
CapturedBody []byte
|
||||
// CapturedBodies records every captured request body when Reusable is set.
|
||||
// (CapturedBody continues to record the most recent capture for back-compat.)
|
||||
CapturedBodies [][]byte
|
||||
}
|
||||
|
||||
// Registry records stubs and implements http.RoundTripper.
|
||||
@@ -51,8 +67,43 @@ func (r *Registry) Register(s *Stub) {
|
||||
func (r *Registry) RoundTrip(req *http.Request) (*http.Response, error) {
|
||||
urlStr := req.URL.String()
|
||||
|
||||
// Read body once up-front so BodyFilter can inspect it without consuming
|
||||
// the original reader; restore for downstream consumers afterwards.
|
||||
// http.RoundTripper requires us to close the original body.
|
||||
var capturedBody []byte
|
||||
if req.Body != nil {
|
||||
var err error
|
||||
capturedBody, err = io.ReadAll(req.Body)
|
||||
_ = req.Body.Close()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("httpmock: read request body: %w", err)
|
||||
}
|
||||
req.Body = io.NopCloser(bytes.NewReader(capturedBody))
|
||||
}
|
||||
|
||||
matched := r.match(req, urlStr, capturedBody)
|
||||
|
||||
if matched != nil {
|
||||
// Restore body again in case OnMatch wants to read it.
|
||||
req.Body = io.NopCloser(bytes.NewReader(capturedBody))
|
||||
if matched.OnMatch != nil {
|
||||
matched.OnMatch(req)
|
||||
}
|
||||
resp, err := stubResponse(matched)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("httpmock: stub %s %s: %w", matched.Method, matched.URL, err)
|
||||
}
|
||||
return resp, nil
|
||||
}
|
||||
return nil, fmt.Errorf("httpmock: no stub for %s %s", req.Method, req.URL)
|
||||
}
|
||||
|
||||
// match selects the first stub whose Method/URL/BodyFilter all match the
|
||||
// request, mutates its capture state, and returns it. defer-Unlock guarantees
|
||||
// a panicking user-supplied BodyFilter cannot leak the mutex.
|
||||
func (r *Registry) match(req *http.Request, urlStr string, capturedBody []byte) *Stub {
|
||||
r.mu.Lock()
|
||||
var matched *Stub
|
||||
defer r.mu.Unlock()
|
||||
for _, s := range r.stubs {
|
||||
if s.matched {
|
||||
continue
|
||||
@@ -63,25 +114,18 @@ func (r *Registry) RoundTrip(req *http.Request) (*http.Response, error) {
|
||||
if s.URL != "" && !strings.Contains(urlStr, s.URL) {
|
||||
continue
|
||||
}
|
||||
s.matched = true
|
||||
if s.BodyFilter != nil && !s.BodyFilter(capturedBody) {
|
||||
continue
|
||||
}
|
||||
if !s.Reusable {
|
||||
s.matched = true
|
||||
}
|
||||
s.CapturedHeaders = req.Header.Clone()
|
||||
if req.Body != nil {
|
||||
s.CapturedBody, _ = io.ReadAll(req.Body)
|
||||
req.Body = io.NopCloser(bytes.NewReader(s.CapturedBody))
|
||||
}
|
||||
matched = s
|
||||
break
|
||||
s.CapturedBody = capturedBody
|
||||
s.CapturedBodies = append(s.CapturedBodies, capturedBody)
|
||||
return s
|
||||
}
|
||||
r.mu.Unlock()
|
||||
|
||||
if matched != nil {
|
||||
resp, err := stubResponse(matched)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("httpmock: stub %s %s: %w", matched.Method, matched.URL, err)
|
||||
}
|
||||
return resp, nil
|
||||
}
|
||||
return nil, fmt.Errorf("httpmock: no stub for %s %s", req.Method, req.URL)
|
||||
return nil
|
||||
}
|
||||
|
||||
// Verify asserts all stubs were matched.
|
||||
@@ -90,9 +134,14 @@ func (r *Registry) Verify(t testing.TB) {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
for _, s := range r.stubs {
|
||||
if !s.matched {
|
||||
t.Errorf("httpmock: unmatched stub: %s %s", s.Method, s.URL)
|
||||
if s.matched {
|
||||
continue
|
||||
}
|
||||
// Reusable stubs never set s.matched; treat any captured hit as a match.
|
||||
if s.Reusable && len(s.CapturedBodies) > 0 {
|
||||
continue
|
||||
}
|
||||
t.Errorf("httpmock: unmatched stub: %s %s", s.Method, s.URL)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -15,6 +15,7 @@ import (
|
||||
var knownArrayFields = []string{
|
||||
"items", "files", "events", "rooms", "records", "nodes",
|
||||
"members", "departments", "calendar_list", "acl_list", "freebusy_list",
|
||||
"users",
|
||||
}
|
||||
|
||||
// FindArrayField finds the primary array field in a response's data object.
|
||||
|
||||
@@ -123,7 +123,7 @@ type searchUser struct {
|
||||
P2PChatID string `json:"p2p_chat_id"`
|
||||
HasChatted bool `json:"has_chatted"`
|
||||
Department string `json:"department"`
|
||||
Signature string `json:"signature"`
|
||||
Signature string `json:"signature,omitempty"`
|
||||
ChatRecencyHint string `json:"chat_recency_hint"`
|
||||
MatchSegments []string `json:"match_segments"`
|
||||
}
|
||||
@@ -150,18 +150,38 @@ var ContactSearchUser = common.Shortcut{
|
||||
{Name: "left-organization", Type: "bool", Desc: "restrict to users who have left the organization (omit to disable; =false rejected)"},
|
||||
{Name: "lang", Desc: "override locale for localized_name (e.g. zh_cn, en_us)"},
|
||||
{Name: "page-size", Type: "int", Default: "20", Desc: "rows per request, 1-30"},
|
||||
{Name: "queries", Desc: "comma-separated keywords searched in parallel; output is a flat users[] with matched_query plus a queries[] sidecar"},
|
||||
},
|
||||
Tips: []string{
|
||||
"Keyword search: lark-cli contact +search-user --query 'alice' --format json",
|
||||
"Look up by ID (or 'me' for self): lark-cli contact +search-user --user-ids 'ou_xxx,me' --format json",
|
||||
"Filter-only enumeration — users you've chatted with: lark-cli contact +search-user --has-chatted --format json",
|
||||
"Keyword search: lark-cli contact +search-user --query 'alice'",
|
||||
"Look up by ID (or 'me' for self): lark-cli contact +search-user --user-ids 'ou_xxx,me'",
|
||||
"Filter-only enumeration — users you've chatted with: lark-cli contact +search-user --has-chatted",
|
||||
"Refine same-name hits: lark-cli contact +search-user --query '张三' --has-chatted --exclude-external-users",
|
||||
"Multi-name fanout: lark-cli contact +search-user --queries 'alice,bob,张三'",
|
||||
"open_id is the stable identifier for follow-up commands; on has_more=true add filters or tighten --query — there is no auto-pagination.",
|
||||
},
|
||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
return validateSearchUser(runtime)
|
||||
},
|
||||
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
if raw := strings.TrimSpace(runtime.Str("queries")); raw != "" {
|
||||
queries := parseAndDedupQueries(raw)
|
||||
filter, err := buildFanoutFilter(runtime)
|
||||
if err != nil {
|
||||
return common.NewDryRunAPI().Set("error", err.Error())
|
||||
}
|
||||
api := common.NewDryRunAPI()
|
||||
for _, q := range queries {
|
||||
body := &searchUserAPIRequest{Query: q}
|
||||
if filter != nil {
|
||||
body.Filter = filter
|
||||
}
|
||||
api.POST(searchUserURL).
|
||||
Params(map[string]interface{}{"page_size": runtime.Int("page-size")}).
|
||||
Body(body)
|
||||
}
|
||||
return api
|
||||
}
|
||||
body, err := buildSearchUserBody(runtime)
|
||||
if err != nil {
|
||||
return common.NewDryRunAPI().Set("error", err.Error())
|
||||
@@ -175,6 +195,13 @@ var ContactSearchUser = common.Shortcut{
|
||||
}
|
||||
|
||||
func executeSearchUser(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
if strings.TrimSpace(runtime.Str("queries")) != "" {
|
||||
return executeSearchUserFanout(ctx, runtime)
|
||||
}
|
||||
return executeSearchUserSingle(ctx, runtime)
|
||||
}
|
||||
|
||||
func executeSearchUserSingle(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
body, err := buildSearchUserBody(runtime)
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -347,10 +374,32 @@ func rowFromItem(item *searchUserAPIItem, lang string, brand core.LarkBrand) sea
|
||||
func validateSearchUser(runtime *common.RuntimeContext) error {
|
||||
if !hasAnySearchInput(runtime) {
|
||||
return common.FlagErrorf(
|
||||
"specify at least one of --query, --user-ids, --has-chatted, --has-enterprise-email, --exclude-external-users, --left-organization",
|
||||
"specify at least one of --query, --queries, --user-ids, --has-chatted, --has-enterprise-email, --exclude-external-users, --left-organization",
|
||||
)
|
||||
}
|
||||
|
||||
queriesRaw := strings.TrimSpace(runtime.Str("queries"))
|
||||
if queriesRaw != "" {
|
||||
if strings.TrimSpace(runtime.Str("query")) != "" {
|
||||
return common.FlagErrorf("--query and --queries are mutually exclusive")
|
||||
}
|
||||
if strings.TrimSpace(runtime.Str("user-ids")) != "" {
|
||||
return common.FlagErrorf("--user-ids and --queries are mutually exclusive")
|
||||
}
|
||||
queries := parseAndDedupQueries(queriesRaw)
|
||||
if len(queries) == 0 {
|
||||
return common.FlagErrorf("--queries: no valid query parsed from %q (separate entries with ',')", queriesRaw)
|
||||
}
|
||||
if len(queries) > maxFanoutQueries {
|
||||
return common.FlagErrorf("--queries: must be at most %d entries (got %d)", maxFanoutQueries, len(queries))
|
||||
}
|
||||
for _, q := range queries {
|
||||
if utf8.RuneCountInString(q) > maxSearchUserQueryChars {
|
||||
return common.FlagErrorf("--queries: entry %q exceeds %d characters", q, maxSearchUserQueryChars)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if q := strings.TrimSpace(runtime.Str("query")); q != "" {
|
||||
if utf8.RuneCountInString(q) > maxSearchUserQueryChars {
|
||||
return common.FlagErrorf("--query: length must be between 1 and %d characters", maxSearchUserQueryChars)
|
||||
@@ -399,6 +448,9 @@ func hasAnySearchInput(runtime *common.RuntimeContext) bool {
|
||||
if strings.TrimSpace(runtime.Str("query")) != "" {
|
||||
return true
|
||||
}
|
||||
if strings.TrimSpace(runtime.Str("queries")) != "" {
|
||||
return true
|
||||
}
|
||||
if strings.TrimSpace(runtime.Str("user-ids")) != "" {
|
||||
return true
|
||||
}
|
||||
|
||||
275
shortcuts/contact/contact_search_user_fanout.go
Normal file
275
shortcuts/contact/contact_search_user_fanout.go
Normal file
@@ -0,0 +1,275 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package contact
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
larkcore "github.com/larksuite/oapi-sdk-go/v3/core"
|
||||
)
|
||||
|
||||
const (
|
||||
maxFanoutQueries = 20
|
||||
fanoutConcurrency = 5
|
||||
)
|
||||
|
||||
// parseAndDedupQueries splits the raw CSV, trims whitespace, drops empty
|
||||
// entries, and deduplicates case-sensitively while preserving first-occurrence
|
||||
// order.
|
||||
func parseAndDedupQueries(raw string) []string {
|
||||
parts := common.SplitCSV(raw)
|
||||
seen := make(map[string]bool, len(parts))
|
||||
out := make([]string, 0, len(parts))
|
||||
for _, p := range parts {
|
||||
p = strings.TrimSpace(p)
|
||||
if p == "" || seen[p] {
|
||||
continue
|
||||
}
|
||||
seen[p] = true
|
||||
out = append(out, p)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
type fanoutResult struct {
|
||||
Index int
|
||||
Query string
|
||||
Users []searchUser
|
||||
HasMore bool
|
||||
ErrMsg string // empty = success
|
||||
ErrCode int // 0 = success or unknown; otherwise an HTTP status or Lark API code corresponding to the first error
|
||||
}
|
||||
|
||||
// isFanoutSummaryFormat gates the per-fanout stderr summary line. Includes csv
|
||||
// because that summary lives on stderr and never corrupts the csv stream on
|
||||
// stdout — single-query mode keeps the narrower isHumanReadableFormat predicate
|
||||
// for its refine hint, so adding csv here doesn't regress that path.
|
||||
func isFanoutSummaryFormat(format string) bool {
|
||||
return format == "pretty" || format == "table" || format == "csv"
|
||||
}
|
||||
|
||||
// runOneQuery converts every failure mode (transport, HTTP status, parse,
|
||||
// API code) into an ErrMsg string instead of returning a Go error. The
|
||||
// fanout dispatcher (Task 6) relies on this so a single failed query never
|
||||
// short-circuits the remaining workers.
|
||||
func runOneQuery(ctx context.Context, runtime *common.RuntimeContext, index int, query string,
|
||||
filter *searchUserAPIFilter) fanoutResult {
|
||||
// Pre-check ctx so queued workers see cancellation before issuing a
|
||||
// request; in-flight workers continue until DoAPI returns.
|
||||
if err := ctx.Err(); err != nil {
|
||||
return fanoutResult{Index: index, Query: query, ErrMsg: err.Error()}
|
||||
}
|
||||
|
||||
body := &searchUserAPIRequest{Query: query}
|
||||
if filter != nil {
|
||||
body.Filter = filter
|
||||
}
|
||||
|
||||
apiResp, err := runtime.DoAPI(&larkcore.ApiReq{
|
||||
HttpMethod: http.MethodPost,
|
||||
ApiPath: searchUserURL,
|
||||
Body: body,
|
||||
QueryParams: larkcore.QueryParams{"page_size": []string{strconv.Itoa(runtime.Int("page-size"))}},
|
||||
})
|
||||
if err != nil {
|
||||
return fanoutResult{Index: index, Query: query, ErrMsg: err.Error()}
|
||||
}
|
||||
if apiResp.StatusCode != http.StatusOK {
|
||||
body := strings.TrimSpace(string(apiResp.RawBody))
|
||||
const maxBody = 200
|
||||
if len(body) > maxBody {
|
||||
body = body[:maxBody] + "..."
|
||||
}
|
||||
msg := fmt.Sprintf("HTTP %d %s", apiResp.StatusCode, http.StatusText(apiResp.StatusCode))
|
||||
if body != "" {
|
||||
msg = fmt.Sprintf("%s: %s", msg, body)
|
||||
}
|
||||
return fanoutResult{Index: index, Query: query,
|
||||
ErrMsg: msg,
|
||||
ErrCode: apiResp.StatusCode}
|
||||
}
|
||||
|
||||
var resp searchUserAPIEnvelope
|
||||
if err := json.Unmarshal(apiResp.RawBody, &resp); err != nil {
|
||||
return fanoutResult{Index: index, Query: query,
|
||||
ErrMsg: fmt.Sprintf("parse response failed: %v", err)}
|
||||
}
|
||||
if resp.Code != 0 {
|
||||
return fanoutResult{Index: index, Query: query,
|
||||
ErrMsg: fmt.Sprintf("API %d: %s", resp.Code, resp.Msg),
|
||||
ErrCode: resp.Code}
|
||||
}
|
||||
|
||||
users, hasMore := projectUsers(resp.Data, runtime.Str("lang"), runtime.Config.Brand)
|
||||
return fanoutResult{Index: index, Query: query, Users: users, HasMore: hasMore}
|
||||
}
|
||||
|
||||
type fanoutUser struct {
|
||||
searchUser
|
||||
MatchedQuery string `json:"matched_query"`
|
||||
}
|
||||
|
||||
type querySummary struct {
|
||||
Query string `json:"query"`
|
||||
Error string `json:"error,omitempty"`
|
||||
HasMore bool `json:"has_more"`
|
||||
}
|
||||
|
||||
type fanoutResponse struct {
|
||||
Users []fanoutUser `json:"users"`
|
||||
Queries []querySummary `json:"queries"`
|
||||
}
|
||||
|
||||
// buildFanoutResponse walks results by Index (input order), flattens users[]
|
||||
// with matched_query, lists every input in queries[] (including successes),
|
||||
// and returns an error only when every query failed. The error wraps the
|
||||
// first failing query's ErrMsg so the CLI exits non-zero on full failure.
|
||||
func buildFanoutResponse(queries []string, results []fanoutResult) (*fanoutResponse, error) {
|
||||
indexed := make([]fanoutResult, len(queries))
|
||||
for _, r := range results {
|
||||
indexed[r.Index] = r
|
||||
}
|
||||
|
||||
out := &fanoutResponse{
|
||||
Users: make([]fanoutUser, 0),
|
||||
Queries: make([]querySummary, 0, len(queries)),
|
||||
}
|
||||
failed := 0
|
||||
var firstErrMsg, firstErrQuery string
|
||||
var firstErrCode int
|
||||
for i, r := range indexed {
|
||||
out.Queries = append(out.Queries, querySummary{
|
||||
Query: queries[i],
|
||||
Error: r.ErrMsg,
|
||||
HasMore: r.HasMore,
|
||||
})
|
||||
if r.ErrMsg != "" {
|
||||
failed++
|
||||
if firstErrMsg == "" {
|
||||
firstErrMsg = r.ErrMsg
|
||||
firstErrQuery = queries[i]
|
||||
firstErrCode = r.ErrCode
|
||||
}
|
||||
continue
|
||||
}
|
||||
for _, u := range r.Users {
|
||||
out.Users = append(out.Users, fanoutUser{searchUser: u, MatchedQuery: queries[i]})
|
||||
}
|
||||
}
|
||||
if failed == len(queries) && len(queries) > 0 {
|
||||
msg := fmt.Sprintf("all %d queries failed; first: %s (query=%q)",
|
||||
len(queries), firstErrMsg, firstErrQuery)
|
||||
// Only the HTTP-status / Lark-API-code branches in runOneQuery populate
|
||||
// ErrCode; transport, parse, panic, and ctx-canceled stay at 0. Code 0
|
||||
// means success in the Lark protocol, so don't pretend it's an API error
|
||||
// when we have nothing structured to report.
|
||||
if firstErrCode != 0 {
|
||||
return nil, output.ErrAPI(firstErrCode, msg, "")
|
||||
}
|
||||
return nil, output.ErrWithHint(output.ExitInternal, "fanout", msg, "")
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func executeSearchUserFanout(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
queries := parseAndDedupQueries(runtime.Str("queries"))
|
||||
|
||||
filter, err := buildFanoutFilter(runtime)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
results := make([]fanoutResult, len(queries))
|
||||
var wg sync.WaitGroup
|
||||
sem := make(chan struct{}, fanoutConcurrency)
|
||||
|
||||
for i, q := range queries {
|
||||
wg.Add(1)
|
||||
sem <- struct{}{}
|
||||
go func(i int, q string) {
|
||||
defer wg.Done()
|
||||
defer func() { <-sem }()
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
results[i] = fanoutResult{
|
||||
Index: i,
|
||||
Query: q,
|
||||
ErrMsg: fmt.Sprintf("internal error: %v", r),
|
||||
}
|
||||
}
|
||||
}()
|
||||
results[i] = runOneQuery(ctx, runtime, i, q, filter)
|
||||
}(i, q)
|
||||
}
|
||||
wg.Wait()
|
||||
|
||||
resp, err := buildFanoutResponse(queries, results)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
failed, hasMoreCount := 0, 0
|
||||
for _, qs := range resp.Queries {
|
||||
if qs.Error != "" {
|
||||
failed++
|
||||
}
|
||||
if qs.HasMore {
|
||||
hasMoreCount++
|
||||
}
|
||||
}
|
||||
|
||||
runtime.OutFormat(resp, &output.Meta{Count: len(resp.Users)}, func(w io.Writer) {
|
||||
if len(resp.Users) == 0 {
|
||||
fmt.Fprintln(w, "No users found.")
|
||||
return
|
||||
}
|
||||
output.PrintTable(w, prettyFanoutUserRows(resp.Users))
|
||||
})
|
||||
|
||||
if isFanoutSummaryFormat(runtime.Format) {
|
||||
fmt.Fprintf(runtime.IO().ErrOut, "\n%d queries, %d total users; %d failed, %d with has_more\n",
|
||||
len(queries), len(resp.Users), failed, hasMoreCount)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func buildFanoutFilter(runtime *common.RuntimeContext) (*searchUserAPIFilter, error) {
|
||||
filter := &searchUserAPIFilter{}
|
||||
hasFilter := false
|
||||
for _, bf := range searchUserBoolFilters {
|
||||
if runtime.Cmd.Flags().Changed(bf.Flag) && runtime.Bool(bf.Flag) {
|
||||
bf.Apply(filter)
|
||||
hasFilter = true
|
||||
}
|
||||
}
|
||||
if !hasFilter {
|
||||
return nil, nil
|
||||
}
|
||||
return filter, nil
|
||||
}
|
||||
|
||||
func prettyFanoutUserRows(users []fanoutUser) []map[string]interface{} {
|
||||
rows := make([]map[string]interface{}, 0, len(users))
|
||||
for _, u := range users {
|
||||
rows = append(rows, map[string]interface{}{
|
||||
"matched_query": u.MatchedQuery,
|
||||
"localized_name": u.LocalizedName,
|
||||
"department": common.TruncateStr(u.Department, 50),
|
||||
"enterprise_email": u.EnterpriseEmail,
|
||||
"has_chatted": u.HasChatted,
|
||||
"chat_recency_hint": u.ChatRecencyHint,
|
||||
"open_id": u.OpenID,
|
||||
})
|
||||
}
|
||||
return rows
|
||||
}
|
||||
@@ -5,10 +5,14 @@ package contact
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
"unicode/utf8"
|
||||
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
@@ -620,6 +624,46 @@ func TestSearchUser_Integration_JSONStructuredFields(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// 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{
|
||||
@@ -808,6 +852,345 @@ func TestSearchUser_Integration_PageSizeFlowsToQuery(t *testing.T) {
|
||||
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)
|
||||
}
|
||||
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)
|
||||
}
|
||||
if got.ErrCode != 503 {
|
||||
t.Errorf("ErrCode = %d, want 503", got.ErrCode)
|
||||
}
|
||||
}
|
||||
|
||||
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 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)
|
||||
}
|
||||
}
|
||||
|
||||
// Codes from the first failure must propagate through output.ErrAPI 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", ErrCode: 99991663},
|
||||
{Index: 1, Query: "bob", ErrMsg: "HTTP 500", ErrCode: 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)
|
||||
}
|
||||
}
|
||||
|
||||
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")
|
||||
}
|
||||
}
|
||||
|
||||
// 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.
|
||||
@@ -827,3 +1210,341 @@ func TestSearchUser_Integration_NoAutoPaginationFlags(t *testing.T) {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
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{}{
|
||||
"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())
|
||||
}
|
||||
queries := data["queries"].([]interface{})
|
||||
if len(queries) != 2 {
|
||||
t.Fatalf("queries: expected 2, got %d", len(queries))
|
||||
}
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,13 +12,14 @@
|
||||
|
||||
## 关键 flag
|
||||
|
||||
`--query` / `--user-ids` / 4 个 bool filter 至少传一个,否则报错。完整 flag 看 `lark-cli contact +search-user --help`。
|
||||
`--query` / `--queries` / `--user-ids` / bool filter 至少传一个。bool filter 显式传 `=false` 会报错——不传等于不过滤。
|
||||
|
||||
| Flag | 作用 |
|
||||
|---|---|
|
||||
| `--query <text>` | 关键词(姓名 / 邮箱 / 手机号),≤ 64 rune |
|
||||
| `--user-ids <csv>` | open_id 列表,逗号分隔,≤ 100;支持 `me` 表示自己;与 `--query` 同传时把搜索范围限定在该集合 |
|
||||
| `--has-chatted` | 仅搜聊过天的(opt-in;**显式 `=false` 会被拒**,下同) |
|
||||
| `--query <text>` | 关键词(姓名 / 邮箱 / 手机号),≤ 50 rune |
|
||||
| `--queries <csv>` | 多个关键词并行搜,**最多 20 条**;与 `--query` / `--user-ids` 互斥;输出新 shape(见下) |
|
||||
| `--user-ids <csv>` | open_id 列表,≤ 100;支持 `me` 表示自己;与 `--query` 同传时把搜索范围限定在该集合 |
|
||||
| `--has-chatted` | 仅搜聊过天的 |
|
||||
| `--has-enterprise-email` | 仅搜有企业邮箱的 |
|
||||
| `--exclude-external-users` | 仅搜同租户(排除外部联系人) |
|
||||
| `--left-organization` | 仅搜已离职的 |
|
||||
@@ -47,6 +48,30 @@ lark-cli contact +search-user --query "王" --exclude-external-users --has-enter
|
||||
lark-cli contact +search-user --has-chatted --left-organization
|
||||
```
|
||||
|
||||
## 批量并行查询 (fanout)
|
||||
|
||||
一次查多个名字:
|
||||
|
||||
```bash
|
||||
lark-cli contact +search-user --queries "Alice,Bob,张三"
|
||||
```
|
||||
|
||||
- 每行 user 带 `matched_query`,标识来自哪个 query
|
||||
- `queries[]` 每个输入一条 `{query, error?, has_more}`,失败的有 `error`
|
||||
- 部分失败不影响其它 query;全部失败才 exit 非 0
|
||||
|
||||
```bash
|
||||
# bool filter 对每个 query 都生效
|
||||
lark-cli contact +search-user --queries "Alice,Bob" --has-chatted
|
||||
|
||||
# 与 --query / --user-ids 互斥
|
||||
lark-cli contact +search-user --queries "a" --query "b" # ❌ exit 2
|
||||
```
|
||||
|
||||
约束:
|
||||
- 最多 20 条; 每条 ≤ 50 字符
|
||||
- 重复条目静默去重;全空 csv (`,,,`) 报错
|
||||
|
||||
## 同名 disambiguation
|
||||
|
||||
搜常见姓名常返回多条同名结果。后续操作若有副作用(发消息、邀请会议等),把候选列给用户挑;**不要擅自选**。
|
||||
@@ -61,28 +86,39 @@ lark-cli contact +search-user --query "张三" \
|
||||
|
||||
## 注意事项
|
||||
|
||||
- **不会自动翻页**。`has_more=true` 表示要 refine query,不是叫你翻页。
|
||||
- **bool filter 显式传 `=false` 会报错**:不传等于不过滤;启用就传 flag(不带值)。
|
||||
- **不会自动翻页**。`has_more=true` 表示需要 refine query。
|
||||
- **`--lang` 只影响输出展示名**,不影响匹配字段。
|
||||
- **`--query` 与 `--user-ids` 同时设**:`--user-ids` 进 `filter.user_ids`(限定搜索范围),`--query` 进顶层(关键字),按服务端 filter 语义在该 ID 集合内匹配;请求结构可 `--dry-run` 确认。
|
||||
- **`--query` 与 `--user-ids` 同时设**:`--user-ids` 限定搜索范围,`--query` 在该集合内匹配。
|
||||
|
||||
## 输出字段 contract
|
||||
|
||||
`data.users[]` 的字段集合稳定,可直接 jq / 反序列化。跨租户用户(`is_cross_tenant=true`)按飞书可见性规则,业务字段可能为空字符串 —— 下游做空值兜底,不要当成"字段缺失"。
|
||||
跨租户用户(`is_cross_tenant=true`)的业务字段可能为空字符串,需做空值兜底。
|
||||
|
||||
| 字段 | 类型 | 说明 | 跨租户 |
|
||||
|---|---|---|---|
|
||||
| `open_id` | string | 稳定标识,后续命令以此为准 | 始终非空 |
|
||||
| `localized_name` | string | 按 `--lang` / brand 选出的展示名;想换语言重查时传 `--lang en_us` 等 | 始终非空(兜底为 open_id) |
|
||||
| `open_id` | string | 稳定标识,后续命令的输入 | 始终非空 |
|
||||
| `localized_name` | string | 按 `--lang` / brand 选出的展示名 | 始终非空(兜底为 open_id) |
|
||||
| `email` | string | 个人邮箱 | 可能为空 |
|
||||
| `enterprise_email` | string | 企业邮箱 | 可能为空 |
|
||||
| `is_activated` | bool | 是否已激活飞书账号(未激活也可投递消息,但用户可能看不到) | 可能 false |
|
||||
| `is_cross_tenant` | bool | 是否跨租户用户(同公司=false,外部联系人=true) | — |
|
||||
| `p2p_chat_id` | string | 与当前用户的现有 P2P 会话 ID(`oc_...`);空表示从未私聊过。可作为任何接受 `--chat-id` 的 IM 命令的输入 | 可能为空 |
|
||||
| `p2p_chat_id` | string | 与当前用户的 P2P 会话 ID(`oc_...`);空表示从未私聊过。可作为接受 `--chat-id` 的 IM 命令的输入 | 可能为空 |
|
||||
| `has_chatted` | bool | `p2p_chat_id != ""` 的派生字段 | — |
|
||||
| `department` | string | 部门路径,服务端可能用 `-` 拼层级,层级数不固定。**按可子串匹配的字符串处理** | 可能为空 |
|
||||
| `signature` | string | 用户个性签名(API 原名 `description`,本 CLI 重命名以反映真实语义)。同名 disambiguation 时可作为辅助信号 | 可能为空 |
|
||||
| `chat_recency_hint` | string | 最近联系提示文案,如 `"Contacted 2 days ago"`;空表示无近期联系 | 可能为空 |
|
||||
| `signature` | string (optional) | 用户个性签名;空时字段不出现 | 可能不出现 |
|
||||
| `chat_recency_hint` | string | 最近联系的提示文案,仅供展示 | 可能为空 |
|
||||
| `match_segments` | string[] | 关键词命中的字符串片段,用于高亮展示;无命中则为空数组 | — |
|
||||
|
||||
表中字段即本 shortcut 的输出契约,移除或改名按 breaking change 处理。
|
||||
### `--queries` 模式额外字段
|
||||
|
||||
`data.users[]` 每条多 `matched_query` (string),指明本行来自哪个 query。
|
||||
|
||||
`data.queries[]` 按输入顺序、dedup 后每个 query 一条:
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
|---|---|---|
|
||||
| `query` | string | 该输入 |
|
||||
| `error` | string (optional) | 失败原因;成功时不出现 |
|
||||
| `has_more` | bool | 该 query 还有更多结果 |
|
||||
|
||||
fanout 模式无顶层 `data.has_more`。
|
||||
|
||||
Reference in New Issue
Block a user