mirror of
https://github.com/larksuite/cli.git
synced 2026-07-04 14:38:53 +08:00
Compare commits
16 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
af1e28a565 | ||
|
|
06e7ae267c | ||
|
|
74f7de386a | ||
|
|
c2b132945e | ||
|
|
88fd3bdab8 | ||
|
|
c70c3fdce2 | ||
|
|
c13f240b9b | ||
|
|
88bf7fc1cd | ||
|
|
25534d72b5 | ||
|
|
815db0c866 | ||
|
|
bb7957245b | ||
|
|
3917b77e91 | ||
|
|
dc0d92708b | ||
|
|
085ffd87f3 | ||
|
|
f6b8091843 | ||
|
|
0e7f507efb |
80
.github/workflows/cli-e2e.yml
vendored
80
.github/workflows/cli-e2e.yml
vendored
@@ -25,6 +25,8 @@ on:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
actions: read
|
||||
checks: write
|
||||
|
||||
jobs:
|
||||
cli-e2e:
|
||||
@@ -65,71 +67,17 @@ jobs:
|
||||
echo "No CLI E2E packages to test after exclusions."
|
||||
exit 1
|
||||
fi
|
||||
go run gotest.tools/gotestsum@v1.12.3 --format testname --junitfile cli-e2e-report.xml -- -count=1 -v $packages
|
||||
# gotestsum requires --packages when --rerun-fails is combined with go test args after --.
|
||||
packages_arg=$(printf '%s\n' "$packages" | paste -sd' ' -)
|
||||
go run gotest.tools/gotestsum@v1.12.3 --rerun-fails=2 --rerun-fails-max-failures=20 --packages="$packages_arg" --format testname --junitfile cli-e2e-report.xml -- -count=1 -v
|
||||
|
||||
- name: Summarize CLI E2E test report
|
||||
- name: Publish CLI E2E test report
|
||||
if: ${{ !cancelled() }}
|
||||
run: |
|
||||
python3 - <<'PY'
|
||||
import os
|
||||
import xml.etree.ElementTree as ET
|
||||
|
||||
report_path = "cli-e2e-report.xml"
|
||||
summary_path = os.environ["GITHUB_STEP_SUMMARY"]
|
||||
|
||||
root = ET.parse(report_path).getroot()
|
||||
suites = [root] if root.tag == "testsuite" else root.findall("testsuite")
|
||||
|
||||
tests = failures = errors = skipped = 0
|
||||
failed_cases = []
|
||||
skipped_cases = []
|
||||
|
||||
for suite in suites:
|
||||
tests += int(suite.attrib.get("tests", 0))
|
||||
failures += int(suite.attrib.get("failures", 0))
|
||||
errors += int(suite.attrib.get("errors", 0))
|
||||
skipped += int(suite.attrib.get("skipped", 0))
|
||||
|
||||
for case in suite.findall("testcase"):
|
||||
classname = case.attrib.get("classname", "")
|
||||
name = case.attrib.get("name", "")
|
||||
label = f"{classname}.{name}" if classname else name
|
||||
|
||||
failure = case.find("failure")
|
||||
error = case.find("error")
|
||||
skipped_node = case.find("skipped")
|
||||
|
||||
if failure is not None or error is not None:
|
||||
message = ""
|
||||
node = failure if failure is not None else error
|
||||
if node is not None:
|
||||
message = node.attrib.get("message", "") or (node.text or "").strip()
|
||||
failed_cases.append((label, message))
|
||||
elif skipped_node is not None:
|
||||
message = skipped_node.attrib.get("message", "") or (skipped_node.text or "").strip()
|
||||
skipped_cases.append((label, message))
|
||||
|
||||
passed = tests - failures - errors - skipped
|
||||
|
||||
with open(summary_path, "a", encoding="utf-8") as f:
|
||||
f.write("## CLI E2E Test Report\n\n")
|
||||
f.write(f"- Total: {tests}\n")
|
||||
f.write(f"- Passed: {passed}\n")
|
||||
f.write(f"- Failed: {failures}\n")
|
||||
f.write(f"- Errors: {errors}\n")
|
||||
f.write(f"- Skipped: {skipped}\n\n")
|
||||
|
||||
if failed_cases:
|
||||
f.write("### Failed Tests\n\n")
|
||||
for label, message in failed_cases:
|
||||
detail = f" - {message}" if message else ""
|
||||
f.write(f"- `{label}`{detail}\n")
|
||||
f.write("\n")
|
||||
|
||||
if skipped_cases:
|
||||
f.write("### Skipped Tests\n\n")
|
||||
for label, message in skipped_cases:
|
||||
detail = f" - {message}" if message else ""
|
||||
f.write(f"- `{label}`{detail}\n")
|
||||
f.write("\n")
|
||||
PY
|
||||
uses: dorny/test-reporter@a43b3a5f7366b97d083190328d2c652e1a8b6aa2 # v3.0.0
|
||||
with:
|
||||
name: CLI E2E Tests
|
||||
path: cli-e2e-report.xml
|
||||
reporter: java-junit
|
||||
use-actions-summary: true
|
||||
list-suites: all
|
||||
list-tests: all
|
||||
|
||||
1
.gitignore
vendored
1
.gitignore
vendored
@@ -35,3 +35,4 @@ tests/mail/reports/
|
||||
# Generated / test artifacts
|
||||
internal/registry/meta_data.json
|
||||
cmd/api/download.bin
|
||||
app.log
|
||||
|
||||
26
CHANGELOG.md
26
CHANGELOG.md
@@ -2,6 +2,31 @@
|
||||
|
||||
All notable changes to this project will be documented in this file.
|
||||
|
||||
## [v1.0.10] - 2026-04-13
|
||||
|
||||
### Features
|
||||
|
||||
- **im**: Support im oapi range download for large files (#283)
|
||||
- **sheets**: Add filter view and condition shortcuts (#422)
|
||||
- **wiki**: Add wiki move shortcut with async task polling (#436)
|
||||
- **drive**: Add drive `+create-shortcut` shortcut (#432)
|
||||
- **drive**: Add drive files patch metadata API (#444)
|
||||
- **task**: Support `--section-guid` flag in tasklist-task-add shortcut (#430)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- **base**: Support large base attachment uploads (#441)
|
||||
- **config**: Clarify init copy for TTY, preserve original for AI (#448)
|
||||
- **im**: Reject `--user-id` under bot identity for chat-messages-list (#340)
|
||||
- **mail**: Add missing scopes for mail `+watch` shortcut (#357)
|
||||
- **mail**: Restrict `--output-dir` to current working directory (#376)
|
||||
|
||||
### Documentation
|
||||
|
||||
- **wiki**: Add wiki member operations to lark-wiki skill (#417)
|
||||
- **task**: Document sections API resources, permissions, and URL parsing (#430)
|
||||
- **doc**: Clarify when markdown escaping is needed (#312)
|
||||
|
||||
## [v1.0.9] - 2026-04-11
|
||||
|
||||
### Features
|
||||
@@ -303,6 +328,7 @@ Bundled AI agent skills for intelligent assistance:
|
||||
- Bilingual documentation (English & Chinese).
|
||||
- CI/CD pipelines: linting, testing, coverage reporting, and automated releases.
|
||||
|
||||
[v1.0.10]: https://github.com/larksuite/cli/releases/tag/v1.0.10
|
||||
[v1.0.9]: https://github.com/larksuite/cli/releases/tag/v1.0.9
|
||||
[v1.0.8]: https://github.com/larksuite/cli/releases/tag/v1.0.8
|
||||
[v1.0.7]: https://github.com/larksuite/cli/releases/tag/v1.0.7
|
||||
|
||||
@@ -177,17 +177,26 @@ func runCreateAppFlow(ctx context.Context, f *cmdutil.Factory, brandOverride cor
|
||||
// Step 2: Build and display verification URL + QR code
|
||||
verificationURL := larkauth.BuildVerificationURL(authResp.VerificationUriComplete, build.Version)
|
||||
|
||||
// Show QR code in terminal
|
||||
qr, qrErr := qrcode.New(verificationURL, qrcode.Medium)
|
||||
if qrErr == nil {
|
||||
fmt.Fprint(f.IOStreams.ErrOut, qr.ToSmallString(false))
|
||||
// Branch on TTY: human-friendly copy in interactive terminals,
|
||||
// preserve original copy for AI / non-interactive callers.
|
||||
if f.IOStreams.IsTerminal {
|
||||
fmt.Fprintf(f.IOStreams.ErrOut, "%s", msg.ScanQRCode)
|
||||
qr, qrErr := qrcode.New(verificationURL, qrcode.Medium)
|
||||
if qrErr == nil {
|
||||
fmt.Fprint(f.IOStreams.ErrOut, qr.ToSmallString(false))
|
||||
}
|
||||
fmt.Fprintf(f.IOStreams.ErrOut, "%s", msg.ScanOrOpenLink)
|
||||
fmt.Fprintf(f.IOStreams.ErrOut, " %s\n\n", verificationURL)
|
||||
fmt.Fprintf(f.IOStreams.ErrOut, "%s\n", msg.WaitingForScan)
|
||||
} else {
|
||||
qr, qrErr := qrcode.New(verificationURL, qrcode.Medium)
|
||||
if qrErr == nil {
|
||||
fmt.Fprint(f.IOStreams.ErrOut, qr.ToSmallString(false))
|
||||
}
|
||||
fmt.Fprintf(f.IOStreams.ErrOut, "%s", msg.OpenLinkNonTTY)
|
||||
fmt.Fprintf(f.IOStreams.ErrOut, " %s\n\n", verificationURL)
|
||||
fmt.Fprintf(f.IOStreams.ErrOut, "%s\n", msg.WaitingForScanNonTTY)
|
||||
}
|
||||
|
||||
fmt.Fprintf(f.IOStreams.ErrOut, "%s", msg.ScanOrOpenLink)
|
||||
fmt.Fprintf(f.IOStreams.ErrOut, " %s\n\n", verificationURL)
|
||||
|
||||
// Step 3: Poll for result
|
||||
fmt.Fprintf(f.IOStreams.ErrOut, "%s\n", msg.WaitingForScan)
|
||||
result, err := larkauth.PollAppRegistration(ctx, httpClient, core.BrandFeishu, authResp.DeviceCode, authResp.Interval, authResp.ExpiresIn, f.IOStreams.ErrOut)
|
||||
if err != nil {
|
||||
return nil, output.ErrAuth("%v", err)
|
||||
|
||||
@@ -16,11 +16,16 @@ type initMsg struct {
|
||||
Platform string
|
||||
SelectPlatform string
|
||||
Feishu string
|
||||
ScanOrOpenLink string
|
||||
WaitingForScan string
|
||||
DetectedLarkTenant string
|
||||
AppCreated string
|
||||
ConfigSaved string
|
||||
// TTY (interactive) variants
|
||||
ScanQRCode string // header shown above QR code
|
||||
ScanOrOpenLink string // post-QR alt link prompt ("or open...")
|
||||
WaitingForScan string // active polling indicator
|
||||
// Non-TTY (AI / non-interactive) variants — preserve original copy
|
||||
OpenLinkNonTTY string // primary link prompt
|
||||
WaitingForScanNonTTY string // passive waiting indicator
|
||||
DetectedLarkTenant string
|
||||
AppCreated string
|
||||
ConfigSaved string
|
||||
}
|
||||
|
||||
var initMsgZh = &initMsg{
|
||||
@@ -29,12 +34,15 @@ var initMsgZh = &initMsg{
|
||||
ConfigExistingApp: "手动输入应用凭证",
|
||||
Platform: "平台",
|
||||
SelectPlatform: "选择平台",
|
||||
Feishu: "飞书",
|
||||
ScanOrOpenLink: "\n打开以下链接配置应用:\n\n",
|
||||
WaitingForScan: "等待配置应用...",
|
||||
DetectedLarkTenant: "[lark-cli] 检测到 Lark 租户,切换端点重试...",
|
||||
AppCreated: "应用配置成功! App ID: %s",
|
||||
ConfigSaved: "应用配置成功! App ID: %s",
|
||||
Feishu: "飞书",
|
||||
ScanQRCode: "\n使用飞书 / Lark 扫码配置应用:\n\n",
|
||||
ScanOrOpenLink: "\n或打开以下链接完成配置:\n",
|
||||
WaitingForScan: "正在获取你的应用配置结果...",
|
||||
OpenLinkNonTTY: "\n打开以下链接配置应用:\n\n",
|
||||
WaitingForScanNonTTY: "等待配置应用...",
|
||||
DetectedLarkTenant: "[lark-cli] 检测到 Lark 租户,切换端点重试...",
|
||||
AppCreated: "应用配置成功! App ID: %s",
|
||||
ConfigSaved: "应用配置成功! App ID: %s",
|
||||
}
|
||||
|
||||
var initMsgEn = &initMsg{
|
||||
@@ -43,12 +51,15 @@ var initMsgEn = &initMsg{
|
||||
ConfigExistingApp: "Enter app credentials yourself",
|
||||
Platform: "Platform",
|
||||
SelectPlatform: "Select platform",
|
||||
Feishu: "Feishu",
|
||||
ScanOrOpenLink: "\nOpen the link below to configure app:\n\n",
|
||||
WaitingForScan: "Waiting for app configuration...",
|
||||
DetectedLarkTenant: "[lark-cli] Detected Lark tenant, switching endpoint...",
|
||||
AppCreated: "App configured! App ID: %s",
|
||||
ConfigSaved: "App configured! App ID: %s",
|
||||
Feishu: "Feishu",
|
||||
ScanQRCode: "\nScan the QR code with Feishu/Lark:\n\n",
|
||||
ScanOrOpenLink: "\nOr open the link below in your browser:\n",
|
||||
WaitingForScan: "Fetching configuration results...",
|
||||
OpenLinkNonTTY: "\nOpen the link below to configure app:\n\n",
|
||||
WaitingForScanNonTTY: "Waiting for app configuration...",
|
||||
DetectedLarkTenant: "[lark-cli] Detected Lark tenant, switching endpoint...",
|
||||
AppCreated: "App configured! App ID: %s",
|
||||
ConfigSaved: "App configured! App ID: %s",
|
||||
}
|
||||
|
||||
func getInitMsg(lang string) *initMsg {
|
||||
|
||||
@@ -54,11 +54,14 @@ func assertAllFieldsNonEmpty(t *testing.T, msg *initMsg, label string) {
|
||||
"Platform": msg.Platform,
|
||||
"SelectPlatform": msg.SelectPlatform,
|
||||
"Feishu": msg.Feishu,
|
||||
"ScanOrOpenLink": msg.ScanOrOpenLink,
|
||||
"WaitingForScan": msg.WaitingForScan,
|
||||
"DetectedLarkTenant": msg.DetectedLarkTenant,
|
||||
"AppCreated": msg.AppCreated,
|
||||
"ConfigSaved": msg.ConfigSaved,
|
||||
"ScanQRCode": msg.ScanQRCode,
|
||||
"ScanOrOpenLink": msg.ScanOrOpenLink,
|
||||
"WaitingForScan": msg.WaitingForScan,
|
||||
"OpenLinkNonTTY": msg.OpenLinkNonTTY,
|
||||
"WaitingForScanNonTTY": msg.WaitingForScanNonTTY,
|
||||
"DetectedLarkTenant": msg.DetectedLarkTenant,
|
||||
"AppCreated": msg.AppCreated,
|
||||
"ConfigSaved": msg.ConfigSaved,
|
||||
}
|
||||
for name, val := range fields {
|
||||
if val == "" {
|
||||
|
||||
@@ -33,6 +33,11 @@ const (
|
||||
LarkErrRefreshRevoked = 20064 // refresh_token revoked
|
||||
LarkErrRefreshAlreadyUsed = 20073 // refresh_token already consumed (single-use rotation)
|
||||
LarkErrRefreshServerError = 20050 // refresh endpoint server-side error, retryable
|
||||
|
||||
// Drive shortcut / cross-space constraints.
|
||||
LarkErrDriveResourceContention = 1061045 // resource contention occurred, please retry
|
||||
LarkErrDriveCrossTenantUnit = 1064510 // cross tenant and unit not support
|
||||
LarkErrDriveCrossBrand = 1064511 // cross brand not support
|
||||
)
|
||||
|
||||
// ClassifyLarkError maps a Lark API error code + message to (exitCode, errType, hint).
|
||||
@@ -60,6 +65,14 @@ func ClassifyLarkError(code int, msg string) (int, string, string) {
|
||||
// rate limit
|
||||
case LarkErrRateLimit:
|
||||
return ExitAPI, "rate_limit", "please try again later"
|
||||
|
||||
// drive-specific constraints that benefit from actionable hints
|
||||
case LarkErrDriveResourceContention:
|
||||
return ExitAPI, "conflict", "please retry later and avoid concurrent duplicate requests"
|
||||
case LarkErrDriveCrossTenantUnit:
|
||||
return ExitAPI, "cross_tenant_unit", "operate on source and target within the same tenant and region/unit"
|
||||
case LarkErrDriveCrossBrand:
|
||||
return ExitAPI, "cross_brand", "operate on source and target within the same brand environment"
|
||||
}
|
||||
|
||||
return ExitAPI, "api_error", ""
|
||||
|
||||
64
internal/output/lark_errors_test.go
Normal file
64
internal/output/lark_errors_test.go
Normal file
@@ -0,0 +1,64 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package output
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// TestClassifyLarkError_DriveCreateShortcutConstraints verifies known Drive shortcut errors map to actionable hints.
|
||||
func TestClassifyLarkError_DriveCreateShortcutConstraints(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
code int
|
||||
wantExitCode int
|
||||
wantType string
|
||||
wantHint string
|
||||
}{
|
||||
{
|
||||
name: "resource contention",
|
||||
code: LarkErrDriveResourceContention,
|
||||
wantExitCode: ExitAPI,
|
||||
wantType: "conflict",
|
||||
wantHint: "avoid concurrent duplicate requests",
|
||||
},
|
||||
{
|
||||
name: "cross tenant unit",
|
||||
code: LarkErrDriveCrossTenantUnit,
|
||||
wantExitCode: ExitAPI,
|
||||
wantType: "cross_tenant_unit",
|
||||
wantHint: "same tenant and region/unit",
|
||||
},
|
||||
{
|
||||
name: "cross brand",
|
||||
code: LarkErrDriveCrossBrand,
|
||||
wantExitCode: ExitAPI,
|
||||
wantType: "cross_brand",
|
||||
wantHint: "same brand environment",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
gotExitCode, gotType, gotHint := ClassifyLarkError(tt.code, "raw msg")
|
||||
if gotExitCode != tt.wantExitCode {
|
||||
t.Fatalf("exitCode=%d, want %d", gotExitCode, tt.wantExitCode)
|
||||
}
|
||||
if gotType != tt.wantType {
|
||||
t.Fatalf("type=%q, want %q", gotType, tt.wantType)
|
||||
}
|
||||
if gotHint == "" {
|
||||
t.Fatal("expected non-empty hint")
|
||||
}
|
||||
if !strings.Contains(gotHint, tt.wantHint) {
|
||||
t.Fatalf("hint=%q, want substring %q", gotHint, tt.wantHint)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@larksuite/cli",
|
||||
"version": "1.0.9",
|
||||
"version": "1.0.10",
|
||||
"description": "The official CLI for Lark/Feishu open platform",
|
||||
"bin": {
|
||||
"lark-cli": "scripts/run.js"
|
||||
|
||||
@@ -865,6 +865,157 @@ func TestBaseRecordExecuteReadCreateDelete(t *testing.T) {
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("upload attachment uses multipart for large file", func(t *testing.T) {
|
||||
factory, stdout, reg := newExecuteFactory(t)
|
||||
|
||||
tmpFile, err := os.CreateTemp(t.TempDir(), "base-attachment-large-*.bin")
|
||||
if err != nil {
|
||||
t.Fatalf("CreateTemp() err=%v", err)
|
||||
}
|
||||
if err := tmpFile.Truncate(common.MaxDriveMediaUploadSinglePartSize + 1); err != nil {
|
||||
t.Fatalf("Truncate() err=%v", err)
|
||||
}
|
||||
if err := tmpFile.Close(); err != nil {
|
||||
t.Fatalf("Close() err=%v", err)
|
||||
}
|
||||
withBaseWorkingDir(t, filepath.Dir(tmpFile.Name()))
|
||||
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/base/v3/bases/app_x/tables/tbl_x/fields/fld_att",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{"id": "fld_att", "name": "附件", "type": "attachment"},
|
||||
},
|
||||
})
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/base/v3/bases/app_x/tables/tbl_x/records/rec_x",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{
|
||||
"record_id": "rec_x",
|
||||
"fields": map[string]interface{}{},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
prepareStub := &httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/drive/v1/medias/upload_prepare",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{
|
||||
"upload_id": "upload_big_1",
|
||||
"block_size": float64(8 * 1024 * 1024),
|
||||
"block_num": float64(3),
|
||||
},
|
||||
},
|
||||
}
|
||||
reg.Register(prepareStub)
|
||||
|
||||
partStubs := make([]*httpmock.Stub, 0, 3)
|
||||
for i := 0; i < 3; i++ {
|
||||
stub := &httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/drive/v1/medias/upload_part",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"msg": "ok",
|
||||
},
|
||||
}
|
||||
partStubs = append(partStubs, stub)
|
||||
reg.Register(stub)
|
||||
}
|
||||
|
||||
finishStub := &httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/drive/v1/medias/upload_finish",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{"file_token": "file_tok_big"},
|
||||
},
|
||||
}
|
||||
reg.Register(finishStub)
|
||||
|
||||
updateStub := &httpmock.Stub{
|
||||
Method: "PATCH",
|
||||
URL: "/open-apis/base/v3/bases/app_x/tables/tbl_x/records/rec_x",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{
|
||||
"record_id": "rec_x",
|
||||
"fields": map[string]interface{}{
|
||||
"附件": []interface{}{
|
||||
map[string]interface{}{
|
||||
"file_token": "file_tok_big",
|
||||
"name": "large-report.bin",
|
||||
"deprecated_set_attachment": true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
reg.Register(updateStub)
|
||||
|
||||
if err := runShortcut(t, BaseRecordUploadAttachment, []string{
|
||||
"+record-upload-attachment",
|
||||
"--base-token", "app_x",
|
||||
"--table-id", "tbl_x",
|
||||
"--record-id", "rec_x",
|
||||
"--field-id", "fld_att",
|
||||
"--file", "./" + filepath.Base(tmpFile.Name()),
|
||||
"--name", "large-report.bin",
|
||||
}, factory, stdout); err != nil {
|
||||
t.Fatalf("err=%v", err)
|
||||
}
|
||||
|
||||
if got := stdout.String(); !strings.Contains(got, `"updated": true`) || !strings.Contains(got, `"file_tok_big"`) || !strings.Contains(got, `"large-report.bin"`) {
|
||||
t.Fatalf("stdout=%s", got)
|
||||
}
|
||||
|
||||
prepareBody := string(prepareStub.CapturedBody)
|
||||
if !strings.Contains(prepareBody, `"file_name":"large-report.bin"`) ||
|
||||
!strings.Contains(prepareBody, `"parent_type":"bitable_file"`) ||
|
||||
!strings.Contains(prepareBody, `"parent_node":"app_x"`) ||
|
||||
!strings.Contains(prepareBody, `"size":20971521`) {
|
||||
t.Fatalf("prepare body=%s", prepareBody)
|
||||
}
|
||||
|
||||
firstPartBody := string(partStubs[0].CapturedBody)
|
||||
if !strings.Contains(firstPartBody, `name="upload_id"`) ||
|
||||
!strings.Contains(firstPartBody, "upload_big_1") ||
|
||||
!strings.Contains(firstPartBody, `name="seq"`) ||
|
||||
!strings.Contains(firstPartBody, "\r\n0\r\n") ||
|
||||
!strings.Contains(firstPartBody, `name="size"`) ||
|
||||
!strings.Contains(firstPartBody, "8388608") {
|
||||
t.Fatalf("first part body=%s", firstPartBody)
|
||||
}
|
||||
|
||||
lastPartBody := string(partStubs[2].CapturedBody)
|
||||
if !strings.Contains(lastPartBody, `name="seq"`) ||
|
||||
!strings.Contains(lastPartBody, "\r\n2\r\n") ||
|
||||
!strings.Contains(lastPartBody, `name="size"`) ||
|
||||
!strings.Contains(lastPartBody, "4194305") {
|
||||
t.Fatalf("last part body=%s", lastPartBody)
|
||||
}
|
||||
|
||||
finishBody := string(finishStub.CapturedBody)
|
||||
if !strings.Contains(finishBody, `"upload_id":"upload_big_1"`) ||
|
||||
!strings.Contains(finishBody, `"block_num":3`) {
|
||||
t.Fatalf("finish body=%s", finishBody)
|
||||
}
|
||||
|
||||
updateBody := string(updateStub.CapturedBody)
|
||||
if !strings.Contains(updateBody, `"附件"`) ||
|
||||
!strings.Contains(updateBody, `"file_token":"file_tok_big"`) ||
|
||||
!strings.Contains(updateBody, `"name":"large-report.bin"`) ||
|
||||
!strings.Contains(updateBody, `"deprecated_set_attachment":true`) {
|
||||
t.Fatalf("update body=%s", updateBody)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("upload attachment rejects non-attachment field", func(t *testing.T) {
|
||||
factory, stdout, reg := newExecuteFactory(t)
|
||||
|
||||
@@ -904,6 +1055,37 @@ func TestBaseRecordExecuteReadCreateDelete(t *testing.T) {
|
||||
t.Fatalf("err=%v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("upload attachment rejects file larger than 2GB", func(t *testing.T) {
|
||||
factory, stdout, _ := newExecuteFactory(t)
|
||||
|
||||
tmpFile, err := os.CreateTemp(t.TempDir(), "base-too-large-*.bin")
|
||||
if err != nil {
|
||||
t.Fatalf("CreateTemp() err=%v", err)
|
||||
}
|
||||
if err := tmpFile.Truncate(2*1024*1024*1024 + 1); err != nil {
|
||||
t.Fatalf("Truncate() err=%v", err)
|
||||
}
|
||||
if err := tmpFile.Close(); err != nil {
|
||||
t.Fatalf("Close() err=%v", err)
|
||||
}
|
||||
withBaseWorkingDir(t, filepath.Dir(tmpFile.Name()))
|
||||
|
||||
err = runShortcut(t, BaseRecordUploadAttachment, []string{
|
||||
"+record-upload-attachment",
|
||||
"--base-token", "app_x",
|
||||
"--table-id", "tbl_x",
|
||||
"--record-id", "rec_x",
|
||||
"--field-id", "fld_att",
|
||||
"--file", "./" + filepath.Base(tmpFile.Name()),
|
||||
}, factory, stdout)
|
||||
if err == nil {
|
||||
t.Fatal("expected validation error, got nil")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "exceeds 2GB limit") {
|
||||
t.Fatalf("err=%v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestBaseViewExecuteReadCreateDeleteAndFilter(t *testing.T) {
|
||||
|
||||
@@ -5,15 +5,11 @@ package base
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
larkcore "github.com/larksuite/oapi-sdk-go/v3/core"
|
||||
|
||||
"github.com/larksuite/cli/extension/fileio"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"github.com/larksuite/cli/internal/util"
|
||||
@@ -21,8 +17,8 @@ import (
|
||||
)
|
||||
|
||||
const (
|
||||
baseAttachmentUploadMaxFileSize = 20 * 1024 * 1024
|
||||
baseAttachmentParentType = "bitable_file"
|
||||
baseAttachmentUploadMaxFileSize int64 = 2 * 1024 * 1024 * 1024
|
||||
baseAttachmentParentType = "bitable_file"
|
||||
)
|
||||
|
||||
var BaseRecordUploadAttachment = common.Shortcut{
|
||||
@@ -37,7 +33,7 @@ var BaseRecordUploadAttachment = common.Shortcut{
|
||||
tableRefFlag(true),
|
||||
recordRefFlag(true),
|
||||
fieldRefFlag(true),
|
||||
{Name: "file", Desc: "local file path (max 20MB)", Required: true},
|
||||
{Name: "file", Desc: "local file path (max 2GB; files > 20MB use multipart upload automatically)", Required: true},
|
||||
{Name: "name", Desc: "attachment file name (default: local file name)"},
|
||||
},
|
||||
DryRun: dryRunRecordUploadAttachment,
|
||||
@@ -52,7 +48,7 @@ func dryRunRecordUploadAttachment(_ context.Context, runtime *common.RuntimeCont
|
||||
if fileName == "" {
|
||||
fileName = filepath.Base(filePath)
|
||||
}
|
||||
return common.NewDryRunAPI().
|
||||
dry := common.NewDryRunAPI().
|
||||
Desc("4-step orchestration: validate attachment field → read existing record attachments → upload file to Base → patch merged attachment array").
|
||||
GET("/open-apis/base/v3/bases/:base_token/tables/:table_id/fields/:field_id").
|
||||
Desc("[1] Read target field and ensure it is an attachment field").
|
||||
@@ -61,15 +57,42 @@ func dryRunRecordUploadAttachment(_ context.Context, runtime *common.RuntimeCont
|
||||
Set("field_id", runtime.Str("field-id")).
|
||||
GET("/open-apis/base/v3/bases/:base_token/tables/:table_id/records/:record_id").
|
||||
Desc("[2] Read current record to preserve existing attachments in the target cell").
|
||||
Set("record_id", runtime.Str("record-id")).
|
||||
POST("/open-apis/drive/v1/medias/upload_all").
|
||||
Desc("[3] Upload local file to the current Base as attachment media (multipart/form-data)").
|
||||
Body(map[string]interface{}{
|
||||
"file_name": fileName,
|
||||
"parent_type": baseAttachmentParentType,
|
||||
"parent_node": runtime.Str("base-token"),
|
||||
"file": "@" + filePath,
|
||||
}).
|
||||
Set("record_id", runtime.Str("record-id"))
|
||||
if baseAttachmentShouldUseMultipart(runtime.FileIO(), filePath) {
|
||||
dry.POST("/open-apis/drive/v1/medias/upload_prepare").
|
||||
Desc("[3a] Initialize multipart attachment upload to the current Base").
|
||||
Body(map[string]interface{}{
|
||||
"file_name": fileName,
|
||||
"parent_type": baseAttachmentParentType,
|
||||
"parent_node": runtime.Str("base-token"),
|
||||
"size": "<file_size>",
|
||||
}).
|
||||
POST("/open-apis/drive/v1/medias/upload_part").
|
||||
Desc("[3b] Upload attachment parts (repeated)").
|
||||
Body(map[string]interface{}{
|
||||
"upload_id": "<upload_id>",
|
||||
"seq": "<chunk_index>",
|
||||
"size": "<chunk_size>",
|
||||
"file": "<chunk_binary>",
|
||||
}).
|
||||
POST("/open-apis/drive/v1/medias/upload_finish").
|
||||
Desc("[3c] Finalize multipart attachment upload and get file token").
|
||||
Body(map[string]interface{}{
|
||||
"upload_id": "<upload_id>",
|
||||
"block_num": "<block_num>",
|
||||
})
|
||||
} else {
|
||||
dry.POST("/open-apis/drive/v1/medias/upload_all").
|
||||
Desc("[3] Upload local file to the current Base as attachment media (multipart/form-data)").
|
||||
Body(map[string]interface{}{
|
||||
"file_name": fileName,
|
||||
"parent_type": baseAttachmentParentType,
|
||||
"parent_node": runtime.Str("base-token"),
|
||||
"file": "@" + filePath,
|
||||
"size": "<file_size>",
|
||||
})
|
||||
}
|
||||
return dry.
|
||||
PATCH("/open-apis/base/v3/bases/:base_token/tables/:table_id/records/:record_id").
|
||||
Desc("[4] Update the target attachment cell with existing attachments plus the uploaded file token").
|
||||
Body(map[string]interface{}{
|
||||
@@ -102,7 +125,7 @@ func executeRecordUploadAttachment(runtime *common.RuntimeContext) error {
|
||||
return output.ErrValidation("file not accessible: %s: %v", filePath, err)
|
||||
}
|
||||
if fileInfo.Size() > baseAttachmentUploadMaxFileSize {
|
||||
return output.ErrValidation("file %.1fMB exceeds 20MB limit", float64(fileInfo.Size())/1024/1024)
|
||||
return output.ErrValidation("file %s exceeds 2GB limit", common.FormatSize(fileInfo.Size()))
|
||||
}
|
||||
|
||||
fileName := strings.TrimSpace(runtime.Str("name"))
|
||||
@@ -124,6 +147,9 @@ func executeRecordUploadAttachment(runtime *common.RuntimeContext) error {
|
||||
}
|
||||
|
||||
fmt.Fprintf(runtime.IO().ErrOut, "Uploading attachment: %s -> record %s field %s\n", fileName, runtime.Str("record-id"), fieldName(field))
|
||||
if fileInfo.Size() > common.MaxDriveMediaUploadSinglePartSize {
|
||||
fmt.Fprintf(runtime.IO().ErrOut, "File exceeds 20MB, using multipart upload\n")
|
||||
}
|
||||
|
||||
attachment, err := uploadAttachmentToBase(runtime, filePath, fileName, runtime.Str("base-token"), fileInfo.Size())
|
||||
if err != nil {
|
||||
@@ -151,6 +177,14 @@ func executeRecordUploadAttachment(runtime *common.RuntimeContext) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func baseAttachmentShouldUseMultipart(fio fileio.FileIO, filePath string) bool {
|
||||
info, err := fio.Stat(filePath)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
return info.Mode().IsRegular() && info.Size() > common.MaxDriveMediaUploadSinglePartSize
|
||||
}
|
||||
|
||||
func fetchBaseField(runtime *common.RuntimeContext, baseToken, tableIDValue, fieldRef string) (map[string]interface{}, error) {
|
||||
return baseV3Call(runtime, "GET", baseV3Path("bases", baseToken, "tables", tableIDValue, "fields", fieldRef), nil, nil)
|
||||
}
|
||||
@@ -209,47 +243,30 @@ func normalizeAttachmentForPatch(attachment map[string]interface{}) map[string]i
|
||||
}
|
||||
|
||||
func uploadAttachmentToBase(runtime *common.RuntimeContext, filePath, fileName, baseToken string, fileSize int64) (map[string]interface{}, error) {
|
||||
f, err := runtime.FileIO().Open(filePath)
|
||||
parentNode := baseToken
|
||||
var (
|
||||
fileToken string
|
||||
err error
|
||||
)
|
||||
if fileSize <= common.MaxDriveMediaUploadSinglePartSize {
|
||||
fileToken, err = common.UploadDriveMediaAll(runtime, common.DriveMediaUploadAllConfig{
|
||||
FilePath: filePath,
|
||||
FileName: fileName,
|
||||
FileSize: fileSize,
|
||||
ParentType: baseAttachmentParentType,
|
||||
ParentNode: &parentNode,
|
||||
})
|
||||
} else {
|
||||
fileToken, err = common.UploadDriveMediaMultipart(runtime, common.DriveMediaMultipartUploadConfig{
|
||||
FilePath: filePath,
|
||||
FileName: fileName,
|
||||
FileSize: fileSize,
|
||||
ParentType: baseAttachmentParentType,
|
||||
ParentNode: parentNode,
|
||||
})
|
||||
}
|
||||
if err != nil {
|
||||
return nil, output.ErrValidation("cannot open file: %v", err)
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
fd := larkcore.NewFormdata()
|
||||
fd.AddField("file_name", fileName)
|
||||
fd.AddField("parent_type", baseAttachmentParentType)
|
||||
fd.AddField("parent_node", baseToken)
|
||||
fd.AddField("size", fmt.Sprintf("%d", fileSize))
|
||||
fd.AddFile("file", f)
|
||||
|
||||
apiResp, err := runtime.DoAPI(&larkcore.ApiReq{
|
||||
HttpMethod: http.MethodPost,
|
||||
ApiPath: "/open-apis/drive/v1/medias/upload_all",
|
||||
Body: fd,
|
||||
}, larkcore.WithFileUpload())
|
||||
if err != nil {
|
||||
var exitErr *output.ExitError
|
||||
if errors.As(err, &exitErr) {
|
||||
return nil, err
|
||||
}
|
||||
return nil, output.ErrNetwork("upload failed: %v", err)
|
||||
}
|
||||
|
||||
var result map[string]interface{}
|
||||
if err := json.Unmarshal(apiResp.RawBody, &result); err != nil {
|
||||
return nil, output.Errorf(output.ExitAPI, "api_error", "upload failed: invalid response JSON: %v", err)
|
||||
}
|
||||
|
||||
code, _ := util.ToFloat64(result["code"])
|
||||
if code != 0 {
|
||||
msg, _ := result["msg"].(string)
|
||||
return nil, output.ErrAPI(int(code), fmt.Sprintf("upload failed: [%d] %s", int(code), msg), result["error"])
|
||||
}
|
||||
|
||||
data, _ := result["data"].(map[string]interface{})
|
||||
fileToken, _ := data["file_token"].(string)
|
||||
if fileToken == "" {
|
||||
return nil, output.Errorf(output.ExitAPI, "api_error", "upload failed: no file_token returned")
|
||||
return nil, err
|
||||
}
|
||||
|
||||
attachment := map[string]interface{}{
|
||||
|
||||
136
shortcuts/drive/drive_create_shortcut.go
Normal file
136
shortcuts/drive/drive_create_shortcut.go
Normal file
@@ -0,0 +1,136 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package drive
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"github.com/larksuite/cli/internal/validate"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
var driveCreateShortcutAllowedTypes = map[string]bool{
|
||||
"file": true,
|
||||
"docx": true,
|
||||
"bitable": true,
|
||||
"doc": true,
|
||||
"sheet": true,
|
||||
"mindnote": true,
|
||||
"slides": true,
|
||||
}
|
||||
|
||||
type driveCreateShortcutSpec struct {
|
||||
FileToken string
|
||||
FileType string
|
||||
FolderToken string
|
||||
}
|
||||
|
||||
func newDriveCreateShortcutSpec(runtime *common.RuntimeContext) driveCreateShortcutSpec {
|
||||
return driveCreateShortcutSpec{
|
||||
FileToken: strings.TrimSpace(runtime.Str("file-token")),
|
||||
FileType: strings.ToLower(strings.TrimSpace(runtime.Str("type"))),
|
||||
FolderToken: strings.TrimSpace(runtime.Str("folder-token")),
|
||||
}
|
||||
}
|
||||
|
||||
// RequestBody builds the create_shortcut API payload from the shortcut spec.
|
||||
func (s driveCreateShortcutSpec) RequestBody() map[string]interface{} {
|
||||
return map[string]interface{}{
|
||||
"parent_token": s.FolderToken,
|
||||
"refer_entity": map[string]interface{}{
|
||||
"refer_token": s.FileToken,
|
||||
"refer_type": s.FileType,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// DriveCreateShortcut creates a Drive shortcut for an existing file in another folder.
|
||||
var DriveCreateShortcut = common.Shortcut{
|
||||
Service: "drive",
|
||||
Command: "+create-shortcut",
|
||||
Description: "Create a Drive shortcut in another folder",
|
||||
Risk: "write",
|
||||
Scopes: []string{"space:document:shortcut"},
|
||||
AuthTypes: []string{"user", "bot"},
|
||||
Flags: []common.Flag{
|
||||
{Name: "file-token", Desc: "source file token to reference", Required: true},
|
||||
{Name: "type", Desc: "source file type (file, docx, bitable, doc, sheet, mindnote, slides)", Required: true},
|
||||
{Name: "folder-token", Desc: "target folder token for the new shortcut", Required: true},
|
||||
},
|
||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
return validateDriveCreateShortcutSpec(newDriveCreateShortcutSpec(runtime))
|
||||
},
|
||||
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
spec := newDriveCreateShortcutSpec(runtime)
|
||||
|
||||
return common.NewDryRunAPI().
|
||||
Desc("Create a Drive shortcut").
|
||||
POST("/open-apis/drive/v1/files/create_shortcut").
|
||||
Desc("[1] Create shortcut").
|
||||
Body(spec.RequestBody())
|
||||
},
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
spec := newDriveCreateShortcutSpec(runtime)
|
||||
|
||||
fmt.Fprintf(
|
||||
runtime.IO().ErrOut,
|
||||
"Creating shortcut for %s %s in folder %s...\n",
|
||||
spec.FileType,
|
||||
common.MaskToken(spec.FileToken),
|
||||
common.MaskToken(spec.FolderToken),
|
||||
)
|
||||
|
||||
data, err := runtime.CallAPI(
|
||||
"POST",
|
||||
"/open-apis/drive/v1/files/create_shortcut",
|
||||
nil,
|
||||
spec.RequestBody(),
|
||||
)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
out := map[string]interface{}{
|
||||
"created": true,
|
||||
"source_file_token": spec.FileToken,
|
||||
"source_type": spec.FileType,
|
||||
"folder_token": spec.FolderToken,
|
||||
}
|
||||
if shortcutToken := common.GetString(data, "succ_shortcut_node", "token"); shortcutToken != "" {
|
||||
out["shortcut_token"] = shortcutToken
|
||||
}
|
||||
if url := common.GetString(data, "succ_shortcut_node", "url"); url != "" {
|
||||
out["url"] = url
|
||||
}
|
||||
if title := common.GetString(data, "succ_shortcut_node", "name"); title != "" {
|
||||
out["title"] = title
|
||||
}
|
||||
|
||||
runtime.Out(out, nil)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
// validateDriveCreateShortcutSpec validates shortcut creation inputs before API execution.
|
||||
func validateDriveCreateShortcutSpec(spec driveCreateShortcutSpec) error {
|
||||
if err := validate.ResourceName(spec.FileToken, "--file-token"); err != nil {
|
||||
return output.ErrValidation("%s", err)
|
||||
}
|
||||
if err := validate.ResourceName(spec.FolderToken, "--folder-token"); err != nil {
|
||||
return output.ErrValidation("%s", err)
|
||||
}
|
||||
if spec.FileType == "wiki" {
|
||||
return output.ErrValidation("unsupported file type: wiki. This shortcut only supports Drive file tokens; wiki documents must be resolved to their underlying file token first")
|
||||
}
|
||||
if spec.FileType == "folder" {
|
||||
return output.ErrValidation("unsupported file type: folder. The create_shortcut API only supports Drive files, not folders")
|
||||
}
|
||||
if !driveCreateShortcutAllowedTypes[spec.FileType] {
|
||||
return output.ErrValidation("unsupported file type: %s. Supported types: file, docx, bitable, doc, sheet, mindnote, slides", spec.FileType)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
336
shortcuts/drive/drive_create_shortcut_test.go
Normal file
336
shortcuts/drive/drive_create_shortcut_test.go
Normal file
@@ -0,0 +1,336 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package drive
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/httpmock"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
// TestValidateDriveCreateShortcutSpecRejectsUnsupportedTypes verifies unsupported source types are rejected early.
|
||||
func TestValidateDriveCreateShortcutSpecRejectsUnsupportedTypes(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
spec driveCreateShortcutSpec
|
||||
wantErr string
|
||||
}{
|
||||
{
|
||||
name: "wiki",
|
||||
spec: driveCreateShortcutSpec{
|
||||
FileToken: "wiki_token_test",
|
||||
FileType: "wiki",
|
||||
FolderToken: "target_folder_token_test",
|
||||
},
|
||||
wantErr: "underlying file token first",
|
||||
},
|
||||
{
|
||||
name: "folder",
|
||||
spec: driveCreateShortcutSpec{
|
||||
FileToken: "folder_token_test",
|
||||
FileType: "folder",
|
||||
FolderToken: "target_folder_token_test",
|
||||
},
|
||||
wantErr: "not folders",
|
||||
},
|
||||
{
|
||||
name: "shortcut",
|
||||
spec: driveCreateShortcutSpec{
|
||||
FileToken: "shortcut_token_test",
|
||||
FileType: "shortcut",
|
||||
FolderToken: "target_folder_token_test",
|
||||
},
|
||||
wantErr: "Supported types",
|
||||
},
|
||||
{
|
||||
name: "missing folder token",
|
||||
spec: driveCreateShortcutSpec{
|
||||
FileToken: "file_token_test",
|
||||
FileType: "docx",
|
||||
},
|
||||
wantErr: "--folder-token must not be empty",
|
||||
},
|
||||
{
|
||||
name: "unknown",
|
||||
spec: driveCreateShortcutSpec{
|
||||
FileToken: "file_token_test",
|
||||
FileType: "unknown",
|
||||
FolderToken: "target_folder_token_test",
|
||||
},
|
||||
wantErr: "Supported types",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
err := validateDriveCreateShortcutSpec(tt.spec)
|
||||
if err == nil {
|
||||
t.Fatal("expected validation error, got nil")
|
||||
}
|
||||
if !strings.Contains(err.Error(), tt.wantErr) {
|
||||
t.Fatalf("error = %q, want substring %q", err.Error(), tt.wantErr)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestDriveCreateShortcutDryRunIncludesSingleCreateRequest verifies dry-run only previews the create request.
|
||||
func TestDriveCreateShortcutDryRunIncludesSingleCreateRequest(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
cmd := &cobra.Command{Use: "drive +create-shortcut"}
|
||||
cmd.Flags().String("file-token", "", "")
|
||||
cmd.Flags().String("type", "", "")
|
||||
cmd.Flags().String("folder-token", "", "")
|
||||
if err := cmd.Flags().Set("file-token", " doc_token_test "); err != nil {
|
||||
t.Fatalf("set --file-token: %v", err)
|
||||
}
|
||||
if err := cmd.Flags().Set("type", " DOCX "); err != nil {
|
||||
t.Fatalf("set --type: %v", err)
|
||||
}
|
||||
if err := cmd.Flags().Set("folder-token", " folder_target_token_test "); err != nil {
|
||||
t.Fatalf("set --folder-token: %v", err)
|
||||
}
|
||||
|
||||
runtime := common.TestNewRuntimeContext(cmd, nil)
|
||||
dry := DriveCreateShortcut.DryRun(context.Background(), runtime)
|
||||
if dry == nil {
|
||||
t.Fatal("DryRun returned nil")
|
||||
}
|
||||
|
||||
data, err := json.Marshal(dry)
|
||||
if err != nil {
|
||||
t.Fatalf("marshal dry run: %v", err)
|
||||
}
|
||||
|
||||
var got struct {
|
||||
API []struct {
|
||||
Method string `json:"method"`
|
||||
Body map[string]interface{} `json:"body"`
|
||||
} `json:"api"`
|
||||
}
|
||||
if err := json.Unmarshal(data, &got); err != nil {
|
||||
t.Fatalf("unmarshal dry run json: %v", err)
|
||||
}
|
||||
if len(got.API) != 1 {
|
||||
t.Fatalf("expected 1 API call, got %d", len(got.API))
|
||||
}
|
||||
if got.API[0].Method != "POST" {
|
||||
t.Fatalf("first method = %q, want POST", got.API[0].Method)
|
||||
}
|
||||
if got.API[0].Body["parent_token"] != "folder_target_token_test" {
|
||||
t.Fatalf("parent_token = %#v, want folder_target_token_test", got.API[0].Body["parent_token"])
|
||||
}
|
||||
referEntity, _ := got.API[0].Body["refer_entity"].(map[string]interface{})
|
||||
if referEntity["refer_token"] != "doc_token_test" || referEntity["refer_type"] != "docx" {
|
||||
t.Fatalf("unexpected refer_entity: %#v", referEntity)
|
||||
}
|
||||
}
|
||||
|
||||
// TestDriveCreateShortcutUsesProvidedFolderToken verifies execution uses the explicit target folder token.
|
||||
func TestDriveCreateShortcutUsesProvidedFolderToken(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
|
||||
createStub := &httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/drive/v1/files/create_shortcut",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{
|
||||
"succ_shortcut_node": map[string]interface{}{
|
||||
"token": "shortcut_token_test",
|
||||
"name": "shortcut_name_test",
|
||||
"type": "docx",
|
||||
"parent_token": "folder_target_token_test",
|
||||
"url": "https://example.feishu.cn/docx/shortcut_token_test",
|
||||
"shortcut_info": map[string]interface{}{
|
||||
"target_type": "docx",
|
||||
"target_token": "doc_token_test",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
reg.Register(createStub)
|
||||
|
||||
err := mountAndRunDrive(t, DriveCreateShortcut, []string{
|
||||
"+create-shortcut",
|
||||
"--file-token", " doc_token_test ",
|
||||
"--type", " DOCX ",
|
||||
"--folder-token", " folder_target_token_test ",
|
||||
"--as", "bot",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
body := decodeCapturedJSONBody(t, createStub)
|
||||
if body["parent_token"] != "folder_target_token_test" {
|
||||
t.Fatalf("parent_token = %#v, want folder_target_token_test", body["parent_token"])
|
||||
}
|
||||
referEntity, _ := body["refer_entity"].(map[string]interface{})
|
||||
if referEntity["refer_token"] != "doc_token_test" || referEntity["refer_type"] != "docx" {
|
||||
t.Fatalf("unexpected refer_entity: %#v", referEntity)
|
||||
}
|
||||
|
||||
data := decodeDriveEnvelope(t, stdout)
|
||||
if data["shortcut_token"] != "shortcut_token_test" {
|
||||
t.Fatalf("shortcut_token = %#v, want shortcut_token_test", data["shortcut_token"])
|
||||
}
|
||||
if data["folder_token"] != "folder_target_token_test" {
|
||||
t.Fatalf("folder_token = %#v, want folder_target_token_test", data["folder_token"])
|
||||
}
|
||||
if data["source_file_token"] != "doc_token_test" {
|
||||
t.Fatalf("source_file_token = %#v, want doc_token_test", data["source_file_token"])
|
||||
}
|
||||
if data["title"] != "shortcut_name_test" {
|
||||
t.Fatalf("title = %#v, want shortcut_name_test", data["title"])
|
||||
}
|
||||
if data["url"] != "https://example.feishu.cn/docx/shortcut_token_test" {
|
||||
t.Fatalf("url = %#v, want https://example.feishu.cn/docx/shortcut_token_test", data["url"])
|
||||
}
|
||||
if data["created"] != true {
|
||||
t.Fatalf("created = %#v, want true", data["created"])
|
||||
}
|
||||
}
|
||||
|
||||
// TestDriveCreateShortcutValidateRequiresFolderToken verifies folder-token is mandatory.
|
||||
func TestDriveCreateShortcutValidateRequiresFolderToken(t *testing.T) {
|
||||
err := validateDriveCreateShortcutSpec(driveCreateShortcutSpec{
|
||||
FileToken: "doc_token_test",
|
||||
FileType: "docx",
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("expected validation error, got nil")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "--folder-token must not be empty") {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestDriveCreateShortcutValidateRejectsWhitespaceOnlyFolderToken verifies runtime normalization rejects blank folder tokens.
|
||||
func TestDriveCreateShortcutValidateRejectsWhitespaceOnlyFolderToken(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
cmd := &cobra.Command{Use: "drive +create-shortcut"}
|
||||
cmd.Flags().String("file-token", "", "")
|
||||
cmd.Flags().String("type", "", "")
|
||||
cmd.Flags().String("folder-token", "", "")
|
||||
if err := cmd.Flags().Set("file-token", "doc_token_test"); err != nil {
|
||||
t.Fatalf("set --file-token: %v", err)
|
||||
}
|
||||
if err := cmd.Flags().Set("type", " DOCX "); err != nil {
|
||||
t.Fatalf("set --type: %v", err)
|
||||
}
|
||||
if err := cmd.Flags().Set("folder-token", " "); err != nil {
|
||||
t.Fatalf("set --folder-token: %v", err)
|
||||
}
|
||||
|
||||
runtime := common.TestNewRuntimeContext(cmd, nil)
|
||||
err := DriveCreateShortcut.Validate(context.Background(), runtime)
|
||||
if err == nil {
|
||||
t.Fatal("expected validation error, got nil")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "--folder-token must not be empty") {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestDriveCreateShortcutClassifiesKnownAPIConstraints verifies known API constraints surface as structured errors.
|
||||
func TestDriveCreateShortcutClassifiesKnownAPIConstraints(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
code int
|
||||
msg string
|
||||
wantType string
|
||||
wantHint string
|
||||
wantMsgPart string
|
||||
}{
|
||||
{
|
||||
name: "resource contention",
|
||||
code: output.LarkErrDriveResourceContention,
|
||||
msg: "resource contention occurred, please retry",
|
||||
wantType: "conflict",
|
||||
wantHint: "avoid concurrent duplicate requests",
|
||||
wantMsgPart: "resource contention occurred",
|
||||
},
|
||||
{
|
||||
name: "cross tenant and unit",
|
||||
code: output.LarkErrDriveCrossTenantUnit,
|
||||
msg: "cross tenant and unit not support",
|
||||
wantType: "cross_tenant_unit",
|
||||
wantHint: "same tenant and region/unit",
|
||||
wantMsgPart: "cross tenant and unit not support",
|
||||
},
|
||||
{
|
||||
name: "cross brand",
|
||||
code: output.LarkErrDriveCrossBrand,
|
||||
msg: "cross brand not support",
|
||||
wantType: "cross_brand",
|
||||
wantHint: "same brand environment",
|
||||
wantMsgPart: "cross brand not support",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
f, _, _, reg := cmdutil.TestFactory(t, driveTestConfig())
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/drive/v1/files/create_shortcut",
|
||||
Body: map[string]interface{}{
|
||||
"code": float64(tt.code),
|
||||
"msg": tt.msg,
|
||||
},
|
||||
})
|
||||
|
||||
err := mountAndRunDrive(t, DriveCreateShortcut, []string{
|
||||
"+create-shortcut",
|
||||
"--file-token", "doc_token_test",
|
||||
"--type", "docx",
|
||||
"--folder-token", "folder_token_test",
|
||||
"--as", "bot",
|
||||
}, f, nil)
|
||||
if err == nil {
|
||||
t.Fatal("expected API error, got nil")
|
||||
}
|
||||
|
||||
var exitErr *output.ExitError
|
||||
if !errors.As(err, &exitErr) || exitErr.Detail == nil {
|
||||
t.Fatalf("expected structured exit error, got %v", err)
|
||||
}
|
||||
if exitErr.Code != output.ExitAPI {
|
||||
t.Fatalf("exit code = %d, want %d", exitErr.Code, output.ExitAPI)
|
||||
}
|
||||
if exitErr.Detail.Type != tt.wantType {
|
||||
t.Fatalf("type = %q, want %q", exitErr.Detail.Type, tt.wantType)
|
||||
}
|
||||
if exitErr.Detail.Code != tt.code {
|
||||
t.Fatalf("detail code = %d, want %d", exitErr.Detail.Code, tt.code)
|
||||
}
|
||||
if !strings.Contains(exitErr.Detail.Message, tt.wantMsgPart) {
|
||||
t.Fatalf("message = %q, want substring %q", exitErr.Detail.Message, tt.wantMsgPart)
|
||||
}
|
||||
if !strings.Contains(exitErr.Detail.Hint, tt.wantHint) {
|
||||
t.Fatalf("hint = %q, want substring %q", exitErr.Detail.Hint, tt.wantHint)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -5,27 +5,31 @@ package drive
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/internal/credential"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"github.com/larksuite/cli/internal/validate"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
// DriveTaskResult exposes a unified read path for the async task types produced
|
||||
// by Drive import, export, and folder move flows.
|
||||
// by Drive import, export, folder move/delete, and wiki move flows.
|
||||
var DriveTaskResult = common.Shortcut{
|
||||
Service: "drive",
|
||||
Command: "+task_result",
|
||||
Description: "Poll async task result for import, export, move, or delete operations",
|
||||
Description: "Poll async task result for import, export, drive move/delete, or wiki move operations",
|
||||
Risk: "read",
|
||||
Scopes: []string{"drive:drive.metadata:readonly"},
|
||||
AuthTypes: []string{"user", "bot"},
|
||||
// This shortcut multiplexes multiple backend APIs with different scope
|
||||
// requirements, so scenario-specific prechecks are handled in Validate.
|
||||
Scopes: []string{},
|
||||
AuthTypes: []string{"user", "bot"},
|
||||
Flags: []common.Flag{
|
||||
{Name: "ticket", Desc: "async task ticket (for import/export tasks)", Required: false},
|
||||
{Name: "task-id", Desc: "async task ID (for move/delete folder tasks)", Required: false},
|
||||
{Name: "scenario", Desc: "task scenario: import, export, or task_check", Required: true},
|
||||
{Name: "task-id", Desc: "async task ID (for drive task_check or wiki_move tasks)", Required: false},
|
||||
{Name: "scenario", Desc: "task scenario: import, export, task_check, or wiki_move", Required: true},
|
||||
{Name: "file-token", Desc: "source document token used for export task status lookup", Required: false},
|
||||
},
|
||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
@@ -34,9 +38,10 @@ var DriveTaskResult = common.Shortcut{
|
||||
"import": true,
|
||||
"export": true,
|
||||
"task_check": true,
|
||||
"wiki_move": true,
|
||||
}
|
||||
if !validScenarios[scenario] {
|
||||
return output.ErrValidation("unsupported scenario: %s. Supported scenarios: import, export, task_check", scenario)
|
||||
return output.ErrValidation("unsupported scenario: %s. Supported scenarios: import, export, task_check, wiki_move", scenario)
|
||||
}
|
||||
|
||||
// Validate required params based on scenario
|
||||
@@ -48,9 +53,9 @@ var DriveTaskResult = common.Shortcut{
|
||||
if err := validate.ResourceName(runtime.Str("ticket"), "--ticket"); err != nil {
|
||||
return output.ErrValidation("%s", err)
|
||||
}
|
||||
case "task_check":
|
||||
case "task_check", "wiki_move":
|
||||
if runtime.Str("task-id") == "" {
|
||||
return output.ErrValidation("--task-id is required for task_check scenario")
|
||||
return output.ErrValidation("--task-id is required for %s scenario", scenario)
|
||||
}
|
||||
if err := validate.ResourceName(runtime.Str("task-id"), "--task-id"); err != nil {
|
||||
return output.ErrValidation("%s", err)
|
||||
@@ -67,7 +72,7 @@ var DriveTaskResult = common.Shortcut{
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
return validateDriveTaskResultScopes(ctx, runtime, scenario)
|
||||
},
|
||||
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
scenario := strings.ToLower(runtime.Str("scenario"))
|
||||
@@ -92,6 +97,11 @@ var DriveTaskResult = common.Shortcut{
|
||||
dry.GET("/open-apis/drive/v1/files/task_check").
|
||||
Desc("[1] Query move/delete folder task status").
|
||||
Params(driveTaskCheckParams(taskID))
|
||||
case "wiki_move":
|
||||
dry.GET("/open-apis/wiki/v2/tasks/:task_id").
|
||||
Desc("[1] Query wiki move task result").
|
||||
Set("task_id", taskID).
|
||||
Params(map[string]interface{}{"task_type": "move"})
|
||||
}
|
||||
|
||||
return dry
|
||||
@@ -116,6 +126,8 @@ var DriveTaskResult = common.Shortcut{
|
||||
result, err = queryExportTask(runtime, ticket, fileToken)
|
||||
case "task_check":
|
||||
result, err = queryTaskCheck(runtime, taskID)
|
||||
case "wiki_move":
|
||||
result, err = queryWikiMoveTask(runtime, taskID)
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
@@ -196,3 +208,263 @@ func queryTaskCheck(runtime *common.RuntimeContext, taskID string) (map[string]i
|
||||
"failed": status.Failed(),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func validateDriveTaskResultScopes(ctx context.Context, runtime *common.RuntimeContext, scenario string) error {
|
||||
result, err := runtime.Factory.Credential.ResolveToken(ctx, credential.NewTokenSpec(runtime.As(), runtime.Config.AppID))
|
||||
if err != nil {
|
||||
// Propagate cancellation/timeout so callers stop instead of falling through
|
||||
// to the API call. Other token errors are non-fatal here: the API call will
|
||||
// surface a clearer permission error.
|
||||
if errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
if result == nil || result.Scopes == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
var required []string
|
||||
switch scenario {
|
||||
case "import", "export", "task_check":
|
||||
required = []string{"drive:drive.metadata:readonly"}
|
||||
case "wiki_move":
|
||||
required = []string{"wiki:space:read"}
|
||||
}
|
||||
|
||||
return requireDriveScopes(result.Scopes, required)
|
||||
}
|
||||
|
||||
func requireDriveScopes(storedScopes string, required []string) error {
|
||||
if len(required) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
missing := missingDriveScopes(storedScopes, required)
|
||||
if len(missing) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
return output.ErrWithHint(output.ExitAuth, "missing_scope",
|
||||
fmt.Sprintf("missing required scope(s): %s", strings.Join(missing, ", ")),
|
||||
fmt.Sprintf("run `lark-cli auth login --scope \"%s\"` in the background. It blocks and outputs a verification URL — retrieve the URL and open it in a browser to complete login.", strings.Join(missing, " ")))
|
||||
}
|
||||
|
||||
func missingDriveScopes(storedScopes string, required []string) []string {
|
||||
granted := make(map[string]bool)
|
||||
for _, scope := range strings.Fields(storedScopes) {
|
||||
granted[scope] = true
|
||||
}
|
||||
|
||||
missing := make([]string, 0, len(required))
|
||||
for _, scope := range required {
|
||||
if !granted[scope] {
|
||||
missing = append(missing, scope)
|
||||
}
|
||||
}
|
||||
return missing
|
||||
}
|
||||
|
||||
type wikiMoveTaskResultStatus struct {
|
||||
Node map[string]interface{}
|
||||
Status int
|
||||
StatusMsg string
|
||||
}
|
||||
|
||||
type wikiMoveTaskQueryStatus struct {
|
||||
TaskID string
|
||||
MoveResults []wikiMoveTaskResultStatus
|
||||
}
|
||||
|
||||
func (s wikiMoveTaskQueryStatus) Ready() bool {
|
||||
if len(s.MoveResults) == 0 {
|
||||
return false
|
||||
}
|
||||
for _, result := range s.MoveResults {
|
||||
if result.Status != 0 {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func (s wikiMoveTaskQueryStatus) Failed() bool {
|
||||
for _, result := range s.MoveResults {
|
||||
if result.Status < 0 {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func (s wikiMoveTaskQueryStatus) FirstResult() *wikiMoveTaskResultStatus {
|
||||
if len(s.MoveResults) == 0 {
|
||||
return nil
|
||||
}
|
||||
return &s.MoveResults[0]
|
||||
}
|
||||
|
||||
// primaryResult picks the most informative move_result for top-level status
|
||||
// surfacing: prefer a failing entry so multi-doc tasks don't mask failures
|
||||
// behind an earlier success, then a still-processing entry, and finally fall
|
||||
// back to the first entry.
|
||||
func (s wikiMoveTaskQueryStatus) primaryResult() *wikiMoveTaskResultStatus {
|
||||
for i := range s.MoveResults {
|
||||
if s.MoveResults[i].Status < 0 {
|
||||
return &s.MoveResults[i]
|
||||
}
|
||||
}
|
||||
for i := range s.MoveResults {
|
||||
if s.MoveResults[i].Status > 0 {
|
||||
return &s.MoveResults[i]
|
||||
}
|
||||
}
|
||||
return s.FirstResult()
|
||||
}
|
||||
|
||||
func (s wikiMoveTaskQueryStatus) PrimaryStatusCode() int {
|
||||
if r := s.primaryResult(); r != nil {
|
||||
return r.Status
|
||||
}
|
||||
return 1
|
||||
}
|
||||
|
||||
func (s wikiMoveTaskQueryStatus) PrimaryStatusLabel() string {
|
||||
if r := s.primaryResult(); r != nil {
|
||||
if msg := strings.TrimSpace(r.StatusMsg); msg != "" {
|
||||
return msg
|
||||
}
|
||||
}
|
||||
switch {
|
||||
case s.Ready():
|
||||
return "success"
|
||||
case s.Failed():
|
||||
return "failure"
|
||||
default:
|
||||
return "processing"
|
||||
}
|
||||
}
|
||||
|
||||
func queryWikiMoveTask(runtime *common.RuntimeContext, taskID string) (map[string]interface{}, error) {
|
||||
status, err := getWikiMoveTaskStatus(runtime, taskID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
out := map[string]interface{}{
|
||||
"scenario": "wiki_move",
|
||||
"task_id": status.TaskID,
|
||||
"ready": status.Ready(),
|
||||
"failed": status.Failed(),
|
||||
"status": status.PrimaryStatusCode(),
|
||||
"status_msg": status.PrimaryStatusLabel(),
|
||||
}
|
||||
|
||||
moveResults := make([]map[string]interface{}, 0, len(status.MoveResults))
|
||||
for _, result := range status.MoveResults {
|
||||
item := map[string]interface{}{
|
||||
"status": result.Status,
|
||||
"status_msg": result.StatusMsg,
|
||||
}
|
||||
if result.Node != nil {
|
||||
item["node"] = result.Node
|
||||
}
|
||||
moveResults = append(moveResults, item)
|
||||
}
|
||||
if len(moveResults) > 0 {
|
||||
out["move_results"] = moveResults
|
||||
}
|
||||
|
||||
if first := status.FirstResult(); first != nil {
|
||||
// Mirror the first moved node at the top level so follow-up commands can
|
||||
// reuse a stable field set without digging into move_results[0].node.
|
||||
if first.Node != nil {
|
||||
out["node"] = first.Node
|
||||
appendWikiMoveNodeFields(out, first.Node)
|
||||
if token := common.GetString(first.Node, "node_token"); token != "" {
|
||||
out["wiki_token"] = token
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func getWikiMoveTaskStatus(runtime *common.RuntimeContext, taskID string) (wikiMoveTaskQueryStatus, error) {
|
||||
if err := validate.ResourceName(taskID, "--task-id"); err != nil {
|
||||
return wikiMoveTaskQueryStatus{}, output.ErrValidation("%s", err)
|
||||
}
|
||||
|
||||
data, err := runtime.CallAPI(
|
||||
"GET",
|
||||
fmt.Sprintf("/open-apis/wiki/v2/tasks/%s", validate.EncodePathSegment(taskID)),
|
||||
map[string]interface{}{"task_type": "move"},
|
||||
nil,
|
||||
)
|
||||
if err != nil {
|
||||
return wikiMoveTaskQueryStatus{}, err
|
||||
}
|
||||
|
||||
return parseWikiMoveTaskQueryStatus(taskID, common.GetMap(data, "task"))
|
||||
}
|
||||
|
||||
func parseWikiMoveTaskQueryStatus(taskID string, task map[string]interface{}) (wikiMoveTaskQueryStatus, error) {
|
||||
if task == nil {
|
||||
return wikiMoveTaskQueryStatus{}, output.Errorf(output.ExitAPI, "api_error", "wiki task response missing task")
|
||||
}
|
||||
|
||||
status := wikiMoveTaskQueryStatus{
|
||||
TaskID: common.GetString(task, "task_id"),
|
||||
}
|
||||
if status.TaskID == "" {
|
||||
status.TaskID = taskID
|
||||
}
|
||||
|
||||
for _, item := range common.GetSlice(task, "move_result") {
|
||||
resultMap, ok := item.(map[string]interface{})
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
|
||||
status.MoveResults = append(status.MoveResults, wikiMoveTaskResultStatus{
|
||||
Node: parseWikiMoveTaskNode(common.GetMap(resultMap, "node")),
|
||||
Status: int(common.GetFloat(resultMap, "status")),
|
||||
StatusMsg: common.GetString(resultMap, "status_msg"),
|
||||
})
|
||||
}
|
||||
|
||||
return status, nil
|
||||
}
|
||||
|
||||
func parseWikiMoveTaskNode(node map[string]interface{}) map[string]interface{} {
|
||||
if node == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
return map[string]interface{}{
|
||||
"space_id": common.GetString(node, "space_id"),
|
||||
"node_token": common.GetString(node, "node_token"),
|
||||
"obj_token": common.GetString(node, "obj_token"),
|
||||
"obj_type": common.GetString(node, "obj_type"),
|
||||
"parent_node_token": common.GetString(node, "parent_node_token"),
|
||||
"node_type": common.GetString(node, "node_type"),
|
||||
"origin_node_token": common.GetString(node, "origin_node_token"),
|
||||
"title": common.GetString(node, "title"),
|
||||
"has_child": common.GetBool(node, "has_child"),
|
||||
}
|
||||
}
|
||||
|
||||
func appendWikiMoveNodeFields(out, node map[string]interface{}) {
|
||||
if out == nil || node == nil {
|
||||
return
|
||||
}
|
||||
out["space_id"] = common.GetString(node, "space_id")
|
||||
out["node_token"] = common.GetString(node, "node_token")
|
||||
out["obj_token"] = common.GetString(node, "obj_token")
|
||||
out["obj_type"] = common.GetString(node, "obj_type")
|
||||
out["parent_node_token"] = common.GetString(node, "parent_node_token")
|
||||
out["node_type"] = common.GetString(node, "node_type")
|
||||
out["origin_node_token"] = common.GetString(node, "origin_node_token")
|
||||
out["title"] = common.GetString(node, "title")
|
||||
out["has_child"] = common.GetBool(node, "has_child")
|
||||
}
|
||||
|
||||
@@ -7,12 +7,15 @@ import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/internal/credential"
|
||||
"github.com/larksuite/cli/internal/httpmock"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
@@ -54,6 +57,13 @@ func TestDriveTaskResultValidateErrorsByScenario(t *testing.T) {
|
||||
},
|
||||
wantErr: "--task-id is required",
|
||||
},
|
||||
{
|
||||
name: "wiki move missing task id",
|
||||
flags: map[string]string{
|
||||
"scenario": "wiki_move",
|
||||
},
|
||||
wantErr: "--task-id is required",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
@@ -277,3 +287,259 @@ func TestDriveTaskResultTaskCheckTreatsFailAsFailed(t *testing.T) {
|
||||
t.Fatalf("stdout missing ready=false: %s", stdout.String())
|
||||
}
|
||||
}
|
||||
|
||||
type mockDriveTaskResultTokenResolver struct {
|
||||
token string
|
||||
scopes string
|
||||
err error
|
||||
}
|
||||
|
||||
func (m *mockDriveTaskResultTokenResolver) ResolveToken(ctx context.Context, req credential.TokenSpec) (*credential.TokenResult, error) {
|
||||
if m.err != nil {
|
||||
return nil, m.err
|
||||
}
|
||||
token := m.token
|
||||
if token == "" {
|
||||
token = "test-token"
|
||||
}
|
||||
return &credential.TokenResult{Token: token, Scopes: m.scopes}, nil
|
||||
}
|
||||
|
||||
func newDriveTaskResultRuntimeWithScopes(t *testing.T, as core.Identity, scopes string) *common.RuntimeContext {
|
||||
t.Helper()
|
||||
|
||||
cfg := driveTestConfig()
|
||||
factory, _, _, _ := cmdutil.TestFactory(t, cfg)
|
||||
factory.Credential = credential.NewCredentialProvider(nil, nil, &mockDriveTaskResultTokenResolver{scopes: scopes}, nil)
|
||||
|
||||
runtime := common.TestNewRuntimeContextWithIdentity(&cobra.Command{Use: "drive +task_result"}, cfg, as)
|
||||
runtime.Factory = factory
|
||||
return runtime
|
||||
}
|
||||
|
||||
func TestDriveTaskResultDryRunWikiMoveIncludesTaskTypeParam(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
cmd := &cobra.Command{Use: "drive +task_result"}
|
||||
cmd.Flags().String("scenario", "", "")
|
||||
cmd.Flags().String("ticket", "", "")
|
||||
cmd.Flags().String("task-id", "", "")
|
||||
cmd.Flags().String("file-token", "", "")
|
||||
if err := cmd.Flags().Set("scenario", "wiki_move"); err != nil {
|
||||
t.Fatalf("set --scenario: %v", err)
|
||||
}
|
||||
if err := cmd.Flags().Set("task-id", "task_123"); err != nil {
|
||||
t.Fatalf("set --task-id: %v", err)
|
||||
}
|
||||
|
||||
runtime := common.TestNewRuntimeContext(cmd, nil)
|
||||
dry := DriveTaskResult.DryRun(context.Background(), runtime)
|
||||
if dry == nil {
|
||||
t.Fatal("DryRun returned nil")
|
||||
}
|
||||
|
||||
data, err := json.Marshal(dry)
|
||||
if err != nil {
|
||||
t.Fatalf("marshal dry run: %v", err)
|
||||
}
|
||||
|
||||
var got struct {
|
||||
API []struct {
|
||||
Params map[string]interface{} `json:"params"`
|
||||
} `json:"api"`
|
||||
}
|
||||
if err := json.Unmarshal(data, &got); err != nil {
|
||||
t.Fatalf("unmarshal dry run json: %v", err)
|
||||
}
|
||||
if len(got.API) != 1 {
|
||||
t.Fatalf("expected 1 API call, got %d", len(got.API))
|
||||
}
|
||||
if got.API[0].Params["task_type"] != "move" {
|
||||
t.Fatalf("wiki move params = %#v, want task_type=move", got.API[0].Params)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDriveTaskResultWikiMoveIncludesFlattenedNodeFields(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/wiki/v2/tasks/task_123",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{
|
||||
"task": map[string]interface{}{
|
||||
"task_id": "task_123",
|
||||
"move_result": []interface{}{
|
||||
map[string]interface{}{
|
||||
"status": 0,
|
||||
"status_msg": "success",
|
||||
"node": map[string]interface{}{
|
||||
"space_id": "space_dst",
|
||||
"node_token": "wik_done",
|
||||
"obj_token": "sheet_token",
|
||||
"obj_type": "sheet",
|
||||
"node_type": "origin",
|
||||
"title": "Roadmap",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
err := mountAndRunDrive(t, DriveTaskResult, []string{
|
||||
"+task_result",
|
||||
"--scenario", "wiki_move",
|
||||
"--task-id", "task_123",
|
||||
"--as", "user",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
data := decodeDriveEnvelope(t, stdout)
|
||||
if data["scenario"] != "wiki_move" || data["task_id"] != "task_123" {
|
||||
t.Fatalf("unexpected wiki_move envelope: %#v", data)
|
||||
}
|
||||
if data["ready"] != true || data["failed"] != false || data["wiki_token"] != "wik_done" {
|
||||
t.Fatalf("unexpected readiness fields: %#v", data)
|
||||
}
|
||||
if data["title"] != "Roadmap" || data["obj_type"] != "sheet" || data["space_id"] != "space_dst" {
|
||||
t.Fatalf("flattened node fields missing: %#v", data)
|
||||
}
|
||||
moveResults, ok := data["move_results"].([]interface{})
|
||||
if !ok || len(moveResults) != 1 {
|
||||
t.Fatalf("move_results = %#v, want one result", data["move_results"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateDriveTaskResultScopesWikiMoveRequiresWikiScope(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
runtime := newDriveTaskResultRuntimeWithScopes(t, core.AsUser, "drive:drive.metadata:readonly")
|
||||
err := validateDriveTaskResultScopes(context.Background(), runtime, "wiki_move")
|
||||
if err == nil || !strings.Contains(err.Error(), "missing required scope(s): wiki:space:read") {
|
||||
t.Fatalf("expected missing wiki scope error, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateDriveTaskResultScopesWikiMoveAcceptsWikiScope(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
runtime := newDriveTaskResultRuntimeWithScopes(t, core.AsUser, "wiki:space:read")
|
||||
err := validateDriveTaskResultScopes(context.Background(), runtime, "wiki_move")
|
||||
if err != nil {
|
||||
t.Fatalf("validateDriveTaskResultScopes() error = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateDriveTaskResultScopesDriveScenariosRequireDriveScope(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
runtime := newDriveTaskResultRuntimeWithScopes(t, core.AsUser, "wiki:space:read")
|
||||
err := validateDriveTaskResultScopes(context.Background(), runtime, "import")
|
||||
if err == nil || !strings.Contains(err.Error(), "missing required scope(s): drive:drive.metadata:readonly") {
|
||||
t.Fatalf("expected missing drive scope error, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseWikiMoveTaskQueryStatusFallbackTaskIDAndNode(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
status, err := parseWikiMoveTaskQueryStatus("task_fallback", map[string]interface{}{
|
||||
"move_result": []interface{}{
|
||||
map[string]interface{}{
|
||||
"status": 0,
|
||||
"status_msg": "success",
|
||||
"node": map[string]interface{}{
|
||||
"space_id": "space_dst",
|
||||
"node_token": "wik_done",
|
||||
"obj_token": "sheet_token",
|
||||
"obj_type": "sheet",
|
||||
"title": "Roadmap",
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("parseWikiMoveTaskQueryStatus() error = %v", err)
|
||||
}
|
||||
if status.TaskID != "task_fallback" || !status.Ready() || status.PrimaryStatusLabel() != "success" {
|
||||
t.Fatalf("unexpected parsed status: %+v", status)
|
||||
}
|
||||
if first := status.FirstResult(); first == nil || first.Node == nil || first.Node["node_token"] != "wik_done" {
|
||||
t.Fatalf("parsed node = %+v", first)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseWikiMoveTaskQueryStatusRejectsMissingTask(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
_, err := parseWikiMoveTaskQueryStatus("task_123", nil)
|
||||
if err == nil || !strings.Contains(err.Error(), "missing task") {
|
||||
t.Fatalf("expected missing task error, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWikiMoveTaskQueryStatusPrimarySurfacesFailureOverEarlierSuccess(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
status := wikiMoveTaskQueryStatus{
|
||||
MoveResults: []wikiMoveTaskResultStatus{
|
||||
{Status: 0, StatusMsg: "success"},
|
||||
{Status: -3, StatusMsg: "permission denied"},
|
||||
{Status: 1, StatusMsg: "processing"},
|
||||
},
|
||||
}
|
||||
if got := status.PrimaryStatusCode(); got != -3 {
|
||||
t.Fatalf("PrimaryStatusCode = %d, want -3", got)
|
||||
}
|
||||
if got := status.PrimaryStatusLabel(); got != "permission denied" {
|
||||
t.Fatalf("PrimaryStatusLabel = %q, want permission denied", got)
|
||||
}
|
||||
// FirstResult must keep its literal "first entry" semantics for callers
|
||||
// that flatten node fields from the first move_result.
|
||||
if first := status.FirstResult(); first == nil || first.StatusMsg != "success" {
|
||||
t.Fatalf("FirstResult = %+v, want first success entry", first)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWikiMoveTaskQueryStatusPrimaryPrefersProcessingOverFirstSuccess(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
status := wikiMoveTaskQueryStatus{
|
||||
MoveResults: []wikiMoveTaskResultStatus{
|
||||
{Status: 0, StatusMsg: "success"},
|
||||
{Status: 1, StatusMsg: "processing"},
|
||||
},
|
||||
}
|
||||
if got := status.PrimaryStatusCode(); got != 1 {
|
||||
t.Fatalf("PrimaryStatusCode = %d, want 1", got)
|
||||
}
|
||||
if got := status.PrimaryStatusLabel(); got != "processing" {
|
||||
t.Fatalf("PrimaryStatusLabel = %q, want processing", got)
|
||||
}
|
||||
}
|
||||
|
||||
type cancelingTokenResolver struct{}
|
||||
|
||||
func (cancelingTokenResolver) ResolveToken(ctx context.Context, req credential.TokenSpec) (*credential.TokenResult, error) {
|
||||
return nil, context.Canceled
|
||||
}
|
||||
|
||||
func TestValidateDriveTaskResultScopesPropagatesContextCancellation(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
cfg := driveTestConfig()
|
||||
factory, _, _, _ := cmdutil.TestFactory(t, cfg)
|
||||
factory.Credential = credential.NewCredentialProvider(nil, nil, cancelingTokenResolver{}, nil)
|
||||
|
||||
runtime := common.TestNewRuntimeContextWithIdentity(&cobra.Command{Use: "drive +task_result"}, cfg, core.AsUser)
|
||||
runtime.Factory = factory
|
||||
|
||||
err := validateDriveTaskResultScopes(context.Background(), runtime, "wiki_move")
|
||||
if err == nil || !errors.Is(err, context.Canceled) {
|
||||
t.Fatalf("expected context.Canceled, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ import "github.com/larksuite/cli/shortcuts/common"
|
||||
func Shortcuts() []common.Shortcut {
|
||||
return []common.Shortcut{
|
||||
DriveUpload,
|
||||
DriveCreateShortcut,
|
||||
DriveDownload,
|
||||
DriveAddComment,
|
||||
DriveExport,
|
||||
|
||||
@@ -5,12 +5,14 @@ package drive
|
||||
|
||||
import "testing"
|
||||
|
||||
// TestShortcutsIncludesExpectedCommands verifies the drive shortcut registry contains the expected commands.
|
||||
func TestShortcutsIncludesExpectedCommands(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
got := Shortcuts()
|
||||
want := []string{
|
||||
"+upload",
|
||||
"+create-shortcut",
|
||||
"+download",
|
||||
"+add-comment",
|
||||
"+export",
|
||||
|
||||
@@ -10,6 +10,7 @@ import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
@@ -395,6 +396,28 @@ func TestShortcutValidateBranches(t *testing.T) {
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("ImChatMessageList rejects both targets", func(t *testing.T) {
|
||||
runtime := newTestRuntimeContext(t, map[string]string{
|
||||
"chat-id": "oc_abc",
|
||||
"user-id": "ou_123",
|
||||
}, nil)
|
||||
err := ImChatMessageList.Validate(context.Background(), runtime)
|
||||
if err == nil || !strings.Contains(err.Error(), "mutually exclusive") {
|
||||
t.Fatalf("ImChatMessageList.Validate() error = %v, want mutually exclusive", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("ImChatMessageList rejects user target for bot identity", func(t *testing.T) {
|
||||
runtime := newTestRuntimeContext(t, map[string]string{
|
||||
"user-id": "ou_123",
|
||||
}, nil)
|
||||
setRuntimeField(t, runtime, "resolvedAs", core.AsBot)
|
||||
err := ImChatMessageList.Validate(context.Background(), runtime)
|
||||
if err == nil || !strings.Contains(err.Error(), "requires user identity") {
|
||||
t.Fatalf("ImChatMessageList.Validate() error = %v, want requires user identity", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("ImMessagesMGet empty ids", func(t *testing.T) {
|
||||
runtime := newTestRuntimeContext(t, map[string]string{
|
||||
"message-ids": " , ",
|
||||
|
||||
@@ -273,7 +273,7 @@ func TestResolveChatIDForMessagesList(t *testing.T) {
|
||||
})
|
||||
|
||||
t.Run("user resolved through p2p lookup", func(t *testing.T) {
|
||||
runtime := newBotShortcutRuntime(t, shortcutRoundTripFunc(func(req *http.Request) (*http.Response, error) {
|
||||
runtime := newUserShortcutRuntime(t, shortcutRoundTripFunc(func(req *http.Request) (*http.Response, error) {
|
||||
switch {
|
||||
case strings.Contains(req.URL.Path, "/open-apis/im/v1/chat_p2p/batch_query"):
|
||||
return shortcutJSONResponse(200, map[string]interface{}{
|
||||
@@ -303,6 +303,23 @@ func TestResolveChatIDForMessagesList(t *testing.T) {
|
||||
t.Fatalf("resolveChatIDForMessagesList() = %q, want %q", got, "oc_resolved")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("user target rejected for bot identity", func(t *testing.T) {
|
||||
runtime := newBotShortcutRuntime(t, shortcutRoundTripFunc(func(req *http.Request) (*http.Response, error) {
|
||||
return nil, fmt.Errorf("unexpected request: %s", req.URL.String())
|
||||
}))
|
||||
cmd := &cobra.Command{Use: "test"}
|
||||
cmd.Flags().String("user-id", "", "")
|
||||
if err := cmd.Flags().Set("user-id", "ou_123"); err != nil {
|
||||
t.Fatalf("Flags().Set() error = %v", err)
|
||||
}
|
||||
runtime.Cmd = cmd
|
||||
|
||||
_, err := resolveChatIDForMessagesList(runtime, false)
|
||||
if err == nil || !strings.Contains(err.Error(), "requires user identity") {
|
||||
t.Fatalf("resolveChatIDForMessagesList() error = %v, want requires user identity", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestBuildMessagesSearchRequest(t *testing.T) {
|
||||
|
||||
@@ -377,6 +377,9 @@ func mediaFallbackOrError(originalValue, mediaType string, uploadErr error) (str
|
||||
|
||||
// resolveP2PChatID resolves user open_id to P2P chat_id.
|
||||
func resolveP2PChatID(runtime *common.RuntimeContext, openID string) (string, error) {
|
||||
if runtime.IsBot() {
|
||||
return "", output.Errorf(output.ExitValidation, "validation", "--user-id requires user identity (--as user); use --chat-id when calling with bot identity")
|
||||
}
|
||||
apiResp, err := runtime.DoAPI(&larkcore.ApiReq{
|
||||
HttpMethod: http.MethodPost,
|
||||
ApiPath: "/open-apis/im/v1/chat_p2p/batch_query",
|
||||
|
||||
@@ -6,6 +6,7 @@ package im
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/md5"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
@@ -13,6 +14,7 @@ import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"reflect"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
"unsafe"
|
||||
@@ -107,12 +109,17 @@ func newBotShortcutRuntime(t *testing.T, rt http.RoundTripper) *common.RuntimeCo
|
||||
return runtime
|
||||
}
|
||||
|
||||
func newUserShortcutRuntime(t *testing.T, rt http.RoundTripper) *common.RuntimeContext {
|
||||
t.Helper()
|
||||
runtime := newBotShortcutRuntime(t, rt)
|
||||
setRuntimeField(t, runtime, "resolvedAs", core.AsUser)
|
||||
return runtime
|
||||
}
|
||||
|
||||
func TestResolveP2PChatID(t *testing.T) {
|
||||
var gotAuth string
|
||||
runtime := newBotShortcutRuntime(t, shortcutRoundTripFunc(func(req *http.Request) (*http.Response, error) {
|
||||
runtime := newUserShortcutRuntime(t, shortcutRoundTripFunc(func(req *http.Request) (*http.Response, error) {
|
||||
switch {
|
||||
case strings.Contains(req.URL.Path, "/open-apis/im/v1/chat_p2p/batch_query"):
|
||||
gotAuth = req.Header.Get("Authorization")
|
||||
return shortcutJSONResponse(200, map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{
|
||||
@@ -133,13 +140,10 @@ func TestResolveP2PChatID(t *testing.T) {
|
||||
if got != "oc_123" {
|
||||
t.Fatalf("resolveP2PChatID() = %q, want %q", got, "oc_123")
|
||||
}
|
||||
if gotAuth != "Bearer tenant-token" {
|
||||
t.Fatalf("Authorization header = %q, want %q", gotAuth, "Bearer tenant-token")
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveP2PChatIDNotFound(t *testing.T) {
|
||||
runtime := newBotShortcutRuntime(t, shortcutRoundTripFunc(func(req *http.Request) (*http.Response, error) {
|
||||
runtime := newUserShortcutRuntime(t, shortcutRoundTripFunc(func(req *http.Request) (*http.Response, error) {
|
||||
switch {
|
||||
case strings.Contains(req.URL.Path, "/open-apis/im/v1/chat_p2p/batch_query"):
|
||||
return shortcutJSONResponse(200, map[string]interface{}{
|
||||
@@ -159,6 +163,17 @@ func TestResolveP2PChatIDNotFound(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveP2PChatIDRejectsBot(t *testing.T) {
|
||||
runtime := newBotShortcutRuntime(t, shortcutRoundTripFunc(func(req *http.Request) (*http.Response, error) {
|
||||
return nil, fmt.Errorf("unexpected request: %s", req.URL.String())
|
||||
}))
|
||||
|
||||
_, err := resolveP2PChatID(runtime, "ou_123")
|
||||
if err == nil || !strings.Contains(err.Error(), "requires user identity") {
|
||||
t.Fatalf("resolveP2PChatID() error = %v, want requires user identity", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveThreadID(t *testing.T) {
|
||||
t.Run("thread id passthrough", func(t *testing.T) {
|
||||
got, err := resolveThreadID(newBotShortcutRuntime(t, shortcutRoundTripFunc(func(req *http.Request) (*http.Response, error) {
|
||||
@@ -273,6 +288,46 @@ func TestDownloadIMResourceToPathSuccess(t *testing.T) {
|
||||
if gotHeaders.Get(cmdutil.HeaderExecutionId) != "exec-123" {
|
||||
t.Fatalf("%s = %q, want %q", cmdutil.HeaderExecutionId, gotHeaders.Get(cmdutil.HeaderExecutionId), "exec-123")
|
||||
}
|
||||
if gotHeaders.Get("Range") != fmt.Sprintf("bytes=0-%d", probeChunkSize-1) {
|
||||
t.Fatalf("Range header = %q, want %q", gotHeaders.Get("Range"), fmt.Sprintf("bytes=0-%d", probeChunkSize-1))
|
||||
}
|
||||
}
|
||||
|
||||
func TestDownloadIMResourceToPathImageUsesSingleRequestWithoutRange(t *testing.T) {
|
||||
var gotHeaders http.Header
|
||||
payload := []byte("image download")
|
||||
runtime := newBotShortcutRuntime(t, shortcutRoundTripFunc(func(req *http.Request) (*http.Response, error) {
|
||||
switch {
|
||||
case strings.Contains(req.URL.Path, "/open-apis/im/v1/messages/om_img/resources/img_123"):
|
||||
gotHeaders = req.Header.Clone()
|
||||
return shortcutRawResponse(200, payload, http.Header{"Content-Type": []string{"image/png"}}), nil
|
||||
default:
|
||||
return nil, fmt.Errorf("unexpected request: %s", req.URL.String())
|
||||
}
|
||||
}))
|
||||
|
||||
cmdutil.TestChdir(t, t.TempDir())
|
||||
|
||||
gotPath, size, err := downloadIMResourceToPath(context.Background(), runtime, "om_img", "img_123", "image", "image")
|
||||
if err != nil {
|
||||
t.Fatalf("downloadIMResourceToPath() error = %v", err)
|
||||
}
|
||||
if size != int64(len(payload)) {
|
||||
t.Fatalf("downloadIMResourceToPath() size = %d, want %d", size, len(payload))
|
||||
}
|
||||
if gotHeaders.Get("Range") != "" {
|
||||
t.Fatalf("Range header = %q, want empty", gotHeaders.Get("Range"))
|
||||
}
|
||||
if !strings.HasSuffix(gotPath, "image.png") {
|
||||
t.Fatalf("saved path = %q, want suffix %q", gotPath, "image.png")
|
||||
}
|
||||
data, err := os.ReadFile("image.png")
|
||||
if err != nil {
|
||||
t.Fatalf("ReadFile() error = %v", err)
|
||||
}
|
||||
if string(data) != string(payload) {
|
||||
t.Fatalf("downloaded payload = %q, want %q", string(data), string(payload))
|
||||
}
|
||||
}
|
||||
|
||||
func TestDownloadIMResourceToPathHTTPErrorBody(t *testing.T) {
|
||||
@@ -293,6 +348,348 @@ func TestDownloadIMResourceToPathHTTPErrorBody(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestDownloadIMResourceToPathRetriesNetworkError(t *testing.T) {
|
||||
attempts := 0
|
||||
payload := []byte("retry success")
|
||||
runtime := newBotShortcutRuntime(t, shortcutRoundTripFunc(func(req *http.Request) (*http.Response, error) {
|
||||
switch {
|
||||
case strings.Contains(req.URL.Path, "tenant_access_token"):
|
||||
return shortcutJSONResponse(200, map[string]interface{}{
|
||||
"code": 0,
|
||||
"tenant_access_token": "tenant-token",
|
||||
"expire": 7200,
|
||||
}), nil
|
||||
case strings.Contains(req.URL.Path, "/open-apis/im/v1/messages/om_retry/resources/file_retry"):
|
||||
attempts++
|
||||
if attempts < 3 {
|
||||
return nil, fmt.Errorf("temporary network failure")
|
||||
}
|
||||
return shortcutRawResponse(200, payload, http.Header{"Content-Type": []string{"application/octet-stream"}}), nil
|
||||
default:
|
||||
return nil, fmt.Errorf("unexpected request: %s", req.URL.String())
|
||||
}
|
||||
}))
|
||||
|
||||
cmdutil.TestChdir(t, t.TempDir())
|
||||
target := "out.bin"
|
||||
_, size, err := downloadIMResourceToPath(context.Background(), runtime, "om_retry", "file_retry", "file", target)
|
||||
if err != nil {
|
||||
t.Fatalf("downloadIMResourceToPath() error = %v", err)
|
||||
}
|
||||
if attempts != 3 {
|
||||
t.Fatalf("download attempts = %d, want 3", attempts)
|
||||
}
|
||||
if size != int64(len(payload)) {
|
||||
t.Fatalf("downloadIMResourceToPath() size = %d, want %d", size, len(payload))
|
||||
}
|
||||
}
|
||||
|
||||
func TestDownloadIMResourceToPathRetrySecondAttemptSuccess(t *testing.T) {
|
||||
attempts := 0
|
||||
payload := []byte("second retry success")
|
||||
runtime := newBotShortcutRuntime(t, shortcutRoundTripFunc(func(req *http.Request) (*http.Response, error) {
|
||||
switch {
|
||||
case strings.Contains(req.URL.Path, "tenant_access_token"):
|
||||
return shortcutJSONResponse(200, map[string]interface{}{
|
||||
"code": 0,
|
||||
"tenant_access_token": "tenant-token",
|
||||
"expire": 7200,
|
||||
}), nil
|
||||
case strings.Contains(req.URL.Path, "/open-apis/im/v1/messages/om_retry2/resources/file_retry2"):
|
||||
attempts++
|
||||
if attempts < 2 {
|
||||
return nil, fmt.Errorf("temporary network failure")
|
||||
}
|
||||
return shortcutRawResponse(200, payload, http.Header{"Content-Type": []string{"application/octet-stream"}}), nil
|
||||
default:
|
||||
return nil, fmt.Errorf("unexpected request: %s", req.URL.String())
|
||||
}
|
||||
}))
|
||||
|
||||
cmdutil.TestChdir(t, t.TempDir())
|
||||
target := "out.bin"
|
||||
_, size, err := downloadIMResourceToPath(context.Background(), runtime, "om_retry2", "file_retry2", "file", target)
|
||||
if err != nil {
|
||||
t.Fatalf("downloadIMResourceToPath() error = %v", err)
|
||||
}
|
||||
if attempts != 2 {
|
||||
t.Fatalf("download attempts = %d, want 2", attempts)
|
||||
}
|
||||
if size != int64(len(payload)) {
|
||||
t.Fatalf("downloadIMResourceToPath() size = %d, want %d", size, len(payload))
|
||||
}
|
||||
}
|
||||
|
||||
func TestDownloadIMResourceToPathRetryContextCanceled(t *testing.T) {
|
||||
attempts := 0
|
||||
runtime := newBotShortcutRuntime(t, shortcutRoundTripFunc(func(req *http.Request) (*http.Response, error) {
|
||||
switch {
|
||||
case strings.Contains(req.URL.Path, "tenant_access_token"):
|
||||
return shortcutJSONResponse(200, map[string]interface{}{
|
||||
"code": 0,
|
||||
"tenant_access_token": "tenant-token",
|
||||
"expire": 7200,
|
||||
}), nil
|
||||
case strings.Contains(req.URL.Path, "/open-apis/im/v1/messages/om_cancel/resources/file_cancel"):
|
||||
attempts++
|
||||
return nil, fmt.Errorf("temporary network failure")
|
||||
default:
|
||||
return nil, fmt.Errorf("unexpected request: %s", req.URL.String())
|
||||
}
|
||||
}))
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
// Cancel context immediately to trigger context error on first retry
|
||||
cancel()
|
||||
|
||||
cmdutil.TestChdir(t, t.TempDir())
|
||||
target := "out.bin"
|
||||
_, _, err := downloadIMResourceToPath(ctx, runtime, "om_cancel", "file_cancel", "file", target)
|
||||
if err != context.Canceled {
|
||||
t.Fatalf("downloadIMResourceToPath() error = %v, want context.Canceled", err)
|
||||
}
|
||||
// First attempt is made, then retry checks ctx.Err() and returns
|
||||
if attempts != 1 {
|
||||
t.Fatalf("download attempts = %d, want 1", attempts)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDownloadIMResourceToPathRangeDownload(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
payloadLen int64
|
||||
wantRanges []string
|
||||
}{
|
||||
{
|
||||
name: "single small chunk",
|
||||
payloadLen: 16,
|
||||
wantRanges: []string{"bytes=0-131071"},
|
||||
},
|
||||
{
|
||||
name: "exact probe chunk",
|
||||
payloadLen: probeChunkSize,
|
||||
wantRanges: []string{"bytes=0-131071"},
|
||||
},
|
||||
{
|
||||
name: "multiple chunks with tail",
|
||||
payloadLen: probeChunkSize + normalChunkSize + 1234,
|
||||
wantRanges: []string{
|
||||
"bytes=0-131071",
|
||||
fmt.Sprintf("bytes=%d-%d", probeChunkSize, probeChunkSize+normalChunkSize-1),
|
||||
fmt.Sprintf("bytes=%d-%d", probeChunkSize+normalChunkSize, probeChunkSize+normalChunkSize+1233),
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "multiple chunks exact 8mb tail",
|
||||
payloadLen: probeChunkSize + 2*normalChunkSize,
|
||||
wantRanges: []string{
|
||||
"bytes=0-131071",
|
||||
fmt.Sprintf("bytes=%d-%d", probeChunkSize, probeChunkSize+normalChunkSize-1),
|
||||
fmt.Sprintf("bytes=%d-%d", probeChunkSize+normalChunkSize, probeChunkSize+2*normalChunkSize-1),
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range cases {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
payload := bytes.Repeat([]byte("range-download-"), int(tt.payloadLen/15)+1)
|
||||
payload = payload[:tt.payloadLen]
|
||||
|
||||
var gotRanges []string
|
||||
runtime := newBotShortcutRuntime(t, shortcutRoundTripFunc(func(req *http.Request) (*http.Response, error) {
|
||||
switch {
|
||||
case strings.Contains(req.URL.Path, "tenant_access_token"):
|
||||
return shortcutJSONResponse(200, map[string]interface{}{
|
||||
"code": 0,
|
||||
"tenant_access_token": "tenant-token",
|
||||
"expire": 7200,
|
||||
}), nil
|
||||
case strings.Contains(req.URL.Path, "/open-apis/im/v1/messages/om_range/resources/file_range"):
|
||||
rangeHeader := req.Header.Get("Range")
|
||||
gotRanges = append(gotRanges, rangeHeader)
|
||||
if req.Header.Get("Authorization") != "Bearer tenant-token" {
|
||||
return nil, fmt.Errorf("missing authorization header")
|
||||
}
|
||||
start, end, err := parseRangeHeader(rangeHeader, int64(len(payload)))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return shortcutRawResponse(http.StatusPartialContent, payload[start:end+1], http.Header{
|
||||
"Content-Type": []string{"application/octet-stream"},
|
||||
"Content-Range": []string{fmt.Sprintf("bytes %d-%d/%d", start, end, len(payload))},
|
||||
}), nil
|
||||
default:
|
||||
return nil, fmt.Errorf("unexpected request: %s", req.URL.String())
|
||||
}
|
||||
}))
|
||||
|
||||
cmdutil.TestChdir(t, t.TempDir())
|
||||
target := filepath.Join("nested", "resource.bin")
|
||||
_, size, err := downloadIMResourceToPath(context.Background(), runtime, "om_range", "file_range", "file", target)
|
||||
if err != nil {
|
||||
t.Fatalf("downloadIMResourceToPath() error = %v", err)
|
||||
}
|
||||
if size != int64(len(payload)) {
|
||||
t.Fatalf("downloadIMResourceToPath() size = %d, want %d", size, len(payload))
|
||||
}
|
||||
if !reflect.DeepEqual(gotRanges, tt.wantRanges) {
|
||||
t.Fatalf("Range requests = %#v, want %#v", gotRanges, tt.wantRanges)
|
||||
}
|
||||
|
||||
got, err := os.ReadFile(target)
|
||||
if err != nil {
|
||||
t.Fatalf("ReadFile() error = %v", err)
|
||||
}
|
||||
if md5.Sum(got) != md5.Sum(payload) {
|
||||
t.Fatalf("downloaded payload MD5 = %x, want %x", md5.Sum(got), md5.Sum(payload))
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestDownloadIMResourceToPathInvalidContentRange(t *testing.T) {
|
||||
runtime := newBotShortcutRuntime(t, shortcutRoundTripFunc(func(req *http.Request) (*http.Response, error) {
|
||||
switch {
|
||||
case strings.Contains(req.URL.Path, "tenant_access_token"):
|
||||
return shortcutJSONResponse(200, map[string]interface{}{
|
||||
"code": 0,
|
||||
"tenant_access_token": "tenant-token",
|
||||
"expire": 7200,
|
||||
}), nil
|
||||
case strings.Contains(req.URL.Path, "/open-apis/im/v1/messages/om_bad/resources/file_bad"):
|
||||
return shortcutRawResponse(http.StatusPartialContent, []byte("bad"), http.Header{
|
||||
"Content-Type": []string{"application/octet-stream"},
|
||||
"Content-Range": []string{"bytes 0-2/not-a-number"},
|
||||
}), nil
|
||||
default:
|
||||
return nil, fmt.Errorf("unexpected request: %s", req.URL.String())
|
||||
}
|
||||
}))
|
||||
|
||||
cmdutil.TestChdir(t, t.TempDir())
|
||||
_, _, err := downloadIMResourceToPath(context.Background(), runtime, "om_bad", "file_bad", "file", "out.bin")
|
||||
if err == nil || !strings.Contains(err.Error(), "invalid Content-Range header") {
|
||||
t.Fatalf("downloadIMResourceToPath() error = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDownloadIMResourceToPathRangeChunkFailureCleansOutput(t *testing.T) {
|
||||
payload := bytes.Repeat([]byte("range-download-"), int((probeChunkSize+1024)/15)+1)
|
||||
payload = payload[:probeChunkSize+1024]
|
||||
|
||||
runtime := newBotShortcutRuntime(t, shortcutRoundTripFunc(func(req *http.Request) (*http.Response, error) {
|
||||
switch {
|
||||
case strings.Contains(req.URL.Path, "/open-apis/im/v1/messages/om_miderr/resources/file_miderr"):
|
||||
rangeHeader := req.Header.Get("Range")
|
||||
if rangeHeader == fmt.Sprintf("bytes=0-%d", probeChunkSize-1) {
|
||||
return shortcutRawResponse(http.StatusPartialContent, payload[:probeChunkSize], http.Header{
|
||||
"Content-Type": []string{"application/octet-stream"},
|
||||
"Content-Range": []string{fmt.Sprintf("bytes 0-%d/%d", probeChunkSize-1, len(payload))},
|
||||
}), nil
|
||||
}
|
||||
return shortcutRawResponse(http.StatusInternalServerError, []byte("chunk failed"), http.Header{"Content-Type": []string{"text/plain"}}), nil
|
||||
default:
|
||||
return nil, fmt.Errorf("unexpected request: %s", req.URL.String())
|
||||
}
|
||||
}))
|
||||
|
||||
cmdutil.TestChdir(t, t.TempDir())
|
||||
|
||||
target := "out.bin"
|
||||
_, _, err := downloadIMResourceToPath(context.Background(), runtime, "om_miderr", "file_miderr", "file", target)
|
||||
if err == nil || !strings.Contains(err.Error(), "HTTP 500: chunk failed") {
|
||||
t.Fatalf("downloadIMResourceToPath() error = %v", err)
|
||||
}
|
||||
if _, statErr := os.Stat(target); !os.IsNotExist(statErr) {
|
||||
t.Fatalf("output file exists after failed download, stat error = %v", statErr)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDownloadIMResourceToPathRangeOverflowCleansOutput(t *testing.T) {
|
||||
payload := []byte("overflow-payload")
|
||||
runtime := newBotShortcutRuntime(t, shortcutRoundTripFunc(func(req *http.Request) (*http.Response, error) {
|
||||
switch {
|
||||
case strings.Contains(req.URL.Path, "/open-apis/im/v1/messages/om_overflow/resources/file_overflow"):
|
||||
return shortcutRawResponse(http.StatusPartialContent, payload, http.Header{
|
||||
"Content-Type": []string{"application/octet-stream"},
|
||||
"Content-Range": []string{"bytes 0-3/4"},
|
||||
}), nil
|
||||
default:
|
||||
return nil, fmt.Errorf("unexpected request: %s", req.URL.String())
|
||||
}
|
||||
}))
|
||||
|
||||
cmdutil.TestChdir(t, t.TempDir())
|
||||
|
||||
target := "out.bin"
|
||||
_, _, err := downloadIMResourceToPath(context.Background(), runtime, "om_overflow", "file_overflow", "file", target)
|
||||
if err == nil || !strings.Contains(err.Error(), "chunk overflow") {
|
||||
t.Fatalf("downloadIMResourceToPath() error = %v", err)
|
||||
}
|
||||
if _, statErr := os.Stat(target); !os.IsNotExist(statErr) {
|
||||
t.Fatalf("output file exists after overflow, stat error = %v", statErr)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDownloadIMResourceToPathRangeShortChunkSizeMismatch(t *testing.T) {
|
||||
payload := bytes.Repeat([]byte("range-download-"), int((probeChunkSize+1024)/15)+1)
|
||||
payload = payload[:probeChunkSize+1024]
|
||||
|
||||
runtime := newBotShortcutRuntime(t, shortcutRoundTripFunc(func(req *http.Request) (*http.Response, error) {
|
||||
switch {
|
||||
case strings.Contains(req.URL.Path, "/open-apis/im/v1/messages/om_short/resources/file_short"):
|
||||
rangeHeader := req.Header.Get("Range")
|
||||
start, end, err := parseRangeHeader(rangeHeader, int64(len(payload)))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
body := payload[start : end+1]
|
||||
if start == probeChunkSize {
|
||||
body = body[:len(body)-10]
|
||||
}
|
||||
return shortcutRawResponse(http.StatusPartialContent, body, http.Header{
|
||||
"Content-Type": []string{"application/octet-stream"},
|
||||
"Content-Range": []string{fmt.Sprintf("bytes %d-%d/%d", start, end, len(payload))},
|
||||
}), nil
|
||||
default:
|
||||
return nil, fmt.Errorf("unexpected request: %s", req.URL.String())
|
||||
}
|
||||
}))
|
||||
|
||||
cmdutil.TestChdir(t, t.TempDir())
|
||||
|
||||
_, _, err := downloadIMResourceToPath(context.Background(), runtime, "om_short", "file_short", "file", "out.bin")
|
||||
if err == nil || !strings.Contains(err.Error(), "file size mismatch") {
|
||||
t.Fatalf("downloadIMResourceToPath() error = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func parseRangeHeader(header string, totalSize int64) (int64, int64, error) {
|
||||
if !strings.HasPrefix(header, "bytes=") {
|
||||
return 0, 0, fmt.Errorf("unexpected range header: %q", header)
|
||||
}
|
||||
parts := strings.SplitN(strings.TrimPrefix(header, "bytes="), "-", 2)
|
||||
if len(parts) != 2 {
|
||||
return 0, 0, fmt.Errorf("unexpected range header: %q", header)
|
||||
}
|
||||
|
||||
start, err := strconv.ParseInt(parts[0], 10, 64)
|
||||
if err != nil {
|
||||
return 0, 0, fmt.Errorf("parse start: %w", err)
|
||||
}
|
||||
end, err := strconv.ParseInt(parts[1], 10, 64)
|
||||
if err != nil {
|
||||
return 0, 0, fmt.Errorf("parse end: %w", err)
|
||||
}
|
||||
if start < 0 || end < start || start >= totalSize {
|
||||
return 0, 0, fmt.Errorf("invalid range bounds: %d-%d for size %d", start, end, totalSize)
|
||||
}
|
||||
if end >= totalSize {
|
||||
end = totalSize - 1
|
||||
}
|
||||
return start, end, nil
|
||||
}
|
||||
|
||||
func TestUploadImageToIMSuccess(t *testing.T) {
|
||||
var gotBody string
|
||||
runtime := newBotShortcutRuntime(t, shortcutRoundTripFunc(func(req *http.Request) (*http.Response, error) {
|
||||
|
||||
@@ -599,6 +599,44 @@ func TestDownloadIMResourceToPathHTTPClientError(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseTotalSize(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
contentRange string
|
||||
want int64
|
||||
wantErr string
|
||||
}{
|
||||
{name: "normal", contentRange: "bytes 0-131071/104857600", want: 104857600},
|
||||
{name: "single probe chunk", contentRange: "bytes 0-131071/131072", want: 131072},
|
||||
{name: "single small chunk", contentRange: "bytes 0-15/16", want: 16},
|
||||
{name: "empty", contentRange: "", wantErr: "content-range is empty"},
|
||||
{name: "invalid prefix", contentRange: "items 0-15/16", wantErr: `unsupported content-range: "items 0-15/16"`},
|
||||
{name: "missing total", contentRange: "bytes 0-15/", wantErr: `unsupported content-range: "bytes 0-15/"`},
|
||||
{name: "wildcard", contentRange: "bytes */16", wantErr: `unsupported content-range: "bytes */16"`},
|
||||
{name: "unknown total size", contentRange: "bytes 0-99/*", wantErr: `unknown total size in content-range: "bytes 0-99/*"`},
|
||||
{name: "invalid total", contentRange: "bytes 0-15/not-a-number", wantErr: "parse total size:"},
|
||||
{name: "zero total size", contentRange: "bytes 0-0/0", wantErr: "invalid total size: 0"},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got, err := parseTotalSize(tt.contentRange)
|
||||
if tt.wantErr != "" {
|
||||
if err == nil || !strings.Contains(err.Error(), tt.wantErr) {
|
||||
t.Fatalf("parseTotalSize() error = %v, want substring %q", err, tt.wantErr)
|
||||
}
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
t.Fatalf("parseTotalSize() unexpected error = %v", err)
|
||||
}
|
||||
if got != tt.want {
|
||||
t.Fatalf("parseTotalSize() = %d, want %d", got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestShortcuts(t *testing.T) {
|
||||
var commands []string
|
||||
for _, shortcut := range Shortcuts() {
|
||||
|
||||
@@ -28,7 +28,7 @@ var ImChatMessageList = common.Shortcut{
|
||||
HasFormat: true,
|
||||
Flags: []common.Flag{
|
||||
{Name: "chat-id", Desc: "(required, mutually exclusive with --user-id) chat ID (oc_xxx)"},
|
||||
{Name: "user-id", Desc: "(required, mutually exclusive with --chat-id) user open_id (ou_xxx)"},
|
||||
{Name: "user-id", Desc: "(required, mutually exclusive with --chat-id; user identity only) user open_id (ou_xxx)"},
|
||||
{Name: "start", Desc: "start time (ISO 8601)"},
|
||||
{Name: "end", Desc: "end time (ISO 8601)"},
|
||||
{Name: "sort", Default: "desc", Desc: "sort order", Enum: []string{"asc", "desc"}},
|
||||
@@ -57,11 +57,21 @@ var ImChatMessageList = common.Shortcut{
|
||||
return d.GET("/open-apis/im/v1/messages").Params(dryParams)
|
||||
},
|
||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
if err := common.ExactlyOne(runtime, "chat-id", "user-id"); err != nil {
|
||||
if runtime.Str("chat-id") == "" && runtime.Str("user-id") == "" {
|
||||
return common.FlagErrorf("specify at least one of --chat-id or --user-id")
|
||||
// Under bot identity, --user-id is not supported; require --chat-id only.
|
||||
if runtime.IsBot() {
|
||||
if runtime.Str("user-id") != "" {
|
||||
return common.FlagErrorf("--user-id requires user identity (--as user); use --chat-id when calling with bot identity")
|
||||
}
|
||||
if runtime.Str("chat-id") == "" {
|
||||
return common.FlagErrorf("specify --chat-id (bot identity does not support --user-id)")
|
||||
}
|
||||
} else {
|
||||
if err := common.ExactlyOne(runtime, "chat-id", "user-id"); err != nil {
|
||||
if runtime.Str("chat-id") == "" && runtime.Str("user-id") == "" {
|
||||
return common.FlagErrorf("specify at least one of --chat-id or --user-id")
|
||||
}
|
||||
return err
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
// Validate ID formats
|
||||
|
||||
@@ -9,6 +9,7 @@ import (
|
||||
"io"
|
||||
"net/http"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
@@ -67,6 +68,9 @@ var ImMessagesResourcesDownload = common.Shortcut{
|
||||
if err != nil {
|
||||
return output.ErrValidation("invalid output path: %s", err)
|
||||
}
|
||||
if _, err := runtime.ResolveSavePath(relPath); err != nil {
|
||||
return output.ErrValidation("unsafe output path: %s", err)
|
||||
}
|
||||
|
||||
finalPath, sizeBytes, err := downloadIMResourceToPath(ctx, runtime, messageId, fileKey, fileType, relPath)
|
||||
if err != nil {
|
||||
@@ -102,7 +106,13 @@ func normalizeDownloadOutputPath(fileKey, outputPath string) (string, error) {
|
||||
return outputPath, nil
|
||||
}
|
||||
|
||||
const defaultIMResourceDownloadTimeout = 120 * time.Second
|
||||
const (
|
||||
defaultIMResourceDownloadTimeout = 120 * time.Second
|
||||
probeChunkSize = int64(128 * 1024)
|
||||
normalChunkSize = int64(8 * 1024 * 1024)
|
||||
imDownloadRequestRetries = 2
|
||||
imDownloadRetryDelay = 300 * time.Millisecond
|
||||
)
|
||||
|
||||
var imMimeToExt = map[string]string{
|
||||
"image/png": ".png",
|
||||
@@ -135,10 +145,199 @@ var imMimeToExt = map[string]string{
|
||||
"application/vnd.openxmlformats-officedocument.presentationml.presentation": ".pptx",
|
||||
}
|
||||
|
||||
func downloadIMResourceToPath(ctx context.Context, runtime *common.RuntimeContext, messageID, fileKey, fileType, safePath string) (string, int64, error) {
|
||||
type rangeChunkReader struct {
|
||||
ctx context.Context
|
||||
runtime *common.RuntimeContext
|
||||
messageID string
|
||||
fileKey string
|
||||
fileType string
|
||||
totalSize int64
|
||||
delivered int64
|
||||
current io.ReadCloser
|
||||
nextOffset int64
|
||||
}
|
||||
|
||||
func newRangeChunkReader(
|
||||
ctx context.Context,
|
||||
runtime *common.RuntimeContext,
|
||||
messageID, fileKey, fileType string,
|
||||
probeBody io.ReadCloser,
|
||||
totalSize int64,
|
||||
) *rangeChunkReader {
|
||||
return &rangeChunkReader{
|
||||
ctx: ctx,
|
||||
runtime: runtime,
|
||||
messageID: messageID,
|
||||
fileKey: fileKey,
|
||||
fileType: fileType,
|
||||
totalSize: totalSize,
|
||||
current: probeBody,
|
||||
nextOffset: probeChunkSize,
|
||||
}
|
||||
}
|
||||
|
||||
func (r *rangeChunkReader) Read(p []byte) (int, error) {
|
||||
for {
|
||||
if r.current != nil {
|
||||
n, err := r.current.Read(p)
|
||||
r.delivered += int64(n)
|
||||
|
||||
if r.delivered > r.totalSize {
|
||||
if err == io.EOF {
|
||||
closeErr := r.current.Close()
|
||||
r.current = nil
|
||||
if closeErr != nil {
|
||||
return 0, closeErr
|
||||
}
|
||||
}
|
||||
return 0, output.ErrNetwork("chunk overflow: delivered %d, expected %d", r.delivered, r.totalSize)
|
||||
}
|
||||
|
||||
switch err {
|
||||
case nil:
|
||||
return n, nil
|
||||
case io.EOF:
|
||||
closeErr := r.current.Close()
|
||||
r.current = nil
|
||||
if closeErr != nil {
|
||||
return n, closeErr
|
||||
}
|
||||
if r.delivered == r.totalSize {
|
||||
if n > 0 {
|
||||
return n, nil
|
||||
}
|
||||
return 0, io.EOF
|
||||
}
|
||||
if n > 0 {
|
||||
return n, nil
|
||||
}
|
||||
default:
|
||||
return n, err
|
||||
}
|
||||
}
|
||||
|
||||
if r.nextOffset >= r.totalSize {
|
||||
if r.delivered == r.totalSize {
|
||||
return 0, io.EOF
|
||||
}
|
||||
return 0, output.ErrNetwork("file size mismatch: expected %d, got %d", r.totalSize, r.delivered)
|
||||
}
|
||||
|
||||
end := min(r.nextOffset+normalChunkSize-1, r.totalSize-1)
|
||||
resp, err := doIMResourceDownloadRequest(r.ctx, r.runtime, r.messageID, r.fileKey, r.fileType, map[string]string{
|
||||
"Range": fmt.Sprintf("bytes=%d-%d", r.nextOffset, end),
|
||||
})
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
if resp.StatusCode >= 400 {
|
||||
defer resp.Body.Close()
|
||||
return 0, downloadResponseError(resp)
|
||||
}
|
||||
if resp.StatusCode != http.StatusPartialContent {
|
||||
resp.Body.Close()
|
||||
return 0, output.ErrNetwork("unexpected status code: %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
r.current = resp.Body
|
||||
r.nextOffset = end + 1
|
||||
}
|
||||
}
|
||||
|
||||
func (r *rangeChunkReader) Close() error {
|
||||
if r.current == nil {
|
||||
return nil
|
||||
}
|
||||
err := r.current.Close()
|
||||
r.current = nil
|
||||
return err
|
||||
}
|
||||
|
||||
func initialIMResourceDownloadHeaders(fileType string) map[string]string {
|
||||
if fileType != "file" {
|
||||
return nil
|
||||
}
|
||||
return map[string]string{
|
||||
"Range": fmt.Sprintf("bytes=0-%d", probeChunkSize-1),
|
||||
}
|
||||
}
|
||||
|
||||
func downloadIMResourceToPath(ctx context.Context, runtime *common.RuntimeContext, messageID, fileKey, fileType, outputPath string) (string, int64, error) {
|
||||
downloadResp, err := doIMResourceDownloadRequest(ctx, runtime, messageID, fileKey, fileType, initialIMResourceDownloadHeaders(fileType))
|
||||
if err != nil {
|
||||
return "", 0, err
|
||||
}
|
||||
|
||||
if downloadResp.StatusCode >= 400 {
|
||||
defer downloadResp.Body.Close()
|
||||
return "", 0, downloadResponseError(downloadResp)
|
||||
}
|
||||
|
||||
finalPath := resolveIMResourceDownloadPath(outputPath, downloadResp.Header.Get("Content-Type"))
|
||||
|
||||
var (
|
||||
body io.ReadCloser
|
||||
sizeBytes int64
|
||||
)
|
||||
switch downloadResp.StatusCode {
|
||||
case http.StatusPartialContent:
|
||||
totalSize, err := parseTotalSize(downloadResp.Header.Get("Content-Range"))
|
||||
if err != nil {
|
||||
downloadResp.Body.Close()
|
||||
return "", 0, output.ErrNetwork("invalid Content-Range header on range response: %s", err)
|
||||
}
|
||||
body = newRangeChunkReader(ctx, runtime, messageID, fileKey, fileType, downloadResp.Body, totalSize)
|
||||
sizeBytes = totalSize
|
||||
|
||||
case http.StatusOK:
|
||||
body = downloadResp.Body
|
||||
sizeBytes = downloadResp.ContentLength
|
||||
|
||||
default:
|
||||
downloadResp.Body.Close()
|
||||
return "", 0, output.ErrNetwork("unexpected status code: %d", downloadResp.StatusCode)
|
||||
}
|
||||
defer body.Close()
|
||||
|
||||
result, err := runtime.FileIO().Save(finalPath, fileio.SaveOptions{
|
||||
ContentType: downloadResp.Header.Get("Content-Type"),
|
||||
ContentLength: sizeBytes,
|
||||
}, body)
|
||||
if err != nil {
|
||||
return "", 0, common.WrapSaveErrorByCategory(err, "api_error")
|
||||
}
|
||||
if sizeBytes >= 0 && result.Size() != sizeBytes {
|
||||
return "", 0, output.ErrNetwork("file size mismatch: expected %d, got %d", sizeBytes, result.Size())
|
||||
}
|
||||
savedPath, resolveErr := runtime.ResolveSavePath(finalPath)
|
||||
if resolveErr != nil || savedPath == "" {
|
||||
savedPath = finalPath
|
||||
}
|
||||
return savedPath, result.Size(), nil
|
||||
}
|
||||
|
||||
func resolveIMResourceDownloadPath(safePath, contentType string) string {
|
||||
if filepath.Ext(safePath) != "" {
|
||||
return safePath
|
||||
}
|
||||
mimeType := strings.Split(contentType, ";")[0]
|
||||
mimeType = strings.TrimSpace(mimeType)
|
||||
if ext, ok := imMimeToExt[mimeType]; ok {
|
||||
return safePath + ext
|
||||
}
|
||||
return safePath
|
||||
}
|
||||
|
||||
func doIMResourceDownloadRequest(ctx context.Context, runtime *common.RuntimeContext, messageID, fileKey, fileType string, headers map[string]string) (*http.Response, error) {
|
||||
query := larkcore.QueryParams{}
|
||||
query.Set("type", fileType)
|
||||
downloadResp, err := runtime.DoAPIStream(ctx, &larkcore.ApiReq{
|
||||
|
||||
headerValues := make(http.Header, len(headers))
|
||||
for key, value := range headers {
|
||||
headerValues.Set(key, value)
|
||||
}
|
||||
|
||||
req := &larkcore.ApiReq{
|
||||
HttpMethod: http.MethodGet,
|
||||
ApiPath: "/open-apis/im/v1/messages/:message_id/resources/:file_key",
|
||||
PathParams: larkcore.PathParams{
|
||||
@@ -146,44 +345,73 @@ func downloadIMResourceToPath(ctx context.Context, runtime *common.RuntimeContex
|
||||
"file_key": fileKey,
|
||||
},
|
||||
QueryParams: query,
|
||||
}, client.WithTimeout(defaultIMResourceDownloadTimeout))
|
||||
if err != nil {
|
||||
return "", 0, err
|
||||
}
|
||||
defer downloadResp.Body.Close()
|
||||
|
||||
if downloadResp.StatusCode >= 400 {
|
||||
body, _ := io.ReadAll(io.LimitReader(downloadResp.Body, 4096))
|
||||
if len(body) > 0 {
|
||||
return "", 0, output.ErrNetwork("download failed: HTTP %d: %s", downloadResp.StatusCode, strings.TrimSpace(string(body)))
|
||||
var lastErr error
|
||||
for attempt := 0; attempt <= imDownloadRequestRetries; attempt++ {
|
||||
resp, err := runtime.DoAPIStream(ctx, req, client.WithTimeout(defaultIMResourceDownloadTimeout), client.WithHeaders(headerValues))
|
||||
if err == nil {
|
||||
return resp, nil
|
||||
}
|
||||
return "", 0, output.ErrNetwork("download failed: HTTP %d", downloadResp.StatusCode)
|
||||
}
|
||||
|
||||
// Auto-detect extension from Content-Type if missing
|
||||
finalPath := safePath
|
||||
if filepath.Ext(safePath) == "" {
|
||||
contentType := downloadResp.Header.Get("Content-Type")
|
||||
mimeType := strings.Split(contentType, ";")[0]
|
||||
mimeType = strings.TrimSpace(mimeType)
|
||||
if ext, ok := imMimeToExt[mimeType]; ok {
|
||||
finalPath = safePath + ext
|
||||
if ctx.Err() != nil {
|
||||
return nil, ctx.Err()
|
||||
}
|
||||
lastErr = err
|
||||
if attempt == imDownloadRequestRetries {
|
||||
break
|
||||
}
|
||||
sleepIMDownloadRetry(ctx, attempt)
|
||||
}
|
||||
|
||||
result, err := runtime.FileIO().Save(finalPath, fileio.SaveOptions{
|
||||
ContentType: downloadResp.Header.Get("Content-Type"),
|
||||
ContentLength: downloadResp.ContentLength,
|
||||
}, downloadResp.Body)
|
||||
if err != nil {
|
||||
return "", 0, output.Errorf(output.ExitInternal, "api_error", "%s",
|
||||
common.WrapSaveError(err, "unsafe output path", "cannot create parent directory", "cannot create file"))
|
||||
if lastErr != nil {
|
||||
return nil, lastErr
|
||||
}
|
||||
savedPath, resolveErr := runtime.ResolveSavePath(finalPath)
|
||||
if resolveErr != nil {
|
||||
// Save succeeded — file is on disk. Fall back to the relative path
|
||||
// rather than returning an error for a successfully written file.
|
||||
savedPath = finalPath
|
||||
}
|
||||
return savedPath, result.Size(), nil
|
||||
return nil, output.ErrNetwork("download request failed")
|
||||
}
|
||||
|
||||
func sleepIMDownloadRetry(ctx context.Context, attempt int) {
|
||||
delay := imDownloadRetryDelay * (1 << uint(attempt))
|
||||
timer := time.NewTimer(delay)
|
||||
defer timer.Stop()
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
case <-timer.C:
|
||||
}
|
||||
}
|
||||
|
||||
func downloadResponseError(resp *http.Response) error {
|
||||
body, _ := io.ReadAll(io.LimitReader(resp.Body, 4096))
|
||||
if len(body) > 0 {
|
||||
return output.ErrNetwork("download failed: HTTP %d: %s", resp.StatusCode, strings.TrimSpace(string(body)))
|
||||
}
|
||||
return output.ErrNetwork("download failed: HTTP %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
func parseTotalSize(contentRange string) (int64, error) {
|
||||
contentRange = strings.TrimSpace(contentRange)
|
||||
if contentRange == "" {
|
||||
return 0, fmt.Errorf("content-range is empty")
|
||||
}
|
||||
if !strings.HasPrefix(contentRange, "bytes ") {
|
||||
return 0, fmt.Errorf("unsupported content-range: %q", contentRange)
|
||||
}
|
||||
|
||||
parts := strings.SplitN(strings.TrimPrefix(contentRange, "bytes "), "/", 2)
|
||||
if len(parts) != 2 || parts[1] == "" {
|
||||
return 0, fmt.Errorf("unsupported content-range: %q", contentRange)
|
||||
}
|
||||
if parts[0] == "*" {
|
||||
return 0, fmt.Errorf("unsupported content-range: %q", contentRange)
|
||||
}
|
||||
if parts[1] == "*" {
|
||||
return 0, fmt.Errorf("unknown total size in content-range: %q", contentRange)
|
||||
}
|
||||
|
||||
totalSize, err := strconv.ParseInt(parts[1], 10, 64)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("parse total size: %w", err)
|
||||
}
|
||||
if totalSize <= 0 {
|
||||
return 0, fmt.Errorf("invalid total size: %d", totalSize)
|
||||
}
|
||||
return totalSize, nil
|
||||
}
|
||||
|
||||
@@ -95,7 +95,7 @@ var MailWatch = common.Shortcut{
|
||||
Command: "+watch",
|
||||
Description: "Watch for incoming mail events via WebSocket (requires scope mail:event and bot event mail.user_mailbox.event.message_received_v1 added). Run with --print-output-schema to see per-format field reference before parsing output.",
|
||||
Risk: "read",
|
||||
Scopes: []string{"mail:event", "mail:user_mailbox.message:readonly", "mail:user_mailbox.message.address:read", "mail:user_mailbox.message.subject:read", "mail:user_mailbox.message.body:read"},
|
||||
Scopes: []string{"mail:event", "mail:user_mailbox.event.mail_address:read", "mail:user_mailbox:readonly", "mail:user_mailbox.message:readonly", "mail:user_mailbox.message.address:read", "mail:user_mailbox.message.subject:read", "mail:user_mailbox.message.body:read"},
|
||||
AuthTypes: []string{"user"},
|
||||
Flags: []common.Flag{
|
||||
{Name: "format", Default: "data", Desc: "json: NDJSON stream with ok/data envelope; data: bare NDJSON stream"},
|
||||
@@ -192,36 +192,23 @@ var MailWatch = common.Shortcut{
|
||||
msgFormat := runtime.Str("msg-format")
|
||||
outputDir := runtime.Str("output-dir")
|
||||
if outputDir != "" {
|
||||
if outputDir == "~" || strings.HasPrefix(outputDir, "~/") {
|
||||
home, err := vfs.UserHomeDir()
|
||||
if err != nil {
|
||||
return fmt.Errorf("cannot expand ~: %w", err)
|
||||
}
|
||||
if outputDir == "~" {
|
||||
outputDir = home
|
||||
} else {
|
||||
outputDir = filepath.Join(home, outputDir[2:])
|
||||
}
|
||||
} else if filepath.IsAbs(outputDir) {
|
||||
outputDir = filepath.Clean(outputDir)
|
||||
} else {
|
||||
safePath, err := validate.SafeOutputPath(outputDir)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
outputDir = safePath
|
||||
// Reject all tilde-prefixed paths — SafeOutputPath treats "~/x" as a
|
||||
// literal relative path (creating a directory named "~"), which is
|
||||
// confusing. This also covers ~user/path forms.
|
||||
if strings.HasPrefix(outputDir, "~") {
|
||||
return output.ErrValidation("--output-dir does not support ~ expansion; use a relative path like ./output instead")
|
||||
}
|
||||
// Resolve symlinks on the output directory so all writes use the real
|
||||
// filesystem path. This prevents a symlink from redirecting writes to
|
||||
// an unintended location (TOCTOU mitigation).
|
||||
// Enforce CWD containment: reject absolute paths, path traversal,
|
||||
// and symlink escapes. SafeOutputPath returns a resolved absolute path
|
||||
// under CWD, preventing writes to arbitrary system directories.
|
||||
safePath, err := validate.SafeOutputPath(outputDir)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
outputDir = safePath
|
||||
if err := vfs.MkdirAll(outputDir, 0700); err != nil {
|
||||
return fmt.Errorf("cannot create output directory %q: %w", outputDir, err)
|
||||
}
|
||||
resolved, err := filepath.EvalSymlinks(outputDir)
|
||||
if err != nil {
|
||||
return fmt.Errorf("cannot resolve output directory: %w", err)
|
||||
}
|
||||
outputDir = resolved
|
||||
}
|
||||
labelIDsInput := runtime.Str("label-ids")
|
||||
folderIDsInput := runtime.Str("folder-ids")
|
||||
|
||||
239
shortcuts/sheets/sheet_filter_view.go
Normal file
239
shortcuts/sheets/sheet_filter_view.go
Normal file
@@ -0,0 +1,239 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package sheets
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"github.com/larksuite/cli/internal/validate"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
func filterViewBasePath(token, sheetID string) string {
|
||||
return fmt.Sprintf("/open-apis/sheets/v3/spreadsheets/%s/sheets/%s/filter_views",
|
||||
validate.EncodePathSegment(token), validate.EncodePathSegment(sheetID))
|
||||
}
|
||||
|
||||
func filterViewItemPath(token, sheetID, filterViewID string) string {
|
||||
return fmt.Sprintf("%s/%s", filterViewBasePath(token, sheetID), validate.EncodePathSegment(filterViewID))
|
||||
}
|
||||
|
||||
func validateFilterViewToken(runtime *common.RuntimeContext) (string, error) {
|
||||
token := runtime.Str("spreadsheet-token")
|
||||
if runtime.Str("url") != "" {
|
||||
token = extractSpreadsheetToken(runtime.Str("url"))
|
||||
}
|
||||
if token == "" {
|
||||
return "", common.FlagErrorf("specify --url or --spreadsheet-token")
|
||||
}
|
||||
return token, nil
|
||||
}
|
||||
|
||||
var SheetCreateFilterView = common.Shortcut{
|
||||
Service: "sheets",
|
||||
Command: "+create-filter-view",
|
||||
Description: "Create a filter view",
|
||||
Risk: "write",
|
||||
Scopes: []string{"sheets:spreadsheet:write_only", "sheets:spreadsheet:read"},
|
||||
AuthTypes: []string{"user", "bot"},
|
||||
Flags: []common.Flag{
|
||||
{Name: "url", Desc: "spreadsheet URL (required if --spreadsheet-token is not set)"},
|
||||
{Name: "spreadsheet-token", Desc: "spreadsheet token (required if --url is not set)"},
|
||||
{Name: "sheet-id", Desc: "sheet ID", Required: true},
|
||||
{Name: "range", Desc: "filter range (e.g. sheetId!A1:H14)", Required: true},
|
||||
{Name: "filter-view-name", Desc: "display name (max 100 chars)"},
|
||||
{Name: "filter-view-id", Desc: "custom 10-char alphanumeric ID (auto-generated if omitted)"},
|
||||
},
|
||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
_, err := validateFilterViewToken(runtime)
|
||||
return err
|
||||
},
|
||||
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
token, _ := validateFilterViewToken(runtime)
|
||||
body := map[string]interface{}{"range": runtime.Str("range")}
|
||||
if s := runtime.Str("filter-view-name"); s != "" {
|
||||
body["filter_view_name"] = s
|
||||
}
|
||||
if s := runtime.Str("filter-view-id"); s != "" {
|
||||
body["filter_view_id"] = s
|
||||
}
|
||||
return common.NewDryRunAPI().
|
||||
POST("/open-apis/sheets/v3/spreadsheets/:token/sheets/:sheet_id/filter_views").
|
||||
Body(body).Set("token", token).Set("sheet_id", runtime.Str("sheet-id"))
|
||||
},
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
token, _ := validateFilterViewToken(runtime)
|
||||
body := map[string]interface{}{"range": runtime.Str("range")}
|
||||
if s := runtime.Str("filter-view-name"); s != "" {
|
||||
body["filter_view_name"] = s
|
||||
}
|
||||
if s := runtime.Str("filter-view-id"); s != "" {
|
||||
body["filter_view_id"] = s
|
||||
}
|
||||
data, err := runtime.CallAPI("POST", filterViewBasePath(token, runtime.Str("sheet-id")), nil, body)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
runtime.Out(data, nil)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
var SheetUpdateFilterView = common.Shortcut{
|
||||
Service: "sheets",
|
||||
Command: "+update-filter-view",
|
||||
Description: "Update a filter view",
|
||||
Risk: "write",
|
||||
Scopes: []string{"sheets:spreadsheet:write_only", "sheets:spreadsheet:read"},
|
||||
AuthTypes: []string{"user", "bot"},
|
||||
Flags: []common.Flag{
|
||||
{Name: "url", Desc: "spreadsheet URL (required if --spreadsheet-token is not set)"},
|
||||
{Name: "spreadsheet-token", Desc: "spreadsheet token (required if --url is not set)"},
|
||||
{Name: "sheet-id", Desc: "sheet ID", Required: true},
|
||||
{Name: "filter-view-id", Desc: "filter view ID", Required: true},
|
||||
{Name: "range", Desc: "new filter range"},
|
||||
{Name: "filter-view-name", Desc: "new display name (max 100 chars)"},
|
||||
},
|
||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
if _, err := validateFilterViewToken(runtime); err != nil {
|
||||
return err
|
||||
}
|
||||
if !runtime.Cmd.Flags().Changed("range") &&
|
||||
!runtime.Cmd.Flags().Changed("filter-view-name") {
|
||||
return common.FlagErrorf("specify at least one of --range or --filter-view-name")
|
||||
}
|
||||
return nil
|
||||
},
|
||||
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
token, _ := validateFilterViewToken(runtime)
|
||||
body := map[string]interface{}{}
|
||||
if s := runtime.Str("range"); s != "" {
|
||||
body["range"] = s
|
||||
}
|
||||
if s := runtime.Str("filter-view-name"); s != "" {
|
||||
body["filter_view_name"] = s
|
||||
}
|
||||
return common.NewDryRunAPI().
|
||||
PATCH("/open-apis/sheets/v3/spreadsheets/:token/sheets/:sheet_id/filter_views/:filter_view_id").
|
||||
Body(body).Set("token", token).Set("sheet_id", runtime.Str("sheet-id")).Set("filter_view_id", runtime.Str("filter-view-id"))
|
||||
},
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
token, _ := validateFilterViewToken(runtime)
|
||||
body := map[string]interface{}{}
|
||||
if s := runtime.Str("range"); s != "" {
|
||||
body["range"] = s
|
||||
}
|
||||
if s := runtime.Str("filter-view-name"); s != "" {
|
||||
body["filter_view_name"] = s
|
||||
}
|
||||
data, err := runtime.CallAPI("PATCH", filterViewItemPath(token, runtime.Str("sheet-id"), runtime.Str("filter-view-id")), nil, body)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
runtime.Out(data, nil)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
var SheetListFilterViews = common.Shortcut{
|
||||
Service: "sheets",
|
||||
Command: "+list-filter-views",
|
||||
Description: "List all filter views in a sheet",
|
||||
Risk: "read",
|
||||
Scopes: []string{"sheets:spreadsheet:read"},
|
||||
AuthTypes: []string{"user", "bot"},
|
||||
Flags: []common.Flag{
|
||||
{Name: "url", Desc: "spreadsheet URL (required if --spreadsheet-token is not set)"},
|
||||
{Name: "spreadsheet-token", Desc: "spreadsheet token (required if --url is not set)"},
|
||||
{Name: "sheet-id", Desc: "sheet ID", Required: true},
|
||||
},
|
||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
_, err := validateFilterViewToken(runtime)
|
||||
return err
|
||||
},
|
||||
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
token, _ := validateFilterViewToken(runtime)
|
||||
return common.NewDryRunAPI().
|
||||
GET("/open-apis/sheets/v3/spreadsheets/:token/sheets/:sheet_id/filter_views/query").
|
||||
Set("token", token).Set("sheet_id", runtime.Str("sheet-id"))
|
||||
},
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
token, _ := validateFilterViewToken(runtime)
|
||||
data, err := runtime.CallAPI("GET", filterViewBasePath(token, runtime.Str("sheet-id"))+"/query", nil, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
runtime.Out(data, nil)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
var SheetGetFilterView = common.Shortcut{
|
||||
Service: "sheets",
|
||||
Command: "+get-filter-view",
|
||||
Description: "Get a filter view by ID",
|
||||
Risk: "read",
|
||||
Scopes: []string{"sheets:spreadsheet:read"},
|
||||
AuthTypes: []string{"user", "bot"},
|
||||
Flags: []common.Flag{
|
||||
{Name: "url", Desc: "spreadsheet URL (required if --spreadsheet-token is not set)"},
|
||||
{Name: "spreadsheet-token", Desc: "spreadsheet token (required if --url is not set)"},
|
||||
{Name: "sheet-id", Desc: "sheet ID", Required: true},
|
||||
{Name: "filter-view-id", Desc: "filter view ID", Required: true},
|
||||
},
|
||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
_, err := validateFilterViewToken(runtime)
|
||||
return err
|
||||
},
|
||||
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
token, _ := validateFilterViewToken(runtime)
|
||||
return common.NewDryRunAPI().
|
||||
GET("/open-apis/sheets/v3/spreadsheets/:token/sheets/:sheet_id/filter_views/:filter_view_id").
|
||||
Set("token", token).Set("sheet_id", runtime.Str("sheet-id")).Set("filter_view_id", runtime.Str("filter-view-id"))
|
||||
},
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
token, _ := validateFilterViewToken(runtime)
|
||||
data, err := runtime.CallAPI("GET", filterViewItemPath(token, runtime.Str("sheet-id"), runtime.Str("filter-view-id")), nil, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
runtime.Out(data, nil)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
var SheetDeleteFilterView = common.Shortcut{
|
||||
Service: "sheets",
|
||||
Command: "+delete-filter-view",
|
||||
Description: "Delete a filter view",
|
||||
Risk: "write",
|
||||
Scopes: []string{"sheets:spreadsheet:write_only", "sheets:spreadsheet:read"},
|
||||
AuthTypes: []string{"user", "bot"},
|
||||
Flags: []common.Flag{
|
||||
{Name: "url", Desc: "spreadsheet URL (required if --spreadsheet-token is not set)"},
|
||||
{Name: "spreadsheet-token", Desc: "spreadsheet token (required if --url is not set)"},
|
||||
{Name: "sheet-id", Desc: "sheet ID", Required: true},
|
||||
{Name: "filter-view-id", Desc: "filter view ID", Required: true},
|
||||
},
|
||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
_, err := validateFilterViewToken(runtime)
|
||||
return err
|
||||
},
|
||||
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
token, _ := validateFilterViewToken(runtime)
|
||||
return common.NewDryRunAPI().
|
||||
DELETE("/open-apis/sheets/v3/spreadsheets/:token/sheets/:sheet_id/filter_views/:filter_view_id").
|
||||
Set("token", token).Set("sheet_id", runtime.Str("sheet-id")).Set("filter_view_id", runtime.Str("filter-view-id"))
|
||||
},
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
token, _ := validateFilterViewToken(runtime)
|
||||
data, err := runtime.CallAPI("DELETE", filterViewItemPath(token, runtime.Str("sheet-id"), runtime.Str("filter-view-id")), nil, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
runtime.Out(data, nil)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
261
shortcuts/sheets/sheet_filter_view_condition.go
Normal file
261
shortcuts/sheets/sheet_filter_view_condition.go
Normal file
@@ -0,0 +1,261 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package sheets
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
|
||||
"github.com/larksuite/cli/internal/validate"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
func filterViewConditionBasePath(token, sheetID, filterViewID string) string {
|
||||
return fmt.Sprintf("%s/conditions", filterViewItemPath(token, sheetID, filterViewID))
|
||||
}
|
||||
|
||||
func filterViewConditionItemPath(token, sheetID, filterViewID, conditionID string) string {
|
||||
return fmt.Sprintf("%s/%s", filterViewConditionBasePath(token, sheetID, filterViewID), validate.EncodePathSegment(conditionID))
|
||||
}
|
||||
|
||||
var SheetCreateFilterViewCondition = common.Shortcut{
|
||||
Service: "sheets",
|
||||
Command: "+create-filter-view-condition",
|
||||
Description: "Create a filter condition on a filter view",
|
||||
Risk: "write",
|
||||
Scopes: []string{"sheets:spreadsheet:write_only", "sheets:spreadsheet:read"},
|
||||
AuthTypes: []string{"user", "bot"},
|
||||
Flags: []common.Flag{
|
||||
{Name: "url", Desc: "spreadsheet URL (required if --spreadsheet-token is not set)"},
|
||||
{Name: "spreadsheet-token", Desc: "spreadsheet token (required if --url is not set)"},
|
||||
{Name: "sheet-id", Desc: "sheet ID", Required: true},
|
||||
{Name: "filter-view-id", Desc: "filter view ID", Required: true},
|
||||
{Name: "condition-id", Desc: "column letter (e.g. E)", Required: true},
|
||||
{Name: "filter-type", Desc: "filter type: hiddenValue, number, text, color", Required: true},
|
||||
{Name: "compare-type", Desc: "comparison operator (e.g. less, beginsWith, between)"},
|
||||
{Name: "expected", Desc: "filter values JSON array (e.g. [\"6\"])", Required: true},
|
||||
},
|
||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
if _, err := validateFilterViewToken(runtime); err != nil {
|
||||
return err
|
||||
}
|
||||
return validateExpectedFlag(runtime.Str("expected"))
|
||||
},
|
||||
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
token, _ := validateFilterViewToken(runtime)
|
||||
body := buildConditionBody(runtime, true)
|
||||
return common.NewDryRunAPI().
|
||||
POST("/open-apis/sheets/v3/spreadsheets/:token/sheets/:sheet_id/filter_views/:filter_view_id/conditions").
|
||||
Body(body).Set("token", token).Set("sheet_id", runtime.Str("sheet-id")).Set("filter_view_id", runtime.Str("filter-view-id"))
|
||||
},
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
token, _ := validateFilterViewToken(runtime)
|
||||
body := buildConditionBody(runtime, true)
|
||||
data, err := runtime.CallAPI("POST", filterViewConditionBasePath(token, runtime.Str("sheet-id"), runtime.Str("filter-view-id")), nil, body)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
runtime.Out(data, nil)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
var SheetUpdateFilterViewCondition = common.Shortcut{
|
||||
Service: "sheets",
|
||||
Command: "+update-filter-view-condition",
|
||||
Description: "Update a filter condition on a filter view",
|
||||
Risk: "write",
|
||||
Scopes: []string{"sheets:spreadsheet:write_only", "sheets:spreadsheet:read"},
|
||||
AuthTypes: []string{"user", "bot"},
|
||||
Flags: []common.Flag{
|
||||
{Name: "url", Desc: "spreadsheet URL (required if --spreadsheet-token is not set)"},
|
||||
{Name: "spreadsheet-token", Desc: "spreadsheet token (required if --url is not set)"},
|
||||
{Name: "sheet-id", Desc: "sheet ID", Required: true},
|
||||
{Name: "filter-view-id", Desc: "filter view ID", Required: true},
|
||||
{Name: "condition-id", Desc: "column letter (e.g. E)", Required: true},
|
||||
{Name: "filter-type", Desc: "filter type: hiddenValue, number, text, color"},
|
||||
{Name: "compare-type", Desc: "comparison operator"},
|
||||
{Name: "expected", Desc: "filter values JSON array"},
|
||||
},
|
||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
if _, err := validateFilterViewToken(runtime); err != nil {
|
||||
return err
|
||||
}
|
||||
if !runtime.Cmd.Flags().Changed("filter-type") &&
|
||||
!runtime.Cmd.Flags().Changed("compare-type") &&
|
||||
!runtime.Cmd.Flags().Changed("expected") {
|
||||
return common.FlagErrorf("specify at least one of --filter-type, --compare-type, or --expected")
|
||||
}
|
||||
if s := runtime.Str("expected"); s != "" {
|
||||
return validateExpectedFlag(s)
|
||||
}
|
||||
return nil
|
||||
},
|
||||
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
token, _ := validateFilterViewToken(runtime)
|
||||
body := buildConditionBody(runtime, false)
|
||||
return common.NewDryRunAPI().
|
||||
PUT("/open-apis/sheets/v3/spreadsheets/:token/sheets/:sheet_id/filter_views/:filter_view_id/conditions/:condition_id").
|
||||
Body(body).Set("token", token).Set("sheet_id", runtime.Str("sheet-id")).
|
||||
Set("filter_view_id", runtime.Str("filter-view-id")).Set("condition_id", runtime.Str("condition-id"))
|
||||
},
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
token, _ := validateFilterViewToken(runtime)
|
||||
body := buildConditionBody(runtime, false)
|
||||
data, err := runtime.CallAPI("PUT",
|
||||
filterViewConditionItemPath(token, runtime.Str("sheet-id"), runtime.Str("filter-view-id"), runtime.Str("condition-id")),
|
||||
nil, body)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
runtime.Out(data, nil)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
var SheetListFilterViewConditions = common.Shortcut{
|
||||
Service: "sheets",
|
||||
Command: "+list-filter-view-conditions",
|
||||
Description: "List all filter conditions of a filter view",
|
||||
Risk: "read",
|
||||
Scopes: []string{"sheets:spreadsheet:read"},
|
||||
AuthTypes: []string{"user", "bot"},
|
||||
Flags: []common.Flag{
|
||||
{Name: "url", Desc: "spreadsheet URL (required if --spreadsheet-token is not set)"},
|
||||
{Name: "spreadsheet-token", Desc: "spreadsheet token (required if --url is not set)"},
|
||||
{Name: "sheet-id", Desc: "sheet ID", Required: true},
|
||||
{Name: "filter-view-id", Desc: "filter view ID", Required: true},
|
||||
},
|
||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
_, err := validateFilterViewToken(runtime)
|
||||
return err
|
||||
},
|
||||
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
token, _ := validateFilterViewToken(runtime)
|
||||
return common.NewDryRunAPI().
|
||||
GET("/open-apis/sheets/v3/spreadsheets/:token/sheets/:sheet_id/filter_views/:filter_view_id/conditions/query").
|
||||
Set("token", token).Set("sheet_id", runtime.Str("sheet-id")).Set("filter_view_id", runtime.Str("filter-view-id"))
|
||||
},
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
token, _ := validateFilterViewToken(runtime)
|
||||
data, err := runtime.CallAPI("GET",
|
||||
filterViewConditionBasePath(token, runtime.Str("sheet-id"), runtime.Str("filter-view-id"))+"/query",
|
||||
nil, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
runtime.Out(data, nil)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
var SheetGetFilterViewCondition = common.Shortcut{
|
||||
Service: "sheets",
|
||||
Command: "+get-filter-view-condition",
|
||||
Description: "Get a filter condition by column",
|
||||
Risk: "read",
|
||||
Scopes: []string{"sheets:spreadsheet:read"},
|
||||
AuthTypes: []string{"user", "bot"},
|
||||
Flags: []common.Flag{
|
||||
{Name: "url", Desc: "spreadsheet URL (required if --spreadsheet-token is not set)"},
|
||||
{Name: "spreadsheet-token", Desc: "spreadsheet token (required if --url is not set)"},
|
||||
{Name: "sheet-id", Desc: "sheet ID", Required: true},
|
||||
{Name: "filter-view-id", Desc: "filter view ID", Required: true},
|
||||
{Name: "condition-id", Desc: "column letter (e.g. E)", Required: true},
|
||||
},
|
||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
_, err := validateFilterViewToken(runtime)
|
||||
return err
|
||||
},
|
||||
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
token, _ := validateFilterViewToken(runtime)
|
||||
return common.NewDryRunAPI().
|
||||
GET("/open-apis/sheets/v3/spreadsheets/:token/sheets/:sheet_id/filter_views/:filter_view_id/conditions/:condition_id").
|
||||
Set("token", token).Set("sheet_id", runtime.Str("sheet-id")).
|
||||
Set("filter_view_id", runtime.Str("filter-view-id")).Set("condition_id", runtime.Str("condition-id"))
|
||||
},
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
token, _ := validateFilterViewToken(runtime)
|
||||
data, err := runtime.CallAPI("GET",
|
||||
filterViewConditionItemPath(token, runtime.Str("sheet-id"), runtime.Str("filter-view-id"), runtime.Str("condition-id")),
|
||||
nil, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
runtime.Out(data, nil)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
var SheetDeleteFilterViewCondition = common.Shortcut{
|
||||
Service: "sheets",
|
||||
Command: "+delete-filter-view-condition",
|
||||
Description: "Delete a filter condition from a filter view",
|
||||
Risk: "write",
|
||||
Scopes: []string{"sheets:spreadsheet:write_only", "sheets:spreadsheet:read"},
|
||||
AuthTypes: []string{"user", "bot"},
|
||||
Flags: []common.Flag{
|
||||
{Name: "url", Desc: "spreadsheet URL (required if --spreadsheet-token is not set)"},
|
||||
{Name: "spreadsheet-token", Desc: "spreadsheet token (required if --url is not set)"},
|
||||
{Name: "sheet-id", Desc: "sheet ID", Required: true},
|
||||
{Name: "filter-view-id", Desc: "filter view ID", Required: true},
|
||||
{Name: "condition-id", Desc: "column letter (e.g. E)", Required: true},
|
||||
},
|
||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
_, err := validateFilterViewToken(runtime)
|
||||
return err
|
||||
},
|
||||
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
token, _ := validateFilterViewToken(runtime)
|
||||
return common.NewDryRunAPI().
|
||||
DELETE("/open-apis/sheets/v3/spreadsheets/:token/sheets/:sheet_id/filter_views/:filter_view_id/conditions/:condition_id").
|
||||
Set("token", token).Set("sheet_id", runtime.Str("sheet-id")).
|
||||
Set("filter_view_id", runtime.Str("filter-view-id")).Set("condition_id", runtime.Str("condition-id"))
|
||||
},
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
token, _ := validateFilterViewToken(runtime)
|
||||
data, err := runtime.CallAPI("DELETE",
|
||||
filterViewConditionItemPath(token, runtime.Str("sheet-id"), runtime.Str("filter-view-id"), runtime.Str("condition-id")),
|
||||
nil, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
runtime.Out(data, nil)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
// validateExpectedFlag checks that --expected is a valid JSON array.
|
||||
func validateExpectedFlag(s string) error {
|
||||
if s == "" {
|
||||
return nil
|
||||
}
|
||||
var arr []interface{}
|
||||
if err := json.Unmarshal([]byte(s), &arr); err != nil {
|
||||
return fmt.Errorf("--expected must be a JSON array (e.g. [\"6\"]), got: %s", s)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// buildConditionBody constructs the request body for condition create/update.
|
||||
func buildConditionBody(runtime *common.RuntimeContext, includeConditionID bool) map[string]interface{} {
|
||||
body := map[string]interface{}{}
|
||||
if includeConditionID {
|
||||
body["condition_id"] = runtime.Str("condition-id")
|
||||
}
|
||||
if s := runtime.Str("filter-type"); s != "" {
|
||||
body["filter_type"] = s
|
||||
}
|
||||
if s := runtime.Str("compare-type"); s != "" {
|
||||
body["compare_type"] = s
|
||||
}
|
||||
if s := runtime.Str("expected"); s != "" {
|
||||
var arr []interface{}
|
||||
// Validate already ensures this is a valid JSON array.
|
||||
_ = json.Unmarshal([]byte(s), &arr)
|
||||
body["expected"] = arr
|
||||
}
|
||||
return body
|
||||
}
|
||||
628
shortcuts/sheets/sheet_filter_view_test.go
Normal file
628
shortcuts/sheets/sheet_filter_view_test.go
Normal file
@@ -0,0 +1,628 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package sheets
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/httpmock"
|
||||
)
|
||||
|
||||
// ── CreateFilterView ─────────────────────────────────────────────────────────
|
||||
|
||||
func TestCreateFilterViewValidateMissingToken(t *testing.T) {
|
||||
t.Parallel()
|
||||
rt := newSheetsTestRuntime(t, map[string]string{
|
||||
"url": "", "spreadsheet-token": "", "sheet-id": "s1", "range": "s1!A1:H14",
|
||||
"filter-view-name": "", "filter-view-id": "",
|
||||
}, nil)
|
||||
err := SheetCreateFilterView.Validate(context.Background(), rt)
|
||||
if err == nil || !strings.Contains(err.Error(), "--url or --spreadsheet-token") {
|
||||
t.Fatalf("expected token error, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateFilterViewValidateSuccess(t *testing.T) {
|
||||
t.Parallel()
|
||||
rt := newSheetsTestRuntime(t, map[string]string{
|
||||
"url": "", "spreadsheet-token": "sht1", "sheet-id": "s1", "range": "s1!A1:H14",
|
||||
"filter-view-name": "", "filter-view-id": "",
|
||||
}, nil)
|
||||
if err := SheetCreateFilterView.Validate(context.Background(), rt); err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateFilterViewDryRun(t *testing.T) {
|
||||
t.Parallel()
|
||||
rt := newSheetsTestRuntime(t, map[string]string{
|
||||
"url": "", "spreadsheet-token": "sht_test", "sheet-id": "sheet1", "range": "sheet1!A1:H14",
|
||||
"filter-view-name": "my view", "filter-view-id": "",
|
||||
}, nil)
|
||||
got := mustMarshalSheetsDryRun(t, SheetCreateFilterView.DryRun(context.Background(), rt))
|
||||
if !strings.Contains(got, `filter_views`) {
|
||||
t.Fatalf("DryRun URL missing filter_views: %s", got)
|
||||
}
|
||||
if !strings.Contains(got, `"filter_view_name":"my view"`) {
|
||||
t.Fatalf("DryRun missing name: %s", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateFilterViewExecuteSuccess(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, sheetsTestConfig())
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST", URL: "/open-apis/sheets/v3/spreadsheets/shtTOKEN/sheets/sheet1/filter_views",
|
||||
Body: map[string]interface{}{"code": 0, "msg": "success", "data": map[string]interface{}{
|
||||
"filter_view": map[string]interface{}{"filter_view_id": "pH9hbVcCXA", "range": "sheet1!A1:H14"},
|
||||
}},
|
||||
})
|
||||
err := mountAndRunSheets(t, SheetCreateFilterView, []string{
|
||||
"+create-filter-view", "--spreadsheet-token", "shtTOKEN",
|
||||
"--sheet-id", "sheet1", "--range", "sheet1!A1:H14", "--as", "user",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if !strings.Contains(stdout.String(), "filter_view_id") {
|
||||
t.Fatalf("stdout missing filter_view_id: %s", stdout.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateFilterViewExecuteAPIError(t *testing.T) {
|
||||
f, _, _, reg := cmdutil.TestFactory(t, sheetsTestConfig())
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST", URL: "/open-apis/sheets/v3/spreadsheets/shtTOKEN/sheets/sheet1/filter_views",
|
||||
Status: 400, Body: map[string]interface{}{"code": 90001, "msg": "invalid"},
|
||||
})
|
||||
err := mountAndRunSheets(t, SheetCreateFilterView, []string{
|
||||
"+create-filter-view", "--spreadsheet-token", "shtTOKEN",
|
||||
"--sheet-id", "sheet1", "--range", "sheet1!A1:H14", "--as", "user",
|
||||
}, f, nil)
|
||||
if err == nil {
|
||||
t.Fatal("expected error")
|
||||
}
|
||||
}
|
||||
|
||||
// ── UpdateFilterView ─────────────────────────────────────────────────────────
|
||||
|
||||
func TestUpdateFilterViewDryRun(t *testing.T) {
|
||||
t.Parallel()
|
||||
rt := newSheetsTestRuntime(t, map[string]string{
|
||||
"url": "", "spreadsheet-token": "sht_test", "sheet-id": "sheet1",
|
||||
"filter-view-id": "pH9hbVcCXA", "range": "sheet1!A1:J20", "filter-view-name": "",
|
||||
}, nil)
|
||||
got := mustMarshalSheetsDryRun(t, SheetUpdateFilterView.DryRun(context.Background(), rt))
|
||||
if !strings.Contains(got, `"method":"PATCH"`) {
|
||||
t.Fatalf("DryRun should use PATCH: %s", got)
|
||||
}
|
||||
if !strings.Contains(got, `pH9hbVcCXA`) {
|
||||
t.Fatalf("DryRun missing filter_view_id: %s", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateFilterViewExecuteSuccess(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, sheetsTestConfig())
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "PATCH", URL: "/open-apis/sheets/v3/spreadsheets/shtTOKEN/sheets/sheet1/filter_views/fv123",
|
||||
Body: map[string]interface{}{"code": 0, "msg": "success", "data": map[string]interface{}{
|
||||
"filter_view": map[string]interface{}{"filter_view_id": "fv123", "range": "sheet1!A1:J20"},
|
||||
}},
|
||||
})
|
||||
err := mountAndRunSheets(t, SheetUpdateFilterView, []string{
|
||||
"+update-filter-view", "--spreadsheet-token", "shtTOKEN",
|
||||
"--sheet-id", "sheet1", "--filter-view-id", "fv123", "--range", "sheet1!A1:J20", "--as", "user",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateFilterViewRejectsNoFields(t *testing.T) {
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, sheetsTestConfig())
|
||||
err := mountAndRunSheets(t, SheetUpdateFilterView, []string{
|
||||
"+update-filter-view", "--spreadsheet-token", "shtTOKEN",
|
||||
"--sheet-id", "sheet1", "--filter-view-id", "fv1",
|
||||
"--as", "user",
|
||||
}, f, stdout)
|
||||
if err == nil {
|
||||
t.Fatal("expected validation error when no update fields provided, got nil")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "at least one") {
|
||||
t.Fatalf("unexpected error message: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// ── ListFilterViews ──────────────────────────────────────────────────────────
|
||||
|
||||
func TestListFilterViewsDryRun(t *testing.T) {
|
||||
t.Parallel()
|
||||
rt := newSheetsTestRuntime(t, map[string]string{
|
||||
"url": "", "spreadsheet-token": "sht_test", "sheet-id": "sheet1",
|
||||
}, nil)
|
||||
got := mustMarshalSheetsDryRun(t, SheetListFilterViews.DryRun(context.Background(), rt))
|
||||
if !strings.Contains(got, `filter_views/query`) {
|
||||
t.Fatalf("DryRun URL missing query: %s", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestListFilterViewsExecuteSuccess(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, sheetsTestConfig())
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET", URL: "/open-apis/sheets/v3/spreadsheets/shtTOKEN/sheets/sheet1/filter_views/query",
|
||||
Body: map[string]interface{}{"code": 0, "msg": "success", "data": map[string]interface{}{
|
||||
"items": []interface{}{map[string]interface{}{"filter_view_id": "fv1"}},
|
||||
}},
|
||||
})
|
||||
err := mountAndRunSheets(t, SheetListFilterViews, []string{
|
||||
"+list-filter-views", "--spreadsheet-token", "shtTOKEN", "--sheet-id", "sheet1", "--as", "user",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if !strings.Contains(stdout.String(), "fv1") {
|
||||
t.Fatalf("stdout missing fv1: %s", stdout.String())
|
||||
}
|
||||
}
|
||||
|
||||
// ── GetFilterView ────────────────────────────────────────────────────────────
|
||||
|
||||
func TestGetFilterViewDryRun(t *testing.T) {
|
||||
t.Parallel()
|
||||
rt := newSheetsTestRuntime(t, map[string]string{
|
||||
"url": "", "spreadsheet-token": "sht_test", "sheet-id": "sheet1", "filter-view-id": "fv123",
|
||||
}, nil)
|
||||
got := mustMarshalSheetsDryRun(t, SheetGetFilterView.DryRun(context.Background(), rt))
|
||||
if !strings.Contains(got, `"method":"GET"`) {
|
||||
t.Fatalf("DryRun should use GET: %s", got)
|
||||
}
|
||||
if !strings.Contains(got, `fv123`) {
|
||||
t.Fatalf("DryRun missing filter_view_id: %s", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetFilterViewExecuteSuccess(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, sheetsTestConfig())
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET", URL: "/open-apis/sheets/v3/spreadsheets/shtTOKEN/sheets/sheet1/filter_views/fv123",
|
||||
Body: map[string]interface{}{"code": 0, "msg": "success", "data": map[string]interface{}{
|
||||
"filter_view": map[string]interface{}{"filter_view_id": "fv123"},
|
||||
}},
|
||||
})
|
||||
err := mountAndRunSheets(t, SheetGetFilterView, []string{
|
||||
"+get-filter-view", "--spreadsheet-token", "shtTOKEN",
|
||||
"--sheet-id", "sheet1", "--filter-view-id", "fv123", "--as", "user",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// ── DeleteFilterView ─────────────────────────────────────────────────────────
|
||||
|
||||
func TestDeleteFilterViewDryRun(t *testing.T) {
|
||||
t.Parallel()
|
||||
rt := newSheetsTestRuntime(t, map[string]string{
|
||||
"url": "", "spreadsheet-token": "sht_test", "sheet-id": "sheet1", "filter-view-id": "fv123",
|
||||
}, nil)
|
||||
got := mustMarshalSheetsDryRun(t, SheetDeleteFilterView.DryRun(context.Background(), rt))
|
||||
if !strings.Contains(got, `"method":"DELETE"`) {
|
||||
t.Fatalf("DryRun should use DELETE: %s", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeleteFilterViewExecuteSuccess(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, sheetsTestConfig())
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "DELETE", URL: "/open-apis/sheets/v3/spreadsheets/shtTOKEN/sheets/sheet1/filter_views/fv123",
|
||||
Body: map[string]interface{}{"code": 0, "msg": "success", "data": map[string]interface{}{}},
|
||||
})
|
||||
err := mountAndRunSheets(t, SheetDeleteFilterView, []string{
|
||||
"+delete-filter-view", "--spreadsheet-token", "shtTOKEN",
|
||||
"--sheet-id", "sheet1", "--filter-view-id", "fv123", "--as", "user",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// ── CreateFilterViewCondition ────────────────────────────────────────────────
|
||||
|
||||
func TestCreateFilterViewConditionValidateMissingToken(t *testing.T) {
|
||||
t.Parallel()
|
||||
rt := newSheetsTestRuntime(t, map[string]string{
|
||||
"url": "", "spreadsheet-token": "", "sheet-id": "s1", "filter-view-id": "fv1",
|
||||
"condition-id": "E", "filter-type": "number", "compare-type": "less", "expected": `["6"]`,
|
||||
}, nil)
|
||||
err := SheetCreateFilterViewCondition.Validate(context.Background(), rt)
|
||||
if err == nil || !strings.Contains(err.Error(), "--url or --spreadsheet-token") {
|
||||
t.Fatalf("expected token error, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateFilterViewConditionDryRun(t *testing.T) {
|
||||
t.Parallel()
|
||||
rt := newSheetsTestRuntime(t, map[string]string{
|
||||
"url": "", "spreadsheet-token": "sht_test", "sheet-id": "sheet1", "filter-view-id": "fv1",
|
||||
"condition-id": "E", "filter-type": "number", "compare-type": "less", "expected": `["6"]`,
|
||||
}, nil)
|
||||
got := mustMarshalSheetsDryRun(t, SheetCreateFilterViewCondition.DryRun(context.Background(), rt))
|
||||
if !strings.Contains(got, `conditions`) {
|
||||
t.Fatalf("DryRun URL missing conditions: %s", got)
|
||||
}
|
||||
if !strings.Contains(got, `"condition_id":"E"`) {
|
||||
t.Fatalf("DryRun missing condition_id: %s", got)
|
||||
}
|
||||
if !strings.Contains(got, `"filter_type":"number"`) {
|
||||
t.Fatalf("DryRun missing filter_type: %s", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateFilterViewConditionExecuteSuccess(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, sheetsTestConfig())
|
||||
stub := &httpmock.Stub{
|
||||
Method: "POST", URL: "/open-apis/sheets/v3/spreadsheets/shtTOKEN/sheets/sheet1/filter_views/fv1/conditions",
|
||||
Body: map[string]interface{}{"code": 0, "msg": "success", "data": map[string]interface{}{
|
||||
"condition": map[string]interface{}{"condition_id": "E", "filter_type": "number"},
|
||||
}},
|
||||
}
|
||||
reg.Register(stub)
|
||||
err := mountAndRunSheets(t, SheetCreateFilterViewCondition, []string{
|
||||
"+create-filter-view-condition", "--spreadsheet-token", "shtTOKEN",
|
||||
"--sheet-id", "sheet1", "--filter-view-id", "fv1",
|
||||
"--condition-id", "E", "--filter-type", "number", "--compare-type", "less",
|
||||
"--expected", `["6"]`, "--as", "user",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
var body map[string]interface{}
|
||||
if err := json.Unmarshal(stub.CapturedBody, &body); err != nil {
|
||||
t.Fatalf("parse body: %v", err)
|
||||
}
|
||||
if body["condition_id"] != "E" {
|
||||
t.Fatalf("unexpected condition_id: %v", body["condition_id"])
|
||||
}
|
||||
}
|
||||
|
||||
// ── UpdateFilterViewCondition ────────────────────────────────────────────────
|
||||
|
||||
func TestUpdateFilterViewConditionDryRun(t *testing.T) {
|
||||
t.Parallel()
|
||||
rt := newSheetsTestRuntime(t, map[string]string{
|
||||
"url": "", "spreadsheet-token": "sht_test", "sheet-id": "sheet1", "filter-view-id": "fv1",
|
||||
"condition-id": "E", "filter-type": "number", "compare-type": "between", "expected": `["2","10"]`,
|
||||
}, nil)
|
||||
got := mustMarshalSheetsDryRun(t, SheetUpdateFilterViewCondition.DryRun(context.Background(), rt))
|
||||
if !strings.Contains(got, `"method":"PUT"`) {
|
||||
t.Fatalf("DryRun should use PUT: %s", got)
|
||||
}
|
||||
if !strings.Contains(got, `"compare_type":"between"`) {
|
||||
t.Fatalf("DryRun missing compare_type: %s", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateFilterViewConditionExecuteSuccess(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, sheetsTestConfig())
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "PUT", URL: "/open-apis/sheets/v3/spreadsheets/shtTOKEN/sheets/sheet1/filter_views/fv1/conditions/E",
|
||||
Body: map[string]interface{}{"code": 0, "msg": "success", "data": map[string]interface{}{
|
||||
"condition": map[string]interface{}{"condition_id": "E"},
|
||||
}},
|
||||
})
|
||||
err := mountAndRunSheets(t, SheetUpdateFilterViewCondition, []string{
|
||||
"+update-filter-view-condition", "--spreadsheet-token", "shtTOKEN",
|
||||
"--sheet-id", "sheet1", "--filter-view-id", "fv1", "--condition-id", "E",
|
||||
"--filter-type", "number", "--compare-type", "between", "--expected", `["2","10"]`, "--as", "user",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateFilterViewConditionRejectsNoFields(t *testing.T) {
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, sheetsTestConfig())
|
||||
err := mountAndRunSheets(t, SheetUpdateFilterViewCondition, []string{
|
||||
"+update-filter-view-condition", "--spreadsheet-token", "shtTOKEN",
|
||||
"--sheet-id", "sheet1", "--filter-view-id", "fv1", "--condition-id", "E",
|
||||
"--as", "user",
|
||||
}, f, stdout)
|
||||
if err == nil {
|
||||
t.Fatal("expected validation error when no update fields provided, got nil")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "at least one") {
|
||||
t.Fatalf("unexpected error message: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// ── ListFilterViewConditions ─────────────────────────────────────────────────
|
||||
|
||||
func TestListFilterViewConditionsDryRun(t *testing.T) {
|
||||
t.Parallel()
|
||||
rt := newSheetsTestRuntime(t, map[string]string{
|
||||
"url": "", "spreadsheet-token": "sht_test", "sheet-id": "sheet1", "filter-view-id": "fv1",
|
||||
}, nil)
|
||||
got := mustMarshalSheetsDryRun(t, SheetListFilterViewConditions.DryRun(context.Background(), rt))
|
||||
if !strings.Contains(got, `conditions/query`) {
|
||||
t.Fatalf("DryRun URL missing conditions/query: %s", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestListFilterViewConditionsExecuteSuccess(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, sheetsTestConfig())
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET", URL: "/open-apis/sheets/v3/spreadsheets/shtTOKEN/sheets/sheet1/filter_views/fv1/conditions/query",
|
||||
Body: map[string]interface{}{"code": 0, "msg": "success", "data": map[string]interface{}{
|
||||
"items": []interface{}{map[string]interface{}{"condition_id": "E"}},
|
||||
}},
|
||||
})
|
||||
err := mountAndRunSheets(t, SheetListFilterViewConditions, []string{
|
||||
"+list-filter-view-conditions", "--spreadsheet-token", "shtTOKEN",
|
||||
"--sheet-id", "sheet1", "--filter-view-id", "fv1", "--as", "user",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// ── GetFilterViewCondition ───────────────────────────────────────────────────
|
||||
|
||||
func TestGetFilterViewConditionDryRun(t *testing.T) {
|
||||
t.Parallel()
|
||||
rt := newSheetsTestRuntime(t, map[string]string{
|
||||
"url": "", "spreadsheet-token": "sht_test", "sheet-id": "sheet1",
|
||||
"filter-view-id": "fv1", "condition-id": "E",
|
||||
}, nil)
|
||||
got := mustMarshalSheetsDryRun(t, SheetGetFilterViewCondition.DryRun(context.Background(), rt))
|
||||
if !strings.Contains(got, `"method":"GET"`) {
|
||||
t.Fatalf("DryRun should use GET: %s", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetFilterViewConditionExecuteSuccess(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, sheetsTestConfig())
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET", URL: "/open-apis/sheets/v3/spreadsheets/shtTOKEN/sheets/sheet1/filter_views/fv1/conditions/E",
|
||||
Body: map[string]interface{}{"code": 0, "msg": "success", "data": map[string]interface{}{
|
||||
"condition": map[string]interface{}{"condition_id": "E", "filter_type": "number"},
|
||||
}},
|
||||
})
|
||||
err := mountAndRunSheets(t, SheetGetFilterViewCondition, []string{
|
||||
"+get-filter-view-condition", "--spreadsheet-token", "shtTOKEN",
|
||||
"--sheet-id", "sheet1", "--filter-view-id", "fv1", "--condition-id", "E", "--as", "user",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// ── DeleteFilterViewCondition ────────────────────────────────────────────────
|
||||
|
||||
func TestDeleteFilterViewConditionDryRun(t *testing.T) {
|
||||
t.Parallel()
|
||||
rt := newSheetsTestRuntime(t, map[string]string{
|
||||
"url": "", "spreadsheet-token": "sht_test", "sheet-id": "sheet1",
|
||||
"filter-view-id": "fv1", "condition-id": "E",
|
||||
}, nil)
|
||||
got := mustMarshalSheetsDryRun(t, SheetDeleteFilterViewCondition.DryRun(context.Background(), rt))
|
||||
if !strings.Contains(got, `"method":"DELETE"`) {
|
||||
t.Fatalf("DryRun should use DELETE: %s", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeleteFilterViewConditionExecuteSuccess(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, sheetsTestConfig())
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "DELETE", URL: "/open-apis/sheets/v3/spreadsheets/shtTOKEN/sheets/sheet1/filter_views/fv1/conditions/E",
|
||||
Body: map[string]interface{}{"code": 0, "msg": "success", "data": map[string]interface{}{}},
|
||||
})
|
||||
err := mountAndRunSheets(t, SheetDeleteFilterViewCondition, []string{
|
||||
"+delete-filter-view-condition", "--spreadsheet-token", "shtTOKEN",
|
||||
"--sheet-id", "sheet1", "--filter-view-id", "fv1", "--condition-id", "E", "--as", "user",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// ── URL flag coverage ────────────────────────────────────────────────────────
|
||||
|
||||
func TestCreateFilterViewWithURL(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, sheetsTestConfig())
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST", URL: "/open-apis/sheets/v3/spreadsheets/shtFromURL/sheets/sheet1/filter_views",
|
||||
Body: map[string]interface{}{"code": 0, "msg": "success", "data": map[string]interface{}{
|
||||
"filter_view": map[string]interface{}{"filter_view_id": "fv1"},
|
||||
}},
|
||||
})
|
||||
err := mountAndRunSheets(t, SheetCreateFilterView, []string{
|
||||
"+create-filter-view", "--url", "https://example.feishu.cn/sheets/shtFromURL",
|
||||
"--sheet-id", "sheet1", "--range", "sheet1!A1:H14", "--as", "user",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestListFilterViewsWithURL(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, sheetsTestConfig())
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET", URL: "/open-apis/sheets/v3/spreadsheets/shtFromURL/sheets/sheet1/filter_views/query",
|
||||
Body: map[string]interface{}{"code": 0, "msg": "success", "data": map[string]interface{}{"items": []interface{}{}}},
|
||||
})
|
||||
err := mountAndRunSheets(t, SheetListFilterViews, []string{
|
||||
"+list-filter-views", "--url", "https://example.feishu.cn/sheets/shtFromURL",
|
||||
"--sheet-id", "sheet1", "--as", "user",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetFilterViewWithURL(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, sheetsTestConfig())
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET", URL: "/open-apis/sheets/v3/spreadsheets/shtFromURL/sheets/sheet1/filter_views/fv1",
|
||||
Body: map[string]interface{}{"code": 0, "msg": "success", "data": map[string]interface{}{
|
||||
"filter_view": map[string]interface{}{"filter_view_id": "fv1"},
|
||||
}},
|
||||
})
|
||||
err := mountAndRunSheets(t, SheetGetFilterView, []string{
|
||||
"+get-filter-view", "--url", "https://example.feishu.cn/sheets/shtFromURL",
|
||||
"--sheet-id", "sheet1", "--filter-view-id", "fv1", "--as", "user",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateFilterViewWithURL(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, sheetsTestConfig())
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "PATCH", URL: "/open-apis/sheets/v3/spreadsheets/shtFromURL/sheets/sheet1/filter_views/fv1",
|
||||
Body: map[string]interface{}{"code": 0, "msg": "success", "data": map[string]interface{}{
|
||||
"filter_view": map[string]interface{}{"filter_view_id": "fv1"},
|
||||
}},
|
||||
})
|
||||
err := mountAndRunSheets(t, SheetUpdateFilterView, []string{
|
||||
"+update-filter-view", "--url", "https://example.feishu.cn/sheets/shtFromURL",
|
||||
"--sheet-id", "sheet1", "--filter-view-id", "fv1", "--range", "sheet1!A1:J20", "--as", "user",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeleteFilterViewWithURL(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, sheetsTestConfig())
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "DELETE", URL: "/open-apis/sheets/v3/spreadsheets/shtFromURL/sheets/sheet1/filter_views/fv1",
|
||||
Body: map[string]interface{}{"code": 0, "msg": "success", "data": map[string]interface{}{}},
|
||||
})
|
||||
err := mountAndRunSheets(t, SheetDeleteFilterView, []string{
|
||||
"+delete-filter-view", "--url", "https://example.feishu.cn/sheets/shtFromURL",
|
||||
"--sheet-id", "sheet1", "--filter-view-id", "fv1", "--as", "user",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateFilterViewConditionWithURL(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, sheetsTestConfig())
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST", URL: "/open-apis/sheets/v3/spreadsheets/shtFromURL/sheets/sheet1/filter_views/fv1/conditions",
|
||||
Body: map[string]interface{}{"code": 0, "msg": "success", "data": map[string]interface{}{
|
||||
"condition": map[string]interface{}{"condition_id": "E"},
|
||||
}},
|
||||
})
|
||||
err := mountAndRunSheets(t, SheetCreateFilterViewCondition, []string{
|
||||
"+create-filter-view-condition", "--url", "https://example.feishu.cn/sheets/shtFromURL",
|
||||
"--sheet-id", "sheet1", "--filter-view-id", "fv1",
|
||||
"--condition-id", "E", "--filter-type", "number", "--compare-type", "less",
|
||||
"--expected", `["6"]`, "--as", "user",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateFilterViewConditionWithURL(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, sheetsTestConfig())
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "PUT", URL: "/open-apis/sheets/v3/spreadsheets/shtFromURL/sheets/sheet1/filter_views/fv1/conditions/E",
|
||||
Body: map[string]interface{}{"code": 0, "msg": "success", "data": map[string]interface{}{
|
||||
"condition": map[string]interface{}{"condition_id": "E"},
|
||||
}},
|
||||
})
|
||||
err := mountAndRunSheets(t, SheetUpdateFilterViewCondition, []string{
|
||||
"+update-filter-view-condition", "--url", "https://example.feishu.cn/sheets/shtFromURL",
|
||||
"--sheet-id", "sheet1", "--filter-view-id", "fv1", "--condition-id", "E",
|
||||
"--filter-type", "number", "--expected", `["5"]`, "--as", "user",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestListFilterViewConditionsWithURL(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, sheetsTestConfig())
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET", URL: "/open-apis/sheets/v3/spreadsheets/shtFromURL/sheets/sheet1/filter_views/fv1/conditions/query",
|
||||
Body: map[string]interface{}{"code": 0, "msg": "success", "data": map[string]interface{}{"items": []interface{}{}}},
|
||||
})
|
||||
err := mountAndRunSheets(t, SheetListFilterViewConditions, []string{
|
||||
"+list-filter-view-conditions", "--url", "https://example.feishu.cn/sheets/shtFromURL",
|
||||
"--sheet-id", "sheet1", "--filter-view-id", "fv1", "--as", "user",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetFilterViewConditionWithURL(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, sheetsTestConfig())
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET", URL: "/open-apis/sheets/v3/spreadsheets/shtFromURL/sheets/sheet1/filter_views/fv1/conditions/E",
|
||||
Body: map[string]interface{}{"code": 0, "msg": "success", "data": map[string]interface{}{
|
||||
"condition": map[string]interface{}{"condition_id": "E"},
|
||||
}},
|
||||
})
|
||||
err := mountAndRunSheets(t, SheetGetFilterViewCondition, []string{
|
||||
"+get-filter-view-condition", "--url", "https://example.feishu.cn/sheets/shtFromURL",
|
||||
"--sheet-id", "sheet1", "--filter-view-id", "fv1", "--condition-id", "E", "--as", "user",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeleteFilterViewConditionWithURL(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, sheetsTestConfig())
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "DELETE", URL: "/open-apis/sheets/v3/spreadsheets/shtFromURL/sheets/sheet1/filter_views/fv1/conditions/E",
|
||||
Body: map[string]interface{}{"code": 0, "msg": "success", "data": map[string]interface{}{}},
|
||||
})
|
||||
err := mountAndRunSheets(t, SheetDeleteFilterViewCondition, []string{
|
||||
"+delete-filter-view-condition", "--url", "https://example.feishu.cn/sheets/shtFromURL",
|
||||
"--sheet-id", "sheet1", "--filter-view-id", "fv1", "--condition-id", "E", "--as", "user",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// ── --expected validation rejects non-array input ────────────────────────────
|
||||
|
||||
func TestCreateFilterViewConditionRejectsNonArrayExpected(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
expected string
|
||||
}{
|
||||
{"plain string", "hello"},
|
||||
{"JSON object", `{"key":"val"}`},
|
||||
{"JSON number", "42"},
|
||||
{"JSON string", `"hello"`},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, sheetsTestConfig())
|
||||
err := mountAndRunSheets(t, SheetCreateFilterViewCondition, []string{
|
||||
"+create-filter-view-condition", "--spreadsheet-token", "shtTOKEN",
|
||||
"--sheet-id", "sheet1", "--filter-view-id", "fv1",
|
||||
"--condition-id", "A", "--filter-type", "text", "--compare-type", "contains",
|
||||
"--expected", tc.expected, "--as", "user",
|
||||
}, f, stdout)
|
||||
if err == nil {
|
||||
t.Fatalf("expected validation error for --expected=%q, got nil", tc.expected)
|
||||
}
|
||||
if !strings.Contains(err.Error(), "--expected must be a JSON array") {
|
||||
t.Fatalf("unexpected error message: %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -26,5 +26,15 @@ func Shortcuts() []common.Shortcut {
|
||||
SheetUpdateDimension,
|
||||
SheetMoveDimension,
|
||||
SheetDeleteDimension,
|
||||
SheetCreateFilterView,
|
||||
SheetUpdateFilterView,
|
||||
SheetListFilterViews,
|
||||
SheetGetFilterView,
|
||||
SheetDeleteFilterView,
|
||||
SheetCreateFilterViewCondition,
|
||||
SheetUpdateFilterViewCondition,
|
||||
SheetListFilterViewConditions,
|
||||
SheetGetFilterViewCondition,
|
||||
SheetDeleteFilterViewCondition,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -30,6 +30,7 @@ var AddTaskToTasklist = common.Shortcut{
|
||||
Flags: []common.Flag{
|
||||
{Name: "tasklist-id", Desc: "tasklist id", Required: true},
|
||||
{Name: "task-id", Desc: "task id (comma-separated for multiple)", Required: true},
|
||||
{Name: "section-guid", Desc: "section guid"},
|
||||
},
|
||||
|
||||
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
@@ -40,6 +41,10 @@ var AddTaskToTasklist = common.Shortcut{
|
||||
"tasklist_guid": extractTasklistGuid(runtime.Str("tasklist-id")),
|
||||
}
|
||||
|
||||
if sectionGuid := strings.TrimSpace(runtime.Str("section-guid")); sectionGuid != "" {
|
||||
body["section_guid"] = sectionGuid
|
||||
}
|
||||
|
||||
return common.NewDryRunAPI().
|
||||
POST("/open-apis/task/v2/tasks/" + taskId + "/add_tasklist").
|
||||
Params(map[string]interface{}{"user_id_type": "open_id"}).
|
||||
@@ -57,6 +62,10 @@ var AddTaskToTasklist = common.Shortcut{
|
||||
"tasklist_guid": tasklistGuid,
|
||||
}
|
||||
|
||||
if sectionGuid := strings.TrimSpace(runtime.Str("section-guid")); sectionGuid != "" {
|
||||
body["section_guid"] = sectionGuid
|
||||
}
|
||||
|
||||
var successful []map[string]interface{}
|
||||
var failed []map[string]interface{}
|
||||
|
||||
|
||||
43
shortcuts/task/tasklist_add_task_test.go
Normal file
43
shortcuts/task/tasklist_add_task_test.go
Normal file
@@ -0,0 +1,43 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package task
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/internal/httpmock"
|
||||
)
|
||||
|
||||
func TestAddTaskToTasklist_Success(t *testing.T) {
|
||||
f, stdout, _, reg := taskShortcutTestFactory(t)
|
||||
warmTenantToken(t, f, reg)
|
||||
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/task/v2/tasks/task-1/add_tasklist",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0, "msg": "success",
|
||||
"data": map[string]interface{}{
|
||||
"task": map[string]interface{}{
|
||||
"guid": "task-1",
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
s := AddTaskToTasklist
|
||||
s.AuthTypes = []string{"bot", "user"}
|
||||
|
||||
args := []string{"+tasklist-task-add", "--tasklist-id", "tl-123", "--task-id", "task-1", "--section-guid", "sec-456", "--as", "bot", "--format", "json"}
|
||||
err := runMountedTaskShortcut(t, s, args, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("expected no error, got %v", err)
|
||||
}
|
||||
|
||||
out := stdout.String()
|
||||
if !strings.Contains(out, `"tasklist_guid":"tl-123"`) && !strings.Contains(out, `"tasklist_guid": "tl-123"`) {
|
||||
t.Errorf("expected tasklist_guid in output, got: %s", out)
|
||||
}
|
||||
}
|
||||
@@ -8,6 +8,7 @@ import "github.com/larksuite/cli/shortcuts/common"
|
||||
// Shortcuts returns all wiki shortcuts.
|
||||
func Shortcuts() []common.Shortcut {
|
||||
return []common.Shortcut{
|
||||
WikiMove,
|
||||
WikiNodeCreate,
|
||||
}
|
||||
}
|
||||
|
||||
671
shortcuts/wiki/wiki_move.go
Normal file
671
shortcuts/wiki/wiki_move.go
Normal file
@@ -0,0 +1,671 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package wiki
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"github.com/larksuite/cli/internal/validate"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
var (
|
||||
wikiMovePollAttempts = 30
|
||||
wikiMovePollInterval = 2 * time.Second
|
||||
)
|
||||
|
||||
const (
|
||||
wikiMoveModeNode = "node"
|
||||
wikiMoveModeDocsToWiki = "docs_to_wiki"
|
||||
)
|
||||
|
||||
var wikiMoveObjectTypes = []string{
|
||||
"doc",
|
||||
"sheet",
|
||||
"bitable",
|
||||
"mindnote",
|
||||
"docx",
|
||||
"file",
|
||||
"slides",
|
||||
}
|
||||
|
||||
// WikiMove moves an existing wiki node inside Wiki or migrates a Drive
|
||||
// document into Wiki with bounded polling for async task completion.
|
||||
var WikiMove = common.Shortcut{
|
||||
Service: "wiki",
|
||||
Command: "+move",
|
||||
Description: "Move a wiki node, or move a Drive document into Wiki",
|
||||
Risk: "write",
|
||||
Scopes: []string{"wiki:node:move", "wiki:node:read", "wiki:space:read"},
|
||||
AuthTypes: []string{"user", "bot"},
|
||||
Flags: []common.Flag{
|
||||
{Name: "node-token", Desc: "wiki node token to move inside Wiki"},
|
||||
{Name: "source-space-id", Desc: "source wiki space ID for --node-token; if omitted, it is resolved from the node token"},
|
||||
{Name: "target-space-id", Desc: "target wiki space ID; required for docs-to-wiki, optional for node move when --target-parent-token is set"},
|
||||
{Name: "target-parent-token", Desc: "target parent wiki node token; if omitted for docs-to-wiki, the document is moved to the target space root"},
|
||||
{Name: "obj-type", Desc: "Drive document type for docs-to-wiki mode", Enum: wikiMoveObjectTypes},
|
||||
{Name: "obj-token", Desc: "Drive document token for docs-to-wiki mode"},
|
||||
{Name: "apply", Type: "bool", Desc: "submit a move request when the caller lacks permission to move the document immediately"},
|
||||
},
|
||||
Tips: []string{
|
||||
"Use --node-token to move an existing wiki node inside or across wiki spaces.",
|
||||
"Use --obj-type and --obj-token to move a Drive document into Wiki.",
|
||||
"If docs-to-wiki returns a long-running task, this command polls for a bounded window and then prints a follow-up drive +task_result command.",
|
||||
},
|
||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
spec := readWikiMoveSpec(runtime)
|
||||
// `my_library` is a per-user personal-library alias; it has no meaning
|
||||
// for a tenant_access_token (--as bot), so reject early with a clear
|
||||
// hint instead of letting the API return a confusing error.
|
||||
if runtime.As().IsBot() && spec.TargetSpaceID == wikiMyLibrarySpaceID {
|
||||
return output.ErrValidation("--target-space-id my_library is a per-user personal library alias and cannot be used with --as bot; resolve it to a real space_id first via `lark-cli wiki spaces get --params '{\"space_id\":\"my_library\"}' --as user`")
|
||||
}
|
||||
return validateWikiMoveSpec(spec)
|
||||
},
|
||||
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
return buildWikiMoveDryRun(readWikiMoveSpec(runtime))
|
||||
},
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
spec := readWikiMoveSpec(runtime)
|
||||
fmt.Fprintf(runtime.IO().ErrOut, "Running wiki move (%s)...\n", spec.Mode())
|
||||
|
||||
out, err := runWikiMove(ctx, wikiMoveAPI{runtime: runtime}, runtime, spec)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
runtime.Out(out, nil)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
type wikiMoveSpec struct {
|
||||
NodeToken string
|
||||
SourceSpaceID string
|
||||
TargetSpaceID string
|
||||
TargetParentToken string
|
||||
ObjType string
|
||||
ObjToken string
|
||||
Apply bool
|
||||
}
|
||||
|
||||
func (spec wikiMoveSpec) Mode() string {
|
||||
if spec.NodeToken != "" {
|
||||
return wikiMoveModeNode
|
||||
}
|
||||
return wikiMoveModeDocsToWiki
|
||||
}
|
||||
|
||||
func (spec wikiMoveSpec) NodeMoveBody() map[string]interface{} {
|
||||
body := map[string]interface{}{}
|
||||
if spec.TargetParentToken != "" {
|
||||
body["target_parent_token"] = spec.TargetParentToken
|
||||
}
|
||||
if spec.TargetSpaceID != "" {
|
||||
body["target_space_id"] = spec.TargetSpaceID
|
||||
}
|
||||
return body
|
||||
}
|
||||
|
||||
func (spec wikiMoveSpec) DocsToWikiBody() map[string]interface{} {
|
||||
body := map[string]interface{}{
|
||||
"obj_type": spec.ObjType,
|
||||
"obj_token": spec.ObjToken,
|
||||
}
|
||||
if spec.TargetParentToken != "" {
|
||||
body["parent_wiki_token"] = spec.TargetParentToken
|
||||
}
|
||||
if spec.Apply {
|
||||
body["apply"] = true
|
||||
}
|
||||
return body
|
||||
}
|
||||
|
||||
type wikiMoveTaskResult struct {
|
||||
Node *wikiNodeRecord
|
||||
Status int
|
||||
StatusMsg string
|
||||
}
|
||||
|
||||
type wikiMoveTaskStatus struct {
|
||||
TaskID string
|
||||
MoveResults []wikiMoveTaskResult
|
||||
}
|
||||
|
||||
func (s wikiMoveTaskStatus) Ready() bool {
|
||||
if len(s.MoveResults) == 0 {
|
||||
return false
|
||||
}
|
||||
for _, result := range s.MoveResults {
|
||||
if result.Status != 0 {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func (s wikiMoveTaskStatus) Failed() bool {
|
||||
for _, result := range s.MoveResults {
|
||||
if result.Status < 0 {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func (s wikiMoveTaskStatus) Pending() bool {
|
||||
return !s.Ready() && !s.Failed()
|
||||
}
|
||||
|
||||
func (s wikiMoveTaskStatus) FirstResult() *wikiMoveTaskResult {
|
||||
if len(s.MoveResults) == 0 {
|
||||
return nil
|
||||
}
|
||||
return &s.MoveResults[0]
|
||||
}
|
||||
|
||||
// primaryResult picks the most informative move_result for top-level status
|
||||
// surfacing: prefer a failing entry so multi-doc tasks don't mask failures
|
||||
// behind an earlier success, then a still-processing entry, and finally fall
|
||||
// back to the first entry.
|
||||
func (s wikiMoveTaskStatus) primaryResult() *wikiMoveTaskResult {
|
||||
for i := range s.MoveResults {
|
||||
if s.MoveResults[i].Status < 0 {
|
||||
return &s.MoveResults[i]
|
||||
}
|
||||
}
|
||||
for i := range s.MoveResults {
|
||||
if s.MoveResults[i].Status > 0 {
|
||||
return &s.MoveResults[i]
|
||||
}
|
||||
}
|
||||
return s.FirstResult()
|
||||
}
|
||||
|
||||
func (s wikiMoveTaskStatus) PrimaryStatusCode() int {
|
||||
if r := s.primaryResult(); r != nil {
|
||||
return r.Status
|
||||
}
|
||||
return 1
|
||||
}
|
||||
|
||||
func (s wikiMoveTaskStatus) PrimaryStatusLabel() string {
|
||||
if r := s.primaryResult(); r != nil {
|
||||
if msg := strings.TrimSpace(r.StatusMsg); msg != "" {
|
||||
return msg
|
||||
}
|
||||
}
|
||||
switch {
|
||||
case s.Ready():
|
||||
return "success"
|
||||
case s.Failed():
|
||||
return "failure"
|
||||
default:
|
||||
return "processing"
|
||||
}
|
||||
}
|
||||
|
||||
type wikiMoveDocsResponse struct {
|
||||
WikiToken string
|
||||
TaskID string
|
||||
Applied bool
|
||||
}
|
||||
|
||||
type wikiMoveClient interface {
|
||||
GetNode(ctx context.Context, token string) (*wikiNodeRecord, error)
|
||||
MoveNode(ctx context.Context, sourceSpaceID string, spec wikiMoveSpec) (*wikiNodeRecord, error)
|
||||
MoveDocsToWiki(ctx context.Context, targetSpaceID string, spec wikiMoveSpec) (*wikiMoveDocsResponse, error)
|
||||
GetMoveTask(ctx context.Context, taskID string) (wikiMoveTaskStatus, error)
|
||||
}
|
||||
|
||||
type wikiMoveAPI struct {
|
||||
runtime *common.RuntimeContext
|
||||
}
|
||||
|
||||
func (api wikiMoveAPI) GetNode(ctx context.Context, token string) (*wikiNodeRecord, error) {
|
||||
data, err := api.runtime.CallAPI(
|
||||
"GET",
|
||||
"/open-apis/wiki/v2/spaces/get_node",
|
||||
map[string]interface{}{"token": token},
|
||||
nil,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return parseWikiNodeRecord(common.GetMap(data, "node"))
|
||||
}
|
||||
|
||||
func (api wikiMoveAPI) MoveNode(ctx context.Context, sourceSpaceID string, spec wikiMoveSpec) (*wikiNodeRecord, error) {
|
||||
data, err := api.runtime.CallAPI(
|
||||
"POST",
|
||||
fmt.Sprintf(
|
||||
"/open-apis/wiki/v2/spaces/%s/nodes/%s/move",
|
||||
validate.EncodePathSegment(sourceSpaceID),
|
||||
validate.EncodePathSegment(spec.NodeToken),
|
||||
),
|
||||
nil,
|
||||
spec.NodeMoveBody(),
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return parseWikiNodeRecord(common.GetMap(data, "node"))
|
||||
}
|
||||
|
||||
func (api wikiMoveAPI) MoveDocsToWiki(ctx context.Context, targetSpaceID string, spec wikiMoveSpec) (*wikiMoveDocsResponse, error) {
|
||||
data, err := api.runtime.CallAPI(
|
||||
"POST",
|
||||
fmt.Sprintf(
|
||||
"/open-apis/wiki/v2/spaces/%s/nodes/move_docs_to_wiki",
|
||||
validate.EncodePathSegment(targetSpaceID),
|
||||
),
|
||||
nil,
|
||||
spec.DocsToWikiBody(),
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &wikiMoveDocsResponse{
|
||||
WikiToken: common.GetString(data, "wiki_token"),
|
||||
TaskID: common.GetString(data, "task_id"),
|
||||
Applied: common.GetBool(data, "applied"),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (api wikiMoveAPI) GetMoveTask(ctx context.Context, taskID string) (wikiMoveTaskStatus, error) {
|
||||
data, err := api.runtime.CallAPI(
|
||||
"GET",
|
||||
fmt.Sprintf("/open-apis/wiki/v2/tasks/%s", validate.EncodePathSegment(taskID)),
|
||||
map[string]interface{}{"task_type": "move"},
|
||||
nil,
|
||||
)
|
||||
if err != nil {
|
||||
return wikiMoveTaskStatus{}, err
|
||||
}
|
||||
return parseWikiMoveTaskStatus(taskID, common.GetMap(data, "task"))
|
||||
}
|
||||
|
||||
func readWikiMoveSpec(runtime *common.RuntimeContext) wikiMoveSpec {
|
||||
return wikiMoveSpec{
|
||||
NodeToken: strings.TrimSpace(runtime.Str("node-token")),
|
||||
SourceSpaceID: strings.TrimSpace(runtime.Str("source-space-id")),
|
||||
TargetSpaceID: strings.TrimSpace(runtime.Str("target-space-id")),
|
||||
TargetParentToken: strings.TrimSpace(runtime.Str("target-parent-token")),
|
||||
ObjType: strings.ToLower(strings.TrimSpace(runtime.Str("obj-type"))),
|
||||
ObjToken: strings.TrimSpace(runtime.Str("obj-token")),
|
||||
Apply: runtime.Bool("apply"),
|
||||
}
|
||||
}
|
||||
|
||||
func validateWikiMoveSpec(spec wikiMoveSpec) error {
|
||||
if err := validateOptionalResourceName(spec.NodeToken, "--node-token"); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := validateOptionalResourceName(spec.SourceSpaceID, "--source-space-id"); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := validateOptionalResourceName(spec.TargetSpaceID, "--target-space-id"); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := validateOptionalResourceName(spec.TargetParentToken, "--target-parent-token"); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := validateOptionalResourceName(spec.ObjToken, "--obj-token"); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if spec.NodeToken != "" {
|
||||
if spec.ObjType != "" || spec.ObjToken != "" || spec.Apply {
|
||||
return output.ErrValidation("--node-token cannot be combined with --obj-type, --obj-token, or --apply")
|
||||
}
|
||||
if spec.TargetParentToken == "" && spec.TargetSpaceID == "" {
|
||||
return output.ErrValidation("--target-parent-token and --target-space-id cannot both be empty for wiki node move")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
if spec.SourceSpaceID != "" {
|
||||
return output.ErrValidation("--source-space-id can only be used with --node-token")
|
||||
}
|
||||
if spec.ObjType == "" && spec.ObjToken == "" && !spec.Apply {
|
||||
return output.ErrValidation("provide --node-token for wiki node move, or provide --obj-type and --obj-token for docs-to-wiki move")
|
||||
}
|
||||
if spec.ObjType == "" {
|
||||
return output.ErrValidation("--obj-type is required for docs-to-wiki move")
|
||||
}
|
||||
if spec.ObjToken == "" {
|
||||
return output.ErrValidation("--obj-token is required for docs-to-wiki move")
|
||||
}
|
||||
if spec.TargetSpaceID == "" {
|
||||
return output.ErrValidation("--target-space-id is required for docs-to-wiki move")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func buildWikiMoveDryRun(spec wikiMoveSpec) *common.DryRunAPI {
|
||||
dry := common.NewDryRunAPI()
|
||||
switch spec.Mode() {
|
||||
case wikiMoveModeNode:
|
||||
step := 1
|
||||
switch {
|
||||
case spec.SourceSpaceID == "" && spec.TargetParentToken != "":
|
||||
dry.Desc("3-step orchestration: resolve source node -> resolve target parent -> move wiki node")
|
||||
case spec.SourceSpaceID == "":
|
||||
dry.Desc("2-step orchestration: resolve source node -> move wiki node")
|
||||
case spec.TargetParentToken != "":
|
||||
dry.Desc("2-step orchestration: resolve target parent -> move wiki node")
|
||||
default:
|
||||
dry.Desc("1-step request: move wiki node")
|
||||
}
|
||||
|
||||
if spec.SourceSpaceID == "" {
|
||||
dry.GET("/open-apis/wiki/v2/spaces/get_node").
|
||||
Desc(fmt.Sprintf("[%d] Resolve source space from node token", step)).
|
||||
Params(map[string]interface{}{"token": spec.NodeToken})
|
||||
step++
|
||||
}
|
||||
if spec.TargetParentToken != "" {
|
||||
dry.GET("/open-apis/wiki/v2/spaces/get_node").
|
||||
Desc(fmt.Sprintf("[%d] Resolve target parent node", step)).
|
||||
Params(map[string]interface{}{"token": spec.TargetParentToken})
|
||||
step++
|
||||
}
|
||||
|
||||
dry.POST(fmt.Sprintf(
|
||||
"/open-apis/wiki/v2/spaces/%s/nodes/%s/move",
|
||||
dryRunWikiMoveSourceSpaceID(spec),
|
||||
validate.EncodePathSegment(spec.NodeToken),
|
||||
)).
|
||||
Desc(fmt.Sprintf("[%d] Move wiki node", step)).
|
||||
Body(spec.NodeMoveBody())
|
||||
case wikiMoveModeDocsToWiki:
|
||||
dry.Desc("2-step orchestration: move Drive document into Wiki -> poll wiki task result when task_id is returned")
|
||||
dry.POST(fmt.Sprintf(
|
||||
"/open-apis/wiki/v2/spaces/%s/nodes/move_docs_to_wiki",
|
||||
dryRunWikiMoveTargetSpaceID(spec),
|
||||
)).
|
||||
Desc("[1] Move Drive document into Wiki").
|
||||
Body(spec.DocsToWikiBody())
|
||||
dry.GET("/open-apis/wiki/v2/tasks/:task_id").
|
||||
Desc("[2] Poll wiki move task result when async").
|
||||
Set("task_id", "<task_id>").
|
||||
Params(map[string]interface{}{"task_type": "move"})
|
||||
default:
|
||||
dry.Set("error", "unknown wiki move mode")
|
||||
}
|
||||
return dry
|
||||
}
|
||||
|
||||
func dryRunWikiMoveSourceSpaceID(spec wikiMoveSpec) string {
|
||||
if spec.SourceSpaceID != "" {
|
||||
return validate.EncodePathSegment(spec.SourceSpaceID)
|
||||
}
|
||||
return "<resolved_source_space_id>"
|
||||
}
|
||||
|
||||
func dryRunWikiMoveTargetSpaceID(spec wikiMoveSpec) string {
|
||||
if spec.TargetSpaceID != "" {
|
||||
return validate.EncodePathSegment(spec.TargetSpaceID)
|
||||
}
|
||||
return "<target_space_id>"
|
||||
}
|
||||
|
||||
func runWikiMove(ctx context.Context, client wikiMoveClient, runtime *common.RuntimeContext, spec wikiMoveSpec) (map[string]interface{}, error) {
|
||||
switch spec.Mode() {
|
||||
case wikiMoveModeNode:
|
||||
return runWikiNodeMove(ctx, client, spec)
|
||||
case wikiMoveModeDocsToWiki:
|
||||
return runWikiDocsToWikiMove(ctx, client, runtime, spec)
|
||||
default:
|
||||
return nil, output.ErrValidation("unknown wiki move mode")
|
||||
}
|
||||
}
|
||||
|
||||
func runWikiNodeMove(ctx context.Context, client wikiMoveClient, spec wikiMoveSpec) (map[string]interface{}, error) {
|
||||
sourceSpaceID, targetSpaceID, err := resolveWikiNodeMoveSpaces(ctx, client, spec)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
node, err := client.MoveNode(ctx, sourceSpaceID, spec)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
out := map[string]interface{}{
|
||||
"mode": wikiMoveModeNode,
|
||||
"source_space_id": sourceSpaceID,
|
||||
"target_space_id": targetSpaceID,
|
||||
}
|
||||
appendWikiNodeOutput(out, node)
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func resolveWikiNodeMoveSpaces(ctx context.Context, client wikiMoveClient, spec wikiMoveSpec) (string, string, error) {
|
||||
// Node move requests may start from just a node token and/or a target parent.
|
||||
// Resolve both ends up front so we can fail on space mismatches before sending
|
||||
// the mutation request.
|
||||
sourceSpaceID := spec.SourceSpaceID
|
||||
if sourceSpaceID == "" {
|
||||
sourceNode, err := client.GetNode(ctx, spec.NodeToken)
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
sourceSpaceID, err = requireWikiNodeSpaceID(sourceNode)
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
}
|
||||
|
||||
targetSpaceID := spec.TargetSpaceID
|
||||
if spec.TargetParentToken != "" {
|
||||
targetParent, err := client.GetNode(ctx, spec.TargetParentToken)
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
parentSpaceID, err := requireWikiNodeSpaceID(targetParent)
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
if targetSpaceID == "" {
|
||||
targetSpaceID = parentSpaceID
|
||||
} else if targetSpaceID != parentSpaceID {
|
||||
return "", "", output.ErrValidation(
|
||||
"--target-space-id %q does not match target parent node space %q",
|
||||
spec.TargetSpaceID,
|
||||
parentSpaceID,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if targetSpaceID == "" {
|
||||
targetSpaceID = sourceSpaceID
|
||||
}
|
||||
|
||||
return sourceSpaceID, targetSpaceID, nil
|
||||
}
|
||||
|
||||
func runWikiDocsToWikiMove(ctx context.Context, client wikiMoveClient, runtime *common.RuntimeContext, spec wikiMoveSpec) (map[string]interface{}, error) {
|
||||
response, err := client.MoveDocsToWiki(ctx, spec.TargetSpaceID, spec)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
out := map[string]interface{}{
|
||||
"mode": wikiMoveModeDocsToWiki,
|
||||
"obj_type": spec.ObjType,
|
||||
"obj_token": spec.ObjToken,
|
||||
"target_space_id": spec.TargetSpaceID,
|
||||
"target_parent_token": spec.TargetParentToken,
|
||||
}
|
||||
|
||||
// move_docs_to_wiki has three success-shaped responses: immediate completion,
|
||||
// approval-request submission, or an async task that must be polled.
|
||||
switch {
|
||||
case response.WikiToken != "":
|
||||
out["ready"] = true
|
||||
out["failed"] = false
|
||||
out["wiki_token"] = response.WikiToken
|
||||
out["node_token"] = response.WikiToken
|
||||
return out, nil
|
||||
case response.Applied:
|
||||
out["ready"] = false
|
||||
out["failed"] = false
|
||||
out["applied"] = true
|
||||
out["status_msg"] = "move request submitted for approval"
|
||||
return out, nil
|
||||
case response.TaskID != "":
|
||||
fmt.Fprintf(runtime.IO().ErrOut, "Docs-to-wiki move is async, polling task %s...\n", response.TaskID)
|
||||
status, ready, err := pollWikiMoveTask(ctx, client, runtime, response.TaskID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
out["task_id"] = response.TaskID
|
||||
out["ready"] = ready
|
||||
out["failed"] = status.Failed()
|
||||
out["status"] = status.PrimaryStatusCode()
|
||||
out["status_msg"] = status.PrimaryStatusLabel()
|
||||
if first := status.FirstResult(); first != nil {
|
||||
appendWikiNodeOutput(out, first.Node)
|
||||
if first.Node != nil && first.Node.NodeToken != "" {
|
||||
out["wiki_token"] = first.Node.NodeToken
|
||||
}
|
||||
}
|
||||
if !ready {
|
||||
nextCommand := wikiMoveTaskResultCommand(response.TaskID, runtime.As())
|
||||
fmt.Fprintf(runtime.IO().ErrOut, "Wiki move task is still in progress. Continue with: %s\n", nextCommand)
|
||||
out["timed_out"] = true
|
||||
out["next_command"] = nextCommand
|
||||
}
|
||||
return out, nil
|
||||
default:
|
||||
return nil, output.Errorf(output.ExitAPI, "api_error", "move_docs_to_wiki returned neither wiki_token, task_id, nor applied result")
|
||||
}
|
||||
}
|
||||
|
||||
func wikiMoveTaskResultCommand(taskID string, identity core.Identity) string {
|
||||
asFlag := string(identity)
|
||||
if asFlag == "" {
|
||||
asFlag = "user"
|
||||
}
|
||||
return fmt.Sprintf("lark-cli drive +task_result --scenario wiki_move --task-id %s --as %s", taskID, asFlag)
|
||||
}
|
||||
|
||||
func pollWikiMoveTask(ctx context.Context, client wikiMoveClient, runtime *common.RuntimeContext, taskID string) (wikiMoveTaskStatus, bool, error) {
|
||||
lastStatus := wikiMoveTaskStatus{TaskID: taskID}
|
||||
var lastErr error
|
||||
hadSuccessfulPoll := false
|
||||
|
||||
// The move request itself already succeeded. Treat poll failures as transient
|
||||
// until every attempt fails, then return a resume hint instead of discarding
|
||||
// the task identifier.
|
||||
for attempt := 1; attempt <= wikiMovePollAttempts; attempt++ {
|
||||
if attempt > 1 {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return lastStatus, false, ctx.Err()
|
||||
case <-time.After(wikiMovePollInterval):
|
||||
}
|
||||
}
|
||||
|
||||
status, err := client.GetMoveTask(ctx, taskID)
|
||||
if err != nil {
|
||||
lastErr = err
|
||||
fmt.Fprintf(runtime.IO().ErrOut, "Wiki move status attempt %d/%d failed: %v\n", attempt, wikiMovePollAttempts, err)
|
||||
continue
|
||||
}
|
||||
lastStatus = status
|
||||
hadSuccessfulPoll = true
|
||||
|
||||
if status.Ready() {
|
||||
fmt.Fprintf(runtime.IO().ErrOut, "Wiki move task completed successfully.\n")
|
||||
return status, true, nil
|
||||
}
|
||||
if status.Failed() {
|
||||
return status, false, output.Errorf(output.ExitAPI, "api_error", "wiki move task failed: %s", status.PrimaryStatusLabel())
|
||||
}
|
||||
|
||||
fmt.Fprintf(runtime.IO().ErrOut, "Wiki move status %d/%d: %s\n", attempt, wikiMovePollAttempts, status.PrimaryStatusLabel())
|
||||
}
|
||||
|
||||
if !hadSuccessfulPoll && lastErr != nil {
|
||||
nextCommand := wikiMoveTaskResultCommand(taskID, runtime.As())
|
||||
hint := fmt.Sprintf(
|
||||
"the wiki move task was created but every status poll failed (task_id=%s)\nretry status lookup with: %s",
|
||||
taskID,
|
||||
nextCommand,
|
||||
)
|
||||
var exitErr *output.ExitError
|
||||
if errors.As(lastErr, &exitErr) && exitErr.Detail != nil {
|
||||
if strings.TrimSpace(exitErr.Detail.Hint) != "" {
|
||||
hint = exitErr.Detail.Hint + "\n" + hint
|
||||
}
|
||||
return lastStatus, false, output.ErrWithHint(exitErr.Code, exitErr.Detail.Type, exitErr.Detail.Message, hint)
|
||||
}
|
||||
return lastStatus, false, output.ErrWithHint(output.ExitAPI, "api_error", lastErr.Error(), hint)
|
||||
}
|
||||
|
||||
return lastStatus, false, nil
|
||||
}
|
||||
|
||||
func parseWikiMoveTaskStatus(taskID string, task map[string]interface{}) (wikiMoveTaskStatus, error) {
|
||||
if task == nil {
|
||||
return wikiMoveTaskStatus{}, output.Errorf(output.ExitAPI, "api_error", "wiki task response missing task")
|
||||
}
|
||||
|
||||
status := wikiMoveTaskStatus{
|
||||
TaskID: common.GetString(task, "task_id"),
|
||||
}
|
||||
if status.TaskID == "" {
|
||||
status.TaskID = taskID
|
||||
}
|
||||
|
||||
for _, item := range common.GetSlice(task, "move_result") {
|
||||
resultMap, ok := item.(map[string]interface{})
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
|
||||
var node *wikiNodeRecord
|
||||
if nodeMap := common.GetMap(resultMap, "node"); nodeMap != nil {
|
||||
parsedNode, err := parseWikiNodeRecord(nodeMap)
|
||||
if err != nil {
|
||||
return wikiMoveTaskStatus{}, err
|
||||
}
|
||||
node = parsedNode
|
||||
}
|
||||
|
||||
status.MoveResults = append(status.MoveResults, wikiMoveTaskResult{
|
||||
Node: node,
|
||||
Status: int(common.GetFloat(resultMap, "status")),
|
||||
StatusMsg: common.GetString(resultMap, "status_msg"),
|
||||
})
|
||||
}
|
||||
|
||||
return status, nil
|
||||
}
|
||||
|
||||
func appendWikiNodeOutput(out map[string]interface{}, node *wikiNodeRecord) {
|
||||
if out == nil || node == nil {
|
||||
return
|
||||
}
|
||||
out["space_id"] = node.SpaceID
|
||||
out["node_token"] = node.NodeToken
|
||||
out["obj_token"] = node.ObjToken
|
||||
out["obj_type"] = node.ObjType
|
||||
out["parent_node_token"] = node.ParentNodeToken
|
||||
out["node_type"] = node.NodeType
|
||||
out["origin_node_token"] = node.OriginNodeToken
|
||||
out["title"] = node.Title
|
||||
out["has_child"] = node.HasChild
|
||||
}
|
||||
905
shortcuts/wiki/wiki_move_test.go
Normal file
905
shortcuts/wiki/wiki_move_test.go
Normal file
@@ -0,0 +1,905 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package wiki
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"reflect"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/internal/credential"
|
||||
"github.com/larksuite/cli/internal/httpmock"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
type fakeWikiMoveNodeCall struct {
|
||||
SourceSpaceID string
|
||||
Spec wikiMoveSpec
|
||||
}
|
||||
|
||||
type fakeWikiDocsToWikiMoveCall struct {
|
||||
TargetSpaceID string
|
||||
Spec wikiMoveSpec
|
||||
}
|
||||
|
||||
type fakeWikiMoveClient struct {
|
||||
nodes map[string]*wikiNodeRecord
|
||||
|
||||
getNodeErr error
|
||||
moveNode *wikiNodeRecord
|
||||
moveNodeErr error
|
||||
docsResp *wikiMoveDocsResponse
|
||||
docsErr error
|
||||
|
||||
taskStatuses []wikiMoveTaskStatus
|
||||
taskErrs []error
|
||||
|
||||
getNodeCalls []string
|
||||
moveNodeCalls []fakeWikiMoveNodeCall
|
||||
docsToWikiCalls []fakeWikiDocsToWikiMoveCall
|
||||
moveTaskCallArgs []string
|
||||
}
|
||||
|
||||
func (fake *fakeWikiMoveClient) GetNode(ctx context.Context, token string) (*wikiNodeRecord, error) {
|
||||
fake.getNodeCalls = append(fake.getNodeCalls, token)
|
||||
if fake.getNodeErr != nil {
|
||||
return nil, fake.getNodeErr
|
||||
}
|
||||
if node, ok := fake.nodes[token]; ok {
|
||||
return node, nil
|
||||
}
|
||||
return &wikiNodeRecord{}, nil
|
||||
}
|
||||
|
||||
func (fake *fakeWikiMoveClient) MoveNode(ctx context.Context, sourceSpaceID string, spec wikiMoveSpec) (*wikiNodeRecord, error) {
|
||||
fake.moveNodeCalls = append(fake.moveNodeCalls, fakeWikiMoveNodeCall{SourceSpaceID: sourceSpaceID, Spec: spec})
|
||||
if fake.moveNodeErr != nil {
|
||||
return nil, fake.moveNodeErr
|
||||
}
|
||||
if fake.moveNode != nil {
|
||||
return fake.moveNode, nil
|
||||
}
|
||||
return &wikiNodeRecord{SpaceID: sourceSpaceID, NodeToken: spec.NodeToken}, nil
|
||||
}
|
||||
|
||||
func (fake *fakeWikiMoveClient) MoveDocsToWiki(ctx context.Context, targetSpaceID string, spec wikiMoveSpec) (*wikiMoveDocsResponse, error) {
|
||||
fake.docsToWikiCalls = append(fake.docsToWikiCalls, fakeWikiDocsToWikiMoveCall{TargetSpaceID: targetSpaceID, Spec: spec})
|
||||
if fake.docsErr != nil {
|
||||
return nil, fake.docsErr
|
||||
}
|
||||
if fake.docsResp != nil {
|
||||
return fake.docsResp, nil
|
||||
}
|
||||
return &wikiMoveDocsResponse{}, nil
|
||||
}
|
||||
|
||||
func (fake *fakeWikiMoveClient) GetMoveTask(ctx context.Context, taskID string) (wikiMoveTaskStatus, error) {
|
||||
idx := len(fake.moveTaskCallArgs)
|
||||
fake.moveTaskCallArgs = append(fake.moveTaskCallArgs, taskID)
|
||||
if idx < len(fake.taskErrs) && fake.taskErrs[idx] != nil {
|
||||
return wikiMoveTaskStatus{TaskID: taskID}, fake.taskErrs[idx]
|
||||
}
|
||||
if idx < len(fake.taskStatuses) {
|
||||
status := fake.taskStatuses[idx]
|
||||
if status.TaskID == "" {
|
||||
status.TaskID = taskID
|
||||
}
|
||||
return status, nil
|
||||
}
|
||||
return wikiMoveTaskStatus{TaskID: taskID}, nil
|
||||
}
|
||||
|
||||
type mockWikiMoveTokenResolver struct {
|
||||
token string
|
||||
scopes string
|
||||
err error
|
||||
}
|
||||
|
||||
type wikiMoveAccountResolver struct {
|
||||
cfg *core.CliConfig
|
||||
}
|
||||
|
||||
func (r *wikiMoveAccountResolver) ResolveAccount(ctx context.Context) (*credential.Account, error) {
|
||||
return credential.AccountFromCliConfig(r.cfg), nil
|
||||
}
|
||||
|
||||
func (m *mockWikiMoveTokenResolver) ResolveToken(ctx context.Context, req credential.TokenSpec) (*credential.TokenResult, error) {
|
||||
if m.err != nil {
|
||||
return nil, m.err
|
||||
}
|
||||
token := m.token
|
||||
if token == "" {
|
||||
token = "test-token"
|
||||
}
|
||||
return &credential.TokenResult{Token: token, Scopes: m.scopes}, nil
|
||||
}
|
||||
|
||||
var wikiMovePollMu sync.Mutex
|
||||
|
||||
func withSingleWikiMovePoll(t *testing.T) {
|
||||
t.Helper()
|
||||
wikiMovePollMu.Lock()
|
||||
|
||||
prevAttempts, prevInterval := wikiMovePollAttempts, wikiMovePollInterval
|
||||
wikiMovePollAttempts, wikiMovePollInterval = 1, 0
|
||||
t.Cleanup(func() {
|
||||
wikiMovePollAttempts, wikiMovePollInterval = prevAttempts, prevInterval
|
||||
wikiMovePollMu.Unlock()
|
||||
})
|
||||
}
|
||||
|
||||
func newWikiMoveRuntimeWithScopes(t *testing.T, as core.Identity, scopes string) (*common.RuntimeContext, *bytes.Buffer) {
|
||||
t.Helper()
|
||||
|
||||
cfg := wikiTestConfig()
|
||||
factory, _, stderr, _ := cmdutil.TestFactory(t, cfg)
|
||||
factory.Credential = credential.NewCredentialProvider(nil, nil, &mockWikiMoveTokenResolver{scopes: scopes}, nil)
|
||||
|
||||
runtime := common.TestNewRuntimeContextWithIdentity(&cobra.Command{Use: "wiki +move"}, cfg, as)
|
||||
runtime.Factory = factory
|
||||
return runtime, stderr
|
||||
}
|
||||
|
||||
func decodeWikiEnvelope(t *testing.T, stdout *bytes.Buffer) map[string]interface{} {
|
||||
t.Helper()
|
||||
|
||||
var env struct {
|
||||
OK bool `json:"ok"`
|
||||
Data map[string]interface{} `json:"data"`
|
||||
}
|
||||
if err := json.Unmarshal(stdout.Bytes(), &env); err != nil {
|
||||
t.Fatalf("unmarshal wiki envelope: %v\nstdout=%s", err, stdout.String())
|
||||
}
|
||||
if !env.OK {
|
||||
t.Fatalf("expected ok=true envelope, got stdout=%s", stdout.String())
|
||||
}
|
||||
return env.Data
|
||||
}
|
||||
|
||||
func decodeWikiCapturedJSONBody(t *testing.T, stub *httpmock.Stub) map[string]interface{} {
|
||||
t.Helper()
|
||||
|
||||
var body map[string]interface{}
|
||||
if err := json.Unmarshal(stub.CapturedBody, &body); err != nil {
|
||||
t.Fatalf("unmarshal captured body: %v\nraw=%s", err, string(stub.CapturedBody))
|
||||
}
|
||||
return body
|
||||
}
|
||||
|
||||
func TestValidateWikiMoveSpecRejectsInvalidCombinations(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
spec wikiMoveSpec
|
||||
wantErr string
|
||||
}{
|
||||
{
|
||||
name: "node move rejects docs flags",
|
||||
spec: wikiMoveSpec{NodeToken: "wik_node", ObjType: "sheet", TargetSpaceID: "space_dst"},
|
||||
wantErr: "cannot be combined",
|
||||
},
|
||||
{
|
||||
name: "node move requires target",
|
||||
spec: wikiMoveSpec{NodeToken: "wik_node"},
|
||||
wantErr: "cannot both be empty",
|
||||
},
|
||||
{
|
||||
name: "source space requires node token",
|
||||
spec: wikiMoveSpec{SourceSpaceID: "space_src", ObjType: "sheet", ObjToken: "sheet_token", TargetSpaceID: "space_dst"},
|
||||
wantErr: "can only be used with --node-token",
|
||||
},
|
||||
{
|
||||
name: "docs to wiki requires obj type",
|
||||
spec: wikiMoveSpec{ObjToken: "sheet_token", TargetSpaceID: "space_dst"},
|
||||
wantErr: "--obj-type is required",
|
||||
},
|
||||
{
|
||||
name: "docs to wiki requires obj token",
|
||||
spec: wikiMoveSpec{ObjType: "sheet", TargetSpaceID: "space_dst"},
|
||||
wantErr: "--obj-token is required",
|
||||
},
|
||||
{
|
||||
name: "docs to wiki requires target space",
|
||||
spec: wikiMoveSpec{ObjType: "sheet", ObjToken: "sheet_token"},
|
||||
wantErr: "--target-space-id is required",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
err := validateWikiMoveSpec(tt.spec)
|
||||
if err == nil || !strings.Contains(err.Error(), tt.wantErr) {
|
||||
t.Fatalf("expected error containing %q, got %v", tt.wantErr, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateWikiMoveSpecAcceptsValidModes(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
for _, spec := range []wikiMoveSpec{
|
||||
{NodeToken: "wik_node", TargetSpaceID: "space_dst"},
|
||||
{ObjType: "sheet", ObjToken: "sheet_token", TargetSpaceID: "space_dst", TargetParentToken: "wik_parent", Apply: true},
|
||||
} {
|
||||
if err := validateWikiMoveSpec(spec); err != nil {
|
||||
t.Fatalf("validateWikiMoveSpec(%+v) error = %v", spec, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestWikiMoveDeclaredScopes(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
want := []string{"wiki:node:move", "wiki:node:read", "wiki:space:read"}
|
||||
if !reflect.DeepEqual(WikiMove.Scopes, want) {
|
||||
t.Fatalf("WikiMove.Scopes = %v, want %v", WikiMove.Scopes, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWikiMoveShortcutMissingDeclaredScope(t *testing.T) {
|
||||
cfg := wikiTestConfig()
|
||||
factory, stdout, _, _ := cmdutil.TestFactory(t, cfg)
|
||||
factory.Credential = credential.NewCredentialProvider(nil, &wikiMoveAccountResolver{cfg: cfg}, &mockWikiMoveTokenResolver{scopes: "wiki:node:read"}, nil)
|
||||
|
||||
err := mountAndRunWiki(t, WikiMove, []string{
|
||||
"+move",
|
||||
"--node-token", "wik_node",
|
||||
"--target-space-id", "space_dst",
|
||||
"--as", "user",
|
||||
}, factory, stdout)
|
||||
if err == nil {
|
||||
t.Fatal("expected missing scope error, got nil")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "missing required scope(s): wiki:node:move") {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWikiMoveTaskStatusPendingAndFallbackLabels(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
pending := wikiMoveTaskStatus{}
|
||||
if !pending.Pending() || pending.PrimaryStatusLabel() != "processing" {
|
||||
t.Fatalf("pending status = %+v", pending)
|
||||
}
|
||||
|
||||
ready := wikiMoveTaskStatus{MoveResults: []wikiMoveTaskResult{{Status: 0}}}
|
||||
if !ready.Ready() || ready.PrimaryStatusLabel() != "success" {
|
||||
t.Fatalf("ready status = %+v", ready)
|
||||
}
|
||||
|
||||
failed := wikiMoveTaskStatus{MoveResults: []wikiMoveTaskResult{{Status: -1}}}
|
||||
if !failed.Failed() || failed.PrimaryStatusLabel() != "failure" {
|
||||
t.Fatalf("failed status = %+v", failed)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWikiMoveTaskStatusPrimarySurfacesFailureOverEarlierSuccess(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
status := wikiMoveTaskStatus{
|
||||
MoveResults: []wikiMoveTaskResult{
|
||||
{Status: 0, StatusMsg: "success"},
|
||||
{Status: -3, StatusMsg: "permission denied"},
|
||||
{Status: 1, StatusMsg: "processing"},
|
||||
},
|
||||
}
|
||||
if got := status.PrimaryStatusCode(); got != -3 {
|
||||
t.Fatalf("PrimaryStatusCode = %d, want -3", got)
|
||||
}
|
||||
if got := status.PrimaryStatusLabel(); got != "permission denied" {
|
||||
t.Fatalf("PrimaryStatusLabel = %q, want permission denied", got)
|
||||
}
|
||||
// FirstResult must keep its literal "first entry" semantics for callers
|
||||
// that flatten node fields from the first move_result.
|
||||
if first := status.FirstResult(); first == nil || first.StatusMsg != "success" {
|
||||
t.Fatalf("FirstResult = %+v, want first success entry", first)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWikiMoveTaskStatusPrimaryPrefersProcessingOverFirstSuccess(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
status := wikiMoveTaskStatus{
|
||||
MoveResults: []wikiMoveTaskResult{
|
||||
{Status: 0, StatusMsg: "success"},
|
||||
{Status: 1, StatusMsg: "processing"},
|
||||
},
|
||||
}
|
||||
if got := status.PrimaryStatusCode(); got != 1 {
|
||||
t.Fatalf("PrimaryStatusCode = %d, want 1", got)
|
||||
}
|
||||
if got := status.PrimaryStatusLabel(); got != "processing" {
|
||||
t.Fatalf("PrimaryStatusLabel = %q, want processing", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWikiMoveValidateRejectsBotMyLibrary(t *testing.T) {
|
||||
cfg := wikiTestConfig()
|
||||
factory, stdout, _, _ := cmdutil.TestFactory(t, cfg)
|
||||
|
||||
err := mountAndRunWiki(t, WikiMove, []string{
|
||||
"+move",
|
||||
"--obj-type", "docx",
|
||||
"--obj-token", "doccnXXX",
|
||||
"--target-space-id", "my_library",
|
||||
"--as", "bot",
|
||||
}, factory, stdout)
|
||||
if err == nil {
|
||||
t.Fatal("expected validation error for bot + my_library, got nil")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "my_library") || !strings.Contains(err.Error(), "--as bot") {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWikiMoveValidateAllowsUserMyLibrary(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
// Bot guard must not affect user identity. We only assert the my_library
|
||||
// validation path doesn't trip; an empty obj-token still fails downstream
|
||||
// for unrelated reasons, so we check the error does not mention my_library.
|
||||
if err := validateWikiMoveSpec(wikiMoveSpec{
|
||||
ObjType: "docx",
|
||||
ObjToken: "doccnXXX",
|
||||
TargetSpaceID: "my_library",
|
||||
}); err != nil {
|
||||
t.Fatalf("validateWikiMoveSpec(user my_library) = %v, want nil", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWikiMoveDryRunNodeMoveIncludesResolutionSteps(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
cmd := &cobra.Command{Use: "wiki +move"}
|
||||
cmd.Flags().String("node-token", "", "")
|
||||
cmd.Flags().String("source-space-id", "", "")
|
||||
cmd.Flags().String("target-space-id", "", "")
|
||||
cmd.Flags().String("target-parent-token", "", "")
|
||||
cmd.Flags().String("obj-type", "", "")
|
||||
cmd.Flags().String("obj-token", "", "")
|
||||
cmd.Flags().Bool("apply", false, "")
|
||||
if err := cmd.Flags().Set("node-token", "wik_node"); err != nil {
|
||||
t.Fatalf("set --node-token: %v", err)
|
||||
}
|
||||
if err := cmd.Flags().Set("target-parent-token", "wik_parent"); err != nil {
|
||||
t.Fatalf("set --target-parent-token: %v", err)
|
||||
}
|
||||
|
||||
runtime := common.TestNewRuntimeContext(cmd, nil)
|
||||
dry := WikiMove.DryRun(context.Background(), runtime)
|
||||
if dry == nil {
|
||||
t.Fatal("DryRun returned nil")
|
||||
}
|
||||
|
||||
data, err := json.Marshal(dry)
|
||||
if err != nil {
|
||||
t.Fatalf("marshal dry run: %v", err)
|
||||
}
|
||||
if !bytes.Contains(data, []byte(`"description":"3-step orchestration:`)) {
|
||||
t.Fatalf("dry run missing 3-step description: %s", string(data))
|
||||
}
|
||||
if !bytes.Contains(data, []byte(`"target_parent_token":"wik_parent"`)) {
|
||||
t.Fatalf("dry run missing target_parent_token body: %s", string(data))
|
||||
}
|
||||
if !bytes.Contains(data, []byte(`/open-apis/wiki/v2/spaces/\u003cresolved_source_space_id\u003e/nodes/wik_node/move`)) {
|
||||
t.Fatalf("dry run missing resolved source placeholder: %s", string(data))
|
||||
}
|
||||
}
|
||||
|
||||
func TestWikiMoveDryRunDocsToWikiIncludesTaskPoll(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
cmd := &cobra.Command{Use: "wiki +move"}
|
||||
cmd.Flags().String("node-token", "", "")
|
||||
cmd.Flags().String("source-space-id", "", "")
|
||||
cmd.Flags().String("target-space-id", "", "")
|
||||
cmd.Flags().String("target-parent-token", "", "")
|
||||
cmd.Flags().String("obj-type", "", "")
|
||||
cmd.Flags().String("obj-token", "", "")
|
||||
cmd.Flags().Bool("apply", false, "")
|
||||
if err := cmd.Flags().Set("obj-type", "sheet"); err != nil {
|
||||
t.Fatalf("set --obj-type: %v", err)
|
||||
}
|
||||
if err := cmd.Flags().Set("obj-token", "sheet_token"); err != nil {
|
||||
t.Fatalf("set --obj-token: %v", err)
|
||||
}
|
||||
if err := cmd.Flags().Set("target-space-id", "space_dst"); err != nil {
|
||||
t.Fatalf("set --target-space-id: %v", err)
|
||||
}
|
||||
if err := cmd.Flags().Set("apply", "true"); err != nil {
|
||||
t.Fatalf("set --apply: %v", err)
|
||||
}
|
||||
|
||||
runtime := common.TestNewRuntimeContext(cmd, nil)
|
||||
dry := WikiMove.DryRun(context.Background(), runtime)
|
||||
if dry == nil {
|
||||
t.Fatal("DryRun returned nil")
|
||||
}
|
||||
|
||||
data, err := json.Marshal(dry)
|
||||
if err != nil {
|
||||
t.Fatalf("marshal dry run: %v", err)
|
||||
}
|
||||
if !bytes.Contains(data, []byte(`"obj_type":"sheet"`)) || !bytes.Contains(data, []byte(`"apply":true`)) {
|
||||
t.Fatalf("dry run missing docs-to-wiki body: %s", string(data))
|
||||
}
|
||||
if !bytes.Contains(data, []byte(`"task_type":"move"`)) {
|
||||
t.Fatalf("dry run missing task polling params: %s", string(data))
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveWikiNodeMoveSpacesUsesSourceAndTargetLookups(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
client := &fakeWikiMoveClient{
|
||||
nodes: map[string]*wikiNodeRecord{
|
||||
"wik_node": {SpaceID: "space_src"},
|
||||
"wik_parent": {SpaceID: "space_dst"},
|
||||
},
|
||||
}
|
||||
|
||||
sourceSpaceID, targetSpaceID, err := resolveWikiNodeMoveSpaces(context.Background(), client, wikiMoveSpec{
|
||||
NodeToken: "wik_node",
|
||||
TargetParentToken: "wik_parent",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("resolveWikiNodeMoveSpaces() error = %v", err)
|
||||
}
|
||||
if sourceSpaceID != "space_src" || targetSpaceID != "space_dst" {
|
||||
t.Fatalf("resolved spaces = (%q, %q), want (%q, %q)", sourceSpaceID, targetSpaceID, "space_src", "space_dst")
|
||||
}
|
||||
if strings.Join(client.getNodeCalls, ",") != "wik_node,wik_parent" {
|
||||
t.Fatalf("getNodeCalls = %v, want source and target-parent lookups", client.getNodeCalls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveWikiNodeMoveSpacesRejectsTargetSpaceMismatch(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
client := &fakeWikiMoveClient{
|
||||
nodes: map[string]*wikiNodeRecord{
|
||||
"wik_parent": {SpaceID: "space_parent"},
|
||||
},
|
||||
}
|
||||
|
||||
_, _, err := resolveWikiNodeMoveSpaces(context.Background(), client, wikiMoveSpec{
|
||||
NodeToken: "wik_node",
|
||||
SourceSpaceID: "space_src",
|
||||
TargetSpaceID: "space_other",
|
||||
TargetParentToken: "wik_parent",
|
||||
})
|
||||
if err == nil || !strings.Contains(err.Error(), "does not match") {
|
||||
t.Fatalf("expected mismatch error, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunWikiNodeMoveReturnsResolvedMetadata(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
client := &fakeWikiMoveClient{
|
||||
nodes: map[string]*wikiNodeRecord{
|
||||
"wik_node": {SpaceID: "space_src"},
|
||||
"wik_parent": {SpaceID: "space_dst"},
|
||||
},
|
||||
moveNode: &wikiNodeRecord{
|
||||
SpaceID: "space_dst",
|
||||
NodeToken: "wik_moved",
|
||||
ObjToken: "sheet_token",
|
||||
ObjType: "sheet",
|
||||
ParentNodeToken: "wik_parent",
|
||||
NodeType: wikiNodeTypeOrigin,
|
||||
Title: "Roadmap",
|
||||
},
|
||||
}
|
||||
|
||||
out, err := runWikiNodeMove(context.Background(), client, wikiMoveSpec{
|
||||
NodeToken: "wik_node",
|
||||
TargetParentToken: "wik_parent",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("runWikiNodeMove() error = %v", err)
|
||||
}
|
||||
if len(client.moveNodeCalls) != 1 {
|
||||
t.Fatalf("MoveNode called %d times, want 1", len(client.moveNodeCalls))
|
||||
}
|
||||
if client.moveNodeCalls[0].SourceSpaceID != "space_src" {
|
||||
t.Fatalf("source space = %q, want %q", client.moveNodeCalls[0].SourceSpaceID, "space_src")
|
||||
}
|
||||
if out["mode"] != wikiMoveModeNode || out["source_space_id"] != "space_src" || out["target_space_id"] != "space_dst" {
|
||||
t.Fatalf("unexpected node move output: %#v", out)
|
||||
}
|
||||
if out["node_token"] != "wik_moved" || out["title"] != "Roadmap" {
|
||||
t.Fatalf("node fields not propagated: %#v", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunWikiMoveDispatchesByMode(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
runtime, _ := newWikiMoveRuntimeWithScopes(t, core.AsUser, "")
|
||||
client := &fakeWikiMoveClient{
|
||||
docsResp: &wikiMoveDocsResponse{WikiToken: "wik_ready"},
|
||||
moveNode: &wikiNodeRecord{SpaceID: "space_dst", NodeToken: "wik_node"},
|
||||
}
|
||||
|
||||
nodeOut, err := runWikiMove(context.Background(), client, runtime, wikiMoveSpec{
|
||||
NodeToken: "wik_node",
|
||||
SourceSpaceID: "space_src",
|
||||
TargetSpaceID: "space_dst",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("runWikiMove(node) error = %v", err)
|
||||
}
|
||||
if nodeOut["mode"] != wikiMoveModeNode {
|
||||
t.Fatalf("node mode output = %#v", nodeOut)
|
||||
}
|
||||
|
||||
docsOut, err := runWikiMove(context.Background(), client, runtime, wikiMoveSpec{
|
||||
ObjType: "sheet",
|
||||
ObjToken: "sheet_token",
|
||||
TargetSpaceID: "space_dst",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("runWikiMove(docs_to_wiki) error = %v", err)
|
||||
}
|
||||
if docsOut["mode"] != wikiMoveModeDocsToWiki {
|
||||
t.Fatalf("docs-to-wiki output = %#v", docsOut)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunWikiDocsToWikiMoveSyncReady(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
runtime, _ := newWikiMoveRuntimeWithScopes(t, core.AsUser, "")
|
||||
client := &fakeWikiMoveClient{
|
||||
docsResp: &wikiMoveDocsResponse{WikiToken: "wik_ready"},
|
||||
}
|
||||
|
||||
out, err := runWikiDocsToWikiMove(context.Background(), client, runtime, wikiMoveSpec{
|
||||
ObjType: "sheet",
|
||||
ObjToken: "sheet_token",
|
||||
TargetSpaceID: "space_dst",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("runWikiDocsToWikiMove() error = %v", err)
|
||||
}
|
||||
if out["ready"] != true || out["failed"] != false {
|
||||
t.Fatalf("expected ready sync result, got %#v", out)
|
||||
}
|
||||
if out["wiki_token"] != "wik_ready" || out["node_token"] != "wik_ready" {
|
||||
t.Fatalf("wiki token fields = %#v", out)
|
||||
}
|
||||
if len(client.docsToWikiCalls) != 1 || client.docsToWikiCalls[0].TargetSpaceID != "space_dst" {
|
||||
t.Fatalf("unexpected docs-to-wiki calls: %#v", client.docsToWikiCalls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunWikiDocsToWikiMoveApplied(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
runtime, _ := newWikiMoveRuntimeWithScopes(t, core.AsUser, "")
|
||||
client := &fakeWikiMoveClient{
|
||||
docsResp: &wikiMoveDocsResponse{Applied: true},
|
||||
}
|
||||
|
||||
out, err := runWikiDocsToWikiMove(context.Background(), client, runtime, wikiMoveSpec{
|
||||
ObjType: "sheet",
|
||||
ObjToken: "sheet_token",
|
||||
TargetSpaceID: "space_dst",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("runWikiDocsToWikiMove() error = %v", err)
|
||||
}
|
||||
if out["applied"] != true || out["ready"] != false || out["failed"] != false {
|
||||
t.Fatalf("expected applied response, got %#v", out)
|
||||
}
|
||||
if out["status_msg"] != "move request submitted for approval" {
|
||||
t.Fatalf("status_msg = %#v", out["status_msg"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunWikiDocsToWikiMoveAsyncReady(t *testing.T) {
|
||||
withSingleWikiMovePoll(t)
|
||||
|
||||
runtime, stderr := newWikiMoveRuntimeWithScopes(t, core.AsUser, "")
|
||||
client := &fakeWikiMoveClient{
|
||||
docsResp: &wikiMoveDocsResponse{TaskID: "task_123"},
|
||||
taskStatuses: []wikiMoveTaskStatus{{
|
||||
MoveResults: []wikiMoveTaskResult{{
|
||||
Status: 0,
|
||||
StatusMsg: "success",
|
||||
Node: &wikiNodeRecord{
|
||||
SpaceID: "space_dst",
|
||||
NodeToken: "wik_done",
|
||||
ObjToken: "sheet_token",
|
||||
ObjType: "sheet",
|
||||
NodeType: wikiNodeTypeOrigin,
|
||||
Title: "Roadmap",
|
||||
},
|
||||
}},
|
||||
}},
|
||||
}
|
||||
|
||||
out, err := runWikiDocsToWikiMove(context.Background(), client, runtime, wikiMoveSpec{
|
||||
ObjType: "sheet",
|
||||
ObjToken: "sheet_token",
|
||||
TargetSpaceID: "space_dst",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("runWikiDocsToWikiMove() error = %v", err)
|
||||
}
|
||||
if out["task_id"] != "task_123" || out["ready"] != true || out["failed"] != false {
|
||||
t.Fatalf("unexpected async-ready output: %#v", out)
|
||||
}
|
||||
if out["wiki_token"] != "wik_done" || out["title"] != "Roadmap" || out["status_msg"] != "success" {
|
||||
t.Fatalf("async-ready output missing flattened fields: %#v", out)
|
||||
}
|
||||
if !strings.Contains(stderr.String(), "Docs-to-wiki move is async") || !strings.Contains(stderr.String(), "completed successfully") {
|
||||
t.Fatalf("stderr = %q, want async progress logs", stderr.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunWikiDocsToWikiMoveAsyncTimeoutReturnsNextCommand(t *testing.T) {
|
||||
withSingleWikiMovePoll(t)
|
||||
|
||||
runtime, stderr := newWikiMoveRuntimeWithScopes(t, core.AsUser, "")
|
||||
client := &fakeWikiMoveClient{
|
||||
docsResp: &wikiMoveDocsResponse{TaskID: "task_123"},
|
||||
taskStatuses: []wikiMoveTaskStatus{{
|
||||
MoveResults: []wikiMoveTaskResult{{Status: 1, StatusMsg: "processing"}},
|
||||
}},
|
||||
}
|
||||
|
||||
out, err := runWikiDocsToWikiMove(context.Background(), client, runtime, wikiMoveSpec{
|
||||
ObjType: "sheet",
|
||||
ObjToken: "sheet_token",
|
||||
TargetSpaceID: "space_dst",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("runWikiDocsToWikiMove() error = %v", err)
|
||||
}
|
||||
if out["ready"] != false || out["timed_out"] != true || out["next_command"] != wikiMoveTaskResultCommand("task_123", core.AsUser) {
|
||||
t.Fatalf("expected timeout response, got %#v", out)
|
||||
}
|
||||
if out["status_msg"] != "processing" {
|
||||
t.Fatalf("status_msg = %#v, want processing", out["status_msg"])
|
||||
}
|
||||
if !strings.Contains(stderr.String(), "Continue with") {
|
||||
t.Fatalf("stderr = %q, want continuation hint", stderr.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunWikiDocsToWikiMoveAsyncFailureReturnsStructuredError(t *testing.T) {
|
||||
withSingleWikiMovePoll(t)
|
||||
|
||||
runtime, _ := newWikiMoveRuntimeWithScopes(t, core.AsUser, "")
|
||||
client := &fakeWikiMoveClient{
|
||||
docsResp: &wikiMoveDocsResponse{TaskID: "task_123"},
|
||||
taskStatuses: []wikiMoveTaskStatus{{
|
||||
MoveResults: []wikiMoveTaskResult{{Status: -1, StatusMsg: "approval rejected"}},
|
||||
}},
|
||||
}
|
||||
|
||||
_, err := runWikiDocsToWikiMove(context.Background(), client, runtime, wikiMoveSpec{
|
||||
ObjType: "sheet",
|
||||
ObjToken: "sheet_token",
|
||||
TargetSpaceID: "space_dst",
|
||||
})
|
||||
if err == nil || !strings.Contains(err.Error(), "wiki move task failed: approval rejected") {
|
||||
t.Fatalf("expected async failure error, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWikiMoveExecuteNodeShortcut(t *testing.T) {
|
||||
factory, stdout, _, reg := cmdutil.TestFactory(t, wikiTestConfig())
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/wiki/v2/spaces/get_node",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{
|
||||
"node": map[string]interface{}{"space_id": "space_src"},
|
||||
},
|
||||
},
|
||||
})
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/wiki/v2/spaces/get_node",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{
|
||||
"node": map[string]interface{}{"space_id": "space_dst"},
|
||||
},
|
||||
},
|
||||
})
|
||||
moveStub := &httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/wiki/v2/spaces/space_src/nodes/wik_node/move",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{
|
||||
"node": map[string]interface{}{
|
||||
"space_id": "space_dst",
|
||||
"node_token": "wik_moved",
|
||||
"obj_token": "sheet_token",
|
||||
"obj_type": "sheet",
|
||||
"parent_node_token": "wik_parent",
|
||||
"node_type": "origin",
|
||||
"title": "Roadmap",
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
reg.Register(moveStub)
|
||||
|
||||
err := mountAndRunWiki(t, WikiMove, []string{
|
||||
"+move",
|
||||
"--node-token", "wik_node",
|
||||
"--target-parent-token", "wik_parent",
|
||||
"--as", "user",
|
||||
}, factory, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("mountAndRunWiki() error = %v", err)
|
||||
}
|
||||
|
||||
data := decodeWikiEnvelope(t, stdout)
|
||||
if data["mode"] != wikiMoveModeNode || data["source_space_id"] != "space_src" || data["target_space_id"] != "space_dst" {
|
||||
t.Fatalf("unexpected node shortcut output: %#v", data)
|
||||
}
|
||||
body := decodeWikiCapturedJSONBody(t, moveStub)
|
||||
if body["target_parent_token"] != "wik_parent" {
|
||||
t.Fatalf("move body = %#v, want target_parent_token", body)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWikiMoveExecuteDocsToWikiShortcutAsyncSuccess(t *testing.T) {
|
||||
withSingleWikiMovePoll(t)
|
||||
|
||||
factory, stdout, _, reg := cmdutil.TestFactory(t, wikiTestConfig())
|
||||
docsStub := &httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/wiki/v2/spaces/space_dst/nodes/move_docs_to_wiki",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{
|
||||
"task_id": "task_123",
|
||||
},
|
||||
},
|
||||
}
|
||||
reg.Register(docsStub)
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/wiki/v2/tasks/task_123",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{
|
||||
"task": map[string]interface{}{
|
||||
"task_id": "task_123",
|
||||
"move_result": []interface{}{
|
||||
map[string]interface{}{
|
||||
"status": 0,
|
||||
"status_msg": "success",
|
||||
"node": map[string]interface{}{
|
||||
"space_id": "space_dst",
|
||||
"node_token": "wik_done",
|
||||
"obj_token": "sheet_token",
|
||||
"obj_type": "sheet",
|
||||
"node_type": "origin",
|
||||
"title": "Roadmap",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
err := mountAndRunWiki(t, WikiMove, []string{
|
||||
"+move",
|
||||
"--obj-type", "sheet",
|
||||
"--obj-token", "sheet_token",
|
||||
"--target-space-id", "space_dst",
|
||||
"--apply",
|
||||
"--as", "user",
|
||||
}, factory, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("mountAndRunWiki() error = %v", err)
|
||||
}
|
||||
|
||||
data := decodeWikiEnvelope(t, stdout)
|
||||
if data["mode"] != wikiMoveModeDocsToWiki || data["ready"] != true || data["wiki_token"] != "wik_done" {
|
||||
t.Fatalf("unexpected docs-to-wiki shortcut output: %#v", data)
|
||||
}
|
||||
body := decodeWikiCapturedJSONBody(t, docsStub)
|
||||
if body["obj_type"] != "sheet" || body["obj_token"] != "sheet_token" || body["apply"] != true {
|
||||
t.Fatalf("docs-to-wiki body = %#v", body)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPollWikiMoveTaskWrapsRepeatedPollFailuresWithHint(t *testing.T) {
|
||||
withSingleWikiMovePoll(t)
|
||||
|
||||
runtime, stderr := newWikiMoveRuntimeWithScopes(t, core.AsUser, "")
|
||||
client := &fakeWikiMoveClient{
|
||||
taskErrs: []error{output.ErrWithHint(output.ExitAPI, "api_error", "poll failed", "retry original")},
|
||||
}
|
||||
|
||||
status, ready, err := pollWikiMoveTask(context.Background(), client, runtime, "task_123")
|
||||
if err == nil {
|
||||
t.Fatal("expected pollWikiMoveTask() error, got nil")
|
||||
}
|
||||
if ready {
|
||||
t.Fatal("expected ready=false when every poll fails")
|
||||
}
|
||||
if status.TaskID != "task_123" {
|
||||
t.Fatalf("status.TaskID = %q, want %q", status.TaskID, "task_123")
|
||||
}
|
||||
var exitErr *output.ExitError
|
||||
if !errors.As(err, &exitErr) || exitErr.Detail == nil {
|
||||
t.Fatalf("expected structured exit error, got %T %v", err, err)
|
||||
}
|
||||
if !strings.Contains(exitErr.Detail.Hint, "retry original") || !strings.Contains(exitErr.Detail.Hint, wikiMoveTaskResultCommand("task_123", core.AsUser)) {
|
||||
t.Fatalf("hint = %q, want original hint and resume command", exitErr.Detail.Hint)
|
||||
}
|
||||
if !strings.Contains(stderr.String(), "Wiki move status attempt 1/1 failed") {
|
||||
t.Fatalf("stderr = %q, want poll failure log", stderr.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseWikiMoveTaskStatusFallbackTaskIDAndNode(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
status, err := parseWikiMoveTaskStatus("task_fallback", map[string]interface{}{
|
||||
"move_result": []interface{}{
|
||||
map[string]interface{}{
|
||||
"status": 0,
|
||||
"status_msg": "success",
|
||||
"node": map[string]interface{}{
|
||||
"space_id": "space_dst",
|
||||
"node_token": "wik_done",
|
||||
"obj_token": "sheet_token",
|
||||
"obj_type": "sheet",
|
||||
"node_type": wikiNodeTypeOrigin,
|
||||
"title": "Roadmap",
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("parseWikiMoveTaskStatus() error = %v", err)
|
||||
}
|
||||
if status.TaskID != "task_fallback" {
|
||||
t.Fatalf("TaskID = %q, want %q", status.TaskID, "task_fallback")
|
||||
}
|
||||
if !status.Ready() || status.PrimaryStatusLabel() != "success" {
|
||||
t.Fatalf("unexpected parsed status: %+v", status)
|
||||
}
|
||||
if first := status.FirstResult(); first == nil || first.Node == nil || first.Node.NodeToken != "wik_done" {
|
||||
t.Fatalf("parsed node = %+v", first)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseWikiMoveTaskStatusRejectsMissingTask(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
_, err := parseWikiMoveTaskStatus("task_123", nil)
|
||||
if err == nil || !strings.Contains(err.Error(), "missing task") {
|
||||
t.Fatalf("expected missing task error, got %v", err)
|
||||
}
|
||||
}
|
||||
@@ -94,15 +94,18 @@ func mountAndRunWiki(t *testing.T, shortcut common.Shortcut, args []string, fact
|
||||
return parent.Execute()
|
||||
}
|
||||
|
||||
func TestWikiShortcutsIncludesNodeCreate(t *testing.T) {
|
||||
func TestWikiShortcutsIncludeMoveAndNodeCreate(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
shortcuts := Shortcuts()
|
||||
if len(shortcuts) != 1 {
|
||||
t.Fatalf("len(Shortcuts()) = %d, want 1", len(shortcuts))
|
||||
if len(shortcuts) != 2 {
|
||||
t.Fatalf("len(Shortcuts()) = %d, want 2", len(shortcuts))
|
||||
}
|
||||
if shortcuts[0].Command != "+node-create" {
|
||||
t.Fatalf("shortcut command = %q, want %q", shortcuts[0].Command, "+node-create")
|
||||
if shortcuts[0].Command != "+move" {
|
||||
t.Fatalf("shortcuts[0].Command = %q, want %q", shortcuts[0].Command, "+move")
|
||||
}
|
||||
if shortcuts[1].Command != "+node-create" {
|
||||
t.Fatalf("shortcuts[1].Command = %q, want %q", shortcuts[1].Command, "+node-create")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -8,6 +8,9 @@
|
||||
- 用户要把本地 `.xlsx` / `.xls` / `.csv` 导入成电子表格,使用 `lark-cli drive +import --type sheet`。
|
||||
- `lark-base` 只负责导入完成后的 Base 内部操作(表、字段、记录、视图),不要在“本地文件 -> Base”这一步提前切到 `lark-base`。
|
||||
|
||||
## 修改标题
|
||||
- 使用 `drive files patch` 命令,通过new_title字段可以修改标题,支持 docx、sheet、bitable、file、wiki、folder 类型
|
||||
|
||||
## 核心概念
|
||||
|
||||
### 文档类型与 Token
|
||||
|
||||
@@ -31,7 +31,7 @@ lark-cli base +record-upload-attachment \
|
||||
| `--table-id <id_or_name>` | 是 | 表 ID 或表名 |
|
||||
| `--record-id <id>` | 是 | 记录 ID |
|
||||
| `--field-id <id_or_name>` | 是 | 附件字段 ID 或字段名 |
|
||||
| `--file <path>` | 是 | 本地文件路径,最大 20MB |
|
||||
| `--file <path>` | 是 | 本地文件路径,最大 2GB |
|
||||
| `--name <name>` | 否 | 写入附件字段时显示的文件名,默认使用本地文件名 |
|
||||
|
||||
|
||||
@@ -43,6 +43,7 @@ lark-cli base +record-upload-attachment \
|
||||
## 坑点
|
||||
|
||||
- ⚠️ 目标字段必须是 `attachment` 字段。
|
||||
- ⚠️ 记录里的附件 `file_token` 属于 Drive media token;下载时不要走 `lark-cli drive +download`,应使用 `lark-cli docs +media-download --token <file_token> --output <path>`。
|
||||
|
||||
## 参考
|
||||
|
||||
|
||||
@@ -134,7 +134,8 @@ lark-cli docs +create --title "产品需求" --markdown '<callout emoji="💡" b
|
||||
|
||||
- 使用标准 Markdown 语法作为基础
|
||||
- 使用自定义 XML 标签实现飞书特有功能(具体标签见各功能章节)
|
||||
- 需要显示特殊字符时使用反斜杠转义:`* ~ ` $ [ ] < > { } | ^`
|
||||
- 只有当字符会被解释为 Markdown / Lark 富文本语法时,才需要使用反斜杠转义:``* ~ ` $ [ ] < > { } | ^``
|
||||
- 普通文本中的孤立字符不要过度转义。例如 `5 * 3`、`version~1.0`、`final_trajectory` 通常应保持原样,只有像 `*斜体*`、`**粗体**`、`~~删除线~~` 这种会触发格式化的写法,想按字面量显示时才需要转义
|
||||
|
||||
---
|
||||
|
||||
@@ -657,7 +658,7 @@ $$
|
||||
## 最佳实践
|
||||
|
||||
- **空行分隔**:不同块类型之间用空行分隔
|
||||
- **转义字符**:特殊字符用 `\` 转义:`\*` `\~` `\``
|
||||
- **转义字符**:只有在字符会触发格式化时才用 `\` 转义。例如想输出字面量 `*斜体*` 时写成 `\*斜体\*`;但 `5 * 3`、`version~1.0`、`final_trajectory` 这类普通文本通常不需要转义
|
||||
- **图片**:使用 URL,系统自动下载上传
|
||||
- **分栏**:列宽总和必须为 100
|
||||
- **表格选择**:简单数据用 Markdown,复杂嵌套用 `<lark-table>`
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
---
|
||||
name: lark-drive
|
||||
version: 1.0.0
|
||||
description: "飞书云空间:管理云空间中的文件和文件夹。上传和下载文件、创建文件夹、复制/移动/删除文件、查看文件元数据、管理文档评论、管理文档权限、订阅用户评论变更事件;也负责把本地 Word/Markdown/Excel/CSV 导入为飞书在线云文档(docx、sheet、bitable)。当用户需要上传或下载文件、整理云空间目录、查看文件详情、管理评论、管理文档权限、订阅用户评论变更事件,或要把本地文件导入成新版文档、电子表格、多维表格/Base 时使用。"
|
||||
description: "飞书云空间:管理云空间中的文件和文件夹。上传和下载文件、创建文件夹、复制/移动/删除文件、查看文件元数据、管理文档评论、管理文档权限、订阅用户评论变更事件、修改文件标题(docx、sheet、bitable、file、folder、wiki);也负责把本地 Word/Markdown/Excel/CSV 导入为飞书在线云文档(docx、sheet、bitable)。当用户需要上传或下载文件、整理云空间目录、查看文件详情、管理评论、管理文档权限、修改文件标题、订阅用户评论变更事件,或要把本地文件导入成新版文档、电子表格、多维表格/Base 时使用。"
|
||||
metadata:
|
||||
requires:
|
||||
bins: ["lark-cli"]
|
||||
@@ -21,6 +21,9 @@ metadata:
|
||||
- 用户要把本地 `.xlsx` / `.xls` / `.csv` 导入成电子表格,使用 `lark-cli drive +import --type sheet`。
|
||||
- `lark-base` 只负责导入完成后的 Base 内部操作(表、字段、记录、视图),不要在“本地文件 -> Base”这一步提前切到 `lark-base`。
|
||||
|
||||
## 修改标题
|
||||
- 使用 `drive files patch` 命令,通过new_title字段可以修改标题,支持 docx、sheet、bitable、file、wiki、folder 类型
|
||||
|
||||
## 核心概念
|
||||
|
||||
### 文档类型与 Token
|
||||
@@ -156,7 +159,7 @@ Drive Folder (云空间文件夹)
|
||||
- 使用 `drive file.comments batch_query` 是**已知评论 ID 后**的批量查询,需要传入具体的评论 ID 列表。
|
||||
- 使用 `drive file.comments list` 用于分页获取评论列表,适合统计评论总数、遍历所有评论,或获取"最新/最后 N 条评论"等场景。
|
||||
|
||||
#### Reaction 场景
|
||||
#### Reaction / 表情场景
|
||||
- 遇到评论 / 回复上的 reaction(表情、各表情数量、谁点了什么、添加/删除表情)相关问题时,**先阅读 [lark-drive-reactions.md](../../skills/lark-drive/references/lark-drive-reactions.md) 了解如何使用**。
|
||||
|
||||
### 典型错误与解决方案
|
||||
@@ -194,6 +197,7 @@ Shortcut 是对常用操作的高级封装(`lark-cli drive +<verb> [flags]`)
|
||||
|----------|------|
|
||||
| [`+upload`](references/lark-drive-upload.md) | Upload a local file to Drive |
|
||||
| [`+download`](references/lark-drive-download.md) | Download a file from Drive to local |
|
||||
| [`+create-shortcut`](references/lark-drive-create-shortcut.md) | Create a shortcut to an existing Drive file in another folder |
|
||||
| [`+add-comment`](references/lark-drive-add-comment.md) | Add a full-document comment, or a local comment to selected docx text (also supports wiki URL resolving to doc/docx) |
|
||||
| [`+export`](references/lark-drive-export.md) | Export a doc/docx/sheet/bitable to a local file with limited polling |
|
||||
| [`+export-download`](references/lark-drive-export-download.md) | Download an exported file by file_token |
|
||||
@@ -216,6 +220,7 @@ lark-cli drive <resource> <method> [flags] # 调用 API
|
||||
- `copy` — 复制文件
|
||||
- `create_folder` — 新建文件夹
|
||||
- `list` — 获取文件夹下的清单
|
||||
- `patch` — 修改文件标题
|
||||
|
||||
### file.comments
|
||||
|
||||
@@ -266,6 +271,7 @@ lark-cli drive <resource> <method> [flags] # 调用 API
|
||||
| `files.copy` | `docs:document:copy` |
|
||||
| `files.create_folder` | `space:folder:create` |
|
||||
| `files.list` | `space:document:retrieve` |
|
||||
| `files.patch` | `docx:document:write_only` |
|
||||
| `file.comments.batch_query` | `docs:document.comment:read` |
|
||||
| `file.comments.create_v2` | `docs:document.comment:create` |
|
||||
| `file.comments.list` | `docs:document.comment:read` |
|
||||
|
||||
103
skills/lark-drive/references/lark-drive-create-shortcut.md
Normal file
103
skills/lark-drive/references/lark-drive-create-shortcut.md
Normal file
@@ -0,0 +1,103 @@
|
||||
|
||||
# drive +create-shortcut
|
||||
|
||||
> **前置条件:** 先阅读 [`../lark-shared/SKILL.md`](../../lark-shared/SKILL.md) 了解认证、全局参数和安全规则。
|
||||
|
||||
在目标文件夹中为一个现有 Drive 文件创建快捷方式。
|
||||
|
||||
## 命令
|
||||
|
||||
```bash
|
||||
# 为普通文件创建快捷方式
|
||||
lark-cli drive +create-shortcut \
|
||||
--folder-token <TARGET_FOLDER_TOKEN> \
|
||||
--file-token <FILE_TOKEN> \
|
||||
--type file
|
||||
|
||||
# 为新版文档创建快捷方式
|
||||
lark-cli drive +create-shortcut \
|
||||
--folder-token <TARGET_FOLDER_TOKEN> \
|
||||
--file-token <DOCX_TOKEN> \
|
||||
--type docx
|
||||
|
||||
# 为电子表格创建快捷方式
|
||||
lark-cli drive +create-shortcut \
|
||||
--folder-token <TARGET_FOLDER_TOKEN> \
|
||||
--file-token <SHEET_TOKEN> \
|
||||
--type sheet
|
||||
|
||||
# 仅预览即将发起的请求,不真正执行
|
||||
lark-cli drive +create-shortcut \
|
||||
--folder-token <TARGET_FOLDER_TOKEN> \
|
||||
--file-token <DOCX_TOKEN> \
|
||||
--type docx \
|
||||
--dry-run
|
||||
```
|
||||
|
||||
## 参数
|
||||
|
||||
| 参数 | 必填 | 说明 |
|
||||
|------|------|------|
|
||||
| `--folder-token` | 是 | 目标父文件夹 token |
|
||||
| `--file-token` | 是 | 源文件 token,表示被引用的原始文件 |
|
||||
| `--type` | 是 | 源文件类型,推荐值:`file`、`docx`、`doc`、`sheet`、`bitable`、`mindnote`、`slides` |
|
||||
|
||||
## 输入规则
|
||||
|
||||
- 该 shortcut 的最小输入是 `--folder-token` + `--file-token` + `--type`
|
||||
- CLI 层会把 `--file-token` 和 `--type` 组装为底层 API 所需的 `refer_entity`
|
||||
- `--file-token` 必须是 Drive 文件 token,不要直接传 wiki 节点 token
|
||||
- 如果来源是 `/wiki/...` 链接,必须先按 [`lark-drive`](../SKILL.md) 中的 wiki 解析流程拿到真实 `obj_token`,再创建快捷方式
|
||||
- 目标位置必须是云空间文件夹;这个 shortcut 不是“复制文件内容”,而是“在另一个文件夹里挂一个引用入口”
|
||||
|
||||
## 类型说明
|
||||
|
||||
| 类型 | 说明 |
|
||||
|------|------|
|
||||
| `file` | 普通文件 |
|
||||
| `docx` | 新版云文档 |
|
||||
| `doc` | 旧版云文档 |
|
||||
| `sheet` | 电子表格 |
|
||||
| `bitable` | 多维表格 |
|
||||
| `mindnote` | 思维笔记 |
|
||||
| `slides` | 幻灯片 |
|
||||
|
||||
## 行为说明
|
||||
|
||||
- 成功时会调用 `POST /open-apis/drive/v1/files/create_shortcut`
|
||||
- 该 shortcut 继承通用能力,可配合 `--as user|bot|auto`、`--format`、`--jq`、`--dry-run` 使用
|
||||
- `--dry-run` 只输出请求方法、路径、身份和请求体预览,不会真正创建快捷方式
|
||||
- 这是写入操作;执行前应确认目标文件夹和源文件都准确无误
|
||||
|
||||
## 限制
|
||||
|
||||
- 该接口不支持并发调用
|
||||
- 调用频率上限为 5 QPS,且 10000 次/天
|
||||
- 不支持跨租户、跨地域创建快捷方式
|
||||
- 不支持跨品牌创建快捷方式
|
||||
- 如果目标父文件夹单层挂载数量超过限制,会返回 `1062507`
|
||||
|
||||
## 权限要求
|
||||
|
||||
- 当前调用身份需要能访问源文件
|
||||
- 当前调用身份需要对目标文件夹有编辑权限
|
||||
- 如果权限不足,常见表现为 `1061004 forbidden`
|
||||
|
||||
## 常见错误
|
||||
|
||||
| 错误码 / 错误信息 | 原因 | 处理建议 |
|
||||
|------|------|------|
|
||||
| `1061002 params error` | 缺少必填参数,或 `--file-token` / `--type` 组合无法构成有效源文件信息 | 检查 `--file-token`、`--type` 是否完整且匹配;如显式传了 `--folder-token`,再确认其值有效 |
|
||||
| `1061003 not found` | 源文件或目标文件夹不存在 | 重新确认 token 是否正确、资源是否已删除 |
|
||||
| `1061004 forbidden` | 对源文件没有访问权限,或对目标文件夹没有编辑权限 | 切换到有权限的身份,或先授予文档 / 文件夹权限 |
|
||||
| `1061005 auth failed` | 身份类型或 access token 不正确 | 检查 `--as` 使用的身份及当前登录态 |
|
||||
| `1061007 file has been delete` | 源文件已删除 | 确认原文件仍存在,再重新执行 |
|
||||
| `1062507 parent node out of sibling num` | 目标文件夹单层挂载数超过上限 | 清理目标目录,或换一个父文件夹 |
|
||||
| `1061045 resource contention occurred, please retry` | 平台内部资源争抢 | 稍后重试,不要并发重复调用 |
|
||||
| `1064510 cross tenant and unit not support` | 跨租户或跨地域请求 | 改为在同租户、同地域范围内操作 |
|
||||
| `1064511 cross brand not support` | 跨品牌请求 | 改为在同品牌环境内操作 |
|
||||
|
||||
## 参考
|
||||
|
||||
- [lark-drive](../SKILL.md) -- 云空间全部命令
|
||||
- [lark-shared](../../lark-shared/SKILL.md) -- 认证和全局参数
|
||||
@@ -5,6 +5,33 @@
|
||||
|
||||
将文件或文件夹移动到用户云空间的其他位置。
|
||||
|
||||
## 与 `wiki +move` 的区别
|
||||
|
||||
- `drive +move` 只处理 **Drive 文件夹树内部** 的位置调整,目标位置用 `--folder-token` 表示
|
||||
- `wiki +move` 处理的是 **Wiki 知识空间 / 页面层级**:要么移动已有 Wiki 节点,要么把 Drive 文档迁入 Wiki
|
||||
- 如果用户说“移动到某个文件夹”“移动到我的空间根目录”,应使用 `drive +move`
|
||||
- 如果用户说“移动到某个知识库 / 页面下”“迁入 Wiki / 知识空间”,应使用 `wiki +move`
|
||||
- 如果用户说“移动到我的文档库 / 我的知识库 / 个人知识库 / my_library”,不要使用 `drive +move`;先按 Wiki 目标处理
|
||||
- `我的文档库` 不是 Drive root folder,也不是 `--folder-token` 省略后的默认目的地
|
||||
- `drive +move` 不支持 wiki 文档;如果目标是 Wiki,不要尝试用 `drive +move` 代替
|
||||
|
||||
## 不要误用到 `我的文档库`
|
||||
|
||||
下面几种说法都**不应该**触发 `drive +move`:
|
||||
|
||||
- `移动到我的文档库`
|
||||
- `放到我的知识库`
|
||||
- `迁入个人知识库`
|
||||
- `move to My Document Library`
|
||||
|
||||
这些目标都应该先走 Wiki 解析流程:
|
||||
|
||||
```bash
|
||||
lark-cli wiki spaces get --params '{"space_id":"my_library"}'
|
||||
```
|
||||
|
||||
拿到真实 `space_id` 后,再改用 `wiki +move`。不要因为 `drive +move` 可以省略 `--folder-token` 就把它当作“我的文档库”的近似目标。
|
||||
|
||||
## 命令
|
||||
|
||||
```bash
|
||||
@@ -60,6 +87,7 @@ lark-cli drive +move \
|
||||
- **轮询超时不是失败**:文件夹移动内置最多轮询 30 次、每次间隔 2 秒;如果轮询结束任务仍未完成,会返回 `task_id`、`status`、`ready=false`、`timed_out=true` 和 `next_command`
|
||||
- **继续查询**:当看到 `next_command` 时,改用 `lark-cli drive +task_result --scenario task_check --task-id <TASK_ID>` 继续查询
|
||||
- **目标文件夹**:如果不指定 `--folder-token`,文件将被移动到用户的根文件夹("我的空间")
|
||||
- **不要混淆产品概念**:这里的“根文件夹 / 我的空间”仅属于 Drive 文件夹树,不等于 Wiki 的“我的文档库”
|
||||
- **权限要求**:需要被移动文件的可管理权限、被移动文件所在位置的编辑权限、目标位置的编辑权限
|
||||
|
||||
## 推荐续跑方式
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
|
||||
> **前置条件:** 先阅读 [`../lark-shared/SKILL.md`](../../lark-shared/SKILL.md) 了解认证、全局参数和安全规则。
|
||||
|
||||
查询异步任务结果。该 shortcut 聚合了导入、导出、移动/删除文件夹等多种异步任务的结果查询,统一接口方便调用。
|
||||
查询异步任务结果。该 shortcut 聚合了导入、导出、移动/删除文件夹、Wiki 节点 / 文档迁入 Wiki 等多种异步任务的结果查询,统一接口方便调用。
|
||||
|
||||
> [!IMPORTANT]
|
||||
> 对于 `import` 场景,如果使用 `--as bot` 且这次查询**已经拿到最终在线文档目标**(`ready=true` 且返回了最终 `token` / `url`),CLI 会**再次尝试为当前 CLI 用户自动授予该资源的 `full_access`(可管理权限)**。
|
||||
@@ -35,15 +35,20 @@ lark-cli drive +task_result \
|
||||
lark-cli drive +task_result \
|
||||
--scenario task_check \
|
||||
--task-id <TASK_ID>
|
||||
|
||||
# 查询 Wiki 移动任务结果(wiki +move 异步超时后的续跑)
|
||||
lark-cli drive +task_result \
|
||||
--scenario wiki_move \
|
||||
--task-id <TASK_ID>
|
||||
```
|
||||
|
||||
## 参数
|
||||
|
||||
| 参数 | 必填 | 说明 |
|
||||
|------|------|------|
|
||||
| `--scenario` | 是 | 任务场景,可选值:`import` (导入任务)、`export` (导出任务)、`task_check` (移动/删除文件夹任务) |
|
||||
| `--scenario` | 是 | 任务场景,可选值:`import` (导入任务)、`export` (导出任务)、`task_check` (移动/删除文件夹任务)、`wiki_move` (Wiki 移动任务) |
|
||||
| `--ticket` | 条件必填 | 异步任务 ticket,**import/export 场景必填** |
|
||||
| `--task-id` | 条件必填 | 异步任务 ID,**task_check 场景必填** |
|
||||
| `--task-id` | 条件必填 | 异步任务 ID,**task_check / wiki_move 场景必填** |
|
||||
| `--file-token` | 条件必填 | 导出任务对应的源文档 token,**export 场景必填** |
|
||||
|
||||
## 场景说明
|
||||
@@ -53,6 +58,7 @@ lark-cli drive +task_result \
|
||||
| `import` | 文档导入任务(如将本地文件导入为云文档) | `--ticket` |
|
||||
| `export` | 文档导出任务(如云文档导出为 PDF/Word) | `--ticket`、`--file-token` |
|
||||
| `task_check` | 文件夹移动/删除任务 | `--task-id` |
|
||||
| `wiki_move` | Wiki 移动任务(`wiki +move` 的 docs-to-wiki 异步流程,超时后续跑用) | `--task-id` |
|
||||
|
||||
## 返回结果
|
||||
|
||||
@@ -135,6 +141,55 @@ lark-cli drive +task_result \
|
||||
- `ready`: 是否已经完成
|
||||
- `failed`: 是否已经失败
|
||||
|
||||
### Wiki_move 场景返回
|
||||
|
||||
```json
|
||||
{
|
||||
"scenario": "wiki_move",
|
||||
"task_id": "<TASK_ID>",
|
||||
"ready": true,
|
||||
"failed": false,
|
||||
"status": 0,
|
||||
"status_msg": "success",
|
||||
"wiki_token": "wikcnXXX",
|
||||
"node_token": "wikcnXXX",
|
||||
"space_id": "<TARGET_SPACE_ID>",
|
||||
"obj_token": "<OBJ_TOKEN>",
|
||||
"obj_type": "docx",
|
||||
"parent_node_token": "",
|
||||
"node_type": "origin",
|
||||
"origin_node_token": "",
|
||||
"title": "项目计划",
|
||||
"has_child": false,
|
||||
"node": {
|
||||
"space_id": "<TARGET_SPACE_ID>",
|
||||
"node_token": "wikcnXXX",
|
||||
"obj_token": "<OBJ_TOKEN>",
|
||||
"obj_type": "docx",
|
||||
"parent_node_token": "",
|
||||
"node_type": "origin",
|
||||
"origin_node_token": "",
|
||||
"title": "项目计划",
|
||||
"has_child": false
|
||||
},
|
||||
"move_results": [
|
||||
{
|
||||
"status": 0,
|
||||
"status_msg": "success",
|
||||
"node": { "...": "同上" }
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
**字段说明:**
|
||||
- `ready`: 所有 `move_results[].status` 都为 `0` 时为 `true`
|
||||
- `failed`: 任一 `move_results[].status` 小于 `0` 时为 `true`
|
||||
- `status` / `status_msg`: 第一个 move_result 的状态码 / 标签(无结果时回退为 `1` / `processing`)
|
||||
- `wiki_token` / `node_token`: 移入 Wiki 后的目标节点 token(首个结果有 `node.node_token` 时镜像到顶层,便于下游脚本使用)
|
||||
- `space_id`、`obj_token`、`obj_type`、`title` 等:从首个 `move_results[0].node` 平铺到顶层,方便直接引用
|
||||
- `move_results`: 保留完整列表(适用于一次任务移动多个文档的场景)
|
||||
|
||||
## 使用场景
|
||||
|
||||
### 配合 +import 使用
|
||||
@@ -162,6 +217,20 @@ lark-cli drive +move --file-token <FOLDER_TOKEN> --type folder --folder-token <T
|
||||
lark-cli drive +task_result --scenario task_check --task-id <TASK_ID>
|
||||
```
|
||||
|
||||
### 配合 wiki +move 使用
|
||||
|
||||
```bash
|
||||
# 1. 把 Drive 文档迁入 Wiki(异步任务可能返回 task_id)
|
||||
lark-cli wiki +move --obj-type docx --obj-token <DOC_TOKEN> --target-space-id <TARGET_SPACE_ID>
|
||||
# 若内置轮询窗口内完成:直接返回 ready=true 和 wiki_token
|
||||
# 若轮询窗口结束仍未完成:返回 ready=false、task_id、timed_out=true 和 next_command
|
||||
|
||||
# 2. 续跑查询 Wiki 移动结果(next_command 即下面这条)
|
||||
lark-cli drive +task_result --scenario wiki_move --task-id <TASK_ID> --as user
|
||||
```
|
||||
|
||||
> **身份保持一致**:续跑命令的 `--as` 必须与原 `wiki +move` 调用一致;`wiki +move` 的 `next_command` 已自动带上正确的 `--as`。
|
||||
|
||||
### 配合 +export 使用
|
||||
|
||||
```bash
|
||||
@@ -184,6 +253,7 @@ lark-cli drive +export-download --file-token <EXPORTED_FILE_TOKEN>
|
||||
| import | `drive:drive.metadata:readonly` |
|
||||
| export | `drive:drive.metadata:readonly` |
|
||||
| task_check | `drive:drive.metadata:readonly` |
|
||||
| wiki_move | `wiki:space:read` |
|
||||
|
||||
> [!NOTE]
|
||||
> `import` 场景在 `--as bot` 且任务最终就绪时,还可能额外尝试一次协作者授权;如果 `permission_grant.status = failed`,请根据失败信息检查应用是否具备相应的文档协作者授权能力。
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
---
|
||||
name: lark-im
|
||||
version: 1.0.0
|
||||
description: "飞书即时通讯:收发消息和管理群聊。发送和回复消息、搜索聊天记录、管理群聊成员、上传下载图片和文件、管理表情回复。当用户需要发消息、查看或搜索聊天记录、下载聊天中的文件、查看群成员时使用。"
|
||||
description: "飞书即时通讯:收发消息和管理群聊。发送和回复消息、搜索聊天记录、管理群聊成员、上传下载图片和文件(支持大文件分片下载)、管理表情回复。当用户需要发消息、查看或搜索聊天记录、下载聊天中的文件、查看群成员时使用。"
|
||||
metadata:
|
||||
requires:
|
||||
bins: ["lark-cli"]
|
||||
@@ -62,7 +62,7 @@ Shortcut 是对常用操作的高级封装(`lark-cli im +<verb> [flags]`)。
|
||||
| [`+chat-update`](references/lark-im-chat-update.md) | Update group chat name or description; user/bot; updates a chat's name or description |
|
||||
| [`+messages-mget`](references/lark-im-messages-mget.md) | Batch get messages by IDs; user/bot; fetches up to 50 om_ message IDs, formats sender names, expands thread replies |
|
||||
| [`+messages-reply`](references/lark-im-messages-reply.md) | Reply to a message (supports thread replies); user/bot; supports text/markdown/post/media replies, reply-in-thread, idempotency key |
|
||||
| [`+messages-resources-download`](references/lark-im-messages-resources-download.md) | Download images/files from a message; user/bot; downloads image/file resources by message-id and file-key to a safe relative output path |
|
||||
| [`+messages-resources-download`](references/lark-im-messages-resources-download.md) | Download images/files from a message; user/bot; supports automatic chunked download for large files (8MB chunks), auto-detects file extension from Content-Type |
|
||||
| [`+messages-search`](references/lark-im-messages-search.md) | Search messages across chats (supports keyword, sender, time range filters) with user identity; user-only; filters by chat/sender/attachment/time, supports auto-pagination via `--page-all` / `--page-limit`, enriches results via batched mget and chats batch_query |
|
||||
| [`+messages-send`](references/lark-im-messages-send.md) | Send a message to a chat or direct message; user/bot; sends to chat-id or user-id with text/markdown/post/media, supports idempotency key |
|
||||
| [`+threads-messages-list`](references/lark-im-threads-messages-list.md) | List messages in a thread; user/bot; accepts om_/omt_ input, resolves message IDs to thread_id, supports sort/pagination |
|
||||
|
||||
@@ -36,7 +36,7 @@ lark-cli im +chat-messages-list --chat-id oc_xxx --format json
|
||||
| Parameter | Required | Description |
|
||||
|------|------|------|
|
||||
| `--chat-id <id>` | One of two | Specify the conversation by its chat_id directly (e.g., group chat `oc_xxx`) |
|
||||
| `--user-id <id>` | One of two | Specify a DM conversation by the other user's open_id (`ou_xxx`); p2p chat_id is resolved automatically |
|
||||
| `--user-id <id>` | One of two | Specify a DM conversation by the other user's open_id (`ou_xxx`); p2p chat_id is resolved automatically. Requires user identity (`--as user`); not supported with bot identity |
|
||||
| `--start <time>` | No | Start time (ISO 8601 or date only) |
|
||||
| `--end <time>` | No | End time (ISO 8601 or date only) |
|
||||
| `--sort <order>` | No | Sort order: `asc` / `desc` (default `desc`) |
|
||||
@@ -116,6 +116,7 @@ lark-cli api GET /open-apis/im/v1/messages \
|
||||
|---------|---------|---------|
|
||||
| `specify --chat-id <chat_id> or --user-id <open_id>` | Neither `--chat-id` nor `--user-id` was provided | You must provide exactly one |
|
||||
| `--chat-id and --user-id cannot be specified together` | Both parameters were provided | Use only one |
|
||||
| `--user-id requires user identity (--as user); use --chat-id when calling with bot identity` | `--user-id` was used with bot identity | The p2p resolution endpoint requires user identity. Either pass `--as user` or look up the p2p `chat_id` separately and pass it via `--chat-id` |
|
||||
| `P2P chat not found for this user` | `--user-id` was used but no p2p chat exists for the current identity and that user | Confirm the target direct-message relationship exists for the current identity |
|
||||
| `--start: invalid time format` | Invalid time format | Use ISO 8601 or date-only format such as `2026-03-10` |
|
||||
| Permission denied | Message read permissions are missing | Ensure the app has `im:message:readonly` and `im:chat:read` enabled |
|
||||
@@ -130,7 +131,7 @@ lark-cli api GET /open-apis/im/v1/messages \
|
||||
```
|
||||
**Do not use `im chats search` or `im chats list` — always use the `+chat-search` shortcut.**
|
||||
2. **Prefer `--chat-id` when available:** if the chat_id is already known, use it directly to avoid extra API calls.
|
||||
3. **For direct messages:** use `--user-id` to resolve the p2p chat automatically instead of looking it up manually.
|
||||
3. **For direct messages:** use `--user-id` to resolve the p2p chat automatically instead of looking it up manually. This requires user identity (`--as user`); with bot identity, resolve the p2p `chat_id` yourself and pass it via `--chat-id`.
|
||||
4. **For time ranges:** both ISO 8601 and date-only inputs are supported. Date-only is usually simpler.
|
||||
5. **For full content:** table output truncates content. Use `--format json` when you need the complete message body.
|
||||
6. **For sender info:** the command already resolves sender names, so you do not need a separate lookup.
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
> **Prerequisite:** Read [`../lark-shared/SKILL.md`](../../lark-shared/SKILL.md) first to understand authentication, global parameters, and safety rules.
|
||||
|
||||
Download image or file resources from a message. Resources are identified by the combination of `message_id` + `file_key`, both of which come directly from message content returned by `im +chat-messages-list`.
|
||||
Download image or file resources from a message. Supports **automatic chunked download for large files** using HTTP Range requests. Resources are identified by the combination of `message_id` + `file_key`, both of which come directly from message content returned by `im +chat-messages-list`.
|
||||
|
||||
> **Note:** read-only message commands render resource keys in message content, but they do not download binaries automatically. Use this command whenever you need to fetch the actual image/file bytes or save them to a specific path.
|
||||
|
||||
@@ -34,10 +34,26 @@ lark-cli im +messages-resources-download --message-id om_xxx --file-key img_v3_x
|
||||
| `--message-id <id>` | Yes | Message ID (`om_xxx` format) |
|
||||
| `--file-key <key>` | Yes | Resource key (`img_xxx` or `file_xxx`) |
|
||||
| `--type <type>` | Yes | Resource type: `image` or `file` |
|
||||
| `--output <path>` | No | Output path (relative paths only; `..` traversal is not allowed; defaults to `file_key` as the file name) |
|
||||
| `--output <path>` | No | Output path (relative paths only; `..` traversal is not allowed; defaults to `file_key` as the file name). File extension is automatically added based on Content-Type if not provided |
|
||||
| `--as <identity>` | No | Identity type: `user` (default) or `bot` |
|
||||
| `--dry-run` | No | Print the request only, do not execute it |
|
||||
|
||||
## Large File Download (Auto Chunking)
|
||||
|
||||
When downloading large files, the command automatically uses **HTTP Range requests** for reliable chunked downloading:
|
||||
|
||||
| Behavior | Details |
|
||||
|----------|---------|
|
||||
| Probe chunk | First 128 KB to detect file size and Content-Type |
|
||||
| Chunk size | 8 MB per subsequent request |
|
||||
| Workers | Single-threaded sequential download (ensures reliability) |
|
||||
| Retries | Up to 2 retries for transient request failures, with exponential backoff |
|
||||
|
||||
**Benefits:**
|
||||
- Reduces the impact of transient request failures during large downloads
|
||||
- Automatically detects and appends correct file extension from Content-Type
|
||||
- Validates file size integrity after download completion
|
||||
|
||||
## `file_key` Sources
|
||||
|
||||
Different resource markers in message content correspond to different `file_key` and `type` values:
|
||||
@@ -69,7 +85,8 @@ lark-cli im +messages-resources-download --message-id om_xxx --file-key img_v3_x
|
||||
| Download failed | `file_key` does not match the `message_id` | Make sure the `file_key` came from that message's content |
|
||||
| Hit error code 234002 or 14005 | No permission, **not** missing API scope | no access to this chat or file was deleted — do not retry, return the error to the user |
|
||||
| Permission denied | `im:message:readonly` is not authorized | Run `auth login --scope "im:message:readonly"` |
|
||||
| File too large | Over the 100 MB limit | This is a Feishu API limitation and cannot be bypassed with this endpoint |
|
||||
| File size mismatch | Chunked download integrity check failed | Network instability during download; retry the command |
|
||||
| Content-Range error | Server returned invalid range header | Transient API issue; retry the command |
|
||||
|
||||
## References
|
||||
|
||||
|
||||
@@ -165,6 +165,16 @@ Shortcut 是对常用操作的高级封装(`lark-cli sheets +<verb> [flags]`
|
||||
| [`+update-dimension`](references/lark-sheets-update-dimension.md) | Update row or column properties (visibility, size) |
|
||||
| [`+move-dimension`](references/lark-sheets-move-dimension.md) | Move rows or columns to a new position |
|
||||
| [`+delete-dimension`](references/lark-sheets-delete-dimension.md) | Delete rows or columns |
|
||||
| [`+create-filter-view`](references/lark-sheets-create-filter-view.md) | Create a filter view |
|
||||
| [`+update-filter-view`](references/lark-sheets-update-filter-view.md) | Update a filter view |
|
||||
| [`+list-filter-views`](references/lark-sheets-list-filter-views.md) | List all filter views in a sheet |
|
||||
| [`+get-filter-view`](references/lark-sheets-get-filter-view.md) | Get a filter view by ID |
|
||||
| [`+delete-filter-view`](references/lark-sheets-delete-filter-view.md) | Delete a filter view |
|
||||
| [`+create-filter-view-condition`](references/lark-sheets-create-filter-view-condition.md) | Create a filter condition on a filter view |
|
||||
| [`+update-filter-view-condition`](references/lark-sheets-update-filter-view-condition.md) | Update a filter condition |
|
||||
| [`+list-filter-view-conditions`](references/lark-sheets-list-filter-view-conditions.md) | List all filter conditions of a filter view |
|
||||
| [`+get-filter-view-condition`](references/lark-sheets-get-filter-view-condition.md) | Get a filter condition by column |
|
||||
| [`+delete-filter-view-condition`](references/lark-sheets-delete-filter-view-condition.md) | Delete a filter condition |
|
||||
|
||||
## API Resources
|
||||
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
|
||||
# sheets +create-filter-view-condition(创建筛选条件)
|
||||
|
||||
> **前置条件:** 先阅读 [`../lark-shared/SKILL.md`](../../lark-shared/SKILL.md) 了解认证、全局参数和安全规则。
|
||||
|
||||
本 skill 对应 shortcut:`lark-cli sheets +create-filter-view-condition`。
|
||||
|
||||
为筛选视图的指定列创建筛选条件。
|
||||
|
||||
> [!CAUTION]
|
||||
> 这是**写入操作** —— 执行前必须确认用户意图。
|
||||
|
||||
## 命令
|
||||
|
||||
```bash
|
||||
# 数值筛选:E 列 < 6
|
||||
lark-cli sheets +create-filter-view-condition --spreadsheet-token "shtxxxxxxxx" \
|
||||
--sheet-id "<sheetId>" --filter-view-id "<fvId>" \
|
||||
--condition-id "E" --filter-type "number" --compare-type "less" --expected '["6"]'
|
||||
|
||||
# 文本筛选:G 列以 a 开头
|
||||
lark-cli sheets +create-filter-view-condition --spreadsheet-token "shtxxxxxxxx" \
|
||||
--sheet-id "<sheetId>" --filter-view-id "<fvId>" \
|
||||
--condition-id "G" --filter-type "text" --compare-type "beginsWith" --expected '["a"]'
|
||||
```
|
||||
|
||||
## 参数
|
||||
|
||||
| 参数 | 必填 | 说明 |
|
||||
|------|------|------|
|
||||
| `--url` | 否 | 电子表格 URL |
|
||||
| `--spreadsheet-token` | 否 | 表格 token |
|
||||
| `--sheet-id` | 是 | 工作表 ID |
|
||||
| `--filter-view-id` | 是 | 筛选视图 ID |
|
||||
| `--condition-id` | 是 | 列字母(如 `E`) |
|
||||
| `--filter-type` | 是 | 筛选类型:`hiddenValue`、`number`、`text`、`color` |
|
||||
| `--compare-type` | 否 | 比较运算符(如 `less`、`beginsWith`、`between`) |
|
||||
| `--expected` | 是 | 筛选值 JSON 数组(如 `["6"]` 或 `["2","10"]`) |
|
||||
|
||||
## 输出
|
||||
|
||||
JSON,包含 `condition`(condition_id, filter_type, compare_type, expected)。
|
||||
@@ -0,0 +1,42 @@
|
||||
|
||||
# sheets +create-filter-view(创建筛选视图)
|
||||
|
||||
> **前置条件:** 先阅读 [`../lark-shared/SKILL.md`](../../lark-shared/SKILL.md) 了解认证、全局参数和安全规则。
|
||||
|
||||
本 skill 对应 shortcut:`lark-cli sheets +create-filter-view`。
|
||||
|
||||
在工作表中创建筛选视图,每个工作表最多 150 个筛选视图。
|
||||
|
||||
> [!CAUTION]
|
||||
> 这是**写入操作** —— 执行前必须确认用户意图。可以先用 `--dry-run` 预览。
|
||||
|
||||
## 命令
|
||||
|
||||
```bash
|
||||
lark-cli sheets +create-filter-view --spreadsheet-token "shtxxxxxxxx" \
|
||||
--sheet-id "<sheetId>" --range "<sheetId>!A1:H14"
|
||||
|
||||
# 指定名称
|
||||
lark-cli sheets +create-filter-view --spreadsheet-token "shtxxxxxxxx" \
|
||||
--sheet-id "<sheetId>" --range "<sheetId>!A1:H14" --filter-view-name "我的筛选"
|
||||
```
|
||||
|
||||
## 参数
|
||||
|
||||
| 参数 | 必填 | 说明 |
|
||||
|------|------|------|
|
||||
| `--url` | 否 | 电子表格 URL(与 `--spreadsheet-token` 二选一) |
|
||||
| `--spreadsheet-token` | 否 | 表格 token |
|
||||
| `--sheet-id` | 是 | 工作表 ID |
|
||||
| `--range` | 是 | 筛选范围(如 `sheetId!A1:H14`) |
|
||||
| `--filter-view-name` | 否 | 显示名称(最多 100 字符) |
|
||||
| `--filter-view-id` | 否 | 自定义 10 位字母数字 ID(不传则自动生成) |
|
||||
|
||||
## 输出
|
||||
|
||||
JSON,包含 `filter_view`(filter_view_id, filter_view_name, range)。
|
||||
|
||||
## 参考
|
||||
|
||||
- [lark-sheets-list-filter-views](lark-sheets-list-filter-views.md) — 查询所有筛选视图
|
||||
- [lark-sheets-create-filter-view-condition](lark-sheets-create-filter-view-condition.md) — 添加筛选条件
|
||||
@@ -0,0 +1,26 @@
|
||||
|
||||
# sheets +delete-filter-view-condition(删除筛选条件)
|
||||
|
||||
> **前置条件:** 先阅读 [`../lark-shared/SKILL.md`](../../lark-shared/SKILL.md) 了解认证、全局参数和安全规则。
|
||||
|
||||
本 skill 对应 shortcut:`lark-cli sheets +delete-filter-view-condition`。
|
||||
|
||||
> [!CAUTION]
|
||||
> 这是**破坏性写入操作** —— 删除后不可恢复。
|
||||
|
||||
## 命令
|
||||
|
||||
```bash
|
||||
lark-cli sheets +delete-filter-view-condition --spreadsheet-token "shtxxxxxxxx" \
|
||||
--sheet-id "<sheetId>" --filter-view-id "<fvId>" --condition-id "E"
|
||||
```
|
||||
|
||||
## 参数
|
||||
|
||||
| 参数 | 必填 | 说明 |
|
||||
|------|------|------|
|
||||
| `--url` | 否 | 电子表格 URL |
|
||||
| `--spreadsheet-token` | 否 | 表格 token |
|
||||
| `--sheet-id` | 是 | 工作表 ID |
|
||||
| `--filter-view-id` | 是 | 筛选视图 ID |
|
||||
| `--condition-id` | 是 | 列字母(如 `E`) |
|
||||
@@ -0,0 +1,25 @@
|
||||
|
||||
# sheets +delete-filter-view(删除筛选视图)
|
||||
|
||||
> **前置条件:** 先阅读 [`../lark-shared/SKILL.md`](../../lark-shared/SKILL.md) 了解认证、全局参数和安全规则。
|
||||
|
||||
本 skill 对应 shortcut:`lark-cli sheets +delete-filter-view`。
|
||||
|
||||
> [!CAUTION]
|
||||
> 这是**破坏性写入操作** —— 删除后不可恢复。执行前必须确认用户意图。
|
||||
|
||||
## 命令
|
||||
|
||||
```bash
|
||||
lark-cli sheets +delete-filter-view --spreadsheet-token "shtxxxxxxxx" \
|
||||
--sheet-id "<sheetId>" --filter-view-id "<fvId>"
|
||||
```
|
||||
|
||||
## 参数
|
||||
|
||||
| 参数 | 必填 | 说明 |
|
||||
|------|------|------|
|
||||
| `--url` | 否 | 电子表格 URL |
|
||||
| `--spreadsheet-token` | 否 | 表格 token |
|
||||
| `--sheet-id` | 是 | 工作表 ID |
|
||||
| `--filter-view-id` | 是 | 筛选视图 ID |
|
||||
@@ -0,0 +1,27 @@
|
||||
|
||||
# sheets +get-filter-view-condition(获取筛选条件)
|
||||
|
||||
> **前置条件:** 先阅读 [`../lark-shared/SKILL.md`](../../lark-shared/SKILL.md) 了解认证、全局参数和安全规则。
|
||||
|
||||
本 skill 对应 shortcut:`lark-cli sheets +get-filter-view-condition`。
|
||||
|
||||
## 命令
|
||||
|
||||
```bash
|
||||
lark-cli sheets +get-filter-view-condition --spreadsheet-token "shtxxxxxxxx" \
|
||||
--sheet-id "<sheetId>" --filter-view-id "<fvId>" --condition-id "E"
|
||||
```
|
||||
|
||||
## 参数
|
||||
|
||||
| 参数 | 必填 | 说明 |
|
||||
|------|------|------|
|
||||
| `--url` | 否 | 电子表格 URL |
|
||||
| `--spreadsheet-token` | 否 | 表格 token |
|
||||
| `--sheet-id` | 是 | 工作表 ID |
|
||||
| `--filter-view-id` | 是 | 筛选视图 ID |
|
||||
| `--condition-id` | 是 | 列字母(如 `E`) |
|
||||
|
||||
## 输出
|
||||
|
||||
JSON,包含 `condition`(condition_id, filter_type, compare_type, expected)。
|
||||
26
skills/lark-sheets/references/lark-sheets-get-filter-view.md
Normal file
26
skills/lark-sheets/references/lark-sheets-get-filter-view.md
Normal file
@@ -0,0 +1,26 @@
|
||||
|
||||
# sheets +get-filter-view(获取筛选视图)
|
||||
|
||||
> **前置条件:** 先阅读 [`../lark-shared/SKILL.md`](../../lark-shared/SKILL.md) 了解认证、全局参数和安全规则。
|
||||
|
||||
本 skill 对应 shortcut:`lark-cli sheets +get-filter-view`。
|
||||
|
||||
## 命令
|
||||
|
||||
```bash
|
||||
lark-cli sheets +get-filter-view --spreadsheet-token "shtxxxxxxxx" \
|
||||
--sheet-id "<sheetId>" --filter-view-id "<fvId>"
|
||||
```
|
||||
|
||||
## 参数
|
||||
|
||||
| 参数 | 必填 | 说明 |
|
||||
|------|------|------|
|
||||
| `--url` | 否 | 电子表格 URL |
|
||||
| `--spreadsheet-token` | 否 | 表格 token |
|
||||
| `--sheet-id` | 是 | 工作表 ID |
|
||||
| `--filter-view-id` | 是 | 筛选视图 ID |
|
||||
|
||||
## 输出
|
||||
|
||||
JSON,包含 `filter_view`(filter_view_id, filter_view_name, range)。
|
||||
@@ -0,0 +1,28 @@
|
||||
|
||||
# sheets +list-filter-view-conditions(查询筛选条件)
|
||||
|
||||
> **前置条件:** 先阅读 [`../lark-shared/SKILL.md`](../../lark-shared/SKILL.md) 了解认证、全局参数和安全规则。
|
||||
|
||||
本 skill 对应 shortcut:`lark-cli sheets +list-filter-view-conditions`。
|
||||
|
||||
查询筛选视图的所有筛选条件。
|
||||
|
||||
## 命令
|
||||
|
||||
```bash
|
||||
lark-cli sheets +list-filter-view-conditions --spreadsheet-token "shtxxxxxxxx" \
|
||||
--sheet-id "<sheetId>" --filter-view-id "<fvId>"
|
||||
```
|
||||
|
||||
## 参数
|
||||
|
||||
| 参数 | 必填 | 说明 |
|
||||
|------|------|------|
|
||||
| `--url` | 否 | 电子表格 URL |
|
||||
| `--spreadsheet-token` | 否 | 表格 token |
|
||||
| `--sheet-id` | 是 | 工作表 ID |
|
||||
| `--filter-view-id` | 是 | 筛选视图 ID |
|
||||
|
||||
## 输出
|
||||
|
||||
JSON,包含 `items[]`(condition_id, filter_type, compare_type, expected)。
|
||||
@@ -0,0 +1,26 @@
|
||||
|
||||
# sheets +list-filter-views(查询筛选视图)
|
||||
|
||||
> **前置条件:** 先阅读 [`../lark-shared/SKILL.md`](../../lark-shared/SKILL.md) 了解认证、全局参数和安全规则。
|
||||
|
||||
本 skill 对应 shortcut:`lark-cli sheets +list-filter-views`。
|
||||
|
||||
查询工作表中的所有筛选视图,返回视图 ID、名称和范围。
|
||||
|
||||
## 命令
|
||||
|
||||
```bash
|
||||
lark-cli sheets +list-filter-views --spreadsheet-token "shtxxxxxxxx" --sheet-id "<sheetId>"
|
||||
```
|
||||
|
||||
## 参数
|
||||
|
||||
| 参数 | 必填 | 说明 |
|
||||
|------|------|------|
|
||||
| `--url` | 否 | 电子表格 URL |
|
||||
| `--spreadsheet-token` | 否 | 表格 token |
|
||||
| `--sheet-id` | 是 | 工作表 ID |
|
||||
|
||||
## 输出
|
||||
|
||||
JSON,包含 `items[]`(filter_view_id, filter_view_name, range)。
|
||||
@@ -0,0 +1,27 @@
|
||||
|
||||
# sheets +update-filter-view-condition(更新筛选条件)
|
||||
|
||||
> **前置条件:** 先阅读 [`../lark-shared/SKILL.md`](../../lark-shared/SKILL.md) 了解认证、全局参数和安全规则。
|
||||
|
||||
本 skill 对应 shortcut:`lark-cli sheets +update-filter-view-condition`。
|
||||
|
||||
## 命令
|
||||
|
||||
```bash
|
||||
lark-cli sheets +update-filter-view-condition --spreadsheet-token "shtxxxxxxxx" \
|
||||
--sheet-id "<sheetId>" --filter-view-id "<fvId>" --condition-id "E" \
|
||||
--filter-type "number" --compare-type "between" --expected '["2","10"]'
|
||||
```
|
||||
|
||||
## 参数
|
||||
|
||||
| 参数 | 必填 | 说明 |
|
||||
|------|------|------|
|
||||
| `--url` | 否 | 电子表格 URL |
|
||||
| `--spreadsheet-token` | 否 | 表格 token |
|
||||
| `--sheet-id` | 是 | 工作表 ID |
|
||||
| `--filter-view-id` | 是 | 筛选视图 ID |
|
||||
| `--condition-id` | 是 | 列字母(如 `E`) |
|
||||
| `--filter-type` | 否 | 筛选类型 |
|
||||
| `--compare-type` | 否 | 比较运算符 |
|
||||
| `--expected` | 否 | 筛选值 JSON 数组 |
|
||||
@@ -0,0 +1,24 @@
|
||||
|
||||
# sheets +update-filter-view(更新筛选视图)
|
||||
|
||||
> **前置条件:** 先阅读 [`../lark-shared/SKILL.md`](../../lark-shared/SKILL.md) 了解认证、全局参数和安全规则。
|
||||
|
||||
本 skill 对应 shortcut:`lark-cli sheets +update-filter-view`。
|
||||
|
||||
## 命令
|
||||
|
||||
```bash
|
||||
lark-cli sheets +update-filter-view --spreadsheet-token "shtxxxxxxxx" \
|
||||
--sheet-id "<sheetId>" --filter-view-id "<fvId>" --range "<sheetId>!A1:J20"
|
||||
```
|
||||
|
||||
## 参数
|
||||
|
||||
| 参数 | 必填 | 说明 |
|
||||
|------|------|------|
|
||||
| `--url` | 否 | 电子表格 URL |
|
||||
| `--spreadsheet-token` | 否 | 表格 token |
|
||||
| `--sheet-id` | 是 | 工作表 ID |
|
||||
| `--filter-view-id` | 是 | 筛选视图 ID |
|
||||
| `--range` | 否 | 新的筛选范围 |
|
||||
| `--filter-view-name` | 否 | 新的显示名称 |
|
||||
@@ -83,6 +83,15 @@ lark-cli task <resource> <method> [flags] # 调用 API
|
||||
- `add` — 添加任务成员
|
||||
- `remove` — 移除任务成员
|
||||
|
||||
### sections
|
||||
|
||||
- `create` — 创建自定义分组
|
||||
- `delete` — 删除自定义分组
|
||||
- `get` — 获取自定义分组详情
|
||||
- `list` — 获取自定义分组列表
|
||||
- `patch` — 更新自定义分组
|
||||
- `tasks` — 获取自定义分组任务列表
|
||||
|
||||
## 权限表
|
||||
|
||||
| 方法 | 所需 scope |
|
||||
@@ -104,3 +113,9 @@ lark-cli task <resource> <method> [flags] # 调用 API
|
||||
| `subtasks.list` | `task:task:read` |
|
||||
| `members.add` | `task:task:write` |
|
||||
| `members.remove` | `task:task:write` |
|
||||
| `sections.create` | `task:section:write` |
|
||||
| `sections.delete` | `task:section:write` |
|
||||
| `sections.get` | `task:section:read` |
|
||||
| `sections.list` | `task:section:read` |
|
||||
| `sections.patch` | `task:section:write` |
|
||||
| `sections.tasks` | `task:section:read` |
|
||||
|
||||
@@ -12,6 +12,12 @@ lark-cli task +tasklist-task-add --tasklist-id "<tasklist_guid>" --task-id "<tas
|
||||
|
||||
# Add multiple tasks to a tasklist
|
||||
lark-cli task +tasklist-task-add --tasklist-id "<tasklist_guid>" --task-id "<task_guid>,<another_task_guid>,<third_task_guid>"
|
||||
|
||||
# Add a task to a specific section in the tasklist
|
||||
lark-cli task +tasklist-task-add \
|
||||
--tasklist-id "<tasklist_guid>" \
|
||||
--task-id "<task_guid>" \
|
||||
--section-guid "<section_guid>"
|
||||
```
|
||||
|
||||
## Parameters
|
||||
@@ -20,6 +26,7 @@ lark-cli task +tasklist-task-add --tasklist-id "<tasklist_guid>" --task-id "<tas
|
||||
|-----------|----------|-------------|
|
||||
| `--tasklist-id <guid>` | Yes | The GUID of the tasklist, or a full AppLink URL. |
|
||||
| `--task-id <guids>` | Yes | Comma-separated list of task GUIDs to add to the tasklist. For Feishu task applinks, use each task's `guid` query parameter, not the `suite_entity_num` / display task ID like `t104121`. |
|
||||
| `--section-guid <guid>` | No | The GUID of the custom section to add the tasks to. If omitted, tasks will be added to the default section. |
|
||||
|
||||
## Workflow
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
---
|
||||
name: lark-wiki
|
||||
version: 1.0.0
|
||||
description: "飞书知识库:管理知识空间和文档节点。创建和查询知识空间、管理节点层级结构、在知识库中组织文档和快捷方式。当用户需要在知识库中查找或创建文档、浏览知识空间结构、移动或复制节点时使用。"
|
||||
description: "飞书知识库:管理知识空间、空间成员和文档节点。创建和查询知识空间、查看和管理空间成员、管理节点层级结构、在知识库中组织文档和快捷方式。当用户需要在知识库中查找或创建文档、浏览知识空间结构、查看或管理空间成员、移动或复制节点时使用。"
|
||||
metadata:
|
||||
requires:
|
||||
bins: ["lark-cli"]
|
||||
@@ -12,14 +12,44 @@ metadata:
|
||||
|
||||
**CRITICAL — 开始前 MUST 先用 Read 工具读取 [`../lark-shared/SKILL.md`](../lark-shared/SKILL.md),其中包含认证、权限处理**
|
||||
|
||||
> **成员管理硬限制:**
|
||||
> - 如果目标是“部门”,先判断身份,再决定是否继续。
|
||||
> - `--as bot` 对应 `tenant_access_token`。官方限制:这种身份下不能使用部门 ID (`opendepartmentid`) 添加知识空间成员。
|
||||
> - 遇到“部门 + --as bot”时,禁止先调用 `lark-cli wiki members create` 试错;直接说明该路径不可行。
|
||||
> - 如果用户明确要求“以 bot 身份运行”,且目标是部门,必须停下说明 bot 路径无法完成,不要静默切到 `--as user`。
|
||||
|
||||
## 快速决策
|
||||
|
||||
- 用户给的是知识库 URL(`.../wiki/<token>`),且后续要查成员/加成员/删成员:先调用 `lark-cli wiki spaces get_node --params '{"token":"<wiki_token>"}'` 获取 `space_id`,后续成员接口统一使用 `space_id`。
|
||||
- 用户说“给知识库添加成员/管理员”:先把目标解析成“用户 / 群 / 部门”三类之一,再决定 `member_type`,不要先调 `wiki members create` 再根据报错反推类型。
|
||||
- 用户说“部门 + bot”:这是已知不支持路径。不要继续尝试 `wiki members create --as bot`;直接提示必须改成 `--as user`,或明确告知当前要求无法完成。
|
||||
- 用户说“用户 / 群 + 添加成员”:先解析对应 ID,再执行 `wiki members create`。
|
||||
|
||||
## 成员添加流程
|
||||
|
||||
- 调用 `lark-cli wiki members create` 前,先把自然语言里的“人 / 群 / 部门”解析成正确的 `member_id`,不要猜格式。
|
||||
- 用户场景默认优先 `member_type=openid`:用 `lark-cli contact +search-user --query "<姓名/邮箱/手机号>" --format json` 获取 `open_id`。
|
||||
- 群组场景使用 `member_type=openchat`:用 `lark-cli im +chat-search --query "<群名关键词>" --format json` 获取 `chat_id`。
|
||||
- `userid` / `unionid` 只在下游明确要求时才使用;先拿到 `open_id`,再调用 `lark-cli api GET /open-apis/contact/v3/users/<open_id> --params '{"user_id_type":"open_id"}' --format json` 读取 `user_id` / `union_id`。
|
||||
- 部门场景使用 `member_type=opendepartmentid`:当前 CLI 没有 shortcut,需调用 `lark-cli api POST /open-apis/contact/v3/departments/search --as user --params '{"department_id_type":"open_department_id"}' --data '{"query":"<部门名>"}'` 获取 `open_department_id`。
|
||||
- 只有在目标类型和身份都已确认可行后,才调用 `lark-cli wiki members create`。对于部门场景,这意味着必须是 `--as user`。
|
||||
|
||||
## Shortcuts(推荐优先使用)
|
||||
|
||||
Shortcut 是对常用操作的高级封装(`lark-cli wiki +<verb> [flags]`)。有 Shortcut 的操作优先使用。
|
||||
|
||||
| Shortcut | 说明 |
|
||||
|----------|------|
|
||||
| [`+move`](references/lark-wiki-move.md) | Move a wiki node, or move a Drive document into Wiki |
|
||||
| [`+node-create`](references/lark-wiki-node-create.md) | Create a wiki node with automatic space resolution |
|
||||
|
||||
## 目标语义约束
|
||||
|
||||
- `我的文档库` / `My Document Library` / `我的知识库` / `个人知识库` / `my_library` 都应视为 **Wiki personal library**,不是 Drive 根目录
|
||||
- 处理这类目标时,先解析 `my_library` 对应的真实 `space_id`,再执行 `wiki +move`、`wiki +node-create` 或其他 Wiki 写操作
|
||||
- 不要因为缺少显式 `space_id` 就退化成 `drive +move`
|
||||
- 如果用户明确说的是 Drive 文件夹、云空间根目录、`我的空间`,才进入 Drive 域处理
|
||||
|
||||
## API Resources
|
||||
|
||||
```bash
|
||||
@@ -35,6 +65,12 @@ lark-cli wiki <resource> <method> [flags] # 调用 API
|
||||
- `get_node` — 获取知识空间节点信息
|
||||
- `list` — 获取知识空间列表
|
||||
|
||||
### members
|
||||
|
||||
- `create` — 添加知识空间成员
|
||||
- `delete` — 删除知识空间成员
|
||||
- `list` — 获取知识空间成员列表
|
||||
|
||||
### nodes
|
||||
|
||||
- `copy` — 创建知识空间节点副本
|
||||
@@ -48,6 +84,9 @@ lark-cli wiki <resource> <method> [flags] # 调用 API
|
||||
| `spaces.get` | `wiki:space:read` |
|
||||
| `spaces.get_node` | `wiki:node:read` |
|
||||
| `spaces.list` | `wiki:space:retrieve` |
|
||||
| `members.create` | `wiki:member:create` |
|
||||
| `members.delete` | `wiki:member:update` |
|
||||
| `members.list` | `wiki:member:retrieve` |
|
||||
| `nodes.copy` | `wiki:node:copy` |
|
||||
| `nodes.create` | `wiki:node:create` |
|
||||
| `nodes.list` | `wiki:node:retrieve` |
|
||||
|
||||
183
skills/lark-wiki/references/lark-wiki-move.md
Normal file
183
skills/lark-wiki/references/lark-wiki-move.md
Normal file
@@ -0,0 +1,183 @@
|
||||
# wiki +move
|
||||
|
||||
> **前置条件:** 先阅读 [`../lark-shared/SKILL.md`](../../lark-shared/SKILL.md) 了解认证、全局参数和安全规则。
|
||||
|
||||
在飞书知识库中移动已有 Wiki 节点,或将 Drive 文档迁入 Wiki。这个 shortcut 统一封装了两类流程:
|
||||
|
||||
- `node` 模式:移动已有 Wiki 节点,可同空间移动,也可跨空间移动
|
||||
- `docs_to_wiki` 模式:把 Drive 文档迁入目标知识空间;必要时可提交移动申请,并在异步任务场景下自动有限轮询
|
||||
|
||||
当 `docs_to_wiki` 返回 `task_id` 时,shortcut 会先轮询一小段时间;如果轮询窗口内仍未完成,会返回 `next_command`,让调用方继续执行 `lark-cli drive +task_result --scenario wiki_move --task-id <TASK_ID>`。
|
||||
|
||||
## 与 `drive +move` 的区别
|
||||
|
||||
- `wiki +move` 的目标是 **知识空间或 Wiki 父节点**,使用 `--target-space-id` / `--target-parent-token`
|
||||
- `drive +move` 的目标是 **Drive 文件夹**,使用 `--folder-token`
|
||||
- 如果源对象已经是 Wiki 节点,必须使用 `wiki +move`,而不是 `drive +move`
|
||||
- 如果源对象还是 Drive 文档,但用户要“迁入知识库”“挂到某个 Wiki 页面下”,也应使用 `wiki +move`
|
||||
- 如果用户只是想整理云空间文件夹,把文件/文件夹挪到另一个 Drive 文件夹,应使用 `drive +move`
|
||||
|
||||
## 口语目标识别
|
||||
|
||||
- 当用户说“移动到某个知识库”“挂到某个页面下”“迁入 Wiki”时,按 **Wiki 目标** 处理,优先使用 `wiki +move`
|
||||
- 当用户说“移动到某个文件夹”“移动到云空间根目录”时,按 **Drive 文件夹目标** 处理,优先使用 `drive +move`
|
||||
- 当用户说“移动到我的文档库”“移动到我的知识库”“放到个人知识库”时,应先按 **Wiki 个人知识库目标** 理解,而不是直接退化成 `drive +move`
|
||||
- 遇到“我的文档库”这类表述时,可以把它理解成:先用 `my_library` 去查询用户个人知识库,再拿到真实 `space_id`
|
||||
- 推荐做法是先执行 `lark-cli wiki spaces get --params '{"space_id":"my_library"}'`,取回真实知识库 `space_id`,再把这个 `space_id` 用到 `wiki +move`
|
||||
- 当前 `wiki +move` 文档的主示例仍以显式 `--target-space-id` / `--target-parent-token` 为主;如果调用方只有自然语言目标,不要因为目标暂时不明确就改走 `drive +move`
|
||||
|
||||
## 命令
|
||||
|
||||
```bash
|
||||
# 将已有 wiki 节点移动到另一个父节点下
|
||||
lark-cli wiki +move \
|
||||
--node-token <NODE_TOKEN> \
|
||||
--target-parent-token <TARGET_PARENT_TOKEN>
|
||||
|
||||
# 将已有 wiki 节点移动到另一个知识空间根目录
|
||||
lark-cli wiki +move \
|
||||
--node-token <NODE_TOKEN> \
|
||||
--target-space-id <TARGET_SPACE_ID>
|
||||
|
||||
# 将 Drive 文档迁入某个知识空间根目录
|
||||
lark-cli wiki +move \
|
||||
--obj-type docx \
|
||||
--obj-token <DOC_TOKEN> \
|
||||
--target-space-id <TARGET_SPACE_ID>
|
||||
|
||||
# 将 Drive 文档迁入某个父节点下;如果当前没有直接移动权限,则提交申请
|
||||
lark-cli wiki +move \
|
||||
--obj-type sheet \
|
||||
--obj-token <SHEET_TOKEN> \
|
||||
--target-space-id <TARGET_SPACE_ID> \
|
||||
--target-parent-token <TARGET_PARENT_TOKEN> \
|
||||
--apply
|
||||
|
||||
# 预览底层调用链
|
||||
lark-cli wiki +move \
|
||||
--obj-type docx \
|
||||
--obj-token <DOC_TOKEN> \
|
||||
--target-space-id <TARGET_SPACE_ID> \
|
||||
--dry-run
|
||||
```
|
||||
|
||||
## 参数
|
||||
|
||||
| 参数 | 必填 | 说明 |
|
||||
|------|------|------|
|
||||
| `--node-token` | 条件必填 | 要移动的 Wiki 节点 token。传入后命令进入 `node` 模式 |
|
||||
| `--source-space-id` | 否 | 源知识空间 ID,仅 `node` 模式可用;不传时会根据 `--node-token` 自动解析 |
|
||||
| `--target-space-id` | 条件必填 | 目标知识空间 ID。`docs_to_wiki` 模式必填;`node` 模式下如果不传,则必须传 `--target-parent-token` |
|
||||
| `--target-parent-token` | 否 | 目标父节点 token。`docs_to_wiki` 不传时表示迁入目标知识空间根目录 |
|
||||
| `--obj-type` | 条件必填 | Drive 文档类型,仅 `docs_to_wiki` 模式可用。可选值:`doc`、`sheet`、`bitable`、`mindnote`、`docx`、`file`、`slides` |
|
||||
| `--obj-token` | 条件必填 | Drive 文档 token,仅 `docs_to_wiki` 模式可用 |
|
||||
| `--apply` | 否 | 仅 `docs_to_wiki` 模式可用;当当前调用方不能直接移动文档时,提交一个 move request |
|
||||
|
||||
## 模式选择与校验规则
|
||||
|
||||
- **`node` 模式**:只要传了 `--node-token`,就会按“移动已有 Wiki 节点”执行
|
||||
- **`docs_to_wiki` 模式**:未传 `--node-token` 时,按“把 Drive 文档迁入 Wiki”执行
|
||||
- `node` 模式下,`--node-token` 不能与 `--obj-type`、`--obj-token`、`--apply` 同时使用
|
||||
- `node` 模式下,`--target-parent-token` 和 `--target-space-id` 不能同时为空
|
||||
- `docs_to_wiki` 模式下,必须同时提供 `--obj-type`、`--obj-token`、`--target-space-id`
|
||||
- `docs_to_wiki` 模式下,`--source-space-id` 非法,只能用于 `node` 模式
|
||||
|
||||
## 空间解析与一致性校验
|
||||
|
||||
### `node` 模式
|
||||
|
||||
- **源空间解析**:如果未传 `--source-space-id`,shortcut 会先调用 `GET /open-apis/wiki/v2/spaces/get_node` 查询 `--node-token`,再读取其 `space_id`
|
||||
- **目标父节点解析**:如果传了 `--target-parent-token`,shortcut 会先解析该父节点所属的 `space_id`
|
||||
- **一致性校验**:如果同时传了 `--target-space-id` 和 `--target-parent-token`,shortcut 会校验两者是否属于同一个知识空间;不一致时直接返回验证错误
|
||||
- **移动到空间根目录**:如果只传 `--target-space-id`,则表示移动到该知识空间根目录
|
||||
|
||||
### `docs_to_wiki` 模式
|
||||
|
||||
- `--target-space-id` 始终必填
|
||||
- `--target-parent-token` 可选;不传时表示移动到目标知识空间根目录
|
||||
- 请求体会自动映射成 `obj_type`、`obj_token`、`parent_wiki_token`、`apply`
|
||||
|
||||
## 行为说明
|
||||
|
||||
- **`node` 模式是同步操作**:请求成功后直接返回移动后的节点信息
|
||||
- **`docs_to_wiki` 可能是同步,也可能是异步**:
|
||||
- 如果接口直接返回 `wiki_token`,shortcut 会立刻返回 `ready=true`
|
||||
- 如果接口返回 `applied=true`,shortcut 会返回 `ready=false`、`failed=false`、`applied=true` 和 `status_msg="move request submitted for approval"`
|
||||
- 如果接口返回 `task_id`,shortcut 会先进入有限轮询
|
||||
- **有限轮询窗口**:固定最多轮询 `30` 次,每次间隔 `2` 秒
|
||||
- **轮询超时不是失败**:如果轮询窗口结束任务仍在处理中,会返回 `task_id`、`status`、`status_msg`、`ready=false`、`timed_out=true` 和 `next_command`
|
||||
- **继续查询**:看到 `next_command` 后,改用 `lark-cli drive +task_result --scenario wiki_move --task-id <TASK_ID>` 继续查
|
||||
- **任务失败直接报错**:如果轮询期间任务进入失败态,shortcut 会直接返回错误,不会再输出 `ready=false` 结果
|
||||
- **轮询请求全部失败时也直接报错**:如果任务已创建,但后续每一次状态查询都失败,shortcut 会返回带 hint 的错误,并给出继续查询命令
|
||||
|
||||
## 返回结果
|
||||
|
||||
### `node` 模式典型返回
|
||||
|
||||
```json
|
||||
{
|
||||
"mode": "node",
|
||||
"source_space_id": "space_src",
|
||||
"target_space_id": "space_dst",
|
||||
"space_id": "space_dst",
|
||||
"node_token": "wikcnode_xxx",
|
||||
"obj_token": "doccn_xxx",
|
||||
"obj_type": "docx",
|
||||
"parent_node_token": "wikcparent_xxx",
|
||||
"node_type": "origin",
|
||||
"origin_node_token": "",
|
||||
"title": "项目计划",
|
||||
"has_child": false
|
||||
}
|
||||
```
|
||||
|
||||
### `docs_to_wiki` 异步超时返回
|
||||
|
||||
```json
|
||||
{
|
||||
"mode": "docs_to_wiki",
|
||||
"obj_type": "docx",
|
||||
"obj_token": "doccn_xxx",
|
||||
"target_space_id": "space_xxx",
|
||||
"target_parent_token": "wikcparent_xxx",
|
||||
"task_id": "7500000000000000001",
|
||||
"ready": false,
|
||||
"failed": false,
|
||||
"status": 1,
|
||||
"status_msg": "processing",
|
||||
"timed_out": true,
|
||||
"next_command": "lark-cli drive +task_result --scenario wiki_move --task-id 7500000000000000001"
|
||||
}
|
||||
```
|
||||
|
||||
**输出字段说明:**
|
||||
|
||||
- `mode`:当前执行模式,值为 `node` 或 `docs_to_wiki`
|
||||
- `ready`:任务是否已经完成并可直接继续使用结果
|
||||
- `failed`:任务是否已失败
|
||||
- `task_id`:异步任务 ID,仅异步场景返回
|
||||
- `status` / `status_msg`:异步任务的主状态码和可读状态
|
||||
- `wiki_token`:docs-to-wiki 成功后返回的 Wiki 节点 token;同时也会镜像到 `node_token`
|
||||
- `space_id`、`node_token`、`obj_token`、`obj_type`、`parent_node_token`、`title` 等:成功拿到节点信息时返回,方便下游继续调用
|
||||
|
||||
## dry-run 编排
|
||||
|
||||
- `node` 模式下,dry-run 会根据是否需要解析源节点 / 目标父节点,展示 1 到 3 步的调用链
|
||||
- `docs_to_wiki` 模式下,dry-run 会展示两步:
|
||||
1. `POST /open-apis/wiki/v2/spaces/{target_space_id}/nodes/move_docs_to_wiki`
|
||||
2. `GET /open-apis/wiki/v2/tasks/{task_id}?task_type=move`
|
||||
|
||||
## 权限说明
|
||||
|
||||
CLI 会在执行前做本地 scope 预检查;当前 shortcut 声明的权限为 `wiki:node:move`、`wiki:node:read`、`wiki:space:read`(分别覆盖 move 写操作、节点解析读操作、以及异步任务轮询读操作)。如果本地 token 已记录 scopes 且缺失任一权限,命令会直接提示重新执行 `lark-cli auth login --scope ...`。
|
||||
|
||||
当异步任务超时后,后续 `lark-cli drive +task_result --scenario wiki_move --task-id <TASK_ID>` 只需要 `wiki:space:read` 权限。
|
||||
|
||||
> [!CAUTION]
|
||||
> `wiki +move` 是**写入操作**。执行前必须确认用户意图,以及目标节点 / 目标知识空间是否明确。
|
||||
|
||||
## 参考
|
||||
|
||||
- [lark-wiki](../SKILL.md) -- 知识库全部命令
|
||||
- [lark-shared](../../lark-shared/SKILL.md) -- 认证和全局参数
|
||||
- [drive +task_result](../../lark-drive/references/lark-drive-task-result.md) -- docs-to-wiki 异步任务的续跑查询命令
|
||||
77
tests/cli_e2e/base/base_basic_workflow_test.go
Normal file
77
tests/cli_e2e/base/base_basic_workflow_test.go
Normal file
@@ -0,0 +1,77 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package base
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
clie2e "github.com/larksuite/cli/tests/cli_e2e"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"github.com/tidwall/gjson"
|
||||
)
|
||||
|
||||
func TestBase_BasicWorkflow(t *testing.T) {
|
||||
parentT := t
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 4*time.Minute)
|
||||
t.Cleanup(cancel)
|
||||
|
||||
baseName := "lark-cli-e2e-base-basic-" + clie2e.GenerateSuffix()
|
||||
baseToken := createBaseWithRetry(t, ctx, baseName)
|
||||
|
||||
t.Run("get base", func(t *testing.T) {
|
||||
result, err := clie2e.RunCmd(ctx, clie2e.Request{
|
||||
Args: []string{"base", "+base-get", "--base-token", baseToken},
|
||||
DefaultAs: "bot",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
result.AssertExitCode(t, 0)
|
||||
result.AssertStdoutStatus(t, true)
|
||||
returnedBaseToken := gjson.Get(result.Stdout, "data.base.app_token").String()
|
||||
if returnedBaseToken == "" {
|
||||
returnedBaseToken = gjson.Get(result.Stdout, "data.base.base_token").String()
|
||||
}
|
||||
assert.Equal(t, baseToken, returnedBaseToken, "stdout:\n%s", result.Stdout)
|
||||
assert.NotEmpty(t, gjson.Get(result.Stdout, "data.base.name").String(), "stdout:\n%s", result.Stdout)
|
||||
})
|
||||
|
||||
tableName := "lark-cli-e2e-table-basic-" + clie2e.GenerateSuffix()
|
||||
tableID, primaryFieldID, primaryViewID := createTableWithRetry(
|
||||
t,
|
||||
parentT,
|
||||
ctx,
|
||||
baseToken,
|
||||
tableName,
|
||||
`[{"name":"Name","type":"text"}]`,
|
||||
`{"name":"Main","type":"grid"}`,
|
||||
)
|
||||
|
||||
t.Run("get table", func(t *testing.T) {
|
||||
result, err := clie2e.RunCmd(ctx, clie2e.Request{
|
||||
Args: []string{"base", "+table-get", "--base-token", baseToken, "--table-id", tableID},
|
||||
DefaultAs: "bot",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
result.AssertExitCode(t, 0)
|
||||
result.AssertStdoutStatus(t, true)
|
||||
assert.Equal(t, tableID, gjson.Get(result.Stdout, "data.table.id").String())
|
||||
assert.Equal(t, tableName, gjson.Get(result.Stdout, "data.table.name").String())
|
||||
})
|
||||
|
||||
t.Run("list tables and find created table", func(t *testing.T) {
|
||||
result, err := clie2e.RunCmd(ctx, clie2e.Request{
|
||||
Args: []string{"base", "+table-list", "--base-token", baseToken},
|
||||
DefaultAs: "bot",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
result.AssertExitCode(t, 0)
|
||||
result.AssertStdoutStatus(t, true)
|
||||
assert.True(t, gjson.Get(result.Stdout, `data.tables.#(id=="`+tableID+`")`).Exists(), "stdout:\n%s", result.Stdout)
|
||||
})
|
||||
|
||||
require.NotEmpty(t, primaryFieldID)
|
||||
require.NotEmpty(t, primaryViewID)
|
||||
}
|
||||
127
tests/cli_e2e/base/base_role_workflow_test.go
Normal file
127
tests/cli_e2e/base/base_role_workflow_test.go
Normal file
@@ -0,0 +1,127 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package base
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
clie2e "github.com/larksuite/cli/tests/cli_e2e"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"github.com/tidwall/gjson"
|
||||
)
|
||||
|
||||
func TestBase_RoleWorkflow(t *testing.T) {
|
||||
parentT := t
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 4*time.Minute)
|
||||
t.Cleanup(cancel)
|
||||
|
||||
baseToken := createBaseWithRetry(t, ctx, "lark-cli-e2e-base-role-"+clie2e.GenerateSuffix())
|
||||
result, err := clie2e.RunCmd(ctx, clie2e.Request{
|
||||
Args: []string{"base", "+advperm-enable", "--base-token", baseToken},
|
||||
DefaultAs: "bot",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
result.AssertExitCode(t, 0)
|
||||
result.AssertStdoutStatus(t, true)
|
||||
|
||||
roleName := "Reviewer-" + clie2e.GenerateSuffix()
|
||||
createRole(t, ctx, baseToken, `{"role_name":"`+roleName+`","role_type":"custom_role"}`)
|
||||
roleID := ""
|
||||
|
||||
parentT.Cleanup(func() {
|
||||
if roleID == "" {
|
||||
return
|
||||
}
|
||||
|
||||
cleanupCtx, cancel := cleanupContext()
|
||||
defer cancel()
|
||||
|
||||
deleteResult, deleteErr := clie2e.RunCmd(cleanupCtx, clie2e.Request{
|
||||
Args: []string{"base", "+role-delete", "--base-token", baseToken, "--role-id", roleID, "--yes"},
|
||||
DefaultAs: "bot",
|
||||
})
|
||||
if deleteErr != nil || deleteResult.ExitCode != 0 {
|
||||
reportCleanupFailure(parentT, "delete role "+roleID, deleteResult, deleteErr)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("list", func(t *testing.T) {
|
||||
result, err := clie2e.RunCmd(ctx, clie2e.Request{
|
||||
Args: []string{"base", "+role-list", "--base-token", baseToken},
|
||||
DefaultAs: "bot",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
result.AssertExitCode(t, 0)
|
||||
result.AssertStdoutStatus(t, true)
|
||||
|
||||
roleListPayload := gjson.Get(result.Stdout, "data.data").String()
|
||||
require.NotEmpty(t, roleListPayload, "stdout:\n%s", result.Stdout)
|
||||
assert.True(t, gjson.Valid(roleListPayload), "role list payload should be valid JSON: %s", roleListPayload)
|
||||
|
||||
roleItems := gjson.Get(roleListPayload, "base_roles").Array()
|
||||
assert.NotEmpty(t, roleItems, "role list should contain at least one role: %s", roleListPayload)
|
||||
|
||||
found := false
|
||||
for _, item := range roleItems {
|
||||
rolePayload := item.String()
|
||||
if !gjson.Valid(rolePayload) {
|
||||
continue
|
||||
}
|
||||
if gjson.Get(rolePayload, "role_name").String() == roleName {
|
||||
roleID = gjson.Get(rolePayload, "role_id").String()
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
require.True(t, found, "stdout:\n%s", result.Stdout)
|
||||
require.NotEmpty(t, roleID, "stdout:\n%s", result.Stdout)
|
||||
})
|
||||
|
||||
t.Run("get", func(t *testing.T) {
|
||||
require.NotEmpty(t, roleID, "role ID should be resolved before get")
|
||||
|
||||
result, err := clie2e.RunCmd(ctx, clie2e.Request{
|
||||
Args: []string{"base", "+role-get", "--base-token", baseToken, "--role-id", roleID},
|
||||
DefaultAs: "bot",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
result.AssertExitCode(t, 0)
|
||||
result.AssertStdoutStatus(t, true)
|
||||
|
||||
rolePayload := gjson.Get(result.Stdout, "data.data").String()
|
||||
require.NotEmpty(t, rolePayload, "stdout:\n%s", result.Stdout)
|
||||
require.True(t, gjson.Valid(rolePayload), "stdout:\n%s", result.Stdout)
|
||||
assert.Equal(t, roleID, gjson.Get(rolePayload, "role_id").String())
|
||||
})
|
||||
|
||||
t.Run("update", func(t *testing.T) {
|
||||
require.NotEmpty(t, roleID, "role ID should be resolved before update")
|
||||
|
||||
updatedRoleName := roleName + " Updated"
|
||||
result, err := clie2e.RunCmd(ctx, clie2e.Request{
|
||||
Args: []string{"base", "+role-update", "--base-token", baseToken, "--role-id", roleID, "--json", `{"role_name":"` + updatedRoleName + `","role_type":"custom_role"}`, "--yes"},
|
||||
DefaultAs: "bot",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
result.AssertExitCode(t, 0)
|
||||
result.AssertStdoutStatus(t, true)
|
||||
|
||||
getResult, err := clie2e.RunCmd(ctx, clie2e.Request{
|
||||
Args: []string{"base", "+role-get", "--base-token", baseToken, "--role-id", roleID},
|
||||
DefaultAs: "bot",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
getResult.AssertExitCode(t, 0)
|
||||
getResult.AssertStdoutStatus(t, true)
|
||||
|
||||
rolePayload := gjson.Get(getResult.Stdout, "data.data").String()
|
||||
require.NotEmpty(t, rolePayload, "stdout:\n%s", getResult.Stdout)
|
||||
require.True(t, gjson.Valid(rolePayload), "stdout:\n%s", getResult.Stdout)
|
||||
assert.Equal(t, updatedRoleName, gjson.Get(rolePayload, "role_name").String())
|
||||
})
|
||||
|
||||
}
|
||||
164
tests/cli_e2e/base/helpers_test.go
Normal file
164
tests/cli_e2e/base/helpers_test.go
Normal file
@@ -0,0 +1,164 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package base
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
clie2e "github.com/larksuite/cli/tests/cli_e2e"
|
||||
"github.com/stretchr/testify/require"
|
||||
"github.com/tidwall/gjson"
|
||||
)
|
||||
|
||||
const cleanupTimeout = 30 * time.Second
|
||||
|
||||
func reportCleanupFailure(parentT *testing.T, prefix string, result *clie2e.Result, err error) {
|
||||
parentT.Helper()
|
||||
|
||||
if err != nil {
|
||||
parentT.Errorf("%s: %v", prefix, err)
|
||||
return
|
||||
}
|
||||
if result == nil {
|
||||
parentT.Errorf("%s: nil result", prefix)
|
||||
return
|
||||
}
|
||||
if isCleanupSuppressedResult(result) {
|
||||
return
|
||||
}
|
||||
|
||||
parentT.Errorf("%s failed: exit=%d stdout=%s stderr=%s", prefix, result.ExitCode, result.Stdout, result.Stderr)
|
||||
}
|
||||
|
||||
func cleanupContext() (context.Context, context.CancelFunc) {
|
||||
return context.WithTimeout(context.Background(), cleanupTimeout)
|
||||
}
|
||||
|
||||
func isCleanupSuppressedResult(result *clie2e.Result) bool {
|
||||
if result == nil {
|
||||
return false
|
||||
}
|
||||
|
||||
raw := strings.TrimSpace(result.Stdout)
|
||||
if raw == "" {
|
||||
raw = strings.TrimSpace(result.Stderr)
|
||||
}
|
||||
if raw == "" {
|
||||
return false
|
||||
}
|
||||
|
||||
start := strings.LastIndex(raw, "\n{")
|
||||
if start >= 0 {
|
||||
start++
|
||||
} else {
|
||||
start = strings.Index(raw, "{")
|
||||
}
|
||||
if start < 0 {
|
||||
return false
|
||||
}
|
||||
|
||||
payload := raw[start:]
|
||||
if !gjson.Valid(payload) {
|
||||
return false
|
||||
}
|
||||
|
||||
if gjson.Get(payload, "error.type").String() != "api_error" {
|
||||
return false
|
||||
}
|
||||
|
||||
if gjson.Get(payload, "error.detail.type").String() == "not_found" ||
|
||||
strings.Contains(strings.ToLower(gjson.Get(payload, "error.message").String()), "not found") {
|
||||
return true
|
||||
}
|
||||
|
||||
return gjson.Get(payload, "error.code").Int() == 800004135 ||
|
||||
strings.Contains(strings.ToLower(gjson.Get(payload, "error.message").String()), " limited")
|
||||
}
|
||||
|
||||
func createBaseWithRetry(t *testing.T, ctx context.Context, name string) string {
|
||||
t.Helper()
|
||||
|
||||
result, err := clie2e.RunCmdWithRetry(ctx, clie2e.Request{
|
||||
Args: []string{"base", "+base-create", "--name", name, "--time-zone", "Asia/Shanghai"},
|
||||
DefaultAs: "bot",
|
||||
}, clie2e.RetryOptions{})
|
||||
require.NoError(t, err)
|
||||
result.AssertExitCode(t, 0)
|
||||
result.AssertStdoutStatus(t, true)
|
||||
|
||||
baseToken := gjson.Get(result.Stdout, "data.base.app_token").String()
|
||||
if baseToken == "" {
|
||||
baseToken = gjson.Get(result.Stdout, "data.base.base_token").String()
|
||||
}
|
||||
require.NotEmpty(t, baseToken, "stdout:\n%s", result.Stdout)
|
||||
return baseToken
|
||||
}
|
||||
|
||||
func createTableWithRetry(t *testing.T, parentT *testing.T, ctx context.Context, baseToken string, name string, fieldsJSON string, viewJSON string) (tableID string, primaryFieldID string, primaryViewID string) {
|
||||
t.Helper()
|
||||
|
||||
args := []string{"base", "+table-create", "--base-token", baseToken, "--name", name}
|
||||
if fieldsJSON != "" {
|
||||
args = append(args, "--fields", fieldsJSON)
|
||||
}
|
||||
if viewJSON != "" {
|
||||
args = append(args, "--view", viewJSON)
|
||||
}
|
||||
|
||||
result, err := clie2e.RunCmdWithRetry(ctx, clie2e.Request{
|
||||
Args: args,
|
||||
DefaultAs: "bot",
|
||||
}, clie2e.RetryOptions{})
|
||||
require.NoError(t, err)
|
||||
result.AssertExitCode(t, 0)
|
||||
result.AssertStdoutStatus(t, true)
|
||||
|
||||
tableID = gjson.Get(result.Stdout, "data.table.id").String()
|
||||
if tableID == "" {
|
||||
tableID = gjson.Get(result.Stdout, "data.table.table_id").String()
|
||||
}
|
||||
require.NotEmpty(t, tableID, "stdout:\n%s", result.Stdout)
|
||||
|
||||
primaryFieldID = gjson.Get(result.Stdout, "data.fields.0.id").String()
|
||||
if primaryFieldID == "" {
|
||||
primaryFieldID = gjson.Get(result.Stdout, "data.fields.0.field_id").String()
|
||||
}
|
||||
|
||||
primaryViewID = gjson.Get(result.Stdout, "data.views.0.id").String()
|
||||
if primaryViewID == "" {
|
||||
primaryViewID = gjson.Get(result.Stdout, "data.views.0.view_id").String()
|
||||
}
|
||||
|
||||
parentT.Cleanup(func() {
|
||||
cleanupCtx, cancel := cleanupContext()
|
||||
defer cancel()
|
||||
|
||||
deleteResult, deleteErr := clie2e.RunCmd(cleanupCtx, clie2e.Request{
|
||||
Args: []string{"base", "+table-delete", "--base-token", baseToken, "--table-id", tableID, "--yes"},
|
||||
DefaultAs: "bot",
|
||||
})
|
||||
if deleteErr != nil || deleteResult.ExitCode != 0 {
|
||||
reportCleanupFailure(parentT, "delete table "+tableID, deleteResult, deleteErr)
|
||||
}
|
||||
})
|
||||
|
||||
return tableID, primaryFieldID, primaryViewID
|
||||
}
|
||||
|
||||
func createRole(t *testing.T, ctx context.Context, baseToken string, body string) string {
|
||||
t.Helper()
|
||||
|
||||
result, err := clie2e.RunCmdWithRetry(ctx, clie2e.Request{
|
||||
Args: []string{"base", "+role-create", "--base-token", baseToken, "--json", body},
|
||||
DefaultAs: "bot",
|
||||
}, clie2e.RetryOptions{})
|
||||
require.NoError(t, err)
|
||||
result.AssertExitCode(t, 0)
|
||||
result.AssertStdoutStatus(t, true)
|
||||
|
||||
return gjson.Get(result.Stdout, "data.role_id").String()
|
||||
}
|
||||
86
tests/cli_e2e/calendar/calendar_create_event_test.go
Normal file
86
tests/cli_e2e/calendar/calendar_create_event_test.go
Normal file
@@ -0,0 +1,86 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package calendar
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
clie2e "github.com/larksuite/cli/tests/cli_e2e"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"github.com/tidwall/gjson"
|
||||
)
|
||||
|
||||
// TestCalendar_CreateEvent tests the workflow of creating a calendar event.
|
||||
func TestCalendar_CreateEvent(t *testing.T) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
|
||||
t.Cleanup(cancel)
|
||||
|
||||
suffix := clie2e.GenerateSuffix()
|
||||
eventSummary := "lark-cli-e2e-event-" + suffix
|
||||
eventDescription := "test event description"
|
||||
|
||||
startAt := time.Now().UTC().Add(1 * time.Hour).Truncate(time.Minute)
|
||||
endAt := startAt.Add(1 * time.Hour)
|
||||
startTime := startAt.Format(time.RFC3339)
|
||||
endTime := endAt.Format(time.RFC3339)
|
||||
|
||||
var eventID string
|
||||
calendarID := getPrimaryCalendarID(t, ctx)
|
||||
|
||||
t.Run("create event with shortcut", func(t *testing.T) {
|
||||
result, err := clie2e.RunCmd(ctx, clie2e.Request{
|
||||
Args: []string{"calendar", "+create",
|
||||
"--summary", eventSummary,
|
||||
"--start", startTime,
|
||||
"--end", endTime,
|
||||
"--calendar-id", calendarID,
|
||||
"--description", eventDescription,
|
||||
},
|
||||
DefaultAs: "bot",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
result.AssertExitCode(t, 0)
|
||||
result.AssertStdoutStatus(t, true)
|
||||
|
||||
eventID = gjson.Get(result.Stdout, "data.event_id").String()
|
||||
require.NotEmpty(t, eventID)
|
||||
})
|
||||
|
||||
t.Run("verify event created", func(t *testing.T) {
|
||||
require.NotEmpty(t, eventID)
|
||||
result, err := clie2e.RunCmd(ctx, clie2e.Request{
|
||||
Args: []string{"calendar", "events", "get"},
|
||||
DefaultAs: "bot",
|
||||
Params: map[string]any{
|
||||
"calendar_id": calendarID,
|
||||
"event_id": eventID,
|
||||
},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
result.AssertExitCode(t, 0)
|
||||
result.AssertStdoutStatus(t, 0)
|
||||
assert.Equal(t, eventSummary, gjson.Get(result.Stdout, "data.event.summary").String())
|
||||
assert.Equal(t, eventDescription, gjson.Get(result.Stdout, "data.event.description").String())
|
||||
assert.Equal(t, unixSecondsRFC3339(startAt), gjson.Get(result.Stdout, "data.event.start_time.timestamp").String())
|
||||
assert.Equal(t, unixSecondsRFC3339(endAt), gjson.Get(result.Stdout, "data.event.end_time.timestamp").String())
|
||||
})
|
||||
|
||||
t.Run("delete event", func(t *testing.T) {
|
||||
require.NotEmpty(t, eventID)
|
||||
result, err := clie2e.RunCmd(ctx, clie2e.Request{
|
||||
Args: []string{"calendar", "events", "delete"},
|
||||
DefaultAs: "bot",
|
||||
Params: map[string]any{
|
||||
"calendar_id": calendarID,
|
||||
"event_id": eventID,
|
||||
},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
result.AssertExitCode(t, 0)
|
||||
result.AssertStdoutStatus(t, 0)
|
||||
})
|
||||
}
|
||||
136
tests/cli_e2e/calendar/calendar_manage_calendar_test.go
Normal file
136
tests/cli_e2e/calendar/calendar_manage_calendar_test.go
Normal file
@@ -0,0 +1,136 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package calendar
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
clie2e "github.com/larksuite/cli/tests/cli_e2e"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"github.com/tidwall/gjson"
|
||||
)
|
||||
|
||||
// TestCalendar_ManageCalendar tests the workflow of managing calendars.
|
||||
func TestCalendar_ManageCalendar(t *testing.T) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
|
||||
t.Cleanup(cancel)
|
||||
|
||||
suffix := clie2e.GenerateSuffix()
|
||||
calendarSummary := "lark-cli-e2e-cal-" + suffix
|
||||
updatedCalendarSummary := calendarSummary + "-updated"
|
||||
calendarDescription := "test calendar created by e2e"
|
||||
|
||||
var createdCalendarID string
|
||||
|
||||
t.Run("list calendars", func(t *testing.T) {
|
||||
result, err := clie2e.RunCmd(ctx, clie2e.Request{
|
||||
Args: []string{"calendar", "calendars", "list"},
|
||||
DefaultAs: "bot",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
result.AssertExitCode(t, 0)
|
||||
result.AssertStdoutStatus(t, 0)
|
||||
require.NotEmpty(t, gjson.Get(result.Stdout, "data.calendar_list").Array(), "stdout:\n%s", result.Stdout)
|
||||
})
|
||||
|
||||
t.Run("get primary calendar", func(t *testing.T) {
|
||||
primaryCalendarID := getPrimaryCalendarID(t, ctx)
|
||||
require.NotEmpty(t, primaryCalendarID)
|
||||
})
|
||||
|
||||
t.Run("create calendar", func(t *testing.T) {
|
||||
result, err := clie2e.RunCmd(ctx, clie2e.Request{
|
||||
Args: []string{"calendar", "calendars", "create"},
|
||||
DefaultAs: "bot",
|
||||
Data: map[string]any{
|
||||
"summary": calendarSummary,
|
||||
"description": calendarDescription,
|
||||
},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
result.AssertExitCode(t, 0)
|
||||
result.AssertStdoutStatus(t, 0)
|
||||
|
||||
createdCalendarID = gjson.Get(result.Stdout, "data.calendar.calendar_id").String()
|
||||
require.NotEmpty(t, createdCalendarID)
|
||||
})
|
||||
|
||||
t.Run("get created calendar", func(t *testing.T) {
|
||||
require.NotEmpty(t, createdCalendarID)
|
||||
result, err := clie2e.RunCmd(ctx, clie2e.Request{
|
||||
Args: []string{"calendar", "calendars", "get"},
|
||||
DefaultAs: "bot",
|
||||
Params: map[string]any{
|
||||
"calendar_id": createdCalendarID,
|
||||
},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
result.AssertExitCode(t, 0)
|
||||
result.AssertStdoutStatus(t, 0)
|
||||
assert.Equal(t, createdCalendarID, gjson.Get(result.Stdout, "data.calendar_id").String())
|
||||
assert.Equal(t, calendarSummary, gjson.Get(result.Stdout, "data.summary").String())
|
||||
assert.Equal(t, calendarDescription, gjson.Get(result.Stdout, "data.description").String())
|
||||
})
|
||||
|
||||
t.Run("find created calendar in list", func(t *testing.T) {
|
||||
require.NotEmpty(t, createdCalendarID)
|
||||
result, err := clie2e.RunCmd(ctx, clie2e.Request{
|
||||
Args: []string{"calendar", "calendars", "list"},
|
||||
DefaultAs: "bot",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
result.AssertExitCode(t, 0)
|
||||
result.AssertStdoutStatus(t, 0)
|
||||
require.True(t, gjson.Get(result.Stdout, `data.calendar_list.#(calendar_id=="`+createdCalendarID+`")`).Exists(), "stdout:\n%s", result.Stdout)
|
||||
})
|
||||
|
||||
t.Run("update calendar", func(t *testing.T) {
|
||||
require.NotEmpty(t, createdCalendarID)
|
||||
result, err := clie2e.RunCmd(ctx, clie2e.Request{
|
||||
Args: []string{"calendar", "calendars", "patch"},
|
||||
DefaultAs: "bot",
|
||||
Params: map[string]any{
|
||||
"calendar_id": createdCalendarID,
|
||||
},
|
||||
Data: map[string]any{
|
||||
"summary": updatedCalendarSummary,
|
||||
},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
result.AssertExitCode(t, 0)
|
||||
result.AssertStdoutStatus(t, 0)
|
||||
})
|
||||
|
||||
t.Run("verify updated calendar", func(t *testing.T) {
|
||||
require.NotEmpty(t, createdCalendarID)
|
||||
result, err := clie2e.RunCmd(ctx, clie2e.Request{
|
||||
Args: []string{"calendar", "calendars", "get"},
|
||||
DefaultAs: "bot",
|
||||
Params: map[string]any{
|
||||
"calendar_id": createdCalendarID,
|
||||
},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
result.AssertExitCode(t, 0)
|
||||
result.AssertStdoutStatus(t, 0)
|
||||
assert.Equal(t, updatedCalendarSummary, gjson.Get(result.Stdout, "data.summary").String())
|
||||
})
|
||||
|
||||
t.Run("delete calendar", func(t *testing.T) {
|
||||
require.NotEmpty(t, createdCalendarID)
|
||||
result, err := clie2e.RunCmd(ctx, clie2e.Request{
|
||||
Args: []string{"calendar", "calendars", "delete"},
|
||||
DefaultAs: "bot",
|
||||
Params: map[string]any{
|
||||
"calendar_id": createdCalendarID,
|
||||
},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
result.AssertExitCode(t, 0)
|
||||
result.AssertStdoutStatus(t, 0)
|
||||
})
|
||||
}
|
||||
35
tests/cli_e2e/calendar/helpers_test.go
Normal file
35
tests/cli_e2e/calendar/helpers_test.go
Normal file
@@ -0,0 +1,35 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package calendar
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strconv"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
clie2e "github.com/larksuite/cli/tests/cli_e2e"
|
||||
"github.com/stretchr/testify/require"
|
||||
"github.com/tidwall/gjson"
|
||||
)
|
||||
|
||||
func getPrimaryCalendarID(t *testing.T, ctx context.Context) string {
|
||||
t.Helper()
|
||||
|
||||
result, err := clie2e.RunCmd(ctx, clie2e.Request{
|
||||
Args: []string{"calendar", "calendars", "primary"},
|
||||
DefaultAs: "bot",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
result.AssertExitCode(t, 0)
|
||||
result.AssertStdoutStatus(t, 0)
|
||||
|
||||
calendarID := gjson.Get(result.Stdout, "data.calendars.0.calendar.calendar_id").String()
|
||||
require.NotEmpty(t, calendarID, "stdout:\n%s", result.Stdout)
|
||||
return calendarID
|
||||
}
|
||||
|
||||
func unixSecondsRFC3339(t time.Time) string {
|
||||
return strconv.FormatInt(t.Unix(), 10)
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
---
|
||||
name: cli-e2e-testcase-writer
|
||||
description: Write scenario-based end-to-end Go testcases for the compiled `lark-cli` binary under `tests/cli_e2e`. Use when adding or updating a CLI testcase that should autonomously explore help and schema output, build a self-contained lifecycle with `clie2e.RunCmd`, organize steps with `t.Run`, clean up with `t.Cleanup`, and assert JSON output with `testify/assert` and `gjson`.
|
||||
description: Use when adding or updating Go CLI E2E coverage for one `tests/cli_e2e/{domain}` domain of the compiled `lark-cli`, especially when the work requires live `--help` or `schema` exploration, scenario-based `clie2e.RunCmd` workflows, and per-domain `coverage.md` maintenance.
|
||||
metadata:
|
||||
requires:
|
||||
bins: ["lark-cli"]
|
||||
@@ -8,211 +8,115 @@ metadata:
|
||||
|
||||
# CLI E2E Testcase Writer
|
||||
|
||||
Write testcase code, not framework code. `tests/cli_e2e/core.go` already provides the harness, and `tests/cli_e2e/demo/task_lifecycle_test.go` is the reference example only. Unless the user explicitly asks for framework work, add or update testcase files only.
|
||||
Work on one domain per run. Produce exactly two artifacts for that domain:
|
||||
- workflow testcase files under `tests/cli_e2e/{domain}/`
|
||||
- `tests/cli_e2e/{domain}/coverage.md`
|
||||
|
||||
## What a good testcase looks like
|
||||
Focus on domain testcase files. Do not change shared E2E support code such as `tests/cli_e2e/core.go` unless the user explicitly asks. Treat `tests/cli_e2e/demo/` as reference only.
|
||||
|
||||
A good cli e2e testcase here is:
|
||||
- scenario-based, not a loose smoke test
|
||||
- self-contained and data-consistent
|
||||
create the resource you later read, update, search, or delete
|
||||
- broad enough to prove the workflow
|
||||
usually create plus one or more follow-up reads or mutations plus teardown
|
||||
- scoped to one feature or one workflow
|
||||
do not turn one testcase into the entire domain
|
||||
- written with normal Go testing primitives
|
||||
## Core standard
|
||||
|
||||
This is different from traditional API test suites where usage docs live elsewhere. Here, the command contract is discoverable from `lark-cli --help`, domain help, subcommand help, and schema output, and the agent is expected to explore and verify it autonomously.
|
||||
- Make the testcase scenario-based and self-contained.
|
||||
- Prove one workflow end to end: create plus follow-up read, or mutate plus teardown.
|
||||
- Prefer one file per workflow or one closely related feature.
|
||||
- For mutable flows, prove persisted state with read-after-write assertions, not just exit code.
|
||||
- Leave prerequisite-heavy paths uncovered when they cannot be proven, and explain why in `coverage.md`.
|
||||
|
||||
## File organization
|
||||
## Workflow
|
||||
|
||||
Put real domain testcases under:
|
||||
|
||||
```text
|
||||
tests/cli_e2e/{domain}/
|
||||
```
|
||||
|
||||
Examples:
|
||||
- `tests/cli_e2e/task/task_status_workflow_test.go`
|
||||
- `tests/cli_e2e/task/task_comment_workflow_test.go`
|
||||
|
||||
Treat `tests/cli_e2e/demo/` as reference material, not as the place to accumulate real coverage.
|
||||
|
||||
## How to split cases
|
||||
|
||||
Split by feature or workflow, not by API surface inventory.
|
||||
|
||||
Good splits:
|
||||
- one file for task status flow: `create -> complete -> get -> reopen -> get`
|
||||
- one file for task comment flow
|
||||
- one file for task reminder flow
|
||||
- one file for tasklist association flow
|
||||
|
||||
Bad split:
|
||||
- one giant `task_test.go` that creates a task, updates it, comments it, reminds it, assigns it, adds followers, attaches tasklists, and queries everything in one lifecycle
|
||||
|
||||
Prefer:
|
||||
- one top-level test per workflow
|
||||
- one file per workflow or per closely related feature
|
||||
- small shared helpers in the same domain test package when setup/cleanup logic truly repeats
|
||||
|
||||
## Explore before writing
|
||||
|
||||
Do not guess command names, flags, or payload fields from memory. Discover them:
|
||||
### 1. Explore the live CLI before writing code
|
||||
|
||||
```bash
|
||||
lark-cli --help
|
||||
lark-cli <domain> --help
|
||||
lark-cli <domain> +<shortcut> -h
|
||||
lark-cli <domain> <resource> <method> -h
|
||||
lark-cli schema <domain>.<resource>.<method>
|
||||
lark-cli <domain> <group> --help
|
||||
lark-cli <domain> <group> <method> -h
|
||||
lark-cli schema <domain>.<group>.<method>
|
||||
```
|
||||
|
||||
Use this exploration loop repeatedly while writing the testcase:
|
||||
1. find the right domain and command path
|
||||
2. decide whether the scenario should use a shortcut or a resource method
|
||||
3. inspect the exact `--params` and `--data` shape
|
||||
4. run the draft testcase
|
||||
5. inspect failures, then go back to help or schema and refine
|
||||
### 2. Count leaf commands for the denominator
|
||||
|
||||
Also inspect environmental constraints before finalizing coverage:
|
||||
- whether the current test environment supports `bot`, `user`, or both
|
||||
- whether the scenario needs external identities, preexisting groups, documents, chats, or other remote fixtures
|
||||
- whether the command path is actually executable in CI-like conditions
|
||||
- A leaf command is one that executes an action — it has no further subcommands.
|
||||
- If `lark-cli <domain> <group> --help` lists no subcommands, `<group>` itself is the leaf.
|
||||
- Count `task +create` as one leaf and `task tasks get` as one leaf.
|
||||
- Do not count parameter combinations.
|
||||
- Reuse coverage already present under `tests/cli_e2e/{domain}/`. Do not count `tests/cli_e2e/demo/`.
|
||||
|
||||
## Use the harness directly
|
||||
### 3. Choose the proof surface before editing
|
||||
|
||||
Call `clie2e.RunCmd` with `clie2e.Request`.
|
||||
Identify the provable risks for the touched workflow: invalid input, missing prerequisite, identity or permission, state transition, output shape, cleanup safety. If only the happy path is testable, document the blocked risk areas in `coverage.md`.
|
||||
|
||||
```go
|
||||
result, err := clie2e.RunCmd(ctx, clie2e.Request{
|
||||
Args: []string{"task", "tasks", "get"},
|
||||
Params: map[string]any{
|
||||
"task_guid": taskGUID,
|
||||
},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
result.AssertExitCode(t, 0)
|
||||
result.AssertStdoutStatus(t, 0)
|
||||
### 4. Add or update the workflow testcase
|
||||
|
||||
- Use `clie2e.RunCmd(ctx, clie2e.Request{...})`.
|
||||
- Put command path and plain flags in `Args`; put JSON in `Params` (URL/path parameters) and `Data` (request body).
|
||||
- Prefer one top-level test per workflow with `t.Run` substeps.
|
||||
- Register teardown on `parentT.Cleanup` so it survives subtest failures.
|
||||
- When touching an existing command, verify the JSON response shape is stable: assert status type, field paths, and identifiers consumed by later steps before changing assertions.
|
||||
|
||||
### 5. Run and iterate
|
||||
|
||||
Run `go test ./tests/cli_e2e/{domain} -count=1` while iterating and before finishing. If command shape or behavior is unclear, re-check help or schema (step 1) before changing assertions.
|
||||
|
||||
### 6. Refresh the domain outputs
|
||||
|
||||
- Update the workflow testcase files.
|
||||
- Update `coverage.md`: recompute the denominator from live help output, mark each command as `shortcut` or `api`, and keep one command table for the whole domain.
|
||||
|
||||
## Testcase rules
|
||||
|
||||
- Override `BinaryPath`, `DefaultAs`, or `Format` on `clie2e.Request` only when the testcase truly needs it.
|
||||
- Use `require.NoError`, `result.AssertExitCode`, `result.AssertStdoutStatus`, `assert`, and `gjson`.
|
||||
- Shortcut responses (`{ok: bool}`) assert `true`; API responses (`{code: int}`) assert `0`.
|
||||
- Use `t.Helper()` only for setup or assertion helpers that are called from multiple tests.
|
||||
- Use table-driven tests only when the scenario shape repeats across inputs.
|
||||
- For expected failures, assert stderr content and exit code when the environment makes them deterministic.
|
||||
- If identity or external fixtures cannot be proven, leave the command uncovered and document the prerequisite rather than faking confidence.
|
||||
|
||||
## coverage.md
|
||||
|
||||
Keep `coverage.md` brief and mechanical. Include:
|
||||
- a domain-specific H1 title
|
||||
- a metrics section with denominator, covered count, and coverage rate
|
||||
- a summary section restating each `Test...` workflow, key `t.Run(...)` proof points, and main blockers
|
||||
- one command table for all commands
|
||||
|
||||
Recommended structure:
|
||||
|
||||
```markdown
|
||||
# <Domain> CLI E2E Coverage
|
||||
|
||||
## Metrics
|
||||
- Denominator: N leaf commands
|
||||
- Covered: N
|
||||
- Coverage: N%
|
||||
|
||||
## Summary
|
||||
- TestXxx: ... key `t.Run(...)` proof points ...
|
||||
- Blocked area: ...
|
||||
|
||||
## Command Table
|
||||
| Status | Cmd | Type | Testcase | Key parameter shapes | Notes / uncovered reason |
|
||||
| --- | --- | --- | --- | --- | --- |
|
||||
| ✓ | task +create | shortcut | task_status_workflow_test.go::TestTask_StatusWorkflow | basic create; create with due | |
|
||||
| ✕ | task +assign | shortcut | | none | requires real user open_id |
|
||||
```
|
||||
|
||||
Use `Request` like this:
|
||||
- `Args`: command path and plain flags
|
||||
- `Params`: JSON for `--params`
|
||||
- `Data`: JSON for `--data`
|
||||
- `BinaryPath`, `DefaultAs`, `Format`: only when the testcase must override defaults
|
||||
- Mark each command `shortcut` or `api`.
|
||||
- Write testcase entries in `go test -run` friendly form.
|
||||
- Commands only exercised in `parentT.Cleanup` teardown are not counted as covered.
|
||||
- Do not split covered and uncovered commands into separate sections.
|
||||
|
||||
## Default testcase shape
|
||||
## Guardrails
|
||||
|
||||
Use one top-level test per workflow. Break the workflow into substeps with `t.Run`.
|
||||
|
||||
```go
|
||||
func TestDomain_Scenario(t *testing.T) {
|
||||
parentT := t
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
|
||||
t.Cleanup(cancel)
|
||||
|
||||
suffix := time.Now().UTC().Format("20060102-150405")
|
||||
var resourceID string
|
||||
|
||||
t.Run("create", func(t *testing.T) {
|
||||
result, err := clie2e.RunCmd(ctx, clie2e.Request{...})
|
||||
require.NoError(t, err)
|
||||
result.AssertExitCode(t, 0)
|
||||
result.AssertStdoutStatus(t, true)
|
||||
resourceID = gjson.Get(result.Stdout, "data.guid").String()
|
||||
require.NotEmpty(t, resourceID)
|
||||
|
||||
parentT.Cleanup(func() {
|
||||
// best-effort delete
|
||||
})
|
||||
})
|
||||
|
||||
t.Run("get", func(t *testing.T) {
|
||||
require.NotEmpty(t, resourceID)
|
||||
})
|
||||
}
|
||||
```
|
||||
|
||||
Use this shape because:
|
||||
- `t.Run` makes reports readable
|
||||
- `parentT.Cleanup` keeps created resources alive for later substeps
|
||||
- one testcase owns one full resource lifecycle
|
||||
|
||||
## Data self-consistency
|
||||
|
||||
Prefer workflows whose data can be created and cleaned up entirely within the testcase.
|
||||
|
||||
Good:
|
||||
- create a task, then get/update/comment/delete that same task
|
||||
- create a tasklist, then add a task created by the testcase
|
||||
|
||||
Be explicit when the data is not self-consistent:
|
||||
- if a testcase needs a real user open_id, preexisting chat, existing document, or tenant-specific fixture, do not invent one
|
||||
- call out the missing prerequisite to the user
|
||||
- if you still want to leave a reference testcase in code, write it with `t.Skip()` and a short reason
|
||||
|
||||
Example:
|
||||
|
||||
```go
|
||||
func TestTask_AssignWorkflow_UserOnly(t *testing.T) {
|
||||
t.Skip("requires a real user open_id and user-capable test environment")
|
||||
}
|
||||
```
|
||||
|
||||
Do not silently hardcode made-up IDs, fake URLs, or guessed remote resources just to make the testcase look complete.
|
||||
|
||||
## Environment constraints
|
||||
|
||||
Assume the current local/CI-like environment may support only `bot` identity by default.
|
||||
|
||||
Implications:
|
||||
- do not assume `--as user` works
|
||||
- commands or workflows that require user identity may be unsupported in the current environment
|
||||
- confirm this by checking help, running the command, or using known repo guidance before writing the final testcase set
|
||||
|
||||
When `--as user` is unavailable:
|
||||
- still implement bot-compatible workflows normally
|
||||
- for user-only workflows, either stop and tell the user what prerequisite is missing, or leave a skipped testcase with `t.Skip()`
|
||||
|
||||
Typical risky areas:
|
||||
- `+get-my-tasks`
|
||||
- commands that require current-user profile or self identity lookup
|
||||
- workflows that need a real user open_id for assign/follower/member mutations
|
||||
|
||||
## Go testing rules
|
||||
|
||||
- Use `t.Run` for lifecycle steps such as `create`, `update`, `get`, `list`, `delete`.
|
||||
- Use `t.Cleanup` for teardown and shared cleanup.
|
||||
- Use `t.Helper()` in local helpers when the same setup or assertion logic really repeats.
|
||||
- Use table-driven tests only when the same scenario shape repeats across multiple inputs. Do not force table-driven style onto a single live workflow.
|
||||
- Use `require.NoError` for command execution and prerequisites.
|
||||
- Use `assert` for returned field values after the command has succeeded.
|
||||
- Use `gjson.Get(result.Stdout, "...")` for JSON field extraction.
|
||||
|
||||
## Output conventions
|
||||
|
||||
- shortcut-style commands often return `{"ok": true, ...}` and should use `result.AssertStdoutStatus(t, true)`
|
||||
- service-style commands often return `{"code": 0, "data": ...}` and should use `result.AssertStdoutStatus(t, 0)`
|
||||
|
||||
Then assert the business fields with `gjson`.
|
||||
|
||||
## Common mistakes
|
||||
|
||||
- Do not modify `tests/cli_e2e/core.go` just because one testcase wants a convenience wrapper.
|
||||
- Do not write a testcase that depends on preexisting remote data.
|
||||
- Do not put agent, model, or vendor brand names into task summaries, comments, tasklist names, fixture IDs, or other visible remote test data; use neutral prefixes such as `lark-cli-e2e-` or `<domain>-e2e-`.
|
||||
- Do not attach cleanup to the create subtest if later subtests still need the resource.
|
||||
- Run as bot identity only; do not assume `--as user` works.
|
||||
- Do not place new real coverage under `tests/cli_e2e/demo/`.
|
||||
- Do not dump all domain behaviors into one file or one testcase.
|
||||
- Do not hardcode obvious defaults unless the command really needs explicit flags.
|
||||
- Do not guess `Params` or `Data` fields when schema output can tell you the exact shape.
|
||||
- Do not fabricate prerequisite data when the scenario needs real external fixtures.
|
||||
- Do not force a user-only workflow to run in a bot-only environment; use `t.Skip()` with a concrete reason.
|
||||
- Do not stop after the first draft. Run, inspect, explore again, and improve the testcase.
|
||||
|
||||
## Validation
|
||||
|
||||
- Run `go test ./tests/cli_e2e/... -count=1`.
|
||||
- Rerun the touched package directly when the testcase is live and slow.
|
||||
- If behavior is unclear, go back to help and schema before changing the testcase.
|
||||
- Do not depend on preexisting remote data.
|
||||
- Do not fabricate open_ids, chats, docs, or other remote fixtures.
|
||||
- Prefer deterministic negative cases over tenant-dependent assertions.
|
||||
- Do not guess `Params` or `Data` fields when help or schema can tell you the exact shape.
|
||||
- Do not hardcode obvious defaults unless the command truly requires explicit flags.
|
||||
- Do not put agent, model, or vendor brand names in visible remote test data; use neutral prefixes such as `lark-cli-e2e-` or `<domain>-e2e-`.
|
||||
- A command is covered only when the testcase asserts returned fields or persisted state, not just exit code.
|
||||
- Cleanup-only execution is not primary coverage, except `delete` in the same workflow that created the resource.
|
||||
|
||||
51
tests/cli_e2e/contact/contact_shortcut_test.go
Normal file
51
tests/cli_e2e/contact/contact_shortcut_test.go
Normal file
@@ -0,0 +1,51 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package contact
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
"github.com/tidwall/gjson"
|
||||
|
||||
clie2e "github.com/larksuite/cli/tests/cli_e2e"
|
||||
)
|
||||
|
||||
func TestContact_GetUser_BotWorkflow(t *testing.T) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
|
||||
t.Cleanup(cancel)
|
||||
|
||||
var targetOpenID string
|
||||
|
||||
t.Run("discover-user-via-api", func(t *testing.T) {
|
||||
// Bot identity cannot use +search-user or +get-user (self).
|
||||
// However, it CAN call the raw API to list users if it has contact permissions.
|
||||
// We use this to discover a real open_id for the next step.
|
||||
result, err := clie2e.RunCmd(ctx, clie2e.Request{
|
||||
Args: []string{"api", "get", "/open-apis/contact/v3/users"},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
result.AssertExitCode(t, 0)
|
||||
result.AssertStdoutStatus(t, 0)
|
||||
targetOpenID = gjson.Get(result.Stdout, "data.items.0.open_id").String()
|
||||
|
||||
require.NotEmpty(t, targetOpenID, "expected to find at least one user via raw API")
|
||||
})
|
||||
|
||||
t.Run("get-user-by-id-as-bot", func(t *testing.T) {
|
||||
require.NotEmpty(t, targetOpenID, "targetOpenID should be populated")
|
||||
// DefaultAs is automatically "bot" in the clie2e framework
|
||||
result, err := clie2e.RunCmd(ctx, clie2e.Request{
|
||||
Args: []string{"contact", "+get-user", "--user-id", targetOpenID},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
result.AssertExitCode(t, 0)
|
||||
result.AssertStdoutStatus(t, true)
|
||||
|
||||
returnedID := gjson.Get(result.Stdout, "data.user.open_id").String()
|
||||
require.Equal(t, targetOpenID, returnedID)
|
||||
})
|
||||
}
|
||||
@@ -16,6 +16,7 @@ import (
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/tidwall/gjson"
|
||||
@@ -57,6 +58,15 @@ type Result struct {
|
||||
RunErr error
|
||||
}
|
||||
|
||||
// RetryOptions configures retry behavior for flaky external API calls.
|
||||
type RetryOptions struct {
|
||||
Attempts int
|
||||
InitialDelay time.Duration
|
||||
MaxDelay time.Duration
|
||||
BackoffMultiple int
|
||||
ShouldRetry func(*Result) bool
|
||||
}
|
||||
|
||||
// RunCmd executes lark-cli and captures stdout/stderr/exit code.
|
||||
func RunCmd(ctx context.Context, req Request) (*Result, error) {
|
||||
binaryPath, err := ResolveBinaryPath(req)
|
||||
@@ -99,6 +109,63 @@ func RunCmd(ctx context.Context, req Request) (*Result, error) {
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// RunCmdWithRetry reruns a command when the result matches the configured retry condition.
|
||||
func RunCmdWithRetry(ctx context.Context, req Request, opts RetryOptions) (*Result, error) {
|
||||
if opts.Attempts <= 0 {
|
||||
opts.Attempts = 4
|
||||
}
|
||||
if opts.InitialDelay <= 0 {
|
||||
opts.InitialDelay = 1 * time.Second
|
||||
}
|
||||
if opts.MaxDelay <= 0 {
|
||||
opts.MaxDelay = 6 * time.Second
|
||||
}
|
||||
if opts.BackoffMultiple <= 1 {
|
||||
opts.BackoffMultiple = 2
|
||||
}
|
||||
if opts.ShouldRetry == nil {
|
||||
opts.ShouldRetry = func(result *Result) bool {
|
||||
return result != nil && result.ExitCode != 0
|
||||
}
|
||||
}
|
||||
|
||||
delay := opts.InitialDelay
|
||||
var lastResult *Result
|
||||
for attempt := 1; attempt <= opts.Attempts; attempt++ {
|
||||
result, err := RunCmd(ctx, req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
lastResult = result
|
||||
if attempt == opts.Attempts || !opts.ShouldRetry(result) {
|
||||
return result, nil
|
||||
}
|
||||
|
||||
timer := time.NewTimer(delay)
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
timer.Stop()
|
||||
return lastResult, nil
|
||||
case <-timer.C:
|
||||
}
|
||||
|
||||
nextDelay := delay * time.Duration(opts.BackoffMultiple)
|
||||
if nextDelay > opts.MaxDelay {
|
||||
delay = opts.MaxDelay
|
||||
} else {
|
||||
delay = nextDelay
|
||||
}
|
||||
}
|
||||
|
||||
return lastResult, nil
|
||||
}
|
||||
|
||||
// GenerateSuffix returns a high-entropy UTC timestamp suffix suitable for remote test resource names.
|
||||
func GenerateSuffix() string {
|
||||
now := time.Now().UTC()
|
||||
return fmt.Sprintf("%s-%09d", now.Format("20060102-150405"), now.Nanosecond())
|
||||
}
|
||||
|
||||
// ResolveBinaryPath finds the CLI binary path using request, env, then PATH.
|
||||
func ResolveBinaryPath(req Request) (string, error) {
|
||||
if req.BinaryPath != "" {
|
||||
|
||||
42
tests/cli_e2e/demo/coverage.md
Normal file
42
tests/cli_e2e/demo/coverage.md
Normal file
@@ -0,0 +1,42 @@
|
||||
# Demo Coverage Template
|
||||
|
||||
> This file is a demo template only.
|
||||
> It shows the expected `coverage.md` shape for real domains under `tests/cli_e2e/{domain}`.
|
||||
> The numbers, command list, and coverage status below are illustrative, not authoritative.
|
||||
> `tests/cli_e2e/demo/` is reference material and is not part of formal CLI E2E coverage accounting.
|
||||
> `lark-cli demo --help` does not exist, so this file cannot be recomputed from live domain help output.
|
||||
|
||||
## Metrics
|
||||
|
||||
- Denominator: 8 leaf commands
|
||||
- Covered: 3
|
||||
- Coverage: 37.5%
|
||||
|
||||
## Summary
|
||||
|
||||
- Purpose: show humans and AI agents how to maintain a per-domain coverage file even when the directory is documentation-only and not backed by a real `lark-cli demo` command tree.
|
||||
- TestDemo_TaskLifecycle: demonstrates one minimal task lifecycle workflow for documentation purposes.
|
||||
- TestDemo_TaskLifecycle/create: runs `task +create` with `summary` and `description`, captures the returned `taskGUID`, and registers parent cleanup for later teardown.
|
||||
- TestDemo_TaskLifecycle/update: runs `task +update --task-id <guid>` and mutates both `summary` and `description` on the created task.
|
||||
- TestDemo_TaskLifecycle/get: runs `task tasks get` for the same task and asserts the persisted `guid`, updated `summary`, and updated `description`.
|
||||
- Cleanup note: `task tasks delete` is executed in `parentT.Cleanup`, but this template intentionally keeps cleanup-only execution marked uncovered so workflow assertions remain distinct from teardown mechanics.
|
||||
- Demo-only gap note: `task +complete`, `task +reopen`, `task +assign`, and `task +get-my-tasks` are intentionally left as uncovered examples for a minimal template.
|
||||
|
||||
## Command Table
|
||||
|
||||
| Status | Cmd | Type | Testcase | Key parameter shapes | Notes / uncovered reason |
|
||||
| --- | --- | --- | --- | --- | --- |
|
||||
| ✓ | task +create | shortcut | task_lifecycle_test.go::TestDemo_TaskLifecycle/create | basic create; summary; description | demo example |
|
||||
| ✓ | task +update | shortcut | task_lifecycle_test.go::TestDemo_TaskLifecycle/update | --task-id; update summary; update description | demo example |
|
||||
| ✓ | task tasks get | api | task_lifecycle_test.go::TestDemo_TaskLifecycle/get | task_guid in --params | demo example |
|
||||
| ✕ | task tasks delete | api | | none | cleanup exists in parentT.Cleanup, but demo coverage intentionally treats cleanup-only execution as uncovered |
|
||||
| ✕ | task +complete | shortcut | | none | not shown in this minimal lifecycle example |
|
||||
| ✕ | task +reopen | shortcut | | none | not shown in this minimal lifecycle example |
|
||||
| ✕ | task +assign | shortcut | | none | example of a user-identity-sensitive command; requires real user fixtures |
|
||||
| ✕ | task +get-my-tasks | shortcut | | none | example of a current-user-dependent command; often unavailable in bot-only environments |
|
||||
|
||||
## Notes
|
||||
|
||||
- In a real domain, recompute the denominator from live `lark-cli --help` exploration instead of copying this file.
|
||||
- Replace demo rows with real command inventory for that domain.
|
||||
- Keep skipped commands unchecked; reuse the `t.Skip(...)` reason as the uncovered reason.
|
||||
48
tests/cli_e2e/docs/docs_create_fetch_test.go
Normal file
48
tests/cli_e2e/docs/docs_create_fetch_test.go
Normal file
@@ -0,0 +1,48 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package docs
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
clie2e "github.com/larksuite/cli/tests/cli_e2e"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"github.com/tidwall/gjson"
|
||||
)
|
||||
|
||||
// TestDocs_CreateAndFetchWorkflow tests the create and fetch lifecycle.
|
||||
func TestDocs_CreateAndFetchWorkflow(t *testing.T) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
|
||||
t.Cleanup(cancel)
|
||||
|
||||
suffix := clie2e.GenerateSuffix()
|
||||
folderName := "lark-cli-e2e-docs-folder-" + suffix
|
||||
docTitle := "lark-cli-e2e-docs-" + suffix
|
||||
docContent := "# Test Document\n\nThis document was created by lark-cli e2e test."
|
||||
|
||||
folderToken := createDocsFolderWithRetry(t, ctx, folderName)
|
||||
var docToken string
|
||||
|
||||
t.Run("create", func(t *testing.T) {
|
||||
docToken = createDocWithRetry(t, ctx, folderToken, docTitle, docContent)
|
||||
})
|
||||
|
||||
t.Run("fetch", func(t *testing.T) {
|
||||
require.NotEmpty(t, docToken, "document token should be created before fetch")
|
||||
|
||||
result, err := clie2e.RunCmd(ctx, clie2e.Request{
|
||||
Args: []string{
|
||||
"docs", "+fetch",
|
||||
"--doc", docToken,
|
||||
},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
result.AssertExitCode(t, 0)
|
||||
result.AssertStdoutStatus(t, true)
|
||||
assert.Equal(t, docTitle, gjson.Get(result.Stdout, "data.title").String())
|
||||
})
|
||||
}
|
||||
67
tests/cli_e2e/docs/docs_update_test.go
Normal file
67
tests/cli_e2e/docs/docs_update_test.go
Normal file
@@ -0,0 +1,67 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package docs
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
clie2e "github.com/larksuite/cli/tests/cli_e2e"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"github.com/tidwall/gjson"
|
||||
)
|
||||
|
||||
// TestDocs_UpdateWorkflow tests the create, update, and verify lifecycle.
|
||||
func TestDocs_UpdateWorkflow(t *testing.T) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
|
||||
t.Cleanup(cancel)
|
||||
|
||||
suffix := clie2e.GenerateSuffix()
|
||||
folderName := "lark-cli-e2e-update-folder-" + suffix
|
||||
originalTitle := "lark-cli-e2e-update-" + suffix
|
||||
updatedTitle := "lark-cli-e2e-update-updated-" + suffix
|
||||
originalContent := "# Original\n\nThis is the original content."
|
||||
updatedContent := "# Updated\n\nThis is the updated content."
|
||||
|
||||
folderToken := createDocsFolderWithRetry(t, ctx, folderName)
|
||||
var docToken string
|
||||
|
||||
t.Run("create", func(t *testing.T) {
|
||||
docToken = createDocWithRetry(t, ctx, folderToken, originalTitle, originalContent)
|
||||
})
|
||||
|
||||
t.Run("update-title-and-content", func(t *testing.T) {
|
||||
require.NotEmpty(t, docToken, "document token should be created before update")
|
||||
|
||||
result, err := clie2e.RunCmd(ctx, clie2e.Request{
|
||||
Args: []string{
|
||||
"docs", "+update",
|
||||
"--doc", docToken,
|
||||
"--mode", "overwrite",
|
||||
"--markdown", updatedContent,
|
||||
"--new-title", updatedTitle,
|
||||
},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
result.AssertExitCode(t, 0)
|
||||
result.AssertStdoutStatus(t, true)
|
||||
})
|
||||
|
||||
t.Run("verify", func(t *testing.T) {
|
||||
require.NotEmpty(t, docToken, "document token should be created before verify")
|
||||
|
||||
result, err := clie2e.RunCmd(ctx, clie2e.Request{
|
||||
Args: []string{
|
||||
"docs", "+fetch",
|
||||
"--doc", docToken,
|
||||
},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
result.AssertExitCode(t, 0)
|
||||
result.AssertStdoutStatus(t, true)
|
||||
assert.Equal(t, updatedTitle, gjson.Get(result.Stdout, "data.title").String())
|
||||
})
|
||||
}
|
||||
54
tests/cli_e2e/docs/helpers_test.go
Normal file
54
tests/cli_e2e/docs/helpers_test.go
Normal file
@@ -0,0 +1,54 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package docs
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
clie2e "github.com/larksuite/cli/tests/cli_e2e"
|
||||
"github.com/stretchr/testify/require"
|
||||
"github.com/tidwall/gjson"
|
||||
)
|
||||
|
||||
func createDocsFolderWithRetry(t *testing.T, ctx context.Context, name string) string {
|
||||
t.Helper()
|
||||
|
||||
result, err := clie2e.RunCmdWithRetry(ctx, clie2e.Request{
|
||||
Args: []string{"drive", "files", "create_folder"},
|
||||
Data: map[string]any{
|
||||
"name": name,
|
||||
"folder_token": "",
|
||||
},
|
||||
}, clie2e.RetryOptions{})
|
||||
require.NoError(t, err)
|
||||
result.AssertExitCode(t, 0)
|
||||
|
||||
folderToken := gjson.Get(result.Stdout, "data.token").String()
|
||||
require.NotEmpty(t, folderToken, "stdout:\n%s", result.Stdout)
|
||||
|
||||
return folderToken
|
||||
}
|
||||
|
||||
func createDocWithRetry(t *testing.T, ctx context.Context, folderToken string, title string, markdown string) string {
|
||||
t.Helper()
|
||||
|
||||
require.NotEmpty(t, folderToken, "folder token is required")
|
||||
|
||||
result, err := clie2e.RunCmdWithRetry(ctx, clie2e.Request{
|
||||
Args: []string{
|
||||
"docs", "+create",
|
||||
"--folder-token", folderToken,
|
||||
"--title", title,
|
||||
"--markdown", markdown,
|
||||
},
|
||||
}, clie2e.RetryOptions{})
|
||||
require.NoError(t, err)
|
||||
result.AssertExitCode(t, 0)
|
||||
result.AssertStdoutStatus(t, true)
|
||||
|
||||
docToken := gjson.Get(result.Stdout, "data.doc_id").String()
|
||||
require.NotEmpty(t, docToken, "stdout:\n%s", result.Stdout)
|
||||
return docToken
|
||||
}
|
||||
29
tests/cli_e2e/drive/drive_files_workflow_test.go
Normal file
29
tests/cli_e2e/drive/drive_files_workflow_test.go
Normal file
@@ -0,0 +1,29 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package drive
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
clie2e "github.com/larksuite/cli/tests/cli_e2e"
|
||||
)
|
||||
|
||||
// TestDrive_FilesCreateFolderWorkflow tests the files create_folder resource command.
|
||||
func TestDrive_FilesCreateFolderWorkflow(t *testing.T) {
|
||||
parentT := t
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
|
||||
t.Cleanup(cancel)
|
||||
|
||||
suffix := clie2e.GenerateSuffix()
|
||||
folderName := "lark-cli-e2e-drive-folder-" + suffix
|
||||
|
||||
t.Run("create_folder", func(t *testing.T) {
|
||||
folderToken := createDriveFolder(t, parentT, ctx, folderName)
|
||||
if folderToken == "" {
|
||||
t.Fatalf("folder token should be available")
|
||||
}
|
||||
})
|
||||
}
|
||||
44
tests/cli_e2e/drive/helpers_test.go
Normal file
44
tests/cli_e2e/drive/helpers_test.go
Normal file
@@ -0,0 +1,44 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package drive
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
clie2e "github.com/larksuite/cli/tests/cli_e2e"
|
||||
"github.com/stretchr/testify/require"
|
||||
"github.com/tidwall/gjson"
|
||||
)
|
||||
|
||||
// createDriveFolder creates a private folder for the current workflow and
|
||||
// deletes it during cleanup.
|
||||
func createDriveFolder(t *testing.T, parentT *testing.T, ctx context.Context, name string) string {
|
||||
t.Helper()
|
||||
|
||||
result, err := clie2e.RunCmd(ctx, clie2e.Request{
|
||||
Args: []string{"drive", "files", "create_folder"},
|
||||
DefaultAs: "bot",
|
||||
Data: map[string]any{
|
||||
"name": name,
|
||||
"folder_token": "",
|
||||
},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
result.AssertExitCode(t, 0)
|
||||
result.AssertStdoutStatus(t, 0)
|
||||
|
||||
folderToken := gjson.Get(result.Stdout, "data.token").String()
|
||||
require.NotEmpty(t, folderToken, "stdout:\n%s", result.Stdout)
|
||||
|
||||
parentT.Cleanup(func() {
|
||||
clie2e.RunCmd(context.Background(), clie2e.Request{
|
||||
Args: []string{"drive", "files", "delete"},
|
||||
DefaultAs: "bot",
|
||||
Params: map[string]any{"file_token": folderToken, "type": "folder"},
|
||||
})
|
||||
})
|
||||
|
||||
return folderToken
|
||||
}
|
||||
124
tests/cli_e2e/im/chat_workflow_test.go
Normal file
124
tests/cli_e2e/im/chat_workflow_test.go
Normal file
@@ -0,0 +1,124 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package im
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
clie2e "github.com/larksuite/cli/tests/cli_e2e"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"github.com/tidwall/gjson"
|
||||
)
|
||||
|
||||
// TestIM_ChatUpdateWorkflow tests the +chat-update shortcut.
|
||||
func TestIM_ChatUpdateWorkflow(t *testing.T) {
|
||||
parentT := t
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
|
||||
t.Cleanup(cancel)
|
||||
|
||||
suffix := clie2e.GenerateSuffix()
|
||||
originalName := "lark-cli-e2e-im-update-" + suffix
|
||||
updatedName := originalName + "-updated"
|
||||
updatedDescription := "Updated description for e2e test"
|
||||
|
||||
chatID := createChat(t, parentT, ctx, originalName)
|
||||
|
||||
t.Run("update chat name", func(t *testing.T) {
|
||||
result, err := clie2e.RunCmd(ctx, clie2e.Request{
|
||||
Args: []string{"im", "+chat-update",
|
||||
"--chat-id", chatID,
|
||||
"--name", updatedName,
|
||||
},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
result.AssertExitCode(t, 0)
|
||||
result.AssertStdoutStatus(t, true)
|
||||
})
|
||||
|
||||
t.Run("update chat description", func(t *testing.T) {
|
||||
result, err := clie2e.RunCmd(ctx, clie2e.Request{
|
||||
Args: []string{"im", "+chat-update",
|
||||
"--chat-id", chatID,
|
||||
"--description", updatedDescription,
|
||||
},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
result.AssertExitCode(t, 0)
|
||||
result.AssertStdoutStatus(t, true)
|
||||
})
|
||||
|
||||
t.Run("get updated chat", func(t *testing.T) {
|
||||
result, err := clie2e.RunCmd(ctx, clie2e.Request{
|
||||
Args: []string{"im", "chats", "get"},
|
||||
Params: map[string]any{"chat_id": chatID},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
result.AssertExitCode(t, 0)
|
||||
result.AssertStdoutStatus(t, 0)
|
||||
|
||||
assert.Equal(t, updatedName, gjson.Get(result.Stdout, "data.name").String())
|
||||
assert.Equal(t, updatedDescription, gjson.Get(result.Stdout, "data.description").String())
|
||||
})
|
||||
}
|
||||
|
||||
// TestIM_ChatsGetWorkflow tests the im chats get command.
|
||||
func TestIM_ChatsGetWorkflow(t *testing.T) {
|
||||
parentT := t
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
|
||||
t.Cleanup(cancel)
|
||||
|
||||
suffix := clie2e.GenerateSuffix()
|
||||
chatName := "lark-cli-e2e-chats-get-" + suffix
|
||||
|
||||
chatID := createChat(t, parentT, ctx, chatName)
|
||||
|
||||
t.Run("get chat info", func(t *testing.T) {
|
||||
result, err := clie2e.RunCmd(ctx, clie2e.Request{
|
||||
Args: []string{"im", "chats", "get"},
|
||||
Params: map[string]any{"chat_id": chatID},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
t.Logf("chats get result: %s", result.Stdout)
|
||||
result.AssertExitCode(t, 0)
|
||||
result.AssertStdoutStatus(t, 0)
|
||||
|
||||
dataExists := gjson.Get(result.Stdout, "data").Exists()
|
||||
require.True(t, dataExists, "data object should exist")
|
||||
|
||||
chatNameGot := gjson.Get(result.Stdout, "data.name").String()
|
||||
require.Equal(t, chatName, chatNameGot)
|
||||
})
|
||||
}
|
||||
|
||||
// TestIM_ChatsLinkWorkflow tests the im chats link command.
|
||||
func TestIM_ChatsLinkWorkflow(t *testing.T) {
|
||||
parentT := t
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
|
||||
t.Cleanup(cancel)
|
||||
|
||||
suffix := clie2e.GenerateSuffix()
|
||||
chatName := "lark-cli-e2e-chats-link-" + suffix
|
||||
|
||||
chatID := createChat(t, parentT, ctx, chatName)
|
||||
|
||||
t.Run("get chat share link", func(t *testing.T) {
|
||||
result, err := clie2e.RunCmd(ctx, clie2e.Request{
|
||||
Args: []string{"im", "chats", "link"},
|
||||
Params: map[string]any{"chat_id": chatID},
|
||||
Data: map[string]any{
|
||||
"validity_period": "week",
|
||||
},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
result.AssertExitCode(t, 0)
|
||||
result.AssertStdoutStatus(t, 0)
|
||||
|
||||
shareLink := gjson.Get(result.Stdout, "data.share_link").String()
|
||||
require.NotEmpty(t, shareLink, "share_link should not be empty")
|
||||
t.Logf("Generated share link: %s", shareLink)
|
||||
})
|
||||
}
|
||||
60
tests/cli_e2e/im/helpers_test.go
Normal file
60
tests/cli_e2e/im/helpers_test.go
Normal file
@@ -0,0 +1,60 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package im
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
clie2e "github.com/larksuite/cli/tests/cli_e2e"
|
||||
"github.com/stretchr/testify/require"
|
||||
"github.com/tidwall/gjson"
|
||||
)
|
||||
|
||||
// createChat creates a private chat with the given name and returns the chatID.
|
||||
// The chat will be automatically cleaned up via parentT.Cleanup().
|
||||
// Note: Chat deletion is not available via lark-cli im command.
|
||||
func createChat(t *testing.T, parentT *testing.T, ctx context.Context, name string) string {
|
||||
t.Helper()
|
||||
|
||||
result, err := clie2e.RunCmd(ctx, clie2e.Request{
|
||||
Args: []string{"im", "+chat-create",
|
||||
"--name", name,
|
||||
"--type", "private",
|
||||
},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
result.AssertExitCode(t, 0)
|
||||
result.AssertStdoutStatus(t, true)
|
||||
|
||||
chatID := gjson.Get(result.Stdout, "data.chat_id").String()
|
||||
require.NotEmpty(t, chatID, "chat_id should not be empty")
|
||||
|
||||
parentT.Cleanup(func() {
|
||||
// No IM chat delete command is currently available in lark-cli,
|
||||
// so created chats are intentionally left in the test account.
|
||||
})
|
||||
|
||||
return chatID
|
||||
}
|
||||
|
||||
// sendMessage sends a text message to the specified chat and returns the messageID.
|
||||
func sendMessage(t *testing.T, parentT *testing.T, ctx context.Context, chatID string, text string) string {
|
||||
t.Helper()
|
||||
|
||||
result, err := clie2e.RunCmd(ctx, clie2e.Request{
|
||||
Args: []string{"im", "+messages-send",
|
||||
"--chat-id", chatID,
|
||||
"--text", text,
|
||||
},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
result.AssertExitCode(t, 0)
|
||||
result.AssertStdoutStatus(t, true)
|
||||
|
||||
messageID := gjson.Get(result.Stdout, "data.message_id").String()
|
||||
require.NotEmpty(t, messageID, "message_id should not be empty")
|
||||
|
||||
return messageID
|
||||
}
|
||||
53
tests/cli_e2e/im/message_workflow_test.go
Normal file
53
tests/cli_e2e/im/message_workflow_test.go
Normal file
@@ -0,0 +1,53 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package im
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
clie2e "github.com/larksuite/cli/tests/cli_e2e"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// TestIM_MessagesReplyWorkflow tests the +messages-reply shortcut.
|
||||
func TestIM_MessagesReplyWorkflow(t *testing.T) {
|
||||
parentT := t
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
|
||||
t.Cleanup(cancel)
|
||||
|
||||
suffix := clie2e.GenerateSuffix()
|
||||
chatName := "lark-cli-e2e-im-reply-" + suffix
|
||||
originalMessage := "Original message for reply test"
|
||||
replyText := "This is a reply"
|
||||
|
||||
chatID := createChat(t, parentT, ctx, chatName)
|
||||
messageID := sendMessage(t, parentT, ctx, chatID, originalMessage)
|
||||
|
||||
t.Run("reply to message with text", func(t *testing.T) {
|
||||
result, err := clie2e.RunCmd(ctx, clie2e.Request{
|
||||
Args: []string{"im", "+messages-reply",
|
||||
"--message-id", messageID,
|
||||
"--text", replyText,
|
||||
},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
result.AssertExitCode(t, 0)
|
||||
result.AssertStdoutStatus(t, true)
|
||||
})
|
||||
|
||||
t.Run("reply to message with markdown", func(t *testing.T) {
|
||||
markdownReply := "**Bold** reply"
|
||||
result, err := clie2e.RunCmd(ctx, clie2e.Request{
|
||||
Args: []string{"im", "+messages-reply",
|
||||
"--message-id", messageID,
|
||||
"--markdown", markdownReply,
|
||||
},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
result.AssertExitCode(t, 0)
|
||||
result.AssertStdoutStatus(t, true)
|
||||
})
|
||||
}
|
||||
239
tests/cli_e2e/sheets/sheets_crud_workflow_test.go
Normal file
239
tests/cli_e2e/sheets/sheets_crud_workflow_test.go
Normal file
@@ -0,0 +1,239 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package sheets
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
clie2e "github.com/larksuite/cli/tests/cli_e2e"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"github.com/tidwall/gjson"
|
||||
)
|
||||
|
||||
// TestSheets_CRUDE2EWorkflow tests the full lifecycle of spreadsheet operations
|
||||
// using all shortcut methods: +create, +read, +write, +append, +find, +info, +export
|
||||
func TestSheets_CRUDE2EWorkflow(t *testing.T) {
|
||||
parentT := t
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
|
||||
t.Cleanup(cancel)
|
||||
|
||||
suffix := clie2e.GenerateSuffix()
|
||||
spreadsheetToken := ""
|
||||
sheetID := ""
|
||||
|
||||
t.Run("create spreadsheet with +create", func(t *testing.T) {
|
||||
result, err := clie2e.RunCmdWithRetry(ctx, clie2e.Request{
|
||||
Args: []string{"sheets", "+create", "--title", "lark-cli-e2e-sheets-" + suffix},
|
||||
}, clie2e.RetryOptions{})
|
||||
require.NoError(t, err)
|
||||
result.AssertExitCode(t, 0)
|
||||
result.AssertStdoutStatus(t, true)
|
||||
|
||||
spreadsheetToken = gjson.Get(result.Stdout, "data.spreadsheet_token").String()
|
||||
require.NotEmpty(t, spreadsheetToken, "spreadsheet token should not be empty, stdout: %s", result.Stdout)
|
||||
|
||||
parentT.Cleanup(func() {
|
||||
// Best-effort cleanup - spreadsheets don't have a direct delete shortcut
|
||||
// The spreadsheet will be cleaned up by the test environment if needed
|
||||
})
|
||||
})
|
||||
|
||||
t.Run("get spreadsheet info with +info", func(t *testing.T) {
|
||||
require.NotEmpty(t, spreadsheetToken, "spreadsheet token is required")
|
||||
result, err := clie2e.RunCmd(ctx, clie2e.Request{
|
||||
Args: []string{"sheets", "+info", "--spreadsheet-token", spreadsheetToken},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
result.AssertExitCode(t, 0)
|
||||
result.AssertStdoutStatus(t, true)
|
||||
|
||||
assert.Equal(t, spreadsheetToken, gjson.Get(result.Stdout, "data.spreadsheet.spreadsheet.token").String())
|
||||
sheetID = gjson.Get(result.Stdout, "data.sheets.sheets.0.sheet_id").String()
|
||||
require.NotEmpty(t, sheetID, "sheet_id should not be empty, stdout: %s", result.Stdout)
|
||||
})
|
||||
|
||||
t.Run("write data with +write", func(t *testing.T) {
|
||||
require.NotEmpty(t, spreadsheetToken, "spreadsheet token is required")
|
||||
require.NotEmpty(t, sheetID, "sheet_id is required")
|
||||
|
||||
values := [][]any{
|
||||
{"Name", "Age", "City"},
|
||||
{"Alice", 25, "Beijing"},
|
||||
{"Bob", 30, "Shanghai"},
|
||||
}
|
||||
valuesJSON, _ := json.Marshal(values)
|
||||
|
||||
result, err := clie2e.RunCmd(ctx, clie2e.Request{
|
||||
Args: []string{
|
||||
"sheets", "+write",
|
||||
"--spreadsheet-token", spreadsheetToken,
|
||||
"--sheet-id", sheetID,
|
||||
"--range", "A1:C3",
|
||||
"--values", string(valuesJSON),
|
||||
},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
result.AssertExitCode(t, 0)
|
||||
result.AssertStdoutStatus(t, true)
|
||||
})
|
||||
|
||||
t.Run("read data with +read", func(t *testing.T) {
|
||||
require.NotEmpty(t, spreadsheetToken, "spreadsheet token is required")
|
||||
require.NotEmpty(t, sheetID, "sheet_id is required")
|
||||
|
||||
result, err := clie2e.RunCmd(ctx, clie2e.Request{
|
||||
Args: []string{
|
||||
"sheets", "+read",
|
||||
"--spreadsheet-token", spreadsheetToken,
|
||||
"--sheet-id", sheetID,
|
||||
"--range", "A1:C3",
|
||||
},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
result.AssertExitCode(t, 0)
|
||||
result.AssertStdoutStatus(t, true)
|
||||
|
||||
// Verify the data was written correctly
|
||||
values := gjson.Get(result.Stdout, "data.valueRange.values")
|
||||
require.True(t, values.IsArray(), "values should be an array, stdout: %s", result.Stdout)
|
||||
assert.Equal(t, "Name", values.Array()[0].Array()[0].String())
|
||||
assert.Equal(t, "Alice", values.Array()[1].Array()[0].String())
|
||||
})
|
||||
|
||||
t.Run("append rows with +append", func(t *testing.T) {
|
||||
require.NotEmpty(t, spreadsheetToken, "spreadsheet token is required")
|
||||
require.NotEmpty(t, sheetID, "sheet_id is required")
|
||||
|
||||
values := [][]any{{"Charlie", 28, "Guangzhou"}}
|
||||
valuesJSON, _ := json.Marshal(values)
|
||||
|
||||
result, err := clie2e.RunCmd(ctx, clie2e.Request{
|
||||
Args: []string{
|
||||
"sheets", "+append",
|
||||
"--spreadsheet-token", spreadsheetToken,
|
||||
"--sheet-id", sheetID,
|
||||
"--range", "A4:C4",
|
||||
"--values", string(valuesJSON),
|
||||
},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
result.AssertExitCode(t, 0)
|
||||
result.AssertStdoutStatus(t, true)
|
||||
})
|
||||
|
||||
t.Run("find cells with +find", func(t *testing.T) {
|
||||
require.NotEmpty(t, spreadsheetToken, "spreadsheet token is required")
|
||||
require.NotEmpty(t, sheetID, "sheet_id is required")
|
||||
|
||||
result, err := clie2e.RunCmd(ctx, clie2e.Request{
|
||||
Args: []string{
|
||||
"sheets", "+find",
|
||||
"--spreadsheet-token", spreadsheetToken,
|
||||
"--sheet-id", sheetID,
|
||||
"--find", "Alice",
|
||||
"--range", fmt.Sprintf("%s!A1:C10", sheetID),
|
||||
},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
result.AssertExitCode(t, 0)
|
||||
assert.Equal(t, true, gjson.Get(result.Stdout, "ok").Bool(), "stdout:\n%s", result.Stdout)
|
||||
|
||||
matchedCells := gjson.Get(result.Stdout, "data.find_result.matched_cells")
|
||||
require.True(t, matchedCells.IsArray(), "matched_cells should be an array, stdout: %s", result.Stdout)
|
||||
assert.True(t, len(matchedCells.Array()) > 0, "should find at least one cell containing 'Alice'")
|
||||
})
|
||||
|
||||
t.Run("export spreadsheet with +export", func(t *testing.T) {
|
||||
require.NotEmpty(t, spreadsheetToken, "spreadsheet token is required")
|
||||
|
||||
// Export is an async operation; verify it initiates correctly
|
||||
// The command may have filesystem race issues but the API call succeeds
|
||||
result, err := clie2e.RunCmd(ctx, clie2e.Request{
|
||||
Args: []string{
|
||||
"sheets", "+export",
|
||||
"--spreadsheet-token", spreadsheetToken,
|
||||
"--file-extension", "xlsx",
|
||||
},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
// Export initiates successfully and returns file_token even if there's a temp file race
|
||||
assert.NotEmpty(t, gjson.Get(result.Stdout, "data.file_token").String(),
|
||||
"export should return file_token, stdout: %s", result.Stdout)
|
||||
})
|
||||
}
|
||||
|
||||
// TestSheets_SpreadsheetsResource tests the spreadsheets resource methods
|
||||
func TestSheets_SpreadsheetsResource(t *testing.T) {
|
||||
parentT := t
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
|
||||
t.Cleanup(cancel)
|
||||
|
||||
suffix := clie2e.GenerateSuffix()
|
||||
spreadsheetToken := ""
|
||||
|
||||
t.Run("create spreadsheet with spreadsheets create", func(t *testing.T) {
|
||||
result, err := clie2e.RunCmdWithRetry(ctx, clie2e.Request{
|
||||
Args: []string{"sheets", "spreadsheets", "create"},
|
||||
Data: map[string]any{
|
||||
"title": "lark-cli-e2e-sheets-resource-" + suffix,
|
||||
},
|
||||
}, clie2e.RetryOptions{})
|
||||
require.NoError(t, err)
|
||||
result.AssertExitCode(t, 0)
|
||||
result.AssertStdoutStatus(t, 0)
|
||||
|
||||
spreadsheetToken = gjson.Get(result.Stdout, "data.spreadsheet.spreadsheet_token").String()
|
||||
require.NotEmpty(t, spreadsheetToken, "spreadsheet token should not be empty, stdout: %s", result.Stdout)
|
||||
|
||||
parentT.Cleanup(func() {
|
||||
// Best-effort cleanup
|
||||
})
|
||||
})
|
||||
|
||||
t.Run("get spreadsheet with spreadsheets get", func(t *testing.T) {
|
||||
require.NotEmpty(t, spreadsheetToken, "spreadsheet token is required")
|
||||
|
||||
result, err := clie2e.RunCmd(ctx, clie2e.Request{
|
||||
Args: []string{"sheets", "spreadsheets", "get"},
|
||||
Params: map[string]any{"spreadsheet_token": spreadsheetToken},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
result.AssertExitCode(t, 0)
|
||||
result.AssertStdoutStatus(t, 0)
|
||||
|
||||
assert.Equal(t, spreadsheetToken, gjson.Get(result.Stdout, "data.spreadsheet.token").String())
|
||||
assert.NotEmpty(t, gjson.Get(result.Stdout, "data.spreadsheet.url").String())
|
||||
})
|
||||
|
||||
t.Run("patch spreadsheet with spreadsheets patch", func(t *testing.T) {
|
||||
require.NotEmpty(t, spreadsheetToken, "spreadsheet token is required")
|
||||
|
||||
updatedTitle := "lark-cli-e2e-sheets-patched-" + suffix
|
||||
result, err := clie2e.RunCmd(ctx, clie2e.Request{
|
||||
Args: []string{"sheets", "spreadsheets", "patch"},
|
||||
Params: map[string]any{"spreadsheet_token": spreadsheetToken},
|
||||
Data: map[string]any{"title": updatedTitle},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
result.AssertExitCode(t, 0)
|
||||
result.AssertStdoutStatus(t, 0)
|
||||
|
||||
// Verify the title was updated by fetching the spreadsheet
|
||||
getResult, err := clie2e.RunCmd(ctx, clie2e.Request{
|
||||
Args: []string{"sheets", "spreadsheets", "get"},
|
||||
Params: map[string]any{"spreadsheet_token": spreadsheetToken},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
getResult.AssertExitCode(t, 0)
|
||||
getResult.AssertStdoutStatus(t, 0)
|
||||
|
||||
// Verify the title was actually updated
|
||||
assert.Equal(t, updatedTitle, gjson.Get(getResult.Stdout, "data.spreadsheet.title").String())
|
||||
})
|
||||
}
|
||||
174
tests/cli_e2e/sheets/sheets_filter_workflow_test.go
Normal file
174
tests/cli_e2e/sheets/sheets_filter_workflow_test.go
Normal file
@@ -0,0 +1,174 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package sheets
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
clie2e "github.com/larksuite/cli/tests/cli_e2e"
|
||||
"github.com/stretchr/testify/require"
|
||||
"github.com/tidwall/gjson"
|
||||
)
|
||||
|
||||
// TestSheets_FilterWorkflow tests the spreadsheet sheet filter operations
|
||||
func TestSheets_FilterWorkflow(t *testing.T) {
|
||||
parentT := t
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
|
||||
t.Cleanup(cancel)
|
||||
|
||||
suffix := clie2e.GenerateSuffix()
|
||||
spreadsheetToken := ""
|
||||
sheetID := ""
|
||||
|
||||
t.Run("create spreadsheet with initial data", func(t *testing.T) {
|
||||
result, err := clie2e.RunCmdWithRetry(ctx, clie2e.Request{
|
||||
Args: []string{"sheets", "+create", "--title", "lark-cli-e2e-sheets-filter-" + suffix},
|
||||
}, clie2e.RetryOptions{})
|
||||
require.NoError(t, err)
|
||||
result.AssertExitCode(t, 0)
|
||||
result.AssertStdoutStatus(t, true)
|
||||
|
||||
spreadsheetToken = gjson.Get(result.Stdout, "data.spreadsheet_token").String()
|
||||
require.NotEmpty(t, spreadsheetToken, "spreadsheet token should not be empty, stdout: %s", result.Stdout)
|
||||
|
||||
parentT.Cleanup(func() {
|
||||
// No sheets delete command is currently available in lark-cli,
|
||||
// so created spreadsheets are intentionally left in the test account.
|
||||
})
|
||||
})
|
||||
|
||||
t.Run("get sheet info", func(t *testing.T) {
|
||||
require.NotEmpty(t, spreadsheetToken, "spreadsheet token is required")
|
||||
|
||||
result, err := clie2e.RunCmd(ctx, clie2e.Request{
|
||||
Args: []string{"sheets", "+info", "--spreadsheet-token", spreadsheetToken},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
result.AssertExitCode(t, 0)
|
||||
result.AssertStdoutStatus(t, true)
|
||||
|
||||
sheetID = gjson.Get(result.Stdout, "data.sheets.sheets.0.sheet_id").String()
|
||||
require.NotEmpty(t, sheetID, "sheet_id should not be empty, stdout: %s", result.Stdout)
|
||||
})
|
||||
|
||||
t.Run("write test data for filtering", func(t *testing.T) {
|
||||
require.NotEmpty(t, spreadsheetToken, "spreadsheet token is required")
|
||||
require.NotEmpty(t, sheetID, "sheet_id is required")
|
||||
|
||||
values := [][]any{
|
||||
{"Name", "Score", "Grade"},
|
||||
{"Alice", 85, "B"},
|
||||
{"Bob", 92, "A"},
|
||||
{"Charlie", 78, "C"},
|
||||
{"Diana", 95, "A"},
|
||||
}
|
||||
valuesJSON, _ := json.Marshal(values)
|
||||
|
||||
result, err := clie2e.RunCmd(ctx, clie2e.Request{
|
||||
Args: []string{
|
||||
"sheets", "+write",
|
||||
"--spreadsheet-token", spreadsheetToken,
|
||||
"--sheet-id", sheetID,
|
||||
"--range", "A1:C5",
|
||||
"--values", string(valuesJSON),
|
||||
},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
result.AssertExitCode(t, 0)
|
||||
result.AssertStdoutStatus(t, true)
|
||||
})
|
||||
|
||||
t.Run("create filter with spreadsheet.sheet.filters create", func(t *testing.T) {
|
||||
require.NotEmpty(t, spreadsheetToken, "spreadsheet token is required")
|
||||
require.NotEmpty(t, sheetID, "sheet_id is required")
|
||||
|
||||
filterData := map[string]any{
|
||||
"range": fmt.Sprintf("%s!A1:D5", sheetID),
|
||||
"col": "C",
|
||||
"filter_type": "multiValue",
|
||||
"condition": map[string]any{
|
||||
"filter_type": "multiValue",
|
||||
"expected": []any{"A", "B"},
|
||||
},
|
||||
}
|
||||
|
||||
result, err := clie2e.RunCmd(ctx, clie2e.Request{
|
||||
Args: []string{"sheets", "spreadsheet.sheet.filters", "create"},
|
||||
Params: map[string]any{
|
||||
"spreadsheet_token": spreadsheetToken,
|
||||
"sheet_id": sheetID,
|
||||
},
|
||||
Data: filterData,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
result.AssertExitCode(t, 0)
|
||||
result.AssertStdoutStatus(t, 0)
|
||||
})
|
||||
|
||||
t.Run("get filter with spreadsheet.sheet.filters get", func(t *testing.T) {
|
||||
require.NotEmpty(t, spreadsheetToken, "spreadsheet token is required")
|
||||
require.NotEmpty(t, sheetID, "sheet_id is required")
|
||||
|
||||
result, err := clie2e.RunCmd(ctx, clie2e.Request{
|
||||
Args: []string{"sheets", "spreadsheet.sheet.filters", "get"},
|
||||
Params: map[string]any{
|
||||
"spreadsheet_token": spreadsheetToken,
|
||||
"sheet_id": sheetID,
|
||||
},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
result.AssertExitCode(t, 0)
|
||||
result.AssertStdoutStatus(t, 0)
|
||||
|
||||
filterInfo := gjson.Get(result.Stdout, "data.sheet_filter_info")
|
||||
require.True(t, filterInfo.Exists(), "filter info should exist, stdout: %s", result.Stdout)
|
||||
})
|
||||
|
||||
t.Run("update filter with spreadsheet.sheet.filters update", func(t *testing.T) {
|
||||
require.NotEmpty(t, spreadsheetToken, "spreadsheet token is required")
|
||||
require.NotEmpty(t, sheetID, "sheet_id is required")
|
||||
|
||||
filterData := map[string]any{
|
||||
"col": "B",
|
||||
"filter_type": "number",
|
||||
"condition": map[string]any{
|
||||
"filter_type": "number",
|
||||
"compare_type": "greater",
|
||||
"expected": []any{80},
|
||||
},
|
||||
}
|
||||
|
||||
result, err := clie2e.RunCmd(ctx, clie2e.Request{
|
||||
Args: []string{"sheets", "spreadsheet.sheet.filters", "update"},
|
||||
Params: map[string]any{
|
||||
"spreadsheet_token": spreadsheetToken,
|
||||
"sheet_id": sheetID,
|
||||
},
|
||||
Data: filterData,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
result.AssertExitCode(t, 0)
|
||||
result.AssertStdoutStatus(t, 0)
|
||||
})
|
||||
|
||||
t.Run("delete filter with spreadsheet.sheet.filters delete", func(t *testing.T) {
|
||||
require.NotEmpty(t, spreadsheetToken, "spreadsheet token is required")
|
||||
require.NotEmpty(t, sheetID, "sheet_id is required")
|
||||
|
||||
result, err := clie2e.RunCmd(ctx, clie2e.Request{
|
||||
Args: []string{"sheets", "spreadsheet.sheet.filters", "delete"},
|
||||
Params: map[string]any{
|
||||
"spreadsheet_token": spreadsheetToken,
|
||||
"sheet_id": sheetID,
|
||||
},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
result.AssertExitCode(t, 0)
|
||||
result.AssertStdoutStatus(t, 0)
|
||||
})
|
||||
}
|
||||
50
tests/cli_e2e/task/coverage.md
Normal file
50
tests/cli_e2e/task/coverage.md
Normal file
@@ -0,0 +1,50 @@
|
||||
# Task CLI E2E Coverage
|
||||
|
||||
## Metrics
|
||||
- Denominator: 29 leaf commands
|
||||
- Covered: 10
|
||||
- Coverage: 34.5%
|
||||
|
||||
## Summary
|
||||
- TestTask_StatusWorkflow: creates a task via `task +create`, then proves `task +complete`, `task tasks get`, and `task +reopen` through `complete`, `get completed task`, `reopen`, and `get reopened task`; asserts `status` flips between `done` and `todo` and `completed_at` is set then cleared.
|
||||
- TestTask_ReminderWorkflow: creates a task with a due time via `task +create`, then proves `task +reminder` and `task tasks get` through `set reminder`, `get task with reminder`, `remove reminder`, and `get task without reminder`; asserts `relative_fire_minute=30`, reminder id presence, and reminder removal.
|
||||
- TestTask_CommentWorkflow: creates a task via `task +create`, runs `comment`, and asserts the returned comment id is non-empty; this is the direct proof for `task +comment`.
|
||||
- TestTask_TasklistWorkflow: runs `create tasklist with task`, then `get tasklist`, `list tasklist tasks`, and `get task`; proves `task +tasklist-create`, `task tasklists get`, `task tasklists tasks`, and `task tasks get` with seeded task payload and task-to-tasklist linkage.
|
||||
- TestTask_TasklistAddTaskWorkflow: creates a standalone tasklist and task, runs `add task to tasklist`, then `list tasklist tasks` and `get task with tasklist link`; proves `task +tasklist-task-add`, `task tasklists tasks`, and `task tasks get`, including no failed tasks in the add response.
|
||||
- Cleanup path note: workflow-created tasks and tasklists are deleted through direct `task tasks delete` / `task tasklists delete` cleanup paths in `helpers_test.go::createTask`, `helpers_test.go::createTasklist`, and `tasklist_workflow_test.go::TestTask_TasklistWorkflow`, but those cleanup-only executions are not counted as command coverage because no testcase asserts delete behavior as the primary proof surface.
|
||||
- Blocked area: assignee, follower, and tasklist member mutations still require stable real-user `open_id` fixtures; the current suite is bot-safe only.
|
||||
- Blocked area: `task +get-my-tasks` still depends on `--as user` identity plus deterministic user-scoped data.
|
||||
- Gap pattern: direct `tasks create/delete/list/patch`, `tasklists create/delete/list/patch`, `members *`, and `subtasks *` APIs still lack deterministic direct-call workflows, so shortcut coverage does not count for those leaf commands.
|
||||
|
||||
## Command Table
|
||||
| Status | Cmd | Type | Testcase | Key parameter shapes | Notes / uncovered reason |
|
||||
| --- | --- | --- | --- | --- | --- |
|
||||
| ✕ | task +assign | shortcut | | none | requires real assignee open_id fixtures; shortcut defaults to `--as user` |
|
||||
| ✓ | task +comment | shortcut | task_comment_workflow_test.go::TestTask_CommentWorkflow/comment | `--task-id`; `--content` | |
|
||||
| ✓ | task +complete | shortcut | task_status_workflow_test.go::TestTask_StatusWorkflow/complete | `--task-id` | |
|
||||
| ✓ | task +create | shortcut | task_status_workflow_test.go::TestTask_StatusWorkflow; task_comment_workflow_test.go::TestTask_CommentWorkflow; task_reminder_workflow_test.go::TestTask_ReminderWorkflow; tasklist_add_task_workflow_test.go::TestTask_TasklistAddTaskWorkflow | `summary` + `description`; `due.timestamp` + `due.is_all_day` | |
|
||||
| ✕ | task +followers | shortcut | | none | requires real follower open_id fixtures; shortcut defaults to `--as user` |
|
||||
| ✕ | task +get-my-tasks | shortcut | | none | depends on `--as user` identity and deterministic user-scoped task data |
|
||||
| ✓ | task +reminder | shortcut | task_reminder_workflow_test.go::TestTask_ReminderWorkflow/set reminder; task_reminder_workflow_test.go::TestTask_ReminderWorkflow/remove reminder | `--task-id --set 30m`; `--task-id --remove` | |
|
||||
| ✓ | task +reopen | shortcut | task_status_workflow_test.go::TestTask_StatusWorkflow/reopen | `--task-id` | |
|
||||
| ✓ | task +tasklist-create | shortcut | tasklist_workflow_test.go::TestTask_TasklistWorkflow/create tasklist with task; tasklist_add_task_workflow_test.go::TestTask_TasklistAddTaskWorkflow | `--name` only; `--name` plus task array in `--data` | |
|
||||
| ✕ | task +tasklist-members | shortcut | | none | requires real member open_id fixtures to add, remove, or set tasklist members |
|
||||
| ✓ | task +tasklist-task-add | shortcut | tasklist_add_task_workflow_test.go::TestTask_TasklistAddTaskWorkflow/add task to tasklist | `--tasklist-id`; `--task-id` | |
|
||||
| ✕ | task +update | shortcut | | none | no dedicated workflow yet for summary, description, or due-field mutation assertions |
|
||||
| ✕ | task members add | api | | none | requires stable member fixtures and explicit direct API-body assertions |
|
||||
| ✕ | task members remove | api | | none | requires stable member fixtures and explicit direct API-body assertions |
|
||||
| ✕ | task subtasks create | api | | none | needs a parent-task workflow plus direct subtask payload assertions |
|
||||
| ✕ | task subtasks list | api | | none | needs deterministic subtask fixtures created in the same workflow |
|
||||
| ✕ | task tasklists add_members | api | | none | requires real member open_id fixtures and direct API coverage |
|
||||
| ✕ | task tasklists create | api | | none | only covered indirectly through `task +tasklist-create`; no direct API invocation yet |
|
||||
| ✕ | task tasklists delete | api | | none | only exercised in parent cleanup; no testcase asserts delete behavior or post-delete state as the primary proof |
|
||||
| ✓ | task tasklists get | api | tasklist_workflow_test.go::TestTask_TasklistWorkflow/get tasklist | `tasklist_guid` in `--params` | |
|
||||
| ✕ | task tasklists list | api | | none | needs isolated list or filter assertions against ambient tasklist data |
|
||||
| ✕ | task tasklists patch | api | | none | no dedicated direct tasklist-update workflow yet |
|
||||
| ✕ | task tasklists remove_members | api | | none | requires real member open_id fixtures and direct API coverage |
|
||||
| ✓ | task tasklists tasks | api | tasklist_workflow_test.go::TestTask_TasklistWorkflow/list tasklist tasks; tasklist_add_task_workflow_test.go::TestTask_TasklistAddTaskWorkflow/list tasklist tasks | `tasklist_guid`; `page_size` | |
|
||||
| ✕ | task tasks create | api | | none | only covered indirectly through `task +create`; no direct API invocation yet |
|
||||
| ✕ | task tasks delete | api | | none | only exercised in parent cleanup; no testcase asserts delete behavior or post-delete state as the primary proof |
|
||||
| ✓ | task tasks get | api | task_status_workflow_test.go::TestTask_StatusWorkflow/get completed task; task_status_workflow_test.go::TestTask_StatusWorkflow/get reopened task; task_reminder_workflow_test.go::TestTask_ReminderWorkflow/get task with reminder; task_reminder_workflow_test.go::TestTask_ReminderWorkflow/get task without reminder; tasklist_workflow_test.go::TestTask_TasklistWorkflow/get task; tasklist_add_task_workflow_test.go::TestTask_TasklistAddTaskWorkflow/get task with tasklist link | `task_guid` in `--params`; assert status, reminders, summary, description, and tasklist link | |
|
||||
| ✕ | task tasks list | api | | none | needs isolated list or filter assertions against ambient task data |
|
||||
| ✕ | task tasks patch | api | | none | no dedicated direct task-update workflow yet |
|
||||
@@ -19,7 +19,7 @@ func TestTask_CommentWorkflow(t *testing.T) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
|
||||
t.Cleanup(cancel)
|
||||
|
||||
suffix := time.Now().UTC().Format("20060102-150405")
|
||||
suffix := clie2e.GenerateSuffix()
|
||||
commentContent := "lark-cli-e2e-comment-" + suffix
|
||||
taskGUID := createTask(t, parentT, ctx, clie2e.Request{
|
||||
Args: []string{"task", "+create"},
|
||||
|
||||
@@ -19,7 +19,7 @@ func TestTask_ReminderWorkflow(t *testing.T) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
|
||||
t.Cleanup(cancel)
|
||||
|
||||
suffix := time.Now().UTC().Format("20060102-150405")
|
||||
suffix := clie2e.GenerateSuffix()
|
||||
taskGUID := createTask(t, parentT, ctx, clie2e.Request{
|
||||
Args: []string{"task", "+create"},
|
||||
Data: map[string]any{
|
||||
@@ -57,9 +57,9 @@ func TestTask_ReminderWorkflow(t *testing.T) {
|
||||
})
|
||||
|
||||
t.Run("remove reminder", func(t *testing.T) {
|
||||
result, err := clie2e.RunCmd(ctx, clie2e.Request{
|
||||
result, err := clie2e.RunCmdWithRetry(ctx, clie2e.Request{
|
||||
Args: []string{"task", "+reminder", "--task-id", taskGUID, "--remove"},
|
||||
})
|
||||
}, clie2e.RetryOptions{})
|
||||
require.NoError(t, err)
|
||||
result.AssertExitCode(t, 0)
|
||||
result.AssertStdoutStatus(t, true)
|
||||
|
||||
@@ -19,7 +19,7 @@ func TestTask_StatusWorkflow(t *testing.T) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
|
||||
t.Cleanup(cancel)
|
||||
|
||||
suffix := time.Now().UTC().Format("20060102-150405")
|
||||
suffix := clie2e.GenerateSuffix()
|
||||
taskGUID := createTask(t, parentT, ctx, clie2e.Request{
|
||||
Args: []string{"task", "+create"},
|
||||
Data: map[string]any{
|
||||
|
||||
@@ -19,7 +19,7 @@ func TestTask_TasklistAddTaskWorkflow(t *testing.T) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
|
||||
t.Cleanup(cancel)
|
||||
|
||||
suffix := time.Now().UTC().Format("20060102-150405")
|
||||
suffix := clie2e.GenerateSuffix()
|
||||
tasklistName := "lark-cli-e2e-tasklist-add-" + suffix
|
||||
taskSummary := "lark-cli-e2e-tasklist-add-task-" + suffix
|
||||
|
||||
|
||||
@@ -19,7 +19,7 @@ func TestTask_TasklistWorkflow(t *testing.T) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
|
||||
t.Cleanup(cancel)
|
||||
|
||||
suffix := time.Now().UTC().Format("20060102-150405")
|
||||
suffix := clie2e.GenerateSuffix()
|
||||
tasklistName := "lark-cli-e2e-tasklist-" + suffix
|
||||
taskSummary := "lark-cli-e2e-task-in-tasklist-" + suffix
|
||||
taskDescription := "created by tests/cli_e2e/task"
|
||||
|
||||
70
tests/cli_e2e/wiki/helpers_test.go
Normal file
70
tests/cli_e2e/wiki/helpers_test.go
Normal file
@@ -0,0 +1,70 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package wiki
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
clie2e "github.com/larksuite/cli/tests/cli_e2e"
|
||||
"github.com/stretchr/testify/require"
|
||||
"github.com/tidwall/gjson"
|
||||
)
|
||||
|
||||
func createWikiNode(t *testing.T, ctx context.Context, req clie2e.Request) gjson.Result {
|
||||
t.Helper()
|
||||
|
||||
result, err := clie2e.RunCmdWithRetry(ctx, req, clie2e.RetryOptions{})
|
||||
require.NoError(t, err)
|
||||
result.AssertExitCode(t, 0)
|
||||
result.AssertStdoutStatus(t, 0)
|
||||
|
||||
node := gjson.Get(result.Stdout, "data.node")
|
||||
require.True(t, node.Exists(), "stdout:\n%s", result.Stdout)
|
||||
|
||||
return node
|
||||
}
|
||||
|
||||
func findWikiNodeByToken(t *testing.T, ctx context.Context, spaceID string, nodeToken string) gjson.Result {
|
||||
t.Helper()
|
||||
|
||||
require.NotEmpty(t, spaceID, "space ID is required")
|
||||
require.NotEmpty(t, nodeToken, "node token is required")
|
||||
|
||||
pageToken := ""
|
||||
seenPageTokens := map[string]struct{}{}
|
||||
for {
|
||||
params := map[string]any{
|
||||
"space_id": spaceID,
|
||||
"page_size": 50,
|
||||
}
|
||||
if pageToken != "" {
|
||||
if _, seen := seenPageTokens[pageToken]; seen {
|
||||
t.Fatalf("wiki node list pagination loop detected for space %q, repeated page_token %q", spaceID, pageToken)
|
||||
}
|
||||
seenPageTokens[pageToken] = struct{}{}
|
||||
params["page_token"] = pageToken
|
||||
}
|
||||
|
||||
result, err := clie2e.RunCmd(ctx, clie2e.Request{
|
||||
Args: []string{"wiki", "nodes", "list"},
|
||||
DefaultAs: "bot",
|
||||
Params: params,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
result.AssertExitCode(t, 0)
|
||||
result.AssertStdoutStatus(t, 0)
|
||||
|
||||
node := gjson.Get(result.Stdout, `data.items.#(node_token=="`+nodeToken+`")`)
|
||||
if node.Exists() {
|
||||
return node
|
||||
}
|
||||
|
||||
hasMore := gjson.Get(result.Stdout, "data.has_more").Bool()
|
||||
pageToken = gjson.Get(result.Stdout, "data.page_token").String()
|
||||
if !hasMore || pageToken == "" {
|
||||
t.Fatalf("wiki node %q not found in listed pages, last stdout:\n%s", nodeToken, result.Stdout)
|
||||
}
|
||||
}
|
||||
}
|
||||
150
tests/cli_e2e/wiki/wiki_workflow_test.go
Normal file
150
tests/cli_e2e/wiki/wiki_workflow_test.go
Normal file
@@ -0,0 +1,150 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package wiki
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
clie2e "github.com/larksuite/cli/tests/cli_e2e"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"github.com/tidwall/gjson"
|
||||
)
|
||||
|
||||
func TestWiki_NodeWorkflow(t *testing.T) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
|
||||
t.Cleanup(cancel)
|
||||
|
||||
suffix := clie2e.GenerateSuffix()
|
||||
createdTitle := "lark-cli-e2e-wiki-create-" + suffix
|
||||
copiedTitle := "lark-cli-e2e-wiki-copy-" + suffix
|
||||
|
||||
var spaceID string
|
||||
var createdNodeToken string
|
||||
var createdObjToken string
|
||||
var copiedNodeToken string
|
||||
|
||||
t.Run("create node", func(t *testing.T) {
|
||||
node := createWikiNode(t, ctx, clie2e.Request{
|
||||
Args: []string{"wiki", "nodes", "create"},
|
||||
DefaultAs: "bot",
|
||||
Params: map[string]any{
|
||||
"space_id": "my_library",
|
||||
},
|
||||
Data: map[string]any{
|
||||
"node_type": "origin",
|
||||
"obj_type": "docx",
|
||||
"title": createdTitle,
|
||||
},
|
||||
})
|
||||
|
||||
spaceID = node.Get("space_id").String()
|
||||
createdNodeToken = node.Get("node_token").String()
|
||||
createdObjToken = node.Get("obj_token").String()
|
||||
require.NotEmpty(t, spaceID)
|
||||
require.NotEmpty(t, createdNodeToken)
|
||||
require.NotEmpty(t, createdObjToken)
|
||||
assert.Equal(t, createdTitle, node.Get("title").String())
|
||||
assert.Equal(t, "origin", node.Get("node_type").String())
|
||||
assert.Equal(t, "docx", node.Get("obj_type").String())
|
||||
})
|
||||
|
||||
t.Run("get created node", func(t *testing.T) {
|
||||
require.NotEmpty(t, createdNodeToken, "node token should be created before get_node")
|
||||
|
||||
result, err := clie2e.RunCmd(ctx, clie2e.Request{
|
||||
Args: []string{"wiki", "spaces", "get_node"},
|
||||
DefaultAs: "bot",
|
||||
Params: map[string]any{
|
||||
"token": createdNodeToken,
|
||||
"obj_type": "wiki",
|
||||
},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
result.AssertExitCode(t, 0)
|
||||
result.AssertStdoutStatus(t, 0)
|
||||
|
||||
assert.Equal(t, createdNodeToken, gjson.Get(result.Stdout, "data.node.node_token").String())
|
||||
assert.Equal(t, createdObjToken, gjson.Get(result.Stdout, "data.node.obj_token").String())
|
||||
assert.Equal(t, createdTitle, gjson.Get(result.Stdout, "data.node.title").String())
|
||||
assert.Equal(t, spaceID, gjson.Get(result.Stdout, "data.node.space_id").String())
|
||||
})
|
||||
|
||||
t.Run("get space", func(t *testing.T) {
|
||||
require.NotEmpty(t, spaceID, "space ID should be available before get")
|
||||
|
||||
result, err := clie2e.RunCmd(ctx, clie2e.Request{
|
||||
Args: []string{"wiki", "spaces", "get"},
|
||||
DefaultAs: "bot",
|
||||
Params: map[string]any{
|
||||
"space_id": spaceID,
|
||||
},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
result.AssertExitCode(t, 0)
|
||||
result.AssertStdoutStatus(t, 0)
|
||||
|
||||
assert.Equal(t, spaceID, gjson.Get(result.Stdout, "data.space.space_id").String())
|
||||
assert.NotEmpty(t, gjson.Get(result.Stdout, "data.space.name").String(), "stdout:\n%s", result.Stdout)
|
||||
})
|
||||
|
||||
t.Run("list spaces", func(t *testing.T) {
|
||||
result, err := clie2e.RunCmd(ctx, clie2e.Request{
|
||||
Args: []string{"wiki", "spaces", "list"},
|
||||
DefaultAs: "bot",
|
||||
Params: map[string]any{
|
||||
"page_size": 1,
|
||||
},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
result.AssertExitCode(t, 0)
|
||||
result.AssertStdoutStatus(t, 0)
|
||||
|
||||
assert.True(t, gjson.Get(result.Stdout, "data.page_token").Exists(), "stdout:\n%s", result.Stdout)
|
||||
assert.True(t, gjson.Get(result.Stdout, "data.items").Exists(), "stdout:\n%s", result.Stdout)
|
||||
})
|
||||
|
||||
t.Run("list nodes and find created node", func(t *testing.T) {
|
||||
require.NotEmpty(t, spaceID, "space ID should be available before list")
|
||||
require.NotEmpty(t, createdNodeToken, "node token should be available before list")
|
||||
|
||||
nodeItem := findWikiNodeByToken(t, ctx, spaceID, createdNodeToken)
|
||||
assert.Equal(t, createdTitle, nodeItem.Get("title").String())
|
||||
assert.Equal(t, createdObjToken, nodeItem.Get("obj_token").String())
|
||||
})
|
||||
|
||||
t.Run("copy node", func(t *testing.T) {
|
||||
require.NotEmpty(t, spaceID, "space ID should be available before copy")
|
||||
require.NotEmpty(t, createdNodeToken, "node token should be available before copy")
|
||||
|
||||
copiedNode := createWikiNode(t, ctx, clie2e.Request{
|
||||
Args: []string{"wiki", "nodes", "copy"},
|
||||
DefaultAs: "bot",
|
||||
Params: map[string]any{
|
||||
"space_id": spaceID,
|
||||
"node_token": createdNodeToken,
|
||||
},
|
||||
Data: map[string]any{
|
||||
"target_space_id": spaceID,
|
||||
"title": copiedTitle,
|
||||
},
|
||||
})
|
||||
|
||||
copiedNodeToken = copiedNode.Get("node_token").String()
|
||||
require.NotEmpty(t, copiedNodeToken)
|
||||
assert.Equal(t, copiedTitle, copiedNode.Get("title").String())
|
||||
assert.Equal(t, spaceID, copiedNode.Get("space_id").String())
|
||||
assert.NotEqual(t, createdNodeToken, copiedNodeToken)
|
||||
})
|
||||
|
||||
t.Run("list nodes and find copied node", func(t *testing.T) {
|
||||
require.NotEmpty(t, spaceID, "space ID should be available before second list")
|
||||
require.NotEmpty(t, copiedNodeToken, "copied node token should be available before second list")
|
||||
|
||||
nodeItem := findWikiNodeByToken(t, ctx, spaceID, copiedNodeToken)
|
||||
assert.Equal(t, copiedTitle, nodeItem.Get("title").String())
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user