Files
larksuite-cli/shortcuts/base/base_resolve_test.go
zgz2048 7df37ed715 feat(base): Add Base URL and title resolve shortcuts (#1338)
* feat(base): add URL and title resolve shortcuts

* docs: clarify base coordinate resolution

* fix(base): address resolve shortcut ci

* fix(base): format resolved record share hint

* fix(base): simplify record share hint data

* fix(base): use field ids in resolved record data

* fix(base): guide record share resolve to update record

* fix(base): include record upsert example in resolve hint

* fix(base): reject add-record urls in resolver

* fix(base): validate title resolve query length

* fix(base): hide resolve alias flags from help

* fix(base): prefer title flag for title resolve

* docs(base): clarify token resolution wording
2026-06-24 22:26:29 +08:00

455 lines
16 KiB
Go

// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package base
import (
"strings"
"testing"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/httpmock"
"github.com/larksuite/cli/shortcuts/common"
"github.com/spf13/cobra"
)
func TestBaseURLResolveBaseURL(t *testing.T) {
t.Run("with coordinates", func(t *testing.T) {
factory, stdout, reg := newExecuteFactory(t)
reg.Register(fieldListStub("bas123", "tbl123"))
err := runShortcutWithAuthTypes(t, BaseURLResolve, authTypes(), []string{
"+url-resolve",
"--url", "https://example.larkoffice.com/base/bas123?table=tbl123&view=vew123&record=rec123",
"--as", "user",
}, factory, stdout)
if err != nil {
t.Fatalf("err=%v", err)
}
data := decodeBaseEnvelope(t, stdout)
if data["input_type"] != "base_url" || data["base_token"] != "bas123" {
t.Fatalf("unexpected output: %#v", data)
}
if data["table_id"] != "tbl123" || data["view_id"] != "vew123" || data["record_id"] != "rec123" {
t.Fatalf("missing Base coordinates: %#v", data)
}
hint, _ := data["hint"].(map[string]interface{})
fields, _ := hint["fields"].(map[string]interface{})
if hint["next_step"] != nextStepRecordList || fields["total"] != float64(2) {
t.Fatalf("unexpected hint: %#v", hint)
}
})
t.Run("base only", func(t *testing.T) {
factory, stdout, _ := newExecuteFactory(t)
err := runShortcutWithAuthTypes(t, BaseURLResolve, authTypes(), []string{
"+url-resolve", "--url", "https://example.larkoffice.com/base/bas123", "--as", "user",
}, factory, stdout)
if err != nil {
t.Fatalf("err=%v", err)
}
data := decodeBaseEnvelope(t, stdout)
if data["input_type"] != "base_url" || data["base_token"] != "bas123" {
t.Fatalf("unexpected output: %#v", data)
}
if _, ok := data["table_id"]; ok {
t.Fatalf("table_id should be omitted for base-only URL: %#v", data)
}
hint, _ := data["hint"].(map[string]interface{})
if hint["next_step"] != nextStepBaseBlockList {
t.Fatalf("unexpected hint: %#v", hint)
}
})
t.Run("field list enrichment failure still returns coordinates", func(t *testing.T) {
factory, stdout, _ := newExecuteFactory(t)
err := runShortcutWithAuthTypes(t, BaseURLResolve, authTypes(), []string{
"+url-resolve", "--url", "https://example.larkoffice.com/base/bas123?table=tbl123", "--as", "user",
}, factory, stdout)
if err != nil {
t.Fatalf("err=%v", err)
}
data := decodeBaseEnvelope(t, stdout)
if data["base_token"] != "bas123" || data["table_id"] != "tbl123" {
t.Fatalf("unexpected output: %#v", data)
}
hint, _ := data["hint"].(map[string]interface{})
if hint["next_step"] != nextStepRecordList {
t.Fatalf("unexpected hint: %#v", hint)
}
if _, ok := hint["fields"]; ok {
t.Fatalf("fields should be omitted when enrichment fails: %#v", hint)
}
})
}
func TestBaseURLResolveWikiURL(t *testing.T) {
t.Run("bitable", func(t *testing.T) {
factory, stdout, reg := newExecuteFactory(t)
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/wiki/v2/spaces/get_node?token=wik123",
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{
"node": map[string]interface{}{
"obj_type": "bitable",
"obj_token": "bas123",
"title": "Demo Base",
},
},
},
})
err := runShortcutWithAuthTypes(t, BaseURLResolve, authTypes(), []string{
"+url-resolve", "--url", "https://example.larkoffice.com/wiki/wik123", "--as", "user",
}, factory, stdout)
if err != nil {
t.Fatalf("err=%v", err)
}
data := decodeBaseEnvelope(t, stdout)
if data["input_type"] != "wiki_url" || data["base_token"] != "bas123" || data["title"] != "Demo Base" {
t.Fatalf("unexpected output: %#v", data)
}
})
t.Run("non bitable", func(t *testing.T) {
factory, stdout, reg := newExecuteFactory(t)
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/wiki/v2/spaces/get_node?token=wikdoc",
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{
"node": map[string]interface{}{"obj_type": "docx", "obj_token": "docx123"},
},
},
})
err := runShortcutWithAuthTypes(t, BaseURLResolve, authTypes(), []string{
"+url-resolve", "--url", "https://example.larkoffice.com/wiki/wikdoc", "--as", "user",
}, factory, stdout)
if err == nil || !strings.Contains(err.Error(), "not Base") {
t.Fatalf("err=%v, want non-Base validation error", err)
}
})
}
func TestBaseURLResolveRecordShareURL(t *testing.T) {
t.Run("enriched", func(t *testing.T) {
factory, stdout, reg := newExecuteFactory(t)
reg.Register(recordShareMetaStub("shr123", "bas123", "tbl123", "rec123"))
reg.Register(recordBatchGetStub("bas123", "tbl123", "rec123"))
reg.Register(fieldListStub("bas123", "tbl123"))
err := runShortcutWithAuthTypes(t, BaseURLResolve, authTypes(), []string{
"+url-resolve", "--url", "https://example.larkoffice.com/record/shr123", "--as", "user",
}, factory, stdout)
if err != nil {
t.Fatalf("err=%v", err)
}
data := decodeBaseEnvelope(t, stdout)
if data["input_type"] != "record_share_url" || data["base_token"] != "bas123" || data["record_id"] != "rec123" {
t.Fatalf("unexpected output: %#v", data)
}
hint, _ := data["hint"].(map[string]interface{})
recordData, _ := hint["record_data"].(map[string]interface{})
fields, _ := hint["fields"].(map[string]interface{})
nextStep, _ := hint["next_step"].(string)
if !strings.Contains(nextStep, "+record-upsert --base-token bas123 --table-id tbl123 --record-id rec123") || recordData["fld_name"] != "Alice" || fields["total"] != float64(2) {
t.Fatalf("unexpected hint: %#v", hint)
}
})
t.Run("enrichment failure still returns meta", func(t *testing.T) {
factory, stdout, reg := newExecuteFactory(t)
reg.Register(recordShareMetaStub("shr123", "bas123", "tbl123", "rec123"))
err := runShortcutWithAuthTypes(t, BaseURLResolve, authTypes(), []string{
"+url-resolve", "--url", "https://example.larkoffice.com/record/shr123", "--as", "user",
}, factory, stdout)
if err != nil {
t.Fatalf("err=%v", err)
}
data := decodeBaseEnvelope(t, stdout)
if data["input_type"] != "record_share_url" || data["base_token"] != "bas123" || data["record_id"] != "rec123" {
t.Fatalf("unexpected output: %#v", data)
}
hint, _ := data["hint"].(map[string]interface{})
nextStep, _ := hint["next_step"].(string)
if !strings.Contains(nextStep, "+record-upsert --base-token bas123 --table-id tbl123 --record-id rec123") {
t.Fatalf("unexpected hint: %#v", hint)
}
if _, ok := hint["record_data"]; ok {
t.Fatalf("record_data should be omitted when enrichment fails: %#v", hint)
}
if _, ok := hint["fields"]; ok {
t.Fatalf("fields should be omitted when enrichment fails: %#v", hint)
}
})
}
func recordShareMetaStub(shareToken, baseToken, tableID, recordID string) *httpmock.Stub {
return &httpmock.Stub{
Method: "GET",
URL: "/open-apis/base/v3/record_share/" + shareToken + "/meta",
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{
"record_share_token": shareToken,
"base_token": baseToken,
"table_id": tableID,
"record_id": recordID,
},
},
}
}
func TestBaseURLResolveFormShareURL(t *testing.T) {
factory, stdout, _ := newExecuteFactory(t)
err := runShortcutWithAuthTypes(t, BaseURLResolve, authTypes(), []string{
"+url-resolve", "--query", "https://example.larkoffice.com/share/base/form/shrform", "--as", "user",
}, factory, stdout)
if err != nil {
t.Fatalf("err=%v", err)
}
data := decodeBaseEnvelope(t, stdout)
if data["input_type"] != "form_share_url" || data["share_token"] != "shrform" {
t.Fatalf("unexpected output: %#v", data)
}
}
func TestBaseURLResolveValidationErrors(t *testing.T) {
tests := []struct {
name string
rawURL string
wantText string
wantHint string
}{
{"dashboard share", "https://example.larkoffice.com/share/base/dashboard/shr1", "CLI does not support resolving Base dashboard share URLs", "provide the URL of the Base itself"},
{"view share", "https://example.larkoffice.com/share/base/view/shr1", "CLI does not support resolving Base view share URLs", "provide the URL of the Base itself"},
{"workspace", "https://example.larkoffice.com/base/workspace/ws1", "CLI does not support resolving Base workspace URLs", "provide the URL of the Base itself"},
{"add record", "https://example.larkoffice.com/base/add/addtoken", "CLI does not support resolving Base add-record URLs", "provide the URL of the Base itself"},
{"unrelated", "https://example.larkoffice.com/docx/doc1", "not a supported Base URL pattern", ""},
{"not url", "bas123", "only accepts full URLs", ""},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
factory, stdout, _ := newExecuteFactory(t)
err := runShortcutWithAuthTypes(t, BaseURLResolve, authTypes(), []string{
"+url-resolve", "--url", tc.rawURL, "--as", "user",
}, factory, stdout)
if err == nil || !strings.Contains(err.Error(), tc.wantText) {
t.Fatalf("err=%v, want contains %q", err, tc.wantText)
}
p, ok := errs.ProblemOf(err)
if !ok || p.Hint == "" {
t.Fatalf("err=%v, want typed error with hint", err)
}
if tc.wantHint != "" && !strings.Contains(p.Hint, tc.wantHint) {
t.Fatalf("hint=%q, want contains %q", p.Hint, tc.wantHint)
}
if strings.Contains(p.Hint, "original /base/{base_token}") {
t.Fatalf("hint should not require original /base URL: %q", p.Hint)
}
})
}
}
func TestBaseResolveInputXOR(t *testing.T) {
t.Run("url resolve", func(t *testing.T) {
factory, stdout, _ := newExecuteFactory(t)
err := runShortcutWithAuthTypes(t, BaseURLResolve, authTypes(), []string{
"+url-resolve", "--url", "https://example.com/base/bas1", "--query", "https://example.com/base/bas2", "--as", "user",
}, factory, stdout)
if err == nil || !strings.Contains(err.Error(), "mutually exclusive") {
t.Fatalf("err=%v, want xor validation", err)
}
})
t.Run("title resolve", func(t *testing.T) {
factory, stdout, _ := newExecuteFactory(t)
err := runShortcutWithAuthTypes(t, BaseTitleResolve, nil, []string{
"+title-resolve", "--title", "Pipeline", "--query", "Sales", "--as", "user",
}, factory, stdout)
if err == nil || !strings.Contains(err.Error(), "mutually exclusive") {
t.Fatalf("err=%v, want xor validation", err)
}
})
}
func TestBaseResolveHelpFlags(t *testing.T) {
for _, tc := range []struct {
shortcut string
definition common.Shortcut
primaryFlag string
primaryDesc string
aliasFlags []string
}{
{
shortcut: "+url-resolve",
definition: BaseURLResolve,
primaryFlag: "url",
primaryDesc: "Base/Wiki/record-share URL to resolve",
aliasFlags: []string{"query"},
},
{
shortcut: "+title-resolve",
definition: BaseTitleResolve,
primaryFlag: "title",
primaryDesc: "Base title keyword",
aliasFlags: []string{"query", "url"},
},
} {
t.Run(tc.shortcut, func(t *testing.T) {
parent := &cobra.Command{Use: "base"}
tc.definition.Mount(parent, &cmdutil.Factory{})
cmd := parent.Commands()[0]
primary := cmd.Flags().Lookup(tc.primaryFlag)
primaryUsage := ""
if primary != nil {
primaryUsage = primary.Usage
}
if primary == nil || !strings.Contains(primaryUsage, tc.primaryDesc) {
t.Fatalf("primary flag %q usage=%q", tc.primaryFlag, primaryUsage)
}
for _, aliasFlag := range tc.aliasFlags {
alias := cmd.Flags().Lookup(aliasFlag)
if alias == nil || !alias.Hidden {
t.Fatalf("alias flag %q should exist and be hidden: %#v", aliasFlag, alias)
}
}
})
}
}
func TestBaseTitleResolve(t *testing.T) {
t.Run("single result", func(t *testing.T) {
factory, stdout, reg := newExecuteFactory(t)
reg.Register(titleResolveSearchStub([]interface{}{
map[string]interface{}{
"title_highlighted": "Sales <h>Pipeline</h>",
"result_meta": map[string]interface{}{
"doc_types": "BITABLE",
"token": "bas123",
"url": "https://example.larkoffice.com/base/bas123",
"owner_name": "Alice",
"update_time_iso": "2026-06-09T10:00:00+08:00",
},
},
}))
err := runShortcutWithAuthTypes(t, BaseTitleResolve, nil, []string{
"+title-resolve", "--title", "Pipeline", "--as", "user",
}, factory, stdout)
if err != nil {
t.Fatalf("err=%v", err)
}
data := decodeBaseEnvelope(t, stdout)
if data["title"] != "Sales Pipeline" || data["base_token"] != "bas123" || data["owner_name"] != "Alice" {
t.Fatalf("unexpected output: %#v", data)
}
})
t.Run("multiple results and filter non bitable", func(t *testing.T) {
factory, stdout, reg := newExecuteFactory(t)
reg.Register(titleResolveSearchStub([]interface{}{
map[string]interface{}{
"title_highlighted": "Doc hit",
"result_meta": map[string]interface{}{"doc_types": "DOCX", "token": "docx123"},
},
map[string]interface{}{
"title_highlighted": "Base <h>One</h>",
"result_meta": map[string]interface{}{"doc_types": "BITABLE", "token": "bas1", "url": "https://example/base/bas1"},
},
map[string]interface{}{
"title_highlighted": "Base <h>Two</h>",
"result_meta": map[string]interface{}{"doc_types": "BITABLE", "token": "bas2", "url": "https://example/base/bas2"},
},
}))
err := runShortcutWithAuthTypes(t, BaseTitleResolve, nil, []string{
"+title-resolve", "--url", "Base", "--as", "user",
}, factory, stdout)
if err != nil {
t.Fatalf("err=%v", err)
}
data := decodeBaseEnvelope(t, stdout)
candidates, _ := data["candidates"].([]interface{})
if len(candidates) != 2 {
t.Fatalf("candidates=%#v, want 2", data["candidates"])
}
})
t.Run("no results", func(t *testing.T) {
factory, stdout, reg := newExecuteFactory(t)
reg.Register(titleResolveSearchStub(nil))
err := runShortcutWithAuthTypes(t, BaseTitleResolve, nil, []string{
"+title-resolve", "--title", "missing", "--as", "user",
}, factory, stdout)
if err == nil || !strings.Contains(err.Error(), "No Base matched") {
t.Fatalf("err=%v, want no result validation", err)
}
})
t.Run("query too long", func(t *testing.T) {
factory, stdout, _ := newExecuteFactory(t)
err := runShortcutWithAuthTypes(t, BaseTitleResolve, nil, []string{
"+title-resolve", "--title", "codex record share resolve 20260616152113", "--as", "user",
}, factory, stdout)
if err == nil || !strings.Contains(err.Error(), "30 characters or fewer") {
t.Fatalf("err=%v, want query length validation", err)
}
})
}
func titleResolveSearchStub(items []interface{}) *httpmock.Stub {
if items == nil {
items = []interface{}{}
}
return &httpmock.Stub{
Method: "POST",
URL: "/open-apis/search/v2/doc_wiki/search",
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{
"res_units": items,
},
},
}
}
func fieldListStub(baseToken, tableID string) *httpmock.Stub {
return &httpmock.Stub{
Method: "GET",
URL: "/open-apis/base/v3/bases/" + baseToken + "/tables/" + tableID + "/fields",
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{
"total": 2,
"fields": []interface{}{
map[string]interface{}{"field_id": "fld_name", "field_name": "Name", "type": "text"},
map[string]interface{}{"field_id": "fld_status", "field_name": "Status", "type": "singleSelect"},
},
},
},
}
}
func recordBatchGetStub(baseToken, tableID, recordID string) *httpmock.Stub {
return &httpmock.Stub{
Method: "POST",
URL: "/open-apis/base/v3/bases/" + baseToken + "/tables/" + tableID + "/records/batch_get",
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{
"record_id_list": []interface{}{recordID},
"field_id_list": []interface{}{"fld_name", "fld_status"},
"fields": []interface{}{"Name", "Status"},
"data": []interface{}{[]interface{}{"Alice", "Done"}},
},
},
}
}