mirror of
https://github.com/larksuite/cli.git
synced 2026-07-09 18:34:03 +08:00
Compare commits
5 Commits
feat/sheet
...
feat/confi
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
771a30a3b1 | ||
|
|
d7a83dfc79 | ||
|
|
5b050095ac | ||
|
|
960f6daabc | ||
|
|
2fcb703943 |
34
.github/workflows/ci.yml
vendored
34
.github/workflows/ci.yml
vendored
@@ -318,39 +318,7 @@ jobs:
|
||||
continue-on-error: true
|
||||
run: go run golang.org/x/vuln/cmd/govulncheck@v1.1.4 ./...
|
||||
- name: Check dependency licenses
|
||||
# --ignore github.com/apache/arrow/go/v17: Arrow is Apache-2.0 overall,
|
||||
# but its LICENSE.txt also inlines the c-ares 3rdparty notice (Arrow's
|
||||
# python wheels statically link c-ares) — and go-licenses' classifier
|
||||
# parses the whole file as a single license, so it reports the module
|
||||
# as "LicenseRef-C-Ares / Unknown". The follow-up step pins the actual
|
||||
# license type by inspecting the LICENSE.txt itself, so the wholesale
|
||||
# --ignore here doesn't become a free pass for future Arrow re-licensing.
|
||||
# Required by sheets +table-put / +table-get / +workbook-create --dataframe (Arrow IPC ingest).
|
||||
run: go run github.com/google/go-licenses/v2@v2.0.1 check ./... --disallowed_types=forbidden,restricted,reciprocal,unknown --ignore github.com/apache/arrow/go/v17
|
||||
- name: Assert Apache Arrow LICENSE.txt remains Apache-2.0
|
||||
# Independent re-check that the go-licenses ignore above is purely a
|
||||
# classifier workaround, not a free pass: confirm Arrow's LICENSE.txt
|
||||
# still opens with the Apache License and still inlines the c-ares
|
||||
# notice that is the actual reason go-licenses misreports the module.
|
||||
# If Arrow ever re-licenses its primary license or drops the c-ares
|
||||
# notice (meaning go-licenses might start reporting a different /
|
||||
# genuinely problematic identifier instead), this step fails and a
|
||||
# human must re-evaluate the --ignore.
|
||||
run: |
|
||||
set -euo pipefail
|
||||
LICENSE_PATH="$(go env GOMODCACHE)/github.com/apache/arrow/go/v17@v17.0.0/LICENSE.txt"
|
||||
if [ ! -f "$LICENSE_PATH" ]; then
|
||||
echo "::error::Apache Arrow LICENSE.txt not found at $LICENSE_PATH" >&2
|
||||
exit 1
|
||||
fi
|
||||
if ! head -50 "$LICENSE_PATH" | grep -q 'Apache License' ; then
|
||||
echo "::error::Apache Arrow LICENSE.txt no longer leads with the Apache License — re-evaluate the go-licenses --ignore" >&2
|
||||
exit 1
|
||||
fi
|
||||
if ! grep -q '3rdparty dependency c-ares' "$LICENSE_PATH" ; then
|
||||
echo "::error::Apache Arrow LICENSE.txt no longer inlines the c-ares notice — go-licenses may now report a different identifier; re-evaluate the --ignore" >&2
|
||||
exit 1
|
||||
fi
|
||||
run: go run github.com/google/go-licenses/v2@v2.0.1 check ./... --disallowed_types=forbidden,restricted,reciprocal,unknown
|
||||
|
||||
license-header:
|
||||
if: ${{ github.event_name == 'pull_request' }}
|
||||
|
||||
30
.github/workflows/semantic-review.yml
vendored
30
.github/workflows/semantic-review.yml
vendored
@@ -47,13 +47,10 @@ jobs:
|
||||
throw new Error(`ambiguous workflow_run pull request bindings: ${runPRs.length}`);
|
||||
}
|
||||
let prNumber = Number(runPRs[0]?.number || 0);
|
||||
const eventBaseSha = runPRs[0]?.base?.sha || "";
|
||||
let eventBaseSha = runPRs[0]?.base?.sha || "";
|
||||
const eventHeadSha = runPRs[0]?.head?.sha || "";
|
||||
const targetHeadSha = run.head_sha;
|
||||
const targetHeadSha = eventHeadSha || run.head_sha;
|
||||
if (!/^[a-f0-9]{40}$/i.test(targetHeadSha)) throw new Error("invalid PR head sha");
|
||||
if (eventHeadSha && eventHeadSha.toLowerCase() !== targetHeadSha.toLowerCase()) {
|
||||
core.notice("PR quality summary using workflow_run head_sha because workflow_run pull request head differs from the CI run head");
|
||||
}
|
||||
|
||||
const factsArtifactPattern = /^quality-gate-facts-([a-f0-9]{40})-([a-f0-9]{40})$/i;
|
||||
const { data: artifactData } = await github.rest.actions.listWorkflowRunArtifacts({
|
||||
@@ -74,11 +71,11 @@ jobs:
|
||||
if (artifactHeadSha.toLowerCase() !== targetHeadSha.toLowerCase()) {
|
||||
artifactError = "facts artifact head sha does not match verified PR head sha";
|
||||
factsArtifactName = "";
|
||||
} else if (eventBaseSha && parsedBaseSha.toLowerCase() !== eventBaseSha.toLowerCase()) {
|
||||
artifactError = "facts artifact base sha does not match workflow_run pull request base sha";
|
||||
factsArtifactName = "";
|
||||
} else {
|
||||
artifactBaseSha = parsedBaseSha;
|
||||
if (eventBaseSha && parsedBaseSha.toLowerCase() !== eventBaseSha.toLowerCase()) {
|
||||
core.notice("PR quality summary using facts artifact base because workflow_run pull request base differs from the CI facts artifact base");
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!prNumber) {
|
||||
@@ -126,7 +123,7 @@ jobs:
|
||||
core.setOutput("stale", "true");
|
||||
return;
|
||||
}
|
||||
const baseSha = artifactBaseSha || eventBaseSha || pr.base.sha;
|
||||
const baseSha = eventBaseSha || artifactBaseSha || pr.base.sha;
|
||||
if (!/^[a-f0-9]{40}$/i.test(baseSha)) throw new Error("invalid PR base sha");
|
||||
if ((eventBaseSha || artifactBaseSha) && pr.base.sha !== baseSha) {
|
||||
core.notice("PR quality summary skipped: workflow_run is stale for this PR base");
|
||||
@@ -258,13 +255,10 @@ jobs:
|
||||
throw new Error(`ambiguous workflow_run pull request bindings: ${runPRs.length}`);
|
||||
}
|
||||
let prNumber = Number(runPRs[0]?.number || 0);
|
||||
const eventBaseSha = runPRs[0]?.base?.sha || "";
|
||||
let eventBaseSha = runPRs[0]?.base?.sha || "";
|
||||
const eventHeadSha = runPRs[0]?.head?.sha || "";
|
||||
const targetHeadSha = run.head_sha;
|
||||
const targetHeadSha = eventHeadSha || run.head_sha;
|
||||
if (!/^[a-f0-9]{40}$/i.test(targetHeadSha)) throw new Error("invalid PR head sha");
|
||||
if (eventHeadSha && eventHeadSha.toLowerCase() !== targetHeadSha.toLowerCase()) {
|
||||
core.notice("semantic review using workflow_run head_sha because workflow_run pull request head differs from the CI run head");
|
||||
}
|
||||
|
||||
const factsArtifactPattern = /^quality-gate-facts-([a-f0-9]{40})-([a-f0-9]{40})$/i;
|
||||
const { data: artifactData } = await github.rest.actions.listWorkflowRunArtifacts({
|
||||
@@ -285,11 +279,11 @@ jobs:
|
||||
if (artifactHeadSha.toLowerCase() !== targetHeadSha.toLowerCase()) {
|
||||
artifactError = "facts artifact head sha does not match verified PR head sha";
|
||||
factsArtifactName = "";
|
||||
} else if (eventBaseSha && parsedBaseSha.toLowerCase() !== eventBaseSha.toLowerCase()) {
|
||||
artifactError = "facts artifact base sha does not match workflow_run pull request base sha";
|
||||
factsArtifactName = "";
|
||||
} else {
|
||||
artifactBaseSha = parsedBaseSha;
|
||||
if (eventBaseSha && parsedBaseSha.toLowerCase() !== eventBaseSha.toLowerCase()) {
|
||||
core.notice("semantic review using facts artifact base because workflow_run pull request base differs from the CI facts artifact base");
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!prNumber) {
|
||||
@@ -337,7 +331,7 @@ jobs:
|
||||
core.setOutput("stale", "true");
|
||||
return;
|
||||
}
|
||||
const baseSha = artifactBaseSha || eventBaseSha || pr.base.sha;
|
||||
const baseSha = eventBaseSha || artifactBaseSha || pr.base.sha;
|
||||
if (!/^[a-f0-9]{40}$/i.test(baseSha)) throw new Error("invalid PR base sha");
|
||||
if ((eventBaseSha || artifactBaseSha) && pr.base.sha !== baseSha) {
|
||||
core.notice("semantic review skipped: workflow_run is stale for this PR base");
|
||||
|
||||
5
.gitignore
vendored
5
.gitignore
vendored
@@ -7,11 +7,6 @@ bin/
|
||||
# Node
|
||||
node_modules/
|
||||
|
||||
# Python (skill-bundled helper scripts)
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
*$py.class
|
||||
|
||||
|
||||
# OS
|
||||
.DS_Store
|
||||
|
||||
49
CHANGELOG.md
49
CHANGELOG.md
@@ -2,53 +2,6 @@
|
||||
|
||||
All notable changes to this project will be documented in this file.
|
||||
|
||||
## [v1.0.57] - 2026-06-23
|
||||
|
||||
### Features
|
||||
|
||||
- **slides**: Add `+screenshot` to capture slide page images (or render a single `<slide>` XML snippet), returning the local file path instead of Base64 (#1358)
|
||||
- **base**: Support record comments (#1043)
|
||||
- **search**: Surface search API notices (#1413)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- **mail**: Resolve folder/label filter once per `+triage list` call (#1512)
|
||||
- **meta**: Backfill enum value descriptions from options (#1541)
|
||||
- **cli**: Add missing CLI headers for git credential helper (#1539)
|
||||
|
||||
### Documentation
|
||||
|
||||
- **doc**: Refine rich block, path, and block ID guidance (#1508)
|
||||
- **mail**: Trim lark-mail skill context (#1527)
|
||||
- **drive**: Add permission governance workflow guidance (#1292)
|
||||
|
||||
### Build
|
||||
|
||||
- **ci**: Bind semantic review to workflow run head (#1551)
|
||||
|
||||
## [v1.0.56] - 2026-06-18
|
||||
|
||||
### Features
|
||||
|
||||
- **apps**: Add `+session-messages-list` for session turn reply messages (#1402)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- **api**: Align API success envelopes (#1489)
|
||||
- **base**: Reject out-of-range pagination flags (#1495)
|
||||
|
||||
### Refactor
|
||||
|
||||
- Retire legacy error envelopes and enforce typed contract (#1449)
|
||||
|
||||
### Documentation
|
||||
|
||||
- **skills**: Soften lark-doc style guidance (#1463)
|
||||
|
||||
### Build
|
||||
|
||||
- Add CI quality gate with semantic review
|
||||
|
||||
## [v1.0.55] - 2026-06-16
|
||||
|
||||
### Features
|
||||
@@ -1236,8 +1189,6 @@ Bundled AI agent skills for intelligent assistance:
|
||||
- Bilingual documentation (English & Chinese).
|
||||
- CI/CD pipelines: linting, testing, coverage reporting, and automated releases.
|
||||
|
||||
[v1.0.57]: https://github.com/larksuite/cli/releases/tag/v1.0.57
|
||||
[v1.0.56]: https://github.com/larksuite/cli/releases/tag/v1.0.56
|
||||
[v1.0.55]: https://github.com/larksuite/cli/releases/tag/v1.0.55
|
||||
[v1.0.54]: https://github.com/larksuite/cli/releases/tag/v1.0.54
|
||||
[v1.0.53]: https://github.com/larksuite/cli/releases/tag/v1.0.53
|
||||
|
||||
@@ -285,12 +285,18 @@ func TestConfigInitRun_NonTerminal_NoFlags_RejectsWithHint(t *testing.T) {
|
||||
if err == nil {
|
||||
t.Fatal("expected error for non-terminal without flags")
|
||||
}
|
||||
msg := err.Error()
|
||||
if !strings.Contains(msg, "--new") {
|
||||
t.Errorf("expected error to mention --new, got: %s", msg)
|
||||
if !strings.Contains(err.Error(), "terminal") {
|
||||
t.Errorf("expected error to mention terminal, got: %s", err.Error())
|
||||
}
|
||||
if !strings.Contains(msg, "terminal") {
|
||||
t.Errorf("expected error to mention terminal, got: %s", msg)
|
||||
// Missing-terminal is a failed precondition (valid request, wrong runtime
|
||||
// state), and the actionable guidance lives in the hint.
|
||||
p, ok := errs.ProblemOf(err)
|
||||
if !ok || p.Subtype != errs.SubtypeFailedPrecondition {
|
||||
t.Fatalf("expected subtype=%q, got problem=%+v", errs.SubtypeFailedPrecondition, p)
|
||||
}
|
||||
// Lock the two-step guidance contract: the hint must point at both flags.
|
||||
if !strings.Contains(p.Hint, "--no-wait") || !strings.Contains(p.Hint, "--device-code") {
|
||||
t.Errorf("hint should describe the two-step flow (--no-wait / --device-code), got: %s", p.Hint)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -32,6 +32,13 @@ type ConfigInitOptions struct {
|
||||
Brand string
|
||||
New bool
|
||||
|
||||
// NoWait initiates a new-app creation and returns immediately with a
|
||||
// device code (non-blocking step 1); DeviceCode completes a creation
|
||||
// previously started with --no-wait (non-blocking step 2). They mirror
|
||||
// `auth login`'s --no-wait / --device-code split.
|
||||
NoWait bool
|
||||
DeviceCode string
|
||||
|
||||
Lang string // raw --lang (string for cobra); normalized to canonical/"" in validateInitLang
|
||||
langExplicit bool // true when --lang was explicitly passed
|
||||
|
||||
@@ -56,9 +63,11 @@ func NewCmdConfigInit(f *cmdutil.Factory, runF func(*ConfigInitOptions) error) *
|
||||
Short: "Initialize configuration (app-id / app-secret-stdin / brand)",
|
||||
Long: `Initialize configuration (app-id / app-secret-stdin / brand).
|
||||
|
||||
For AI agents: use --new to create a new app. The command blocks until the user
|
||||
completes setup in the browser. Run it in the background and retrieve the
|
||||
verification URL from its output.
|
||||
For AI agents: prefer the non-blocking two-step flow. Run '--new --no-wait' to
|
||||
get a device code and verification URL immediately (printed as JSON), send the
|
||||
URL/QR to the user, then run '--device-code <code>' after they confirm to finish.
|
||||
The plain '--new' still blocks until the user completes setup in the browser if
|
||||
you need the old behavior.
|
||||
|
||||
Inside an Agent context (OPENCLAW_HOME / HERMES_HOME set) this command
|
||||
refuses by default — use 'lark-cli config bind' to bind to the Agent's
|
||||
@@ -81,6 +90,8 @@ if the user explicitly wants a separate app inside the Agent workspace.`,
|
||||
}
|
||||
|
||||
cmd.Flags().BoolVar(&opts.New, "new", false, "create a new app directly (skip mode selection)")
|
||||
cmd.Flags().BoolVar(&opts.NoWait, "no-wait", false, "create a new app but return immediately with a device code; complete later with --device-code (non-blocking, for AI agents)")
|
||||
cmd.Flags().StringVar(&opts.DeviceCode, "device-code", "", "complete a new-app creation started with --no-wait, using its device code")
|
||||
cmd.Flags().StringVar(&opts.AppID, "app-id", "", "App ID (non-interactive)")
|
||||
cmd.Flags().BoolVar(&opts.AppSecretStdin, "app-secret-stdin", false, "Read App Secret from stdin to avoid process list exposure")
|
||||
cmd.Flags().StringVar(&opts.Brand, "brand", "feishu", "feishu or lark (non-interactive, default feishu)")
|
||||
@@ -132,7 +143,7 @@ func guardAgentWorkspace(opts *ConfigInitOptions) error {
|
||||
|
||||
// hasAnyNonInteractiveFlag returns true if any non-interactive flag is set.
|
||||
func (o *ConfigInitOptions) hasAnyNonInteractiveFlag() bool {
|
||||
return o.New || o.AppID != "" || o.AppSecretStdin
|
||||
return o.New || o.AppID != "" || o.AppSecretStdin || o.NoWait || o.DeviceCode != ""
|
||||
}
|
||||
|
||||
// cleanupOldConfig clears keychain entries (AppSecret + UAT) for all apps in existing config except the app whose AppId equals skipAppID.
|
||||
@@ -308,6 +319,22 @@ func updateExistingProfileWithoutSecret(existing *core.MultiAppConfig, profileNa
|
||||
func configInitRun(opts *ConfigInitOptions) error {
|
||||
f := opts.Factory
|
||||
|
||||
// Validate the non-blocking flags before touching stdin so a contradictory
|
||||
// combination (e.g. --no-wait --app-secret-stdin) fails fast instead of
|
||||
// blocking on a stdin read.
|
||||
if opts.NoWait && opts.DeviceCode != "" {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--no-wait and --device-code cannot be used together").WithParam("--device-code")
|
||||
}
|
||||
if (opts.NoWait || opts.DeviceCode != "") && (opts.AppID != "" || opts.AppSecretStdin) {
|
||||
// Point remediation at whichever non-blocking flag the caller actually
|
||||
// passed (mutual exclusion above guarantees at most one is set here).
|
||||
conflictParam := "--no-wait"
|
||||
if opts.DeviceCode != "" {
|
||||
conflictParam = "--device-code"
|
||||
}
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--no-wait/--device-code create a new app and cannot be combined with --app-id/--app-secret-stdin").WithParam(conflictParam)
|
||||
}
|
||||
|
||||
// Read secret from stdin if --app-secret-stdin is set
|
||||
if opts.AppSecretStdin {
|
||||
scanner := bufio.NewScanner(f.IOStreams.In)
|
||||
@@ -335,6 +362,15 @@ func configInitRun(opts *ConfigInitOptions) error {
|
||||
}
|
||||
}
|
||||
|
||||
// Non-blocking step 2: complete a creation started with --no-wait.
|
||||
if opts.DeviceCode != "" {
|
||||
return resumeAppRegistration(opts)
|
||||
}
|
||||
// Non-blocking step 1: initiate a new-app creation and return immediately.
|
||||
if opts.NoWait {
|
||||
return initiateNoWaitAppRegistration(opts, existing)
|
||||
}
|
||||
|
||||
// Mode 1: Non-interactive
|
||||
if opts.AppID != "" && opts.appSecret != "" {
|
||||
brand := parseBrand(opts.Brand)
|
||||
@@ -437,9 +473,12 @@ func configInitRun(opts *ConfigInitOptions) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Non-terminal: cannot run interactive mode, guide user to --new
|
||||
// Non-terminal: the request is valid but the runtime state is wrong (no
|
||||
// terminal for interactive mode) — a failed precondition, not a bad
|
||||
// argument. Point the caller at the non-blocking two-step flow.
|
||||
if !f.IOStreams.IsTerminal {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "config init requires a terminal for interactive mode. Run with --new to create a new app:\n lark-cli config init --new\nThis command blocks until setup is complete and outputs a verification URL. Run it in the background, then retrieve the URL from its output.")
|
||||
return errs.NewValidationError(errs.SubtypeFailedPrecondition, "config init interactive mode requires a terminal").
|
||||
WithHint("Create a new app non-interactively with the two-step flow: `lark-cli config init --new --no-wait` (prints device_code + verification_url, returns immediately), then `lark-cli config init --device-code <code>` after the user finishes in the browser. Or run `lark-cli config init --new` in a terminal.")
|
||||
}
|
||||
|
||||
// Mode 5: Legacy interactive (readline fallback)
|
||||
|
||||
@@ -182,6 +182,11 @@ func runCreateAppFlow(ctx context.Context, f *cmdutil.Factory, brandOverride cor
|
||||
httpClient := transport.NewHTTPClient(0)
|
||||
authResp, err := larkauth.RequestAppRegistration(httpClient, larkBrand, f.IOStreams.ErrOut)
|
||||
if err != nil {
|
||||
// Pass a lower-layer typed error (e.g. a network/transport error) through
|
||||
// unchanged; only wrap genuinely-untyped failures as invalid_client.
|
||||
if _, ok := errs.ProblemOf(err); ok {
|
||||
return nil, err
|
||||
}
|
||||
return nil, errs.NewConfigError(errs.SubtypeInvalidClient, "app registration failed: %v", err).WithCause(err)
|
||||
}
|
||||
|
||||
|
||||
265
cmd/config/init_nowait.go
Normal file
265
cmd/config/init_nowait.go
Normal file
@@ -0,0 +1,265 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package config
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"time"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
larkauth "github.com/larksuite/cli/internal/auth"
|
||||
"github.com/larksuite/cli/internal/build"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"github.com/larksuite/cli/internal/transport"
|
||||
)
|
||||
|
||||
// newRegistrationHTTPClient builds the HTTP client used for app-registration
|
||||
// traffic. It is a package var so tests can inject a stub transport.
|
||||
var newRegistrationHTTPClient = func() *http.Client { return transport.NewHTTPClient(0) }
|
||||
|
||||
// initNoWaitHint is the agent-facing guidance embedded in the --no-wait JSON
|
||||
// output, mirroring the two-step contract of `auth login --no-wait`.
|
||||
const initNoWaitHint = "**Generate AND display the QR code:** call `lark-cli auth qrcode <verification_url>` and show it (PNG via --output; ASCII via --ascii only if the user asks). " +
|
||||
"**You MUST include the QR image in your response** — generating the file alone is not enough. Output the URL first, then the QR image below it. " +
|
||||
"**Treat verification_url as an opaque string** — do not URL-encode/decode it or add spaces/punctuation. " +
|
||||
"**Hand control back:** make the QR/URL the final message of this turn; do NOT run --device-code in the same turn. Tell the user to come back and notify you after they finish creating the app in the browser. " +
|
||||
"**After the user confirms:** YOU must finish by running lark-cli with the exact arguments in `resume_args`, passing each element as a separate literal argument (do not re-quote or shell-interpret them). It already carries the right flags. " +
|
||||
"**Do NOT cache verification_url or device_code** — run `lark-cli config init --new --no-wait` fresh whenever a new app is needed."
|
||||
|
||||
// initiateNoWaitAppRegistration runs the non-blocking first step: request a
|
||||
// device code, cache the resume context, print JSON, and return immediately
|
||||
// without polling.
|
||||
func initiateNoWaitAppRegistration(opts *ConfigInitOptions, existing *core.MultiAppConfig) error {
|
||||
f := opts.Factory
|
||||
brand := parseBrand(opts.Brand)
|
||||
|
||||
httpClient := newRegistrationHTTPClient()
|
||||
authResp, err := larkauth.RequestAppRegistration(httpClient, brand, f.IOStreams.ErrOut)
|
||||
if err != nil {
|
||||
// Pass a lower-layer typed error (e.g. a network/transport error) through
|
||||
// unchanged; only wrap genuinely-untyped failures as invalid_client.
|
||||
if _, ok := errs.ProblemOf(err); ok {
|
||||
return err
|
||||
}
|
||||
return errs.NewConfigError(errs.SubtypeInvalidClient, "app registration failed: %v", err).WithCause(err)
|
||||
}
|
||||
|
||||
rec := initNoWaitRecord{
|
||||
Version: initNoWaitCacheVersion,
|
||||
Brand: string(brand),
|
||||
ProfileName: opts.ProfileName,
|
||||
Lang: opts.Lang,
|
||||
LangExplicit: opts.langExplicit,
|
||||
Interval: authResp.Interval,
|
||||
ExpiresAt: time.Now().Unix() + int64(authResp.ExpiresIn),
|
||||
ConfigDigest: computeConfigDigest(existing),
|
||||
}
|
||||
// The resume step (--device-code) fully depends on this cache to finish
|
||||
// persisting the app — unlike auth login, which can re-derive its scope. So
|
||||
// a cache-write failure is fatal: fail now rather than hand back a
|
||||
// device_code the user can never complete.
|
||||
if err := saveInitNoWaitRecord(authResp.DeviceCode, rec); err != nil {
|
||||
return errs.NewInternalError(errs.SubtypeStorage, "failed to persist the context needed by `config init --device-code`: %v", err).WithCause(err)
|
||||
}
|
||||
|
||||
// Emit the resume step as an argv array rather than a shell string: the
|
||||
// device_code is opaque and may contain spaces or metacharacters, and a
|
||||
// single quoted string can't be both POSIX- and cmd.exe-safe. argv sidesteps
|
||||
// quoting entirely — agents pass each element as a literal argument.
|
||||
// --force-init must be carried along: guardAgentWorkspace runs in RunE
|
||||
// before the cache is read, so resuming without it inside an agent workspace
|
||||
// would be rejected. (Profile name is recovered from the cache.)
|
||||
resumeArgs := []string{"lark-cli", "config", "init", "--device-code", authResp.DeviceCode}
|
||||
if opts.ForceInit {
|
||||
resumeArgs = append(resumeArgs, "--force-init")
|
||||
}
|
||||
|
||||
verificationURL := larkauth.BuildVerificationURL(authResp.VerificationUriComplete, build.Version)
|
||||
data := map[string]interface{}{
|
||||
"verification_url": verificationURL,
|
||||
"device_code": authResp.DeviceCode,
|
||||
"expires_in": authResp.ExpiresIn,
|
||||
"resume_args": resumeArgs,
|
||||
"hint": initNoWaitHint,
|
||||
}
|
||||
encoder := json.NewEncoder(f.IOStreams.Out)
|
||||
encoder.SetEscapeHTML(false)
|
||||
if err := encoder.Encode(data); err != nil {
|
||||
return errs.NewInternalError(errs.SubtypeSDKError, "failed to write JSON output: %v", err).WithCause(err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// resumeAppRegistration runs the non-blocking second step: poll with a device
|
||||
// code from a previous --no-wait call, then persist the new app and probe it.
|
||||
func resumeAppRegistration(opts *ConfigInitOptions) error {
|
||||
f := opts.Factory
|
||||
|
||||
rec, err := loadInitNoWaitRecord(opts.DeviceCode)
|
||||
if err != nil {
|
||||
// The record exists but could not be read/parsed (permissions, disk,
|
||||
// corruption). The resume step fully depends on this cache, so surface a
|
||||
// storage error instead of the misleading "no pending creation"
|
||||
// validation path — the user should fix local storage, not assume the
|
||||
// device code is bad and throw away a still-valid creation attempt.
|
||||
return errs.NewInternalError(errs.SubtypeStorage, "failed to read the cached resume context: %v", err).WithCause(err)
|
||||
}
|
||||
if rec == nil {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument,
|
||||
"no pending app creation found for this device code; re-initiate with `lark-cli config init --new --no-wait`").
|
||||
WithParam("--device-code")
|
||||
}
|
||||
|
||||
// Expiry check against the cached absolute deadline (device codes are
|
||||
// short-lived — the registration default is 300s).
|
||||
remaining := rec.ExpiresAt - time.Now().Unix()
|
||||
if remaining <= 0 {
|
||||
_ = removeInitNoWaitRecord(opts.DeviceCode)
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument,
|
||||
"device code expired; re-initiate with `lark-cli config init --new --no-wait`").
|
||||
WithParam("--device-code")
|
||||
}
|
||||
|
||||
// Drift guard (fast path): bail out before the long poll if the config
|
||||
// already changed since initiation, so we don't waste minutes polling.
|
||||
existing, err := loadConfigForDriftCheck()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if computeConfigDigest(existing) != rec.ConfigDigest {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument,
|
||||
"configuration changed since this app creation was started; re-initiate with `lark-cli config init --new --no-wait` to avoid overwriting it").
|
||||
WithParam("--device-code")
|
||||
}
|
||||
|
||||
interval := rec.Interval
|
||||
if interval <= 0 {
|
||||
interval = 5
|
||||
}
|
||||
|
||||
httpClient := newRegistrationHTTPClient()
|
||||
result, pollErr := pollAppRegistrationResume(opts.Ctx, httpClient, opts.DeviceCode, interval, int(remaining), f.IOStreams.ErrOut)
|
||||
if pollErr != nil {
|
||||
// Clear the cache only on terminal failures (denied / expired /
|
||||
// timed-out). Keep it on cancellation or transient errors so the user
|
||||
// can retry with the same device code while it is still valid.
|
||||
if appRegShouldClearCache(pollErr) {
|
||||
_ = removeInitNoWaitRecord(opts.DeviceCode)
|
||||
}
|
||||
// Pass an already-typed error through unchanged (e.g. the ConfigError
|
||||
// for a missing client_id/secret) instead of downgrading it to
|
||||
// authentication/unknown — matching runCreateAppFlow.
|
||||
if _, ok := errs.ProblemOf(pollErr); ok {
|
||||
return pollErr
|
||||
}
|
||||
return errs.NewAuthenticationError(errs.SubtypeUnknown, "%v", pollErr).WithCause(pollErr)
|
||||
}
|
||||
|
||||
// Re-check drift immediately before persisting. The poll above can block
|
||||
// for minutes while the user finishes in the browser, and a concurrent
|
||||
// process may have changed config.json in that window — saving the stale
|
||||
// pre-poll snapshot would drop those edits. Reload and compare again.
|
||||
existing, err = loadConfigForDriftCheck()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if computeConfigDigest(existing) != rec.ConfigDigest {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument,
|
||||
"configuration changed while the app was being created, so it was not saved (to avoid overwriting that change); re-run `lark-cli config init --new --no-wait`").
|
||||
WithParam("--device-code")
|
||||
}
|
||||
|
||||
// Determine the final brand from the response, falling back to the cached
|
||||
// brand. The cached brand only seeds link generation + this fallback; the
|
||||
// Lark-tenant re-poll inside pollAppRegistrationResume is what actually
|
||||
// detects a Lark tenant.
|
||||
finalBrand := parseBrand(rec.Brand)
|
||||
if result.UserInfo != nil && result.UserInfo.TenantBrand == "lark" {
|
||||
finalBrand = core.BrandLark
|
||||
} else if result.UserInfo != nil && result.UserInfo.TenantBrand == "feishu" {
|
||||
finalBrand = core.BrandFeishu
|
||||
}
|
||||
|
||||
secret, err := core.ForStorage(result.ClientID, core.PlainSecret(result.ClientSecret), f.Keychain)
|
||||
if err != nil {
|
||||
return errs.NewInternalError(errs.SubtypeSDKError, "%v", err).WithCause(err)
|
||||
}
|
||||
if err := saveInitConfig(rec.ProfileName, existing, f, result.ClientID, secret, finalBrand, rec.Lang); err != nil {
|
||||
// Preserve a typed error (e.g. the --name conflict ValidationError) via
|
||||
// the shared helper instead of downgrading everything to storage —
|
||||
// matching the blocking init paths.
|
||||
return wrapSaveConfigError(err)
|
||||
}
|
||||
|
||||
// Config persisted — only now is it safe to drop the resume cache. Clearing
|
||||
// it only after a successful save means a failure in the drift re-check,
|
||||
// ForStorage, or saveInitConfig above leaves the cache intact so the user
|
||||
// can retry `--device-code` (the remote app already exists).
|
||||
_ = removeInitNoWaitRecord(opts.DeviceCode)
|
||||
|
||||
if rec.LangExplicit && rec.Lang != "" {
|
||||
msg := getInitMsg(opts.UILang)
|
||||
fmt.Fprintln(f.IOStreams.ErrOut, fmt.Sprintf(msg.LangPreferenceSet, rec.Lang))
|
||||
}
|
||||
|
||||
output.PrintJson(f.IOStreams.Out, map[string]interface{}{"appId": result.ClientID, "appSecret": "****", "brand": finalBrand})
|
||||
if err := runProbe(opts.Ctx, f, result.ClientID, result.ClientSecret, finalBrand); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// pollAppRegistrationResume polls the registration endpoint (feishu first, then
|
||||
// the lark endpoint on the tenant_brand=lark special case) and returns the raw
|
||||
// error so the caller can classify it for cache-cleanup decisions.
|
||||
func pollAppRegistrationResume(ctx context.Context, httpClient *http.Client, deviceCode string, interval, expiresIn int, errOut io.Writer) (*larkauth.AppRegistrationResult, error) {
|
||||
result, err := larkauth.PollAppRegistration(ctx, httpClient, core.BrandFeishu, deviceCode, interval, expiresIn, errOut)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// Lark tenant special case: if tenant_brand=lark and no client_secret,
|
||||
// re-poll against the lark endpoint to obtain the secret.
|
||||
if result.ClientSecret == "" && result.UserInfo != nil && result.UserInfo.TenantBrand == "lark" {
|
||||
result, err = larkauth.PollAppRegistration(ctx, httpClient, core.BrandLark, deviceCode, interval, expiresIn, errOut)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
if result.ClientID == "" || result.ClientSecret == "" {
|
||||
return nil, errs.NewConfigError(errs.SubtypeInvalidClient, "app registration succeeded but missing client_id or client_secret")
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// appRegShouldClearCache reports whether the cached resume context should be
|
||||
// discarded after a poll outcome. Success and terminal failures (user denied,
|
||||
// device code expired, deadline elapsed) clear it; cancellation and transient
|
||||
// errors keep it so the user can retry while the device code is still valid.
|
||||
func appRegShouldClearCache(err error) bool {
|
||||
if err == nil {
|
||||
return true
|
||||
}
|
||||
return errors.Is(err, larkauth.ErrAppRegDenied) ||
|
||||
errors.Is(err, larkauth.ErrAppRegExpired) ||
|
||||
errors.Is(err, larkauth.ErrAppRegTimeout)
|
||||
}
|
||||
|
||||
// loadConfigForDriftCheck loads the config for the drift comparison. A missing
|
||||
// config (first-time setup) is fine — it yields a nil config and an empty
|
||||
// digest. A genuine storage failure (permission denied, corruption) is surfaced
|
||||
// as a typed storage error rather than being silently read as "config drift".
|
||||
func loadConfigForDriftCheck() (*core.MultiAppConfig, error) {
|
||||
existing, err := core.LoadMultiAppConfig()
|
||||
if err != nil && !errors.Is(err, os.ErrNotExist) {
|
||||
return nil, errs.NewInternalError(errs.SubtypeStorage, "failed to load config for the drift check: %v", err).WithCause(err)
|
||||
}
|
||||
return existing, nil
|
||||
}
|
||||
116
cmd/config/init_nowait_cache.go
Normal file
116
cmd/config/init_nowait_cache.go
Normal file
@@ -0,0 +1,116 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package config
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/internal/validate"
|
||||
"github.com/larksuite/cli/internal/vfs"
|
||||
)
|
||||
|
||||
// initNoWaitCacheVersion is the schema version of the cached init context.
|
||||
// Bump it when the record shape changes so stale entries are ignored.
|
||||
const initNoWaitCacheVersion = 1
|
||||
|
||||
// initNoWaitRecord is the context persisted by `config init --new --no-wait` so
|
||||
// that the later `--device-code` step can complete the app creation. It must
|
||||
// never hold a secret, verification URL, or full config — only what the resume
|
||||
// step needs to finish persisting the new app.
|
||||
type initNoWaitRecord struct {
|
||||
Version int `json:"version"`
|
||||
Brand string `json:"brand"`
|
||||
ProfileName string `json:"profile_name"`
|
||||
Lang string `json:"lang"`
|
||||
LangExplicit bool `json:"lang_explicit"`
|
||||
Interval int `json:"interval"`
|
||||
ExpiresAt int64 `json:"expires_at"` // unix seconds; absolute device-code deadline
|
||||
ConfigDigest string `json:"config_digest"`
|
||||
}
|
||||
|
||||
// initNoWaitCacheDir returns the directory used to persist config init
|
||||
// --no-wait context keyed by device_code.
|
||||
func initNoWaitCacheDir() string {
|
||||
return filepath.Join(core.GetConfigDir(), "cache", "config_init_nowait")
|
||||
}
|
||||
|
||||
// initNoWaitCachePath returns the cache file path for a given device_code.
|
||||
func initNoWaitCachePath(deviceCode string) string {
|
||||
return filepath.Join(initNoWaitCacheDir(), initNoWaitCacheKey(deviceCode)+".json")
|
||||
}
|
||||
|
||||
// initNoWaitCacheKey derives a collision-free, filesystem-safe filename token
|
||||
// from an opaque device_code. A sha256 hex digest avoids the collisions a
|
||||
// character-replacement sanitizer would cause (e.g. "a/b" and "a:b" both
|
||||
// mapping to "a_b").
|
||||
func initNoWaitCacheKey(deviceCode string) string {
|
||||
sum := sha256.Sum256([]byte(deviceCode))
|
||||
return hex.EncodeToString(sum[:])
|
||||
}
|
||||
|
||||
// saveInitNoWaitRecord persists the resume context for a device_code.
|
||||
func saveInitNoWaitRecord(deviceCode string, rec initNoWaitRecord) error {
|
||||
if err := vfs.MkdirAll(initNoWaitCacheDir(), 0700); err != nil {
|
||||
return err
|
||||
}
|
||||
data, err := json.Marshal(rec)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return validate.AtomicWrite(initNoWaitCachePath(deviceCode), data, 0600)
|
||||
}
|
||||
|
||||
// loadInitNoWaitRecord loads the resume context for a device_code. It returns
|
||||
// (nil, nil) when no cache entry exists.
|
||||
func loadInitNoWaitRecord(deviceCode string) (*initNoWaitRecord, error) {
|
||||
data, err := vfs.ReadFile(initNoWaitCachePath(deviceCode))
|
||||
if err != nil {
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
return nil, nil
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
var rec initNoWaitRecord
|
||||
if err := json.Unmarshal(data, &rec); err != nil {
|
||||
_ = vfs.Remove(initNoWaitCachePath(deviceCode))
|
||||
return nil, err
|
||||
}
|
||||
if rec.Version != initNoWaitCacheVersion {
|
||||
_ = vfs.Remove(initNoWaitCachePath(deviceCode))
|
||||
return nil, nil
|
||||
}
|
||||
return &rec, nil
|
||||
}
|
||||
|
||||
// removeInitNoWaitRecord deletes the cache entry for a device_code.
|
||||
func removeInitNoWaitRecord(deviceCode string) error {
|
||||
err := vfs.Remove(initNoWaitCachePath(deviceCode))
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
return nil
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
// computeConfigDigest returns a stable digest of the existing config so the
|
||||
// resume step can detect drift between initiation and completion. The digest
|
||||
// is a hash of config.json content (app IDs, brands, users, secret references)
|
||||
// — it contains no plaintext secret and is safe to cache. A nil config and an
|
||||
// (unexpected) marshal error both map to the empty digest.
|
||||
func computeConfigDigest(existing *core.MultiAppConfig) string {
|
||||
if existing == nil {
|
||||
return ""
|
||||
}
|
||||
data, err := json.Marshal(existing)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
sum := sha256.Sum256(data)
|
||||
return hex.EncodeToString(sum[:])
|
||||
}
|
||||
521
cmd/config/init_nowait_test.go
Normal file
521
cmd/config/init_nowait_test.go
Normal file
@@ -0,0 +1,521 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package config
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
larkauth "github.com/larksuite/cli/internal/auth"
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
)
|
||||
|
||||
// roundTripFunc adapts a function to an http.RoundTripper.
|
||||
type roundTripFunc func(*http.Request) (*http.Response, error)
|
||||
|
||||
func (f roundTripFunc) RoundTrip(r *http.Request) (*http.Response, error) { return f(r) }
|
||||
|
||||
// TestNoWait_InitiateThenResume_EndToEnd drives the full two-step flow against a
|
||||
// real local HTTP server: initiate writes the on-disk cache, then a SEPARATE
|
||||
// resume call polls the same server, succeeds, and persists the new app. Only
|
||||
// the device_code + the cache bridge the two invocations — exactly as the two
|
||||
// CLI commands would. (A black-box binary E2E of the success path is impossible
|
||||
// without a human: endpoints are hardcoded HTTPS and the real device flow needs
|
||||
// a browser scan, so this in-process run through httptest is the highest-fidelity
|
||||
// autonomous end-to-end.)
|
||||
func TestNoWait_InitiateThenResume_EndToEnd(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
|
||||
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
_ = r.ParseForm()
|
||||
switch r.FormValue("action") {
|
||||
case "begin":
|
||||
_, _ = w.Write([]byte(`{"device_code":"E2E-DEVICE-CODE","user_code":"E2E-UC","verification_uri":"https://example.test/verify","expires_in":600,"interval":1}`))
|
||||
case "poll":
|
||||
_, _ = w.Write([]byte(`{"client_id":"cli_e2e","client_secret":"sec_e2e","user_info":{"tenant_brand":"feishu","open_id":"ou_e2e"}}`))
|
||||
default:
|
||||
http.Error(w, "unexpected action "+r.FormValue("action"), http.StatusBadRequest)
|
||||
}
|
||||
}))
|
||||
defer ts.Close()
|
||||
tsURL, _ := url.Parse(ts.URL)
|
||||
|
||||
// Redirect the registration client to the local test server.
|
||||
orig := newRegistrationHTTPClient
|
||||
newRegistrationHTTPClient = func() *http.Client {
|
||||
return &http.Client{Transport: roundTripFunc(func(r *http.Request) (*http.Response, error) {
|
||||
r.URL.Scheme, r.URL.Host = tsURL.Scheme, tsURL.Host
|
||||
return http.DefaultTransport.RoundTrip(r)
|
||||
})}
|
||||
}
|
||||
t.Cleanup(func() { newRegistrationHTTPClient = orig })
|
||||
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, nil)
|
||||
|
||||
// Step 1 — initiate: should print device_code and write the resume cache.
|
||||
initOpts := &ConfigInitOptions{Factory: f, Ctx: context.Background(), Brand: "feishu", New: true, NoWait: true}
|
||||
if err := initiateNoWaitAppRegistration(initOpts, nil); err != nil {
|
||||
t.Fatalf("initiate: %v", err)
|
||||
}
|
||||
var out map[string]interface{}
|
||||
if err := json.Unmarshal(stdout.Bytes(), &out); err != nil {
|
||||
t.Fatalf("initiate stdout not JSON: %v; raw=%s", err, stdout.String())
|
||||
}
|
||||
if out["device_code"] != "E2E-DEVICE-CODE" {
|
||||
t.Fatalf("device_code = %v, want E2E-DEVICE-CODE", out["device_code"])
|
||||
}
|
||||
if rec, _ := loadInitNoWaitRecord("E2E-DEVICE-CODE"); rec == nil {
|
||||
t.Fatal("initiate did not write the resume cache")
|
||||
}
|
||||
|
||||
// Step 2 — resume (separate invocation; bridged only by device_code + cache).
|
||||
stdout.Reset()
|
||||
resumeOpts := &ConfigInitOptions{Factory: f, Ctx: context.Background(), DeviceCode: "E2E-DEVICE-CODE"}
|
||||
if err := resumeAppRegistration(resumeOpts); err != nil {
|
||||
t.Fatalf("resume: %v", err)
|
||||
}
|
||||
|
||||
// The new app must be persisted to config...
|
||||
cfg, err := core.LoadMultiAppConfig()
|
||||
if err != nil || cfg == nil {
|
||||
t.Fatalf("config not persisted: %v", err)
|
||||
}
|
||||
if app := cfg.CurrentAppConfig(""); app == nil || app.AppId != "cli_e2e" {
|
||||
t.Fatalf("persisted app = %+v, want AppId cli_e2e", app)
|
||||
}
|
||||
// ...the cache cleared after the successful save...
|
||||
if rec, _ := loadInitNoWaitRecord("E2E-DEVICE-CODE"); rec != nil {
|
||||
t.Error("resume should clear the cache after a successful save")
|
||||
}
|
||||
// ...and the success JSON emitted.
|
||||
if !strings.Contains(stdout.String(), "cli_e2e") {
|
||||
t.Errorf("resume stdout missing appId: %s", stdout.String())
|
||||
}
|
||||
}
|
||||
|
||||
// stubRT returns a single canned HTTP response for every request.
|
||||
type stubRT struct {
|
||||
status int
|
||||
body string
|
||||
}
|
||||
|
||||
func (s stubRT) RoundTrip(*http.Request) (*http.Response, error) {
|
||||
return &http.Response{StatusCode: s.status, Body: io.NopCloser(strings.NewReader(s.body)), Header: make(http.Header)}, nil
|
||||
}
|
||||
|
||||
// seqRT returns successive canned responses (last one repeats), for flows that
|
||||
// poll more than once (e.g. the Lark-tenant re-poll).
|
||||
type seqRT struct {
|
||||
bodies []string
|
||||
i int
|
||||
}
|
||||
|
||||
func (s *seqRT) RoundTrip(*http.Request) (*http.Response, error) {
|
||||
idx := s.i
|
||||
if idx >= len(s.bodies) {
|
||||
idx = len(s.bodies) - 1
|
||||
}
|
||||
s.i++
|
||||
return &http.Response{StatusCode: 200, Body: io.NopCloser(strings.NewReader(s.bodies[idx])), Header: make(http.Header)}, nil
|
||||
}
|
||||
|
||||
// withStubRegistrationClient swaps the registration HTTP client for the test.
|
||||
func withStubRegistrationClient(t *testing.T, rt http.RoundTripper) {
|
||||
t.Helper()
|
||||
orig := newRegistrationHTTPClient
|
||||
newRegistrationHTTPClient = func() *http.Client { return &http.Client{Transport: rt} }
|
||||
t.Cleanup(func() { newRegistrationHTTPClient = orig })
|
||||
}
|
||||
|
||||
// --- cache round-trip ---
|
||||
|
||||
func TestInitNoWaitCache_RoundTrip(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
rec := initNoWaitRecord{
|
||||
Version: initNoWaitCacheVersion,
|
||||
Brand: "feishu",
|
||||
ProfileName: "work",
|
||||
Lang: "zh_cn",
|
||||
LangExplicit: true,
|
||||
Interval: 5,
|
||||
ExpiresAt: time.Now().Unix() + 300,
|
||||
ConfigDigest: "abc123",
|
||||
}
|
||||
const dc = "device-code-xyz"
|
||||
|
||||
if err := saveInitNoWaitRecord(dc, rec); err != nil {
|
||||
t.Fatalf("save: %v", err)
|
||||
}
|
||||
got, err := loadInitNoWaitRecord(dc)
|
||||
if err != nil {
|
||||
t.Fatalf("load: %v", err)
|
||||
}
|
||||
if got == nil {
|
||||
t.Fatal("load returned nil for a saved record")
|
||||
}
|
||||
if *got != rec {
|
||||
t.Errorf("round-trip mismatch:\n got %+v\n want %+v", *got, rec)
|
||||
}
|
||||
|
||||
if err := removeInitNoWaitRecord(dc); err != nil {
|
||||
t.Fatalf("remove: %v", err)
|
||||
}
|
||||
got2, err := loadInitNoWaitRecord(dc)
|
||||
if err != nil {
|
||||
t.Fatalf("load after remove: %v", err)
|
||||
}
|
||||
if got2 != nil {
|
||||
t.Errorf("expected nil after remove, got %+v", got2)
|
||||
}
|
||||
// Removing a non-existent record must be a no-op, not an error.
|
||||
if err := removeInitNoWaitRecord(dc); err != nil {
|
||||
t.Errorf("remove of missing record should be nil, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestInitNoWaitCache_LoadMissing(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
got, err := loadInitNoWaitRecord("never-saved")
|
||||
if err != nil {
|
||||
t.Fatalf("load missing: %v", err)
|
||||
}
|
||||
if got != nil {
|
||||
t.Errorf("expected nil for missing record, got %+v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestInitNoWaitCache_VersionMismatchIgnored(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
const dc = "stale-version"
|
||||
rec := initNoWaitRecord{Version: initNoWaitCacheVersion + 1, ExpiresAt: time.Now().Unix() + 300}
|
||||
if err := saveInitNoWaitRecord(dc, rec); err != nil {
|
||||
t.Fatalf("save: %v", err)
|
||||
}
|
||||
got, err := loadInitNoWaitRecord(dc)
|
||||
if err != nil {
|
||||
t.Fatalf("load: %v", err)
|
||||
}
|
||||
if got != nil {
|
||||
t.Errorf("expected nil for version mismatch, got %+v", got)
|
||||
}
|
||||
// The stale entry should have been discarded by the load.
|
||||
got2, _ := loadInitNoWaitRecord(dc)
|
||||
if got2 != nil {
|
||||
t.Errorf("stale-version entry was not removed on load")
|
||||
}
|
||||
}
|
||||
|
||||
func TestInitNoWaitCacheKey(t *testing.T) {
|
||||
// Distinct device codes that a char-replacement sanitizer would collide
|
||||
// ("a/b" and "a:b" -> "a_b") must map to distinct keys.
|
||||
if initNoWaitCacheKey("a/b") == initNoWaitCacheKey("a:b") {
|
||||
t.Error("distinct device codes must not collide on the cache key")
|
||||
}
|
||||
// Deterministic.
|
||||
if initNoWaitCacheKey("xyz") != initNoWaitCacheKey("xyz") {
|
||||
t.Error("cache key must be deterministic")
|
||||
}
|
||||
// sha256 hex: 64 chars, filesystem-safe regardless of input.
|
||||
k := initNoWaitCacheKey("has /, :, ;, spaces and 'quotes'")
|
||||
if len(k) != 64 {
|
||||
t.Errorf("expected 64-char sha256 hex key, got %d: %q", len(k), k)
|
||||
}
|
||||
}
|
||||
|
||||
// --- config digest ---
|
||||
|
||||
func TestComputeConfigDigest(t *testing.T) {
|
||||
if d := computeConfigDigest(nil); d != "" {
|
||||
t.Errorf("nil digest = %q, want empty", d)
|
||||
}
|
||||
cfg1 := &core.MultiAppConfig{Apps: []core.AppConfig{{AppId: "cli_a", Brand: core.BrandFeishu}}}
|
||||
cfg1Dup := &core.MultiAppConfig{Apps: []core.AppConfig{{AppId: "cli_a", Brand: core.BrandFeishu}}}
|
||||
cfg2 := &core.MultiAppConfig{Apps: []core.AppConfig{{AppId: "cli_b", Brand: core.BrandFeishu}}}
|
||||
|
||||
if computeConfigDigest(cfg1) == "" {
|
||||
t.Error("non-nil config digest should be non-empty")
|
||||
}
|
||||
if computeConfigDigest(cfg1) != computeConfigDigest(cfg1Dup) {
|
||||
t.Error("equal configs should produce equal digests")
|
||||
}
|
||||
if computeConfigDigest(cfg1) == computeConfigDigest(cfg2) {
|
||||
t.Error("different configs should produce different digests")
|
||||
}
|
||||
}
|
||||
|
||||
// --- failure classification for cache cleanup ---
|
||||
|
||||
func TestAppRegShouldClearCache(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
err error
|
||||
want bool
|
||||
}{
|
||||
{"success", nil, true},
|
||||
{"denied", larkauth.ErrAppRegDenied, true},
|
||||
{"expired", larkauth.ErrAppRegExpired, true},
|
||||
{"expired wrapped", fmt.Errorf("%w, please try again", larkauth.ErrAppRegExpired), true},
|
||||
{"timeout", larkauth.ErrAppRegTimeout, true},
|
||||
{"timeout wrapped", fmt.Errorf("%w, please try again", larkauth.ErrAppRegTimeout), true},
|
||||
{"cancelled", larkauth.ErrAppRegCancelled, false},
|
||||
{"transient generic", fmt.Errorf("network boom"), false},
|
||||
{"missing fields", fmt.Errorf("app registration succeeded but missing client_id or client_secret"), false},
|
||||
}
|
||||
for _, c := range cases {
|
||||
if got := appRegShouldClearCache(c.err); got != c.want {
|
||||
t.Errorf("%s: appRegShouldClearCache = %v, want %v", c.name, got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// --- initiate (stubbed registration client) ---
|
||||
|
||||
func TestInitiateNoWaitAppRegistration_WritesCacheAndJSON(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, nil)
|
||||
withStubRegistrationClient(t, stubRT{200, `{"device_code":"dc-abc","user_code":"U-1","verification_uri":"https://open.feishu.cn","expires_in":3600,"interval":5}`})
|
||||
|
||||
opts := &ConfigInitOptions{Factory: f, Ctx: context.Background(), Brand: "feishu", New: true, NoWait: true, ForceInit: true}
|
||||
if err := initiateNoWaitAppRegistration(opts, nil); err != nil {
|
||||
t.Fatalf("initiate: %v", err)
|
||||
}
|
||||
|
||||
var out map[string]interface{}
|
||||
if err := json.Unmarshal(stdout.Bytes(), &out); err != nil {
|
||||
t.Fatalf("stdout not JSON: %v; raw=%s", err, stdout.String())
|
||||
}
|
||||
if out["device_code"] != "dc-abc" {
|
||||
t.Errorf("device_code = %v, want dc-abc", out["device_code"])
|
||||
}
|
||||
args, ok := out["resume_args"].([]interface{})
|
||||
if !ok || len(args) == 0 || args[len(args)-1] != "--force-init" {
|
||||
t.Errorf("resume_args should end with --force-init, got %v", out["resume_args"])
|
||||
}
|
||||
|
||||
rec, _ := loadInitNoWaitRecord("dc-abc")
|
||||
if rec == nil {
|
||||
t.Fatal("cache record not written")
|
||||
}
|
||||
if rec.Brand != "feishu" || rec.Version != initNoWaitCacheVersion {
|
||||
t.Errorf("cache record = %+v", *rec)
|
||||
}
|
||||
}
|
||||
|
||||
// --- pollAppRegistrationResume (stubbed client) ---
|
||||
|
||||
func TestPollAppRegistrationResume_Success(t *testing.T) {
|
||||
c := &http.Client{Transport: stubRT{200, `{"client_id":"cli_x","client_secret":"sec","user_info":{"tenant_brand":"feishu"}}`}}
|
||||
res, err := pollAppRegistrationResume(context.Background(), c, "dc", 0, 60, io.Discard)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if res.ClientID != "cli_x" || res.ClientSecret != "sec" {
|
||||
t.Errorf("got %+v", res)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPollAppRegistrationResume_MissingSecret(t *testing.T) {
|
||||
c := &http.Client{Transport: stubRT{200, `{"client_id":"cli_x"}`}}
|
||||
if _, err := pollAppRegistrationResume(context.Background(), c, "dc", 0, 60, io.Discard); err == nil {
|
||||
t.Error("expected error when client_secret is missing")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPollAppRegistrationResume_LarkRetry(t *testing.T) {
|
||||
// First poll (feishu endpoint): lark tenant, no secret -> triggers re-poll
|
||||
// against the lark endpoint, which returns the secret.
|
||||
rt := &seqRT{bodies: []string{
|
||||
`{"client_id":"cli_x","client_secret":"","user_info":{"tenant_brand":"lark"}}`,
|
||||
`{"client_id":"cli_x","client_secret":"larksec","user_info":{"tenant_brand":"lark"}}`,
|
||||
}}
|
||||
res, err := pollAppRegistrationResume(context.Background(), &http.Client{Transport: rt}, "dc", 0, 60, io.Discard)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if res.ClientSecret != "larksec" {
|
||||
t.Errorf("expected lark re-poll to yield the secret, got %+v", res)
|
||||
}
|
||||
}
|
||||
|
||||
// Full resume happy path: stubbed poll succeeds, the app is persisted, and the
|
||||
// cache is cleared. (runProbe hits the factory's mock client, which has no stub
|
||||
// and returns an untyped error that runProbe swallows.)
|
||||
func TestResumeAppRegistration_Success(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, nil)
|
||||
withStubRegistrationClient(t, stubRT{200, `{"client_id":"cli_new","client_secret":"sec","user_info":{"tenant_brand":"feishu"}}`})
|
||||
|
||||
const dc = "resume-ok"
|
||||
rec := initNoWaitRecord{
|
||||
Version: initNoWaitCacheVersion,
|
||||
Brand: "feishu",
|
||||
Interval: 1, // keep the single poll fast
|
||||
ExpiresAt: time.Now().Unix() + 300,
|
||||
ConfigDigest: computeConfigDigest(nil),
|
||||
}
|
||||
if err := saveInitNoWaitRecord(dc, rec); err != nil {
|
||||
t.Fatalf("save: %v", err)
|
||||
}
|
||||
|
||||
opts := &ConfigInitOptions{Factory: f, Ctx: context.Background(), DeviceCode: dc}
|
||||
if err := resumeAppRegistration(opts); err != nil {
|
||||
t.Fatalf("resume: %v", err)
|
||||
}
|
||||
|
||||
cfg, _ := core.LoadMultiAppConfig()
|
||||
if cfg == nil || cfg.CurrentAppConfig("") == nil || cfg.CurrentAppConfig("").AppId != "cli_new" {
|
||||
t.Errorf("config not persisted with new app id: %+v", cfg)
|
||||
}
|
||||
if got, _ := loadInitNoWaitRecord(dc); got != nil {
|
||||
t.Error("cache should be cleared after a successful save")
|
||||
}
|
||||
if !strings.Contains(stdout.String(), "cli_new") {
|
||||
t.Errorf("stdout missing new appId: %s", stdout.String())
|
||||
}
|
||||
}
|
||||
|
||||
// A profile-name conflict on the resume save path must surface as the typed
|
||||
// ValidationError(--name), not be downgraded to an internal/storage error.
|
||||
func TestResumeAppRegistration_ProfileNameConflict_PreservesValidationError(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
f, _, _, _ := cmdutil.TestFactory(t, nil)
|
||||
withStubRegistrationClient(t, stubRT{200, `{"client_id":"cli_new","client_secret":"sec","user_info":{"tenant_brand":"feishu"}}`})
|
||||
|
||||
// Seed a config whose app id collides with the profile name we resume into.
|
||||
seeded := &core.MultiAppConfig{Apps: []core.AppConfig{
|
||||
{AppId: "cli_existing", AppSecret: core.PlainSecret("s"), Brand: core.BrandFeishu},
|
||||
}}
|
||||
if err := core.SaveMultiAppConfig(seeded); err != nil {
|
||||
t.Fatalf("seed config: %v", err)
|
||||
}
|
||||
loaded, _ := core.LoadMultiAppConfig() // digest must match what resume recomputes
|
||||
|
||||
const dc = "conflict-dc"
|
||||
rec := initNoWaitRecord{
|
||||
Version: initNoWaitCacheVersion,
|
||||
Brand: "feishu",
|
||||
ProfileName: "cli_existing", // collides with the existing appId in saveAsProfile
|
||||
Interval: 1,
|
||||
ExpiresAt: time.Now().Unix() + 300,
|
||||
ConfigDigest: computeConfigDigest(loaded),
|
||||
}
|
||||
if err := saveInitNoWaitRecord(dc, rec); err != nil {
|
||||
t.Fatalf("save cache: %v", err)
|
||||
}
|
||||
|
||||
opts := &ConfigInitOptions{Factory: f, Ctx: context.Background(), DeviceCode: dc}
|
||||
assertValidationParam(t, resumeAppRegistration(opts), "--name")
|
||||
}
|
||||
|
||||
// --- flag validation (returns before any network) ---
|
||||
|
||||
func TestConfigInitRun_NoWaitAndDeviceCodeMutuallyExclusive(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
f, _, _, _ := cmdutil.TestFactory(t, nil)
|
||||
opts := &ConfigInitOptions{Factory: f, Ctx: context.Background(), NoWait: true, DeviceCode: "x"}
|
||||
assertValidationParam(t, configInitRun(opts), "--device-code")
|
||||
}
|
||||
|
||||
func TestConfigInitRun_NoWaitWithAppIDRejected(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
f, _, _, _ := cmdutil.TestFactory(t, nil)
|
||||
opts := &ConfigInitOptions{Factory: f, Ctx: context.Background(), NoWait: true, AppID: "cli_x"}
|
||||
assertValidationParam(t, configInitRun(opts), "--no-wait")
|
||||
}
|
||||
|
||||
// The conflict error must point at the flag the caller actually passed: with
|
||||
// --device-code (not --no-wait) + --app-id, remediation should name --device-code.
|
||||
func TestConfigInitRun_DeviceCodeWithAppIDReportsDeviceCode(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
f, _, _, _ := cmdutil.TestFactory(t, nil)
|
||||
opts := &ConfigInitOptions{Factory: f, Ctx: context.Background(), DeviceCode: "dc", AppID: "cli_x"}
|
||||
assertValidationParam(t, configInitRun(opts), "--device-code")
|
||||
}
|
||||
|
||||
// --- resume guards (return before any network) ---
|
||||
|
||||
func TestResumeAppRegistration_NoCacheEntry(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
f, _, _, _ := cmdutil.TestFactory(t, nil)
|
||||
opts := &ConfigInitOptions{Factory: f, Ctx: context.Background(), DeviceCode: "missing-dc"}
|
||||
assertValidationParam(t, resumeAppRegistration(opts), "--device-code")
|
||||
}
|
||||
|
||||
func TestResumeAppRegistration_ExpiredClearsCache(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
f, _, _, _ := cmdutil.TestFactory(t, nil)
|
||||
const dc = "expired-dc"
|
||||
rec := initNoWaitRecord{
|
||||
Version: initNoWaitCacheVersion,
|
||||
Brand: "feishu",
|
||||
Interval: 5,
|
||||
ExpiresAt: time.Now().Unix() - 10, // already past
|
||||
}
|
||||
if err := saveInitNoWaitRecord(dc, rec); err != nil {
|
||||
t.Fatalf("save: %v", err)
|
||||
}
|
||||
opts := &ConfigInitOptions{Factory: f, Ctx: context.Background(), DeviceCode: dc}
|
||||
assertValidationParam(t, resumeAppRegistration(opts), "--device-code")
|
||||
|
||||
if got, _ := loadInitNoWaitRecord(dc); got != nil {
|
||||
t.Error("expired cache entry should have been removed")
|
||||
}
|
||||
}
|
||||
|
||||
// A cache file that exists but cannot be parsed is a storage failure, not a
|
||||
// "no pending creation" validation error — the user should fix storage rather
|
||||
// than assume the device code is bad.
|
||||
func TestResumeAppRegistration_CorruptCacheIsStorageError(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
f, _, _, _ := cmdutil.TestFactory(t, nil)
|
||||
const dc = "corrupt-dc"
|
||||
if err := os.MkdirAll(initNoWaitCacheDir(), 0o700); err != nil {
|
||||
t.Fatalf("mkdir: %v", err)
|
||||
}
|
||||
if err := os.WriteFile(initNoWaitCachePath(dc), []byte("{ not valid json"), 0o600); err != nil {
|
||||
t.Fatalf("write: %v", err)
|
||||
}
|
||||
opts := &ConfigInitOptions{Factory: f, Ctx: context.Background(), DeviceCode: dc}
|
||||
err := resumeAppRegistration(opts)
|
||||
var intErr *errs.InternalError
|
||||
if !errors.As(err, &intErr) {
|
||||
t.Fatalf("expected *errs.InternalError for unreadable cache, got %T: %v", err, err)
|
||||
}
|
||||
if p, ok := errs.ProblemOf(err); !ok || p.Subtype != errs.SubtypeStorage {
|
||||
t.Fatalf("expected subtype=%q, got problem=%+v", errs.SubtypeStorage, p)
|
||||
}
|
||||
if errors.Unwrap(err) == nil {
|
||||
t.Fatal("expected the underlying cache-read failure to be preserved as a cause")
|
||||
}
|
||||
}
|
||||
|
||||
func TestResumeAppRegistration_ConfigDrift(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
f, _, _, _ := cmdutil.TestFactory(t, nil)
|
||||
const dc = "drift-dc"
|
||||
rec := initNoWaitRecord{
|
||||
Version: initNoWaitCacheVersion,
|
||||
Brand: "feishu",
|
||||
Interval: 5,
|
||||
ExpiresAt: time.Now().Unix() + 300,
|
||||
ConfigDigest: "stale-digest-that-will-not-match-current-config",
|
||||
}
|
||||
if err := saveInitNoWaitRecord(dc, rec); err != nil {
|
||||
t.Fatalf("save: %v", err)
|
||||
}
|
||||
opts := &ConfigInitOptions{Factory: f, Ctx: context.Background(), DeviceCode: dc}
|
||||
assertValidationParam(t, resumeAppRegistration(opts), "--device-code")
|
||||
}
|
||||
@@ -23,7 +23,7 @@ type ImMessageReceiveOutput struct {
|
||||
ChatType string `json:"chat_type,omitempty" desc:"Conversation type" enum:"p2p,group"`
|
||||
MessageType string `json:"message_type,omitempty" desc:"Message type"`
|
||||
SenderID string `json:"sender_id,omitempty" desc:"Sender open_id; prefixed with ou_" kind:"open_id"`
|
||||
Content string `json:"content,omitempty" desc:"Message content. For most types (text/post/image/file/audio, etc.) this is pre-rendered human-readable text."`
|
||||
Content string `json:"content,omitempty" desc:"Message content. For most types (text/post/image/file/audio, etc.) this is pre-rendered human-readable text. For interactive (cards) it stays as the raw JSON string and callers must fromjson to parse it."`
|
||||
}
|
||||
|
||||
func processImMessageReceive(_ context.Context, _ event.APIClient, raw *event.RawEvent, _ map[string]string) (json.RawMessage, error) {
|
||||
@@ -55,10 +55,8 @@ func processImMessageReceive(_ context.Context, _ event.APIClient, raw *event.Ra
|
||||
}
|
||||
|
||||
msg := envelope.Event.Message
|
||||
var content string
|
||||
if msg.MessageType == "interactive" {
|
||||
content = convertlib.ConvertInteractiveEventContent(msg.Content, msg.Mentions)
|
||||
} else {
|
||||
content := msg.Content
|
||||
if msg.MessageType != "interactive" {
|
||||
content = convertlib.ConvertBodyContent(msg.MessageType, &convertlib.ConvertContext{
|
||||
RawContent: msg.Content,
|
||||
MentionMap: convertlib.BuildMentionKeyMap(msg.Mentions),
|
||||
|
||||
12
go.mod
12
go.mod
@@ -27,8 +27,6 @@ require (
|
||||
gopkg.in/yaml.v3 v3.0.1
|
||||
)
|
||||
|
||||
require github.com/apache/arrow/go/v17 v17.0.0
|
||||
|
||||
require (
|
||||
github.com/atotto/clipboard v0.1.4 // indirect
|
||||
github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
|
||||
@@ -44,17 +42,13 @@ require (
|
||||
github.com/davecgh/go-spew v1.1.1 // indirect
|
||||
github.com/dustin/go-humanize v1.0.1 // indirect
|
||||
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect
|
||||
github.com/goccy/go-json v0.10.3 // indirect
|
||||
github.com/godbus/dbus/v5 v5.2.2 // indirect
|
||||
github.com/gogo/protobuf v1.3.2 // indirect
|
||||
github.com/google/flatbuffers v24.3.25+incompatible // indirect
|
||||
github.com/gopherjs/gopherjs v1.17.2 // indirect
|
||||
github.com/gorilla/websocket v1.5.0 // indirect
|
||||
github.com/inconshreveable/mousetrap v1.1.0 // indirect
|
||||
github.com/itchyny/timefmt-go v0.1.6 // indirect
|
||||
github.com/jtolds/gls v4.20.0+incompatible // indirect
|
||||
github.com/klauspost/compress v1.17.9 // indirect
|
||||
github.com/klauspost/cpuid/v2 v2.2.8 // indirect
|
||||
github.com/lucasb-eyer/go-colorful v1.2.0 // indirect
|
||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||
github.com/mattn/go-localereader v0.0.1 // indirect
|
||||
@@ -63,16 +57,10 @@ require (
|
||||
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect
|
||||
github.com/muesli/cancelreader v0.2.2 // indirect
|
||||
github.com/muesli/termenv v0.16.0 // indirect
|
||||
github.com/pierrec/lz4/v4 v4.1.21 // indirect
|
||||
github.com/pmezard/go-difflib v1.0.0 // indirect
|
||||
github.com/rivo/uniseg v0.4.7 // indirect
|
||||
github.com/smarty/assertions v1.15.0 // indirect
|
||||
github.com/tidwall/match v1.1.1 // indirect
|
||||
github.com/tidwall/pretty v1.2.0 // indirect
|
||||
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
|
||||
github.com/zeebo/xxh3 v1.0.2 // indirect
|
||||
golang.org/x/exp v0.0.0-20240222234643-814bf88cf225 // indirect
|
||||
golang.org/x/mod v0.18.0 // indirect
|
||||
golang.org/x/tools v0.22.0 // indirect
|
||||
golang.org/x/xerrors v0.0.0-20231012003039-104605ab7028 // indirect
|
||||
)
|
||||
|
||||
32
go.sum
32
go.sum
@@ -2,8 +2,6 @@ github.com/MakeNowJust/heredoc v1.0.0 h1:cXCdzVdstXyiTqTvfqk9SDHpKNjxuom+DOlyEeQ
|
||||
github.com/MakeNowJust/heredoc v1.0.0/go.mod h1:mG5amYoWBHf8vpLOuehzbGGw0EHxpZZ6lCpQ4fNJ8LE=
|
||||
github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY=
|
||||
github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU=
|
||||
github.com/apache/arrow/go/v17 v17.0.0 h1:RRR2bdqKcdbss9Gxy2NS/hK8i4LDMh23L6BbkN5+F54=
|
||||
github.com/apache/arrow/go/v17 v17.0.0/go.mod h1:jR7QHkODl15PfYyjM2nU+yTLScZ/qfj7OSUZmJ8putc=
|
||||
github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4=
|
||||
github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI=
|
||||
github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k=
|
||||
@@ -54,16 +52,12 @@ github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkp
|
||||
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
|
||||
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4=
|
||||
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM=
|
||||
github.com/goccy/go-json v0.10.3 h1:KZ5WoDbxAIgm2HNbYckL0se1fHD6rz5j4ywS6ebzDqA=
|
||||
github.com/goccy/go-json v0.10.3/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
|
||||
github.com/godbus/dbus/v5 v5.2.2 h1:TUR3TgtSVDmjiXOgAAyaZbYmIeP3DPkld3jgKGV8mXQ=
|
||||
github.com/godbus/dbus/v5 v5.2.2/go.mod h1:3AAv2+hPq5rdnr5txxxRwiGjPXamgoIHgz9FPBfOp3c=
|
||||
github.com/gofrs/flock v0.8.1 h1:+gYjHKf32LDeiEEFhQaotPbLuUXjY5ZqxKgXy7n59aw=
|
||||
github.com/gofrs/flock v0.8.1/go.mod h1:F1TvTiK9OcQqauNUHlbJvyl9Qa1QvF/gOUDKA14jxHU=
|
||||
github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q=
|
||||
github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q=
|
||||
github.com/google/flatbuffers v24.3.25+incompatible h1:CX395cjN9Kke9mmalRoL3d81AtFUxJM+yDthflgJGkI=
|
||||
github.com/google/flatbuffers v24.3.25+incompatible/go.mod h1:1AeVuKshWv4vARoZatz6mlQ0JxURH0Kv5+zNeJKJCa8=
|
||||
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
github.com/gopherjs/gopherjs v1.17.2 h1:fQnZVsXk8uxXIStYb0N4bGk7jeyTalG/wsZjQ25dO0g=
|
||||
@@ -80,16 +74,11 @@ github.com/jtolds/gls v4.20.0+incompatible h1:xdiiI2gbIgH/gLH7ADydsJ1uDOEzR8yvV7
|
||||
github.com/jtolds/gls v4.20.0+incompatible/go.mod h1:QJZ7F/aHp+rZTRtaJ1ow/lLfFfVYBRgL+9YlvaHOwJU=
|
||||
github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8=
|
||||
github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck=
|
||||
github.com/klauspost/compress v1.17.9 h1:6KIumPrER1LHsvBVuDa0r5xaG0Es51mhhB9BQB2qeMA=
|
||||
github.com/klauspost/compress v1.17.9/go.mod h1:Di0epgTjJY877eYKx5yC51cX2A2Vl2ibi7bDH9ttBbw=
|
||||
github.com/klauspost/cpuid/v2 v2.2.8 h1:+StwCXwm9PdpiEkPyzBXIy+M9KUb4ODm0Zarf1kS5BM=
|
||||
github.com/klauspost/cpuid/v2 v2.2.8/go.mod h1:Lcz8mBdAVJIBVzewtcLocK12l3Y+JytZYpaMropDUws=
|
||||
github.com/kr/pretty v0.1.0 h1:L/CwN0zerZDmRFUapSPitk6f+Q3+0za1rQkzVuMiMFI=
|
||||
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
|
||||
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
|
||||
github.com/kr/text v0.1.0 h1:45sCR5RtlFHMR4UwH9sdQ5TC8v0qDQCHnXt+kaKSTVE=
|
||||
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
|
||||
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
|
||||
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
|
||||
github.com/larksuite/oapi-sdk-go/v3 v3.5.4 h1:U2S9x9LrfH++ZqJ+YAiUlqzCWJmVXhFdS8Z7rIBH8H0=
|
||||
github.com/larksuite/oapi-sdk-go/v3 v3.5.4/go.mod h1:ZEplY+kwuIrj/nqw5uSCINNATcH3KdxSN7y+UxYY5fI=
|
||||
github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY=
|
||||
@@ -108,8 +97,6 @@ github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELU
|
||||
github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo=
|
||||
github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc=
|
||||
github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk=
|
||||
github.com/pierrec/lz4/v4 v4.1.21 h1:yOVMLb6qSIDP67pl/5F7RepeKYu/VmTyEXvuMI5d9mQ=
|
||||
github.com/pierrec/lz4/v4 v4.1.21/go.mod h1:gZWDp/Ze/IJXGXf23ltt2EXimqmTUXEy0GFuRQyBid4=
|
||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
|
||||
@@ -146,20 +133,14 @@ github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9de
|
||||
github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
|
||||
github.com/zalando/go-keyring v0.2.8 h1:6sD/Ucpl7jNq10rM2pgqTs0sZ9V3qMrqfIIy5YPccHs=
|
||||
github.com/zalando/go-keyring v0.2.8/go.mod h1:tsMo+VpRq5NGyKfxoBVjCuMrG47yj8cmakZDO5QGii0=
|
||||
github.com/zeebo/assert v1.3.0 h1:g7C04CbJuIDKNPFHmsk4hwZDO5O+kntRxzaUoNXj+IQ=
|
||||
github.com/zeebo/assert v1.3.0/go.mod h1:Pq9JiuJQpG8JLJdtkwrJESF0Foym2/D9XMU5ciN/wJ0=
|
||||
github.com/zeebo/xxh3 v1.0.2 h1:xZmwmqxHZA8AI603jOQ0tMqmBr9lPeFwGg6d+xy9DC0=
|
||||
github.com/zeebo/xxh3 v1.0.2/go.mod h1:5NWz9Sef7zIDm2JHfFlcQvNekmcEl9ekUZQQKCYaDcA=
|
||||
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
|
||||
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
||||
golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
|
||||
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
|
||||
golang.org/x/exp v0.0.0-20240222234643-814bf88cf225 h1:LfspQV/FYTatPTr/3HzIcmiUFH7PGP+OQ6mgDYo3yuQ=
|
||||
golang.org/x/exp v0.0.0-20240222234643-814bf88cf225/go.mod h1:CxmFvTBINI24O/j8iY7H1xHzx2i4OsyguNBmN/uPtqc=
|
||||
golang.org/x/exp v0.0.0-20231006140011-7918f672742d h1:jtJma62tbqLibJ5sFQz8bKtEM8rJBtfilJ2qTU199MI=
|
||||
golang.org/x/exp v0.0.0-20231006140011-7918f672742d/go.mod h1:ldy0pHrwJyGW56pPQzzkH36rKxoZW1tw7ZJpeKx+hdo=
|
||||
golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
|
||||
golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
|
||||
golang.org/x/mod v0.18.0 h1:5+9lSbEzPSdWkH32vYPBwEpX8KwDbM52Ud9xBUvNlb0=
|
||||
golang.org/x/mod v0.18.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
|
||||
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
|
||||
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||
golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||
@@ -175,7 +156,6 @@ golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5h
|
||||
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.33.0 h1:q3i8TbbEz+JRD9ywIRlyRAQbM0qF7hu24q3teo2hbuw=
|
||||
golang.org/x/sys v0.33.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
|
||||
@@ -189,16 +169,10 @@ golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGm
|
||||
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
|
||||
golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE=
|
||||
golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=
|
||||
golang.org/x/tools v0.22.0 h1:gqSGLZqv+AI9lIQzniJ0nZDRG5GBPsSi+DRNHWNz6yA=
|
||||
golang.org/x/tools v0.22.0/go.mod h1:aCwcsjqvq7Yqt6TNyX7QMU2enbQ/Gt0bo6krSeEri+c=
|
||||
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
golang.org/x/xerrors v0.0.0-20231012003039-104605ab7028 h1:+cNy6SZtPcJQH3LJVLOSmiC7MMxXNOb3PU/VUEz+EhU=
|
||||
golang.org/x/xerrors v0.0.0-20231012003039-104605ab7028/go.mod h1:NDW/Ps6MPRej6fsCIbMTohpP40sJ/P/vI1MoTEGwX90=
|
||||
gonum.org/v1/gonum v0.15.0 h1:2lYxjRbTYyxkJxlhC+LvJIx3SsANPdRybu1tGj9/OrQ=
|
||||
gonum.org/v1/gonum v0.15.0/go.mod h1:xzZVBJBtS+Mz4q0Yl2LJTk+OxOg4jiXZ7qBoM0uISGo=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 h1:YR8cESwS4TdDjEe65xsg0ogRM/Nc3DYOhEAlW+xobZo=
|
||||
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
|
||||
@@ -6,6 +6,7 @@ package auth
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
@@ -13,9 +14,24 @@ import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
)
|
||||
|
||||
// Sentinel errors returned by PollAppRegistration so callers can classify a
|
||||
// failure (e.g. to decide whether a cached device code should be discarded)
|
||||
// via errors.Is without parsing message strings.
|
||||
var (
|
||||
// ErrAppRegDenied means the user rejected the app registration.
|
||||
ErrAppRegDenied = errors.New("app registration denied by user")
|
||||
// ErrAppRegExpired means the device code is no longer valid.
|
||||
ErrAppRegExpired = errors.New("device code expired")
|
||||
// ErrAppRegCancelled means polling was cancelled via the context.
|
||||
ErrAppRegCancelled = errors.New("polling was cancelled")
|
||||
// ErrAppRegTimeout means the local polling deadline elapsed.
|
||||
ErrAppRegTimeout = errors.New("app registration timed out")
|
||||
)
|
||||
|
||||
// AppRegistrationResponse is the response from the app registration begin endpoint.
|
||||
type AppRegistrationResponse struct {
|
||||
DeviceCode string
|
||||
@@ -63,7 +79,7 @@ func RequestAppRegistration(httpClient *http.Client, brand core.LarkBrand, errOu
|
||||
|
||||
resp, err := httpClient.Do(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
return nil, errs.NewNetworkError(errs.SubtypeNetworkTransport, "app registration request failed: %v", err).WithCause(err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
logHTTPResponse(resp)
|
||||
@@ -138,13 +154,13 @@ func PollAppRegistration(ctx context.Context, httpClient *http.Client, brand cor
|
||||
for time.Now().Before(deadline) && attempts < maxPollAttempts {
|
||||
attempts++
|
||||
if ctx.Err() != nil {
|
||||
return nil, fmt.Errorf("polling was cancelled")
|
||||
return nil, ErrAppRegCancelled
|
||||
}
|
||||
|
||||
select {
|
||||
case <-time.After(time.Duration(currentInterval) * time.Second):
|
||||
case <-ctx.Done():
|
||||
return nil, fmt.Errorf("polling was cancelled")
|
||||
return nil, ErrAppRegCancelled
|
||||
}
|
||||
|
||||
form := url.Values{}
|
||||
@@ -205,9 +221,9 @@ func PollAppRegistration(ctx context.Context, httpClient *http.Client, brand cor
|
||||
fmt.Fprintf(errOut, "[lark-cli] app-registration: slow_down, interval increased to %ds\n", currentInterval)
|
||||
continue
|
||||
case "access_denied":
|
||||
return nil, fmt.Errorf("app registration denied by user")
|
||||
return nil, ErrAppRegDenied
|
||||
case "expired_token", "invalid_grant":
|
||||
return nil, fmt.Errorf("device code expired, please try again")
|
||||
return nil, fmt.Errorf("%w, please try again", ErrAppRegExpired)
|
||||
}
|
||||
|
||||
desc := getStr(data, "error_description")
|
||||
@@ -223,5 +239,5 @@ func PollAppRegistration(ctx context.Context, httpClient *http.Client, brand cor
|
||||
if attempts >= maxPollAttempts {
|
||||
fmt.Fprintf(errOut, "[lark-cli] [WARN] app-registration: max poll attempts (%d) reached\n", maxPollAttempts)
|
||||
}
|
||||
return nil, fmt.Errorf("app registration timed out, please try again")
|
||||
return nil, fmt.Errorf("%w, please try again", ErrAppRegTimeout)
|
||||
}
|
||||
|
||||
95
internal/auth/app_registration_sentinel_test.go
Normal file
95
internal/auth/app_registration_sentinel_test.go
Normal file
@@ -0,0 +1,95 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package auth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
)
|
||||
|
||||
// stubRoundTripper returns a canned response for every request.
|
||||
type stubRoundTripper struct {
|
||||
status int
|
||||
body string
|
||||
}
|
||||
|
||||
func (s stubRoundTripper) RoundTrip(*http.Request) (*http.Response, error) {
|
||||
return &http.Response{
|
||||
StatusCode: s.status,
|
||||
Body: io.NopCloser(strings.NewReader(s.body)),
|
||||
Header: make(http.Header),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// TestAppRegSentinelMessages locks the user-facing message text so the
|
||||
// interactive create flow (which renders these via "%v") does not regress when
|
||||
// the errors gained errors.Is support.
|
||||
func TestAppRegSentinelMessages(t *testing.T) {
|
||||
cases := map[string]string{
|
||||
ErrAppRegDenied.Error(): "app registration denied by user",
|
||||
ErrAppRegCancelled.Error(): "polling was cancelled",
|
||||
fmt.Errorf("%w, please try again", ErrAppRegExpired).Error(): "device code expired, please try again",
|
||||
fmt.Errorf("%w, please try again", ErrAppRegTimeout).Error(): "app registration timed out, please try again",
|
||||
}
|
||||
for got, want := range cases {
|
||||
if got != want {
|
||||
t.Errorf("message = %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestPollAppRegistration_Classifies verifies that terminal poll outcomes are
|
||||
// returned as the matching sentinel error (interval 0 keeps the test fast).
|
||||
func TestPollAppRegistration_Classifies(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
body string
|
||||
want error
|
||||
}{
|
||||
{"access_denied", `{"error":"access_denied"}`, ErrAppRegDenied},
|
||||
{"expired_token", `{"error":"expired_token"}`, ErrAppRegExpired},
|
||||
{"invalid_grant", `{"error":"invalid_grant"}`, ErrAppRegExpired},
|
||||
}
|
||||
for _, c := range cases {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
client := &http.Client{Transport: stubRoundTripper{status: 200, body: c.body}}
|
||||
_, err := PollAppRegistration(context.Background(), client, core.BrandFeishu, "dc", 0, 60, io.Discard)
|
||||
if !errors.Is(err, c.want) {
|
||||
t.Fatalf("err = %v, want errors.Is(%v)", err, c.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestPollAppRegistration_Success(t *testing.T) {
|
||||
body := `{"client_id":"cli_x","client_secret":"sec","user_info":{"tenant_brand":"feishu","open_id":"ou_1"}}`
|
||||
client := &http.Client{Transport: stubRoundTripper{status: 200, body: body}}
|
||||
res, err := PollAppRegistration(context.Background(), client, core.BrandFeishu, "dc", 0, 60, io.Discard)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if res.ClientID != "cli_x" || res.ClientSecret != "sec" {
|
||||
t.Errorf("got client_id=%q secret=%q, want cli_x/sec", res.ClientID, res.ClientSecret)
|
||||
}
|
||||
if res.UserInfo == nil || res.UserInfo.TenantBrand != "feishu" {
|
||||
t.Errorf("user info not parsed: %+v", res.UserInfo)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPollAppRegistration_CancelledContext(t *testing.T) {
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
cancel() // cancel up front
|
||||
client := &http.Client{Transport: stubRoundTripper{status: 200, body: `{"error":"authorization_pending"}`}}
|
||||
_, err := PollAppRegistration(ctx, client, core.BrandFeishu, "dc", 0, 60, io.Discard)
|
||||
if !errors.Is(err, ErrAppRegCancelled) {
|
||||
t.Fatalf("err = %v, want errors.Is(ErrAppRegCancelled)", err)
|
||||
}
|
||||
}
|
||||
@@ -113,8 +113,7 @@ type EnumOption struct {
|
||||
}
|
||||
|
||||
// EnumOptions returns the field's allowed values paired with their descriptions
|
||||
// — from enum (with descriptions backfilled from options when the field carries
|
||||
// both forms), or from options when enum is absent — coerced to the canonical
|
||||
// — from enum, or from options when enum is absent — coerced to the canonical
|
||||
// type and ordered: numeric and boolean values are sorted; string values keep
|
||||
// source order (which can encode priority). Uncoercible literals are dropped.
|
||||
// Returns nil when the field declares no enum constraint.
|
||||
@@ -123,14 +122,9 @@ func (f Field) EnumOptions() []EnumOption {
|
||||
var out []EnumOption
|
||||
switch {
|
||||
case len(f.Enum) > 0:
|
||||
// key by raw literal so enum "1" and option 1 align across JSON types
|
||||
desc := make(map[string]string, len(f.Options))
|
||||
for _, o := range f.Options {
|
||||
desc[fmt.Sprintf("%v", o.Value)] = o.Description
|
||||
}
|
||||
for _, e := range f.Enum {
|
||||
if v, ok := coerceLiteral(ct, e); ok {
|
||||
out = append(out, EnumOption{Value: v, Description: desc[fmt.Sprintf("%v", e)]})
|
||||
out = append(out, EnumOption{Value: v})
|
||||
}
|
||||
}
|
||||
case len(f.Options) > 0:
|
||||
|
||||
@@ -80,39 +80,6 @@ func TestField_EnumOptions(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestField_EnumOptions_BothEnumAndOptions(t *testing.T) {
|
||||
// enum is the value set; descriptions backfilled from options, empty where absent
|
||||
f := Field{Type: "string", Enum: []any{"1", "2", "3", "4", "6"}, Options: []Option{
|
||||
{Value: "1", Description: "from"},
|
||||
{Value: "2", Description: "to"},
|
||||
{Value: "6", Description: "subject"},
|
||||
}}
|
||||
want := []EnumOption{
|
||||
{Value: "1", Description: "from"},
|
||||
{Value: "2", Description: "to"},
|
||||
{Value: "3", Description: ""},
|
||||
{Value: "4", Description: ""},
|
||||
{Value: "6", Description: "subject"},
|
||||
}
|
||||
if got := f.EnumOptions(); !reflect.DeepEqual(got, want) {
|
||||
t.Errorf("EnumOptions(enum+options) = %+v, want %+v", got, want)
|
||||
}
|
||||
|
||||
// enum values stored as strings match option values stored as numbers
|
||||
fi := Field{Type: "integer", Enum: []any{"10", "2", "1"}, Options: []Option{
|
||||
{Value: 1, Description: "one"},
|
||||
{Value: 2, Description: "two"},
|
||||
}}
|
||||
wantI := []EnumOption{
|
||||
{Value: int64(1), Description: "one"},
|
||||
{Value: int64(2), Description: "two"},
|
||||
{Value: int64(10), Description: ""},
|
||||
}
|
||||
if got := fi.EnumOptions(); !reflect.DeepEqual(got, wantI) {
|
||||
t.Errorf("EnumOptions(integer enum+options) = %+v, want %+v", got, wantI)
|
||||
}
|
||||
}
|
||||
|
||||
func TestField_Enum_NumberAndBoolean(t *testing.T) {
|
||||
// number: string-stored floats coerced to float64 and numerically sorted
|
||||
if got := (Field{Type: "number", Enum: []any{"2.5", "1.5", "10"}}).EnumValues(); !reflect.DeepEqual(got, []any{1.5, 2.5, float64(10)}) {
|
||||
|
||||
@@ -472,18 +472,6 @@ func TestConvert_EnumDescriptions(t *testing.T) {
|
||||
if bare.EnumDescriptions != nil {
|
||||
t.Errorf("bare enum must have nil EnumDescriptions, got %v", bare.EnumDescriptions)
|
||||
}
|
||||
|
||||
// enum + options both present -> enumDescriptions backfilled, aligned, "" where absent
|
||||
both := Convert(meta.Field{Type: "string", Enum: []any{"1", "2", "3"}, Options: []meta.Option{
|
||||
{Value: "1", Description: "from"},
|
||||
{Value: "2", Description: "to"},
|
||||
}})
|
||||
if !reflect.DeepEqual(both.Enum, []interface{}{"1", "2", "3"}) {
|
||||
t.Errorf("both Enum = %v", both.Enum)
|
||||
}
|
||||
if !reflect.DeepEqual(both.EnumDescriptions, []string{"from", "to", ""}) {
|
||||
t.Errorf("both EnumDescriptions = %v, want [from to \"\"] aligned with enum", both.EnumDescriptions)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildMeta_AffordanceFromMethod(t *testing.T) {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@larksuite/cli",
|
||||
"version": "1.0.57",
|
||||
"version": "1.0.55",
|
||||
"description": "The official CLI for Lark/Feishu open platform",
|
||||
"bin": {
|
||||
"lark-cli": "scripts/run.js"
|
||||
|
||||
@@ -89,7 +89,7 @@ def main():
|
||||
count = len(data.get("services", []))
|
||||
print(f"fetch-meta: OK, {count} services from remote API", file=sys.stderr)
|
||||
|
||||
with open(OUT_PATH, "w", encoding="utf-8", newline="\n") as fp:
|
||||
with open(OUT_PATH, "w") as fp:
|
||||
json.dump(data, fp, ensure_ascii=False, indent=2)
|
||||
fp.write("\n")
|
||||
|
||||
|
||||
@@ -179,10 +179,7 @@ fi
|
||||
require_in_step "$summary_verify_step" 'workflowPath !== ".github/workflows/ci.yml"' "PR quality summary must verify the triggering workflow path"
|
||||
require_in_step "$summary_verify_step" 'run.event !== "pull_request"' "PR quality summary must only handle pull_request workflow_run events"
|
||||
require_in_step "$summary_verify_step" 'run.repository.id !== context.payload.repository.id' "PR quality summary must verify workflow_run repository id"
|
||||
require_in_step "$summary_verify_step" 'const targetHeadSha = run.head_sha' "PR quality summary must use the CI run head SHA as the verified PR head"
|
||||
require_in_step "$summary_verify_step" 'eventHeadSha && eventHeadSha.toLowerCase() !== targetHeadSha.toLowerCase()' "PR quality summary should tolerate mutable workflow_run PR head metadata"
|
||||
require_in_step "$summary_verify_step" 'factsArtifactPattern' "PR quality summary should use the base-bound facts artifact name when available"
|
||||
require_in_step "$summary_verify_step" 'const baseSha = artifactBaseSha || eventBaseSha || pr.base.sha' "PR quality summary must prefer the CI-time artifact base SHA"
|
||||
require_in_step "$summary_verify_step" 'core.setOutput("artifact_error"' "PR quality summary must expose artifact binding failures"
|
||||
require_in_step "$summary_artifact_step" 'factsArtifactName' "PR quality summary artifact step must use the verified facts artifact binding"
|
||||
require_in_step "$summary_extract_facts_step" 'SEMANTIC_REVIEW_DECISION_OUT' "PR quality summary artifact verifier must write an infrastructure decision on verifier failure"
|
||||
@@ -201,9 +198,8 @@ require_in_step "$verify_step" 'workflowPath !== ".github/workflows/ci.yml"' "se
|
||||
require_in_step "$verify_step" 'run.repository.id !== context.payload.repository.id' "semantic-review must verify workflow_run repository id"
|
||||
require_in_step "$verify_step" 'run.event !== "pull_request"' "semantic-review must only handle pull_request workflow_run events"
|
||||
require_in_step "$verify_step" 'run.conclusion !== "success"' "semantic-review must only consume successful CI runs"
|
||||
require_in_step "$verify_step" 'const eventHeadSha = runPRs[0]?.head?.sha || ""' "semantic-review should inspect workflow_run PR head metadata"
|
||||
require_in_step "$verify_step" 'const targetHeadSha = run.head_sha' "semantic-review target PR head must come from the completed CI run"
|
||||
require_in_step "$verify_step" 'eventHeadSha && eventHeadSha.toLowerCase() !== targetHeadSha.toLowerCase()' "semantic-review should tolerate mutable workflow_run PR head metadata"
|
||||
require_in_step "$verify_step" 'const eventHeadSha = runPRs[0]?.head?.sha || ""' "semantic-review must prefer workflow_run PR head when GitHub provides it"
|
||||
require_in_step "$verify_step" 'const targetHeadSha = eventHeadSha || run.head_sha' "semantic-review target PR head must come from the workflow_run event"
|
||||
require_in_step "$verify_step" 'factsArtifactPattern' "semantic-review must use a base-bound facts artifact name"
|
||||
require_in_step "$verify_step" 'listWorkflowRunArtifacts' "semantic-review must read the workflow_run artifacts before resolving fallback base SHA"
|
||||
require_in_step "$verify_step" 'artifactHeadSha.toLowerCase() !== targetHeadSha.toLowerCase()' "semantic-review must not let the artifact choose a different PR head"
|
||||
@@ -214,8 +210,8 @@ require_in_step "$verify_step" 'commit_sha: targetHeadSha' "semantic-review fall
|
||||
require_in_step "$verify_step" 'github.rest.pulls.list' "semantic-review must have a pull-list fallback when commit association is empty"
|
||||
require_in_step "$verify_step" 'candidatePRs.length > 1' "semantic-review must fail closed when commit-to-PR fallback is ambiguous"
|
||||
require_in_step "$verify_step" 'pr.head.sha !== targetHeadSha' "semantic-review must skip stale PR heads"
|
||||
require_in_step "$verify_step" 'eventBaseSha && parsedBaseSha.toLowerCase() !== eventBaseSha.toLowerCase()' "semantic-review should tolerate mutable workflow_run PR base metadata"
|
||||
require_in_step "$verify_step" 'const baseSha = artifactBaseSha || eventBaseSha || pr.base.sha' "semantic-review must prefer the CI-time artifact base SHA"
|
||||
require_in_step "$verify_step" 'eventBaseSha && parsedBaseSha.toLowerCase() !== eventBaseSha.toLowerCase()' "semantic-review must reject mismatched event and artifact base SHAs"
|
||||
require_in_step "$verify_step" 'const baseSha = eventBaseSha || artifactBaseSha' "semantic-review fallback must use the CI-time artifact base SHA"
|
||||
require_in_step "$verify_step" 'pr.base.sha !== baseSha' "semantic-review must skip stale PR bases"
|
||||
require_in_step "$verify_step" 'core.setOutput("run_id"' "semantic-review must pass verified workflow run id to publisher"
|
||||
require_in_step "$verify_step" 'core.setOutput("head_repo_id"' "semantic-review must pass verified head repo id"
|
||||
|
||||
@@ -7,7 +7,6 @@ import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/extension/fileio"
|
||||
@@ -85,9 +84,6 @@ var AppsHTMLPublish = common.Shortcut{
|
||||
// for dry-run "advisory preview" semantics).
|
||||
dry.Set("validation_error", err.Error())
|
||||
}
|
||||
if hits := oversizeHTMLFiles(candidates); len(hits) > 0 {
|
||||
dry.Set("oversize_html", hits)
|
||||
}
|
||||
dry.Set("file_count", len(candidates))
|
||||
var totalSize int64
|
||||
names := make([]string, 0, len(candidates))
|
||||
@@ -144,22 +140,18 @@ type appsHTMLPublishSpec struct {
|
||||
// per-environment .env.* files for every stage).
|
||||
const maxSensitiveListInError = 5
|
||||
|
||||
// truncatedJoin joins items with ", ", capping at max entries and appending
|
||||
// "(and N more)" for the remainder, so an inline error list stays readable when
|
||||
// a payload has many hits.
|
||||
func truncatedJoin(items []string, max int) string {
|
||||
if len(items) <= max {
|
||||
return strings.Join(items, ", ")
|
||||
}
|
||||
return strings.Join(items[:max], ", ") + fmt.Sprintf(" (and %d more)", len(items)-max)
|
||||
}
|
||||
|
||||
// sensitiveCandidatesError builds the Validate-time rejection when --path
|
||||
// contains credential files and --allow-sensitive was not set.
|
||||
func sensitiveCandidatesError(hits []string) error {
|
||||
var sample string
|
||||
if len(hits) <= maxSensitiveListInError {
|
||||
sample = strings.Join(hits, ", ")
|
||||
} else {
|
||||
sample = strings.Join(hits[:maxSensitiveListInError], ", ") +
|
||||
fmt.Sprintf(" (and %d more)", len(hits)-maxSensitiveListInError)
|
||||
}
|
||||
return appsValidationParamError("--path",
|
||||
"--path contains %d credential file(s) that should not be published: %s",
|
||||
len(hits), truncatedJoin(hits, maxSensitiveListInError)).
|
||||
"--path contains %d credential file(s) that should not be published: %s", len(hits), sample).
|
||||
WithHint("remove these files from the publish payload, OR pass --allow-sensitive if shipping them is intentional (e.g. a docs site demoing credential-file formats)")
|
||||
}
|
||||
|
||||
@@ -176,30 +168,6 @@ var maxHTMLPublishTarballBytes int64 = 20 * 1024 * 1024
|
||||
// Mutable for tests.
|
||||
var maxHTMLPublishRawBytes int64 = 200 * 1024 * 1024
|
||||
|
||||
// maxHTMLPublishSingleHTMLFileBytes 单个 .html 文件上限,对齐妙搭服务端 10MB 约束。
|
||||
// 用 var 而非 const,便于单测调小覆盖拦截路径。
|
||||
var maxHTMLPublishSingleHTMLFileBytes int64 = 10 * 1024 * 1024
|
||||
|
||||
// oversizeHTMLFiles 返回 candidates 中扩展名为 .html(大小写不敏感)且单个 Size 超过
|
||||
// maxHTMLPublishSingleHTMLFileBytes 的 RelPath 列表。只针对 .html 文件,不波及图片/字体/JS。
|
||||
func oversizeHTMLFiles(candidates []htmlPublishCandidate) []string {
|
||||
var hits []string
|
||||
for _, c := range candidates {
|
||||
if strings.EqualFold(filepath.Ext(c.RelPath), ".html") && c.Size > maxHTMLPublishSingleHTMLFileBytes {
|
||||
hits = append(hits, c.RelPath)
|
||||
}
|
||||
}
|
||||
return hits
|
||||
}
|
||||
|
||||
// oversizeHTMLFilesError 构造单文件超限的 Validate 风格拒绝。
|
||||
func oversizeHTMLFilesError(hits []string) error {
|
||||
return appsValidationParamError("--path",
|
||||
"--path contains %d HTML file(s) exceeding the %d bytes (10MB) per-file limit: %s",
|
||||
len(hits), maxHTMLPublishSingleHTMLFileBytes, truncatedJoin(hits, maxSensitiveListInError)).
|
||||
WithHint("split or trim oversized HTML file(s); the 10MB cap applies to each single .html file")
|
||||
}
|
||||
|
||||
// ensureIndexHTML 要求 walker 抓到的 candidates 里必须含 index.html。
|
||||
// 目录形态:根目录下必须有 index.html。
|
||||
// 单文件形态:文件名必须就是 index.html。
|
||||
@@ -222,9 +190,6 @@ func runHTMLPublish(ctx context.Context, fio fileio.FileIO, publisher appsHTMLPu
|
||||
if err := ensureIndexHTML(candidates); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if hits := oversizeHTMLFiles(candidates); len(hits) > 0 {
|
||||
return nil, oversizeHTMLFilesError(hits)
|
||||
}
|
||||
var rawTotal int64
|
||||
for _, c := range candidates {
|
||||
rawTotal += c.Size
|
||||
|
||||
@@ -503,82 +503,3 @@ func TestRunHTMLPublish_RejectsOversizeRawCandidates(t *testing.T) {
|
||||
t.Fatalf("client must not be called when raw cap hit")
|
||||
}
|
||||
}
|
||||
|
||||
func TestOversizeHTMLFiles(t *testing.T) {
|
||||
orig := maxHTMLPublishSingleHTMLFileBytes
|
||||
maxHTMLPublishSingleHTMLFileBytes = 100
|
||||
defer func() { maxHTMLPublishSingleHTMLFileBytes = orig }()
|
||||
|
||||
cands := []htmlPublishCandidate{
|
||||
{RelPath: "index.html", Size: 50},
|
||||
{RelPath: "big.html", Size: 4096},
|
||||
{RelPath: "BIG.HTML", Size: 4096}, // 大小写不敏感
|
||||
{RelPath: "huge.png", Size: 9000}, // 非 .html,忽略
|
||||
}
|
||||
hits := oversizeHTMLFiles(cands)
|
||||
if len(hits) != 2 {
|
||||
t.Fatalf("hits=%v, want [big.html BIG.HTML]", hits)
|
||||
}
|
||||
for _, h := range hits {
|
||||
if h == "huge.png" || h == "index.html" {
|
||||
t.Fatalf("unexpected hit %q", h)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestMaxHTMLPublishSingleHTMLFileBytes_Default(t *testing.T) {
|
||||
if maxHTMLPublishSingleHTMLFileBytes != 10*1024*1024 {
|
||||
t.Fatalf("default=%d, want %d (10MiB)", maxHTMLPublishSingleHTMLFileBytes, 10*1024*1024)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunHTMLPublish_RejectsOversizeHTMLFile(t *testing.T) {
|
||||
orig := maxHTMLPublishSingleHTMLFileBytes
|
||||
maxHTMLPublishSingleHTMLFileBytes = 100
|
||||
defer func() { maxHTMLPublishSingleHTMLFileBytes = orig }()
|
||||
|
||||
dir := t.TempDir()
|
||||
if err := os.WriteFile(filepath.Join(dir, "index.html"), []byte("<html></html>"), 0o644); err != nil {
|
||||
t.Fatalf("write: %v", err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(dir, "big.html"), []byte(strings.Repeat("x", 4096)), 0o644); err != nil {
|
||||
t.Fatalf("write: %v", err)
|
||||
}
|
||||
fake := &fakeAppsHTMLPublishClient{}
|
||||
_, err := runHTMLPublish(context.Background(), newTestFIO(), fake, appsHTMLPublishSpec{AppID: "app_x", Path: dir})
|
||||
if err == nil {
|
||||
t.Fatalf("expected per-file oversize error")
|
||||
}
|
||||
problem := requireAppsValidationProblem(t, err)
|
||||
if !strings.Contains(problem.Message, "big.html") || !strings.Contains(problem.Message, "10MB") {
|
||||
t.Fatalf("message=%q, want contains 'big.html' and '10MB'", problem.Message)
|
||||
}
|
||||
if problem.Hint == "" {
|
||||
t.Fatalf("expected non-empty hint")
|
||||
}
|
||||
if len(fake.calls) != 0 {
|
||||
t.Fatalf("client must not be called when an HTML file is oversize")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunHTMLPublish_IgnoresOversizeNonHTML(t *testing.T) {
|
||||
// 单 .html 上限调小,但超限文件是 .png → 不被本护栏拦截,正常发布。
|
||||
orig := maxHTMLPublishSingleHTMLFileBytes
|
||||
maxHTMLPublishSingleHTMLFileBytes = 100
|
||||
defer func() { maxHTMLPublishSingleHTMLFileBytes = orig }()
|
||||
|
||||
dir := t.TempDir()
|
||||
if err := os.WriteFile(filepath.Join(dir, "index.html"), []byte("<html></html>"), 0o644); err != nil {
|
||||
t.Fatalf("write: %v", err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(dir, "big.png"), []byte(strings.Repeat("x", 4096)), 0o644); err != nil {
|
||||
t.Fatalf("write: %v", err)
|
||||
}
|
||||
fake := &fakeAppsHTMLPublishClient{resp: &htmlPublishResponse{URL: "https://miaoda/app_x"}}
|
||||
if _, err := runHTMLPublish(context.Background(), newTestFIO(), fake, appsHTMLPublishSpec{AppID: "app_x", Path: dir}); err != nil {
|
||||
t.Fatalf("non-html oversize must not be blocked by the .html cap: %v", err)
|
||||
}
|
||||
if len(fake.calls) != 1 {
|
||||
t.Fatalf("client should be called; calls=%v", fake.calls)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -99,14 +99,7 @@ var AppsInit = common.Shortcut{
|
||||
dry.Set("dir_error", err.Error())
|
||||
dir = defaultCloneDir(appID)
|
||||
} else if isAlreadyInitialized(dir) {
|
||||
if existing, e := ensureInitDirMatchesApp(dir, appID); e != nil {
|
||||
if existing != "" {
|
||||
dry.Set("app_id_mismatch", existing)
|
||||
}
|
||||
dry.Set("dir_error", e.Error())
|
||||
} else {
|
||||
dry.Set("already_initialized", true)
|
||||
}
|
||||
dry.Set("already_initialized", true)
|
||||
} else if e := ensureEmptyDir(dir); e != nil {
|
||||
dry.Set("dir_error", e.Error())
|
||||
}
|
||||
@@ -206,61 +199,6 @@ func isAlreadyInitialized(dir string) bool {
|
||||
return err == nil && !info.IsDir()
|
||||
}
|
||||
|
||||
// readMetaAppID 读取 <dir>/.spark/meta.json 的 app_id,用于判断目标目录是否同一个妙搭应用。
|
||||
// 返回 (appID, isSparkProject, err):
|
||||
// - meta.json 不存在 → ("", false, nil) 非妙搭工程
|
||||
// - 读取/解析失败(损坏/不可读) → ("", false, err) 无法确认是否妙搭工程
|
||||
// - 解析成功 → (trim 后的 app_id, true, nil)(app_id 缺失/为空时为 "")
|
||||
func readMetaAppID(dir string) (string, bool, error) {
|
||||
b, err := os.ReadFile(filepath.Join(dir, metaRelPath)) //nolint:forbidigo // shortcuts cannot import internal/vfs (depguard rule shortcuts-no-vfs); path is under the validated clone dir, and FileIO.Open rejects absolute paths.
|
||||
if os.IsNotExist(err) {
|
||||
return "", false, nil
|
||||
}
|
||||
if err != nil {
|
||||
return "", false, appsFileIOError(err, "read %s failed: %v", metaRelPath, err)
|
||||
}
|
||||
var m struct {
|
||||
AppID string `json:"app_id"`
|
||||
}
|
||||
if err := json.Unmarshal(b, &m); err != nil {
|
||||
return "", false, appsFileIOError(err, "parse %s failed: %v", metaRelPath, err)
|
||||
}
|
||||
return strings.TrimSpace(m.AppID), true, nil
|
||||
}
|
||||
|
||||
// ensureInitDirMatchesApp 校验「已存在的目标目录」能否被 appID 安全复用:
|
||||
// - 不是妙搭工程(无 meta.json) → nil(交给 ensureEmptyDir 判空/非空)
|
||||
// - 是妙搭工程且 app_id 与 appID 一致 → nil(走已初始化短路,复用本地代码)
|
||||
// - 是妙搭工程但 app_id 不一致(含为空) → 报错,提示换目录
|
||||
// - meta.json 损坏/不可读,无法确认 → 报错(fail closed),提示换目录
|
||||
//
|
||||
// 返回值 existing 是目录里已存在的 app_id(仅"已是另一个 app"的拒绝场景非空),供调用方在
|
||||
// dry-run 里回填 app_id_mismatch,避免二次读 meta.json。
|
||||
func ensureInitDirMatchesApp(dir, appID string) (existing string, err error) {
|
||||
existing, isSpark, readErr := readMetaAppID(dir)
|
||||
if readErr != nil {
|
||||
return "", appsValidationParamError("--dir",
|
||||
"target directory %q already exists but its %s is unreadable or corrupted; cannot confirm it belongs to app %s, refusing to use it",
|
||||
dir, metaRelPath, appID).
|
||||
WithHint("choose a different --dir, or repair/remove the directory, before running +init").
|
||||
WithCause(readErr)
|
||||
}
|
||||
if !isSpark || existing == appID {
|
||||
return existing, nil
|
||||
}
|
||||
if existing == "" {
|
||||
// meta 存在但缺 app_id:更可能是同一应用上次 +init 中断留下的半成品,而非另一个 app。
|
||||
return "", appsValidationParamError("--dir",
|
||||
"target directory %q has a %s without an app_id; cannot confirm it belongs to app %s, refusing to use it",
|
||||
dir, metaRelPath, appID).
|
||||
WithHint("remove the directory and re-run +init, or choose a different --dir")
|
||||
}
|
||||
return existing, appsValidationParamError("--dir",
|
||||
"target directory %q is already initialized for a different app (%s); refusing to initialize app %s into it",
|
||||
dir, existing, appID).
|
||||
WithHint("choose a different --dir (or cd into the matching project) before running +init")
|
||||
}
|
||||
|
||||
// ensureMetaAppID patches <dir>/.spark/meta.json to include app_id when the file
|
||||
// exists but lacks (or has an empty) app_id. Other fields are preserved. When
|
||||
// the file does not exist, this is a no-op (we never create it).
|
||||
@@ -440,11 +378,6 @@ func appsInitExecute(ctx context.Context, rctx *common.RuntimeContext) error {
|
||||
return err
|
||||
}
|
||||
|
||||
// 异 app 目录护栏:拒绝把当前 app 初始化进另一个 app 的已初始化工程。
|
||||
if _, err := ensureInitDirMatchesApp(dir, appID); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Already-initialized short-circuit: a dir containing .spark/meta.json is an
|
||||
// initialized app repo -> skip clone/scaffold/commit, but still refresh
|
||||
// the local env so a re-run picks up the latest startup env vars.
|
||||
|
||||
@@ -363,7 +363,7 @@ func TestAppsInit_AlreadyInitialized_ShortCircuit(t *testing.T) {
|
||||
if err := os.MkdirAll(filepath.Join(dir, ".spark"), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(dir, metaRelPath), []byte(`{"app_id":"app_x"}`), 0o644); err != nil {
|
||||
if err := os.WriteFile(filepath.Join(dir, metaRelPath), []byte(`{"app_id":"whatever"}`), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
f := &fakeCommandRunner{results: map[string]fakeCallResult{"env-pull": envPullOK(filepath.Join(abs, ".env.local"))}}
|
||||
@@ -394,40 +394,6 @@ func TestAppsInit_AlreadyInitialized_ShortCircuit(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestAppsInit_AlreadyInitialized_AppIDMismatch(t *testing.T) {
|
||||
dir := relCloneDir(t)
|
||||
if err := os.MkdirAll(filepath.Join(dir, ".spark"), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// 目录是 app_other 的工程,却用 --app-id app_x 初始化 → 必须报错且不拉 env。
|
||||
if err := os.WriteFile(filepath.Join(dir, metaRelPath), []byte(`{"app_id":"app_other"}`), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
f := &fakeCommandRunner{}
|
||||
withFakeRunner(t, f)
|
||||
factory, stdout, _ := newAppsExecuteFactory(t)
|
||||
err := runAppsShortcut(t, AppsInit, []string{"+init", "--app-id", "app_x", "--dir", dir, "--as", "user"}, factory, stdout)
|
||||
if err == nil {
|
||||
t.Fatal("mismatched app_id must error")
|
||||
}
|
||||
problem := requireAppsValidationProblem(t, err)
|
||||
if problem.Subtype != errs.SubtypeInvalidArgument {
|
||||
t.Fatalf("subtype=%q, want %q", problem.Subtype, errs.SubtypeInvalidArgument)
|
||||
}
|
||||
var ve *errs.ValidationError
|
||||
if !errors.As(err, &ve) || ve.Param != "--dir" {
|
||||
t.Fatalf("expected *errs.ValidationError with Param=--dir, got %T param=%v", err, ve)
|
||||
}
|
||||
if !strings.Contains(problem.Message, "different app") {
|
||||
t.Fatalf("message=%q, want 'different app'", problem.Message)
|
||||
}
|
||||
for _, c := range f.calls {
|
||||
if containsAll(c, "+env-pull") || containsAll(c, "git", "clone") {
|
||||
t.Errorf("mismatch must not run env-pull/clone; got %v", f.calls)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestAppsInit_HappyPathCleanTree(t *testing.T) {
|
||||
f := &fakeCommandRunner{results: map[string]fakeCallResult{
|
||||
"credential-init": credInitOK("http://u:t@h/app_x.git"),
|
||||
@@ -1502,125 +1468,6 @@ func TestAppsInit_Description_IsAboutCode(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestReadMetaAppID(t *testing.T) {
|
||||
writeMeta := func(t *testing.T, content string) string {
|
||||
t.Helper()
|
||||
dir := t.TempDir()
|
||||
if err := os.MkdirAll(filepath.Join(dir, ".spark"), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(dir, metaRelPath), []byte(content), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return dir
|
||||
}
|
||||
|
||||
// 不存在 meta.json → ("", false, nil)
|
||||
if got, ok, err := readMetaAppID(t.TempDir()); ok || got != "" || err != nil {
|
||||
t.Fatalf("no meta: got (%q,%v,%v), want (\"\",false,nil)", got, ok, err)
|
||||
}
|
||||
// 存在且有 app_id → (app_id, true, nil)
|
||||
if got, ok, err := readMetaAppID(writeMeta(t, `{"app_id":"app_a"}`)); !ok || got != "app_a" || err != nil {
|
||||
t.Fatalf("with app_id: got (%q,%v,%v), want (\"app_a\",true,nil)", got, ok, err)
|
||||
}
|
||||
// 存在但 app_id 空 → ("", true, nil)
|
||||
if got, ok, err := readMetaAppID(writeMeta(t, `{"name":"x"}`)); !ok || got != "" || err != nil {
|
||||
t.Fatalf("empty app_id: got (%q,%v,%v), want (\"\",true,nil)", got, ok, err)
|
||||
}
|
||||
// 存在但坏 JSON → ("", false, err)(无法确认)
|
||||
if got, ok, err := readMetaAppID(writeMeta(t, `{not json`)); ok || got != "" || err == nil {
|
||||
t.Fatalf("bad json: got (%q,%v,err=%v), want (\"\",false,non-nil)", got, ok, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnsureInitDirMatchesApp(t *testing.T) {
|
||||
writeMeta := func(t *testing.T, content string) string {
|
||||
t.Helper()
|
||||
dir := t.TempDir()
|
||||
if err := os.MkdirAll(filepath.Join(dir, ".spark"), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(dir, metaRelPath), []byte(content), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return dir
|
||||
}
|
||||
|
||||
// 无 meta(非妙搭工程)→ nil(交给 ensureEmptyDir)
|
||||
if _, err := ensureInitDirMatchesApp(t.TempDir(), "app_x"); err != nil {
|
||||
t.Fatalf("no meta should pass: %v", err)
|
||||
}
|
||||
// 同 app_id → (app_id, nil)(走已初始化短路)
|
||||
if existing, err := ensureInitDirMatchesApp(writeMeta(t, `{"app_id":"app_x"}`), "app_x"); err != nil || existing != "app_x" {
|
||||
t.Fatalf("same app should pass: existing=%q err=%v", existing, err)
|
||||
}
|
||||
|
||||
// 不同 app_id → error(换目录),返回 existing=app_other;断言 typed metadata(subtype/param)
|
||||
existing, errMismatch := ensureInitDirMatchesApp(writeMeta(t, `{"app_id":"app_other"}`), "app_x")
|
||||
if errMismatch == nil {
|
||||
t.Fatal("different app should error")
|
||||
}
|
||||
if existing != "app_other" {
|
||||
t.Fatalf("mismatch should return existing app_id, got %q", existing)
|
||||
}
|
||||
problem := requireAppsValidationProblem(t, errMismatch) // 已校验 Category==Validation
|
||||
if problem.Subtype != errs.SubtypeInvalidArgument {
|
||||
t.Fatalf("subtype=%q, want %q", problem.Subtype, errs.SubtypeInvalidArgument)
|
||||
}
|
||||
var ve *errs.ValidationError
|
||||
if !errors.As(errMismatch, &ve) {
|
||||
t.Fatalf("expected *errs.ValidationError, got %T", errMismatch)
|
||||
}
|
||||
if ve.Param != "--dir" {
|
||||
t.Fatalf("param=%q, want --dir", ve.Param)
|
||||
}
|
||||
if !strings.Contains(problem.Message, "different app") || !strings.Contains(problem.Message, "app_other") {
|
||||
t.Fatalf("message=%q, want 'different app' and 'app_other'", problem.Message)
|
||||
}
|
||||
if !strings.Contains(problem.Hint, "different --dir") {
|
||||
t.Fatalf("hint=%q, want 'different --dir'", problem.Hint)
|
||||
}
|
||||
|
||||
// 空 app_id(缺 app_id 标记的半成品)→ error,独立文案(非 "different app"),返回 existing=""
|
||||
emptyExisting, errEmpty := ensureInitDirMatchesApp(writeMeta(t, `{"name":"x"}`), "app_x")
|
||||
if errEmpty == nil {
|
||||
t.Fatal("empty meta app_id should error (cannot confirm same app)")
|
||||
}
|
||||
if emptyExisting != "" {
|
||||
t.Fatalf("empty app_id should return existing=\"\", got %q", emptyExisting)
|
||||
}
|
||||
pEmpty := requireAppsValidationProblem(t, errEmpty)
|
||||
if pEmpty.Subtype != errs.SubtypeInvalidArgument {
|
||||
t.Fatalf("empty subtype=%q, want %q", pEmpty.Subtype, errs.SubtypeInvalidArgument)
|
||||
}
|
||||
if !strings.Contains(pEmpty.Message, "without an app_id") {
|
||||
t.Fatalf("empty app_id should have its own message, msg=%q", pEmpty.Message)
|
||||
}
|
||||
if strings.Contains(pEmpty.Message, "different app") {
|
||||
t.Fatalf("empty app_id must not reuse the different-app wording, msg=%q", pEmpty.Message)
|
||||
}
|
||||
|
||||
// meta 损坏/不可读 → error(fail closed),返回 existing=""
|
||||
badExisting, errBad := ensureInitDirMatchesApp(writeMeta(t, `{not json`), "app_x")
|
||||
if errBad == nil {
|
||||
t.Fatal("corrupted meta should fail closed")
|
||||
}
|
||||
if badExisting != "" {
|
||||
t.Fatalf("corrupted should return existing=\"\", got %q", badExisting)
|
||||
}
|
||||
pBad := requireAppsValidationProblem(t, errBad)
|
||||
if pBad.Subtype != errs.SubtypeInvalidArgument {
|
||||
t.Fatalf("corrupted subtype=%q, want %q", pBad.Subtype, errs.SubtypeInvalidArgument)
|
||||
}
|
||||
if !strings.Contains(pBad.Message, "unreadable or corrupted") {
|
||||
t.Fatalf("corrupted meta msg=%q, want 'unreadable or corrupted'", pBad.Message)
|
||||
}
|
||||
var veBad *errs.ValidationError
|
||||
if !errors.As(errBad, &veBad) || veBad.Param != "--dir" {
|
||||
t.Fatalf("corrupted: expected ValidationError Param=--dir, got %T param=%v", errBad, veBad)
|
||||
}
|
||||
}
|
||||
|
||||
// TestRunScaffold_SubprocessFailureIsExternalTool pins the typed
|
||||
// classification of an external-tool failure: a failing git subprocess
|
||||
// surfaces as internal/external_tool with the cause preserved.
|
||||
|
||||
@@ -17,7 +17,6 @@ import (
|
||||
"text/tabwriter"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
larkcore "github.com/larksuite/oapi-sdk-go/v3/core"
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
@@ -33,7 +32,6 @@ import (
|
||||
)
|
||||
|
||||
const gitCredentialIssuePath = apiBasePath + "/apps/:app_id/git_info"
|
||||
const gitCredentialHelperReportedShortcut = appsService + ":+git-credential-helper"
|
||||
|
||||
// gitCredentialIssueHint is the actionable next-step attached to a failed
|
||||
// Git-credential issuance. A 5xx is flagged retryable separately at the call site.
|
||||
@@ -304,12 +302,7 @@ func (i factoryIssuer) Issue(ctx context.Context, appID string, profile gitcred.
|
||||
HttpMethod: http.MethodGet,
|
||||
ApiPath: issuePath(appID),
|
||||
}
|
||||
ctx = contextWithGitCredentialHelperShortcut(ctx)
|
||||
var opts []larkcore.RequestOptionFunc
|
||||
if optFn := cmdutil.ShortcutHeaderOpts(ctx); optFn != nil {
|
||||
opts = append(opts, optFn)
|
||||
}
|
||||
resp, err := ac.DoSDKRequest(ctx, req, core.AsUser, opts...)
|
||||
resp, err := ac.DoSDKRequest(ctx, req, core.AsUser)
|
||||
data, err := parseIssueCredentialData(resp, err, errclass.ClassifyContext{
|
||||
Brand: string(cfg.Brand),
|
||||
AppID: cfg.AppID,
|
||||
@@ -321,13 +314,6 @@ func (i factoryIssuer) Issue(ctx context.Context, appID string, profile gitcred.
|
||||
return issuedFromData(appID, data)
|
||||
}
|
||||
|
||||
func contextWithGitCredentialHelperShortcut(ctx context.Context) context.Context {
|
||||
if _, ok := cmdutil.ShortcutNameFromContext(ctx); ok {
|
||||
return ctx
|
||||
}
|
||||
return cmdutil.ContextWithShortcut(ctx, gitCredentialHelperReportedShortcut, uuid.New().String())
|
||||
}
|
||||
|
||||
func runGitCredentialHelper(ctx context.Context, f *cmdutil.Factory, appID, action string) error {
|
||||
if f == nil || f.IOStreams == nil {
|
||||
return nil
|
||||
|
||||
@@ -825,7 +825,7 @@ func TestRunGitCredentialHelperActions(t *testing.T) {
|
||||
func TestFactoryIssuerBranches(t *testing.T) {
|
||||
factory, _, reg := newAppsExecuteFactory(t)
|
||||
expiresAt := time.Now().Add(24 * time.Hour).Unix()
|
||||
issueStub := &httpmock.Stub{
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/spark/v1/apps/app_xxx/git_info",
|
||||
Body: map[string]interface{}{
|
||||
@@ -836,8 +836,7 @@ func TestFactoryIssuerBranches(t *testing.T) {
|
||||
"StatusCode": 0,
|
||||
},
|
||||
},
|
||||
}
|
||||
reg.Register(issueStub)
|
||||
})
|
||||
issued, err := (factoryIssuer{f: factory}).Issue(context.Background(), "app_xxx", gitcred.ProfileContext{})
|
||||
if err != nil {
|
||||
t.Fatalf("factory issuer returned error: %v", err)
|
||||
@@ -845,12 +844,6 @@ func TestFactoryIssuerBranches(t *testing.T) {
|
||||
if issued.PAT != "pat-token" {
|
||||
t.Fatalf("PAT = %q", issued.PAT)
|
||||
}
|
||||
if got := issueStub.CapturedHeaders.Get(cmdutil.HeaderShortcut); got != gitCredentialHelperReportedShortcut {
|
||||
t.Fatalf("%s = %q, want %q", cmdutil.HeaderShortcut, got, gitCredentialHelperReportedShortcut)
|
||||
}
|
||||
if got := issueStub.CapturedHeaders.Get(cmdutil.HeaderExecutionId); got == "" {
|
||||
t.Fatalf("%s header missing", cmdutil.HeaderExecutionId)
|
||||
}
|
||||
|
||||
factory.Config = func() (*core.CliConfig, error) { return nil, errors.New("config failed") }
|
||||
if _, err := (factoryIssuer{f: factory}).Issue(context.Background(), "app_xxx", gitcred.ProfileContext{}); err == nil {
|
||||
@@ -887,20 +880,6 @@ func TestFactoryIssuerBranches(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestContextWithGitCredentialHelperShortcutPreservesExistingShortcut(t *testing.T) {
|
||||
ctx := cmdutil.ContextWithShortcut(context.Background(), "apps:+git-credential-init", "exec-existing")
|
||||
got := contextWithGitCredentialHelperShortcut(ctx)
|
||||
|
||||
name, ok := cmdutil.ShortcutNameFromContext(got)
|
||||
if !ok || name != "apps:+git-credential-init" {
|
||||
t.Fatalf("shortcut = %q ok=%v, want existing shortcut", name, ok)
|
||||
}
|
||||
executionID, ok := cmdutil.ExecutionIdFromContext(got)
|
||||
if !ok || executionID != "exec-existing" {
|
||||
t.Fatalf("execution id = %q ok=%v, want existing execution id", executionID, ok)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGitCredentialHelpersAndParsers(t *testing.T) {
|
||||
if issuePath(" app/with space ") != "/open-apis/spark/v1/apps/app%2Fwith%20space/git_info" {
|
||||
t.Fatalf("issuePath escaped incorrectly: %s", issuePath(" app/with space "))
|
||||
|
||||
@@ -199,7 +199,16 @@ func TestParseDriveMediaMultipartUploadSessionTypedValidatesResponseFields(t *te
|
||||
t.Parallel()
|
||||
|
||||
_, err := parseDriveMediaMultipartUploadSessionTyped(tt.data)
|
||||
requireProblem(t, err, errs.CategoryInternal, errs.SubtypeInvalidResponse, tt.wantText)
|
||||
if err == nil || !strings.Contains(err.Error(), tt.wantText) {
|
||||
t.Fatalf("err = %v, want substring %q", err, tt.wantText)
|
||||
}
|
||||
p, ok := errs.ProblemOf(err)
|
||||
if !ok {
|
||||
t.Fatalf("expected typed problem, got %T (%v)", err, err)
|
||||
}
|
||||
if p.Subtype != errs.SubtypeInvalidResponse {
|
||||
t.Fatalf("subtype = %s, want invalid_response", p.Subtype)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -142,7 +142,9 @@ func TestNormalizeMCPToolResult(t *testing.T) {
|
||||
|
||||
got, err := normalizeMCPToolResult(tt.raw)
|
||||
if tt.wantErr != "" {
|
||||
requireProblem(t, err, errs.CategoryAPI, errs.SubtypeUnknown, tt.wantErr)
|
||||
if err == nil || !strings.Contains(err.Error(), tt.wantErr) {
|
||||
t.Fatalf("expected error containing %q, got %v", tt.wantErr, err)
|
||||
}
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
|
||||
@@ -49,21 +49,8 @@ type RuntimeContext struct {
|
||||
apiClientFunc func() (*client.APIClient, error) // sync.OnceValues; initialized in newRuntimeContext
|
||||
botInfoFunc func() (*BotInfo, error) // sync.OnceValues; lazy bot identity from /bot/v3/info
|
||||
larkSDK *lark.Client // eagerly initialized in mountDeclarative
|
||||
stdinConsumed bool // set when any flag has consumed stdin (`-`); used so out-of-band binary readers (e.g. sheets +table-put --dataframe) can refuse a second stdin consumer instead of racing for an already-empty stream
|
||||
}
|
||||
|
||||
// StdinConsumed reports whether stdin has already been consumed by an Input
|
||||
// flag's `-` form via resolveInputFlags. Out-of-band binary readers that read
|
||||
// stdin themselves (currently sheets +table-put / +workbook-create --dataframe)
|
||||
// must check this before reading — a process has a single stdin, so two
|
||||
// consumers would race and one would see an empty stream.
|
||||
func (ctx *RuntimeContext) StdinConsumed() bool { return ctx.stdinConsumed }
|
||||
|
||||
// MarkStdinConsumed marks stdin as consumed. Out-of-band binary readers must
|
||||
// call this after they read stdin so a later Input-flag `-` is rejected cleanly
|
||||
// instead of racing on an empty stream.
|
||||
func (ctx *RuntimeContext) MarkStdinConsumed() { ctx.stdinConsumed = true }
|
||||
|
||||
// ── Identity ──
|
||||
|
||||
// As returns the current identity.
|
||||
@@ -236,12 +223,6 @@ func (ctx *RuntimeContext) Float64(name string) float64 {
|
||||
return v
|
||||
}
|
||||
|
||||
// IntArray returns an int-array flag value (repeated flag, also supports CSV splitting).
|
||||
func (ctx *RuntimeContext) IntArray(name string) []int {
|
||||
v, _ := ctx.Cmd.Flags().GetIntSlice(name)
|
||||
return v
|
||||
}
|
||||
|
||||
// StrArray returns a string-array flag value (repeated flag, no CSV splitting).
|
||||
func (ctx *RuntimeContext) StrArray(name string) []string {
|
||||
v, _ := ctx.Cmd.Flags().GetStringArray(name)
|
||||
@@ -1042,6 +1023,7 @@ func stripUTF8BOM(s string) string {
|
||||
// resolveInputFlags resolves @file and - (stdin) for flags with Input sources.
|
||||
// Must be called before Validate/DryRun/Execute so that runtime.Str() returns resolved content.
|
||||
func resolveInputFlags(rctx *RuntimeContext, flags []Flag) error {
|
||||
stdinUsed := false
|
||||
for _, fl := range flags {
|
||||
if len(fl.Input) == 0 {
|
||||
continue
|
||||
@@ -1061,15 +1043,11 @@ func resolveInputFlags(rctx *RuntimeContext, flags []Flag) error {
|
||||
return ValidationErrorf("--%s does not support stdin (-)", fl.Name).
|
||||
WithParam("--" + fl.Name)
|
||||
}
|
||||
// stdinConsumed also covers out-of-band readers like sheets +table-put
|
||||
// --dataframe (binary, doesn't go through Input). A process has a
|
||||
// single stdin, so we reject a second consumer regardless of source.
|
||||
if rctx.stdinConsumed {
|
||||
if stdinUsed {
|
||||
return ValidationErrorf("--%s: stdin (-) can only be used by one flag", fl.Name).
|
||||
WithParam("--"+fl.Name).
|
||||
WithHint("a process has a single stdin, so only one flag per call may use '-'; pass the others as @file (e.g. --%s @/path/to/file)", fl.Name)
|
||||
WithParam("--" + fl.Name)
|
||||
}
|
||||
rctx.stdinConsumed = true
|
||||
stdinUsed = true
|
||||
data, err := io.ReadAll(rctx.IO().In)
|
||||
if err != nil {
|
||||
return ValidationErrorf("--%s: failed to read from stdin: %v", fl.Name, err).
|
||||
@@ -1182,13 +1160,7 @@ func registerShortcutFlagsWithContext(ctx context.Context, cmd *cobra.Command, f
|
||||
hints = append(hints, "@file")
|
||||
}
|
||||
if slices.Contains(fl.Input, Stdin) {
|
||||
// "- reads stdin" intentionally avoids implying each flag has
|
||||
// its own stdin: a process has a single stdin, so at most one
|
||||
// flag per call may use "-" (the rest must use @file). The old
|
||||
// per-flag "- for stdin" wording led AI agents to write
|
||||
// `--a - <x --b - <y`, where the second `<` silently clobbers
|
||||
// the first and `--a` reads the wrong payload.
|
||||
hints = append(hints, "- reads stdin (one flag per call; use @file for others)")
|
||||
hints = append(hints, "- for stdin")
|
||||
}
|
||||
desc += " (supports " + strings.Join(hints, ", ") + ")"
|
||||
}
|
||||
@@ -1204,8 +1176,6 @@ func registerShortcutFlagsWithContext(ctx context.Context, cmd *cobra.Command, f
|
||||
var d float64
|
||||
fmt.Sscanf(fl.Default, "%g", &d)
|
||||
cmd.Flags().Float64(fl.Name, d, desc)
|
||||
case "int_array":
|
||||
cmd.Flags().IntSlice(fl.Name, nil, desc)
|
||||
case "string_array":
|
||||
cmd.Flags().StringArray(fl.Name, nil, desc)
|
||||
case "string_slice":
|
||||
|
||||
@@ -4,12 +4,9 @@
|
||||
package common
|
||||
|
||||
import (
|
||||
"context"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
@@ -22,7 +19,6 @@ func TestRejectPositionalArgs_WithArgs(t *testing.T) {
|
||||
if err == nil {
|
||||
t.Fatal("expected error for positional arg, got nil")
|
||||
}
|
||||
// rejectPositionalArgs returns a raw fmt.Errorf via cobra's PositionalArgs contract — not a typed envelope, message-substring assertion is intentional.
|
||||
if !strings.Contains(err.Error(), "positional arguments are not supported") {
|
||||
t.Errorf("expected positional args rejection message, got: %v", err)
|
||||
}
|
||||
@@ -40,7 +36,6 @@ func TestRejectPositionalArgs_MultipleArgs(t *testing.T) {
|
||||
if err == nil {
|
||||
t.Fatal("expected error for multiple positional args, got nil")
|
||||
}
|
||||
// rejectPositionalArgs returns a raw fmt.Errorf via cobra's PositionalArgs contract — not a typed envelope, message-substring assertion is intentional.
|
||||
if !strings.Contains(err.Error(), "positional arguments are not supported") {
|
||||
t.Errorf("unexpected error message: %v", err)
|
||||
}
|
||||
@@ -61,29 +56,3 @@ func TestRejectPositionalArgs_NoArgs(t *testing.T) {
|
||||
t.Fatalf("expected no error for empty args, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestShortcutFlagIntArray(t *testing.T) {
|
||||
f, _, _, _ := cmdutil.TestFactory(t, nil)
|
||||
parent := &cobra.Command{Use: "root"}
|
||||
var got []int
|
||||
shortcut := Shortcut{
|
||||
Service: "slides",
|
||||
Command: "+screenshot",
|
||||
Description: "capture screenshots",
|
||||
Flags: []Flag{
|
||||
{Name: "slide-number", Type: "int_array"},
|
||||
},
|
||||
Execute: func(ctx context.Context, runtime *RuntimeContext) error {
|
||||
got = runtime.IntArray("slide-number")
|
||||
return nil
|
||||
},
|
||||
}
|
||||
shortcut.Mount(parent, f)
|
||||
parent.SetArgs([]string{"+screenshot", "--as", "user", "--slide-number", "1", "--slide-number", "2,3"})
|
||||
if err := parent.Execute(); err != nil {
|
||||
t.Fatalf("Execute() error = %v", err)
|
||||
}
|
||||
if want := []int{1, 2, 3}; !reflect.DeepEqual(got, want) {
|
||||
t.Fatalf("slide-number = %#v, want %#v", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -171,7 +171,6 @@ func TestFetchBotInfo_APICodeNonZero(t *testing.T) {
|
||||
if err == nil {
|
||||
t.Fatal("expected error for non-zero code")
|
||||
}
|
||||
// fetchBotInfo returns a raw fmt.Errorf, not a typed envelope — message-substring assertion is intentional.
|
||||
if !strings.Contains(err.Error(), "[99991]") {
|
||||
t.Errorf("error = %q, want substring [99991]", err.Error())
|
||||
}
|
||||
@@ -198,7 +197,6 @@ func TestFetchBotInfo_EmptyOpenID(t *testing.T) {
|
||||
if err == nil {
|
||||
t.Fatal("expected error for empty open_id")
|
||||
}
|
||||
// fetchBotInfo returns a raw fmt.Errorf, not a typed envelope — message-substring assertion is intentional.
|
||||
if !strings.Contains(err.Error(), "open_id is empty") {
|
||||
t.Errorf("error = %q, want substring 'open_id is empty'", err.Error())
|
||||
}
|
||||
@@ -220,7 +218,6 @@ func TestFetchBotInfo_HTTP4xx(t *testing.T) {
|
||||
if err == nil {
|
||||
t.Fatal("expected error for HTTP 403")
|
||||
}
|
||||
// fetchBotInfo returns a raw fmt.Errorf, not a typed envelope — message-substring assertion is intentional.
|
||||
if !strings.Contains(err.Error(), "403") {
|
||||
t.Errorf("error = %q, want substring '403'", err.Error())
|
||||
}
|
||||
@@ -241,7 +238,7 @@ func TestFetchBotInfo_InvalidJSON(t *testing.T) {
|
||||
if err == nil {
|
||||
t.Fatal("expected error for invalid JSON")
|
||||
}
|
||||
// Error may come from SDK-level parse or our unmarshal wrapper — both are raw fmt.Errorf, not a typed envelope.
|
||||
// Error may come from SDK-level parse or our unmarshal wrapper
|
||||
if !strings.Contains(err.Error(), "unmarshal") && !strings.Contains(err.Error(), "invalid character") {
|
||||
t.Errorf("error = %q, want JSON parse failure", err.Error())
|
||||
}
|
||||
@@ -282,7 +279,6 @@ func TestFetchBotInfo_CanBotFalse(t *testing.T) {
|
||||
if info != nil {
|
||||
t.Errorf("expected nil info, got %+v", info)
|
||||
}
|
||||
// fetchBotInfo returns a raw fmt.Errorf, not a typed envelope — message-substring assertion is intentional.
|
||||
if !strings.Contains(err.Error(), "not available") {
|
||||
t.Errorf("error = %q, want substring 'not available'", err.Error())
|
||||
}
|
||||
@@ -295,7 +291,6 @@ func TestBotInfo_NilFunc(t *testing.T) {
|
||||
if err == nil {
|
||||
t.Fatal("expected error for nil botInfoFunc")
|
||||
}
|
||||
// BotInfo() returns a raw fmt.Errorf when botInfoFunc is nil, not a typed envelope — message-substring assertion is intentional.
|
||||
if !strings.Contains(err.Error(), "not fully initialized") {
|
||||
t.Errorf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
@@ -129,9 +129,9 @@ func TestResolveInputFlags_StdinNotSupported(t *testing.T) {
|
||||
if err == nil {
|
||||
t.Fatal("expected error for stdin not supported")
|
||||
}
|
||||
vErr := assertValidationParam(t, err, "--data")
|
||||
if !strings.Contains(vErr.Message, "does not support stdin") {
|
||||
t.Errorf("unexpected error message: %q", vErr.Message)
|
||||
assertValidationParam(t, err, "--data")
|
||||
if !strings.Contains(err.Error(), "does not support stdin") {
|
||||
t.Errorf("unexpected error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -143,9 +143,9 @@ func TestResolveInputFlags_FileNotSupported(t *testing.T) {
|
||||
if err == nil {
|
||||
t.Fatal("expected error for file not supported")
|
||||
}
|
||||
vErr := assertValidationParam(t, err, "--data")
|
||||
if !strings.Contains(vErr.Message, "does not support file input") {
|
||||
t.Errorf("unexpected error message: %q", vErr.Message)
|
||||
assertValidationParam(t, err, "--data")
|
||||
if !strings.Contains(err.Error(), "does not support file input") {
|
||||
t.Errorf("unexpected error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -160,9 +160,9 @@ func TestResolveInputFlags_FileNotFound(t *testing.T) {
|
||||
if err == nil {
|
||||
t.Fatal("expected error for missing file")
|
||||
}
|
||||
vErr := assertValidationParam(t, err, "--markdown")
|
||||
if !strings.Contains(vErr.Message, "cannot read file") {
|
||||
t.Errorf("unexpected error message: %q", vErr.Message)
|
||||
assertValidationParam(t, err, "--markdown")
|
||||
if !strings.Contains(err.Error(), "cannot read file") {
|
||||
t.Errorf("unexpected error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -174,9 +174,9 @@ func TestResolveInputFlags_EmptyFilePath(t *testing.T) {
|
||||
if err == nil {
|
||||
t.Fatal("expected error for empty file path")
|
||||
}
|
||||
vErr := assertValidationParam(t, err, "--markdown")
|
||||
if !strings.Contains(vErr.Message, "file path cannot be empty after @") {
|
||||
t.Errorf("unexpected error message: %q", vErr.Message)
|
||||
assertValidationParam(t, err, "--markdown")
|
||||
if !strings.Contains(err.Error(), "file path cannot be empty after @") {
|
||||
t.Errorf("unexpected error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -216,14 +216,9 @@ func TestResolveInputFlags_DuplicateStdin(t *testing.T) {
|
||||
if err == nil {
|
||||
t.Fatal("expected error for duplicate stdin usage")
|
||||
}
|
||||
vErr := assertValidationParam(t, err, "--b")
|
||||
if !strings.Contains(vErr.Message, "stdin (-) can only be used by one flag") {
|
||||
t.Errorf("unexpected error message: %q", vErr.Message)
|
||||
}
|
||||
// The hint must steer an AI agent to the fix (@file for the extra flags),
|
||||
// since `--a - <x --b - <y` is the exact misuse this guards against.
|
||||
if !strings.Contains(vErr.Hint, "@file") {
|
||||
t.Errorf("hint %q should mention @file as the fix", vErr.Hint)
|
||||
assertValidationParam(t, err, "--b")
|
||||
if !strings.Contains(err.Error(), "stdin (-) can only be used by one flag") {
|
||||
t.Errorf("unexpected error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -186,7 +186,9 @@ func TestRunShortcut_JqAndFormatConflict(t *testing.T) {
|
||||
if err == nil {
|
||||
t.Fatal("expected error for --jq + --format table conflict")
|
||||
}
|
||||
requireValidation(t, err, "mutually exclusive")
|
||||
if !strings.Contains(err.Error(), "mutually exclusive") {
|
||||
t.Errorf("expected 'mutually exclusive' error, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunShortcut_JqInvalidExpression(t *testing.T) {
|
||||
@@ -206,7 +208,9 @@ func TestRunShortcut_JqInvalidExpression(t *testing.T) {
|
||||
if err == nil {
|
||||
t.Fatal("expected error for invalid jq expression")
|
||||
}
|
||||
requireValidation(t, err, "invalid jq expression")
|
||||
if !strings.Contains(err.Error(), "invalid jq expression") {
|
||||
t.Errorf("expected 'invalid jq expression' error, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunShortcut_JqRuntimeError_PropagatesError(t *testing.T) {
|
||||
|
||||
@@ -1,50 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package common
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
)
|
||||
|
||||
// requireProblem asserts err carries a typed errs.Problem with the given
|
||||
// category and (optional) subtype, and that its message contains msgContains
|
||||
// (skip the message check by passing ""). Returns the Problem so callers can
|
||||
// drill into the typed envelope's category-specific fields (e.g. cast to
|
||||
// *errs.ValidationError to read .Param / .Params / .Cause).
|
||||
func requireProblem(t *testing.T, err error, wantCategory errs.Category, wantSubtype errs.Subtype, msgContains string) *errs.Problem {
|
||||
t.Helper()
|
||||
if err == nil {
|
||||
t.Fatal("expected error, got nil")
|
||||
}
|
||||
p, ok := errs.ProblemOf(err)
|
||||
if !ok {
|
||||
t.Fatalf("expected typed error carrying errs.Problem, got %T: %v", err, err)
|
||||
}
|
||||
if p.Category != wantCategory {
|
||||
t.Errorf("category = %q, want %q (err=%v)", p.Category, wantCategory, err)
|
||||
}
|
||||
if wantSubtype != "" && p.Subtype != wantSubtype {
|
||||
t.Errorf("subtype = %q, want %q (err=%v)", p.Subtype, wantSubtype, err)
|
||||
}
|
||||
if msgContains != "" && !strings.Contains(p.Message, msgContains) {
|
||||
t.Errorf("message = %q, want containing %q", p.Message, msgContains)
|
||||
}
|
||||
return p
|
||||
}
|
||||
|
||||
// requireValidation is shorthand for CategoryValidation + SubtypeInvalidArgument.
|
||||
// Returns *errs.ValidationError so callers can also assert on .Param / .Params / .Cause.
|
||||
func requireValidation(t *testing.T, err error, msgContains string) *errs.ValidationError {
|
||||
t.Helper()
|
||||
requireProblem(t, err, errs.CategoryValidation, errs.SubtypeInvalidArgument, msgContains)
|
||||
var ve *errs.ValidationError
|
||||
if !errors.As(err, &ve) {
|
||||
t.Fatalf("expected *errs.ValidationError, got %T: %v", err, err)
|
||||
}
|
||||
return ve
|
||||
}
|
||||
@@ -18,7 +18,7 @@ const (
|
||||
// Flag describes a CLI flag for a shortcut.
|
||||
type Flag struct {
|
||||
Name string // flag name (e.g. "calendar-id")
|
||||
Type string // "string" (default) | "bool" | "int" | "float64" | "int_array" | "string_array" | "string_slice"
|
||||
Type string // "string" (default) | "bool" | "int" | "float64" | "string_array" | "string_slice"
|
||||
Default string // default value as string
|
||||
Desc string // help text
|
||||
Hidden bool // hidden from --help, still readable at runtime
|
||||
|
||||
@@ -85,7 +85,6 @@ type searchUserAPIData struct {
|
||||
Items []searchUserAPIItem `json:"items"`
|
||||
HasMore bool `json:"has_more"`
|
||||
PageToken string `json:"page_token"`
|
||||
Notice string `json:"notice"`
|
||||
}
|
||||
|
||||
type searchUserAPIItem struct {
|
||||
@@ -127,7 +126,6 @@ type searchUser struct {
|
||||
type searchUserResponse struct {
|
||||
Users []searchUser `json:"users"`
|
||||
HasMore bool `json:"has_more"`
|
||||
Notice string `json:"notice,omitempty"`
|
||||
}
|
||||
|
||||
var ContactSearchUser = common.Shortcut{
|
||||
@@ -191,7 +189,6 @@ var ContactSearchUser = common.Shortcut{
|
||||
Execute: executeSearchUser,
|
||||
}
|
||||
|
||||
// executeSearchUser dispatches contact search to single-query or fanout mode.
|
||||
func executeSearchUser(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
if strings.TrimSpace(runtime.Str("queries")) != "" {
|
||||
return executeSearchUserFanout(ctx, runtime)
|
||||
@@ -199,7 +196,6 @@ func executeSearchUser(ctx context.Context, runtime *common.RuntimeContext) erro
|
||||
return executeSearchUserSingle(ctx, runtime)
|
||||
}
|
||||
|
||||
// executeSearchUserSingle performs one contact search and preserves server notices.
|
||||
func executeSearchUserSingle(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
body, err := buildSearchUserBody(runtime)
|
||||
if err != nil {
|
||||
@@ -226,7 +222,7 @@ func executeSearchUserSingle(ctx context.Context, runtime *common.RuntimeContext
|
||||
}
|
||||
|
||||
users, hasMore := projectUsers(respData, runtime.Str("lang"), runtime.Config.Brand)
|
||||
out := searchUserResponse{Users: users, HasMore: hasMore, Notice: respData.Notice}
|
||||
out := searchUserResponse{Users: users, HasMore: hasMore}
|
||||
|
||||
runtime.OutFormat(out, &output.Meta{Count: len(users)}, func(w io.Writer) {
|
||||
if len(users) == 0 {
|
||||
|
||||
@@ -45,17 +45,22 @@ type fanoutResult struct {
|
||||
Query string
|
||||
Users []searchUser
|
||||
HasMore bool
|
||||
Notice string
|
||||
ErrMsg string // empty = success
|
||||
Err error // original failure, kept for typed all-failed propagation
|
||||
}
|
||||
|
||||
// isFanoutSummaryFormat gates the per-fanout stderr summary line.
|
||||
// isFanoutSummaryFormat gates the per-fanout stderr summary line. Includes csv
|
||||
// because that summary lives on stderr and never corrupts the csv stream on
|
||||
// stdout — single-query mode keeps the narrower isHumanReadableFormat predicate
|
||||
// for its refine hint, so adding csv here doesn't regress that path.
|
||||
func isFanoutSummaryFormat(format string) bool {
|
||||
return format == "pretty" || format == "table" || format == "csv"
|
||||
}
|
||||
|
||||
// runOneQuery converts one fanout request into either users or an error summary.
|
||||
// runOneQuery converts every failure mode (transport, HTTP status, parse,
|
||||
// API code) into an ErrMsg string instead of returning a Go error. The
|
||||
// fanout dispatcher (Task 6) relies on this so a single failed query never
|
||||
// short-circuits the remaining workers.
|
||||
func runOneQuery(ctx context.Context, runtime *common.RuntimeContext, index int, query string,
|
||||
filter *searchUserAPIFilter) fanoutResult {
|
||||
// Pre-check ctx so queued workers see cancellation before issuing a
|
||||
@@ -89,10 +94,9 @@ func runOneQuery(ctx context.Context, runtime *common.RuntimeContext, index int,
|
||||
}
|
||||
|
||||
users, hasMore := projectUsers(respData, runtime.Str("lang"), runtime.Config.Brand)
|
||||
return fanoutResult{Index: index, Query: query, Users: users, HasMore: hasMore, Notice: respData.Notice}
|
||||
return fanoutResult{Index: index, Query: query, Users: users, HasMore: hasMore}
|
||||
}
|
||||
|
||||
// fanoutErrorResult records a failed fanout query without stopping other workers.
|
||||
func fanoutErrorResult(index int, query string, err error) fanoutResult {
|
||||
if err == nil {
|
||||
return fanoutResult{Index: index, Query: query}
|
||||
@@ -109,16 +113,17 @@ type querySummary struct {
|
||||
Query string `json:"query"`
|
||||
Error string `json:"error,omitempty"`
|
||||
HasMore bool `json:"has_more"`
|
||||
Notice string `json:"notice,omitempty"`
|
||||
}
|
||||
|
||||
type fanoutResponse struct {
|
||||
Users []fanoutUser `json:"users"`
|
||||
Queries []querySummary `json:"queries"`
|
||||
Notice string `json:"notice,omitempty"`
|
||||
}
|
||||
|
||||
// buildFanoutResponse flattens ordered fanout results and fails only when all queries fail.
|
||||
// buildFanoutResponse walks results by Index (input order), flattens users[]
|
||||
// with matched_query, lists every input in queries[] (including successes),
|
||||
// and returns an error only when every query failed. The error wraps the
|
||||
// first failing query's ErrMsg so the CLI exits non-zero on full failure.
|
||||
func buildFanoutResponse(queries []string, results []fanoutResult) (*fanoutResponse, error) {
|
||||
indexed := make([]fanoutResult, len(queries))
|
||||
for _, r := range results {
|
||||
@@ -137,7 +142,6 @@ func buildFanoutResponse(queries []string, results []fanoutResult) (*fanoutRespo
|
||||
Query: queries[i],
|
||||
Error: r.ErrMsg,
|
||||
HasMore: r.HasMore,
|
||||
Notice: r.Notice,
|
||||
})
|
||||
if r.ErrMsg != "" {
|
||||
failed++
|
||||
@@ -148,9 +152,6 @@ func buildFanoutResponse(queries []string, results []fanoutResult) (*fanoutRespo
|
||||
}
|
||||
continue
|
||||
}
|
||||
if out.Notice == "" {
|
||||
out.Notice = r.Notice
|
||||
}
|
||||
for _, u := range r.Users {
|
||||
out.Users = append(out.Users, fanoutUser{searchUser: u, MatchedQuery: queries[i]})
|
||||
}
|
||||
|
||||
@@ -562,7 +562,6 @@ func mountAndRun(t *testing.T, s common.Shortcut, args []string, f *cmdutil.Fact
|
||||
return parent.Execute()
|
||||
}
|
||||
|
||||
// searchUserStub returns a representative user search response with a notice.
|
||||
func searchUserStub() *httpmock.Stub {
|
||||
return &httpmock.Stub{
|
||||
Method: "POST",
|
||||
@@ -570,7 +569,6 @@ func searchUserStub() *httpmock.Stub {
|
||||
Body: map[string]interface{}{
|
||||
"code": 0, "msg": "ok",
|
||||
"data": map[string]interface{}{
|
||||
"notice": "The query is too long and has been truncated to the first 50 characters for search.",
|
||||
"items": []interface{}{
|
||||
map[string]interface{}{
|
||||
"id": "ou_a",
|
||||
@@ -592,7 +590,6 @@ func searchUserStub() *httpmock.Stub {
|
||||
}
|
||||
}
|
||||
|
||||
// TestSearchUser_Integration_PrettyRendersExpectedColumns verifies human output columns.
|
||||
func TestSearchUser_Integration_PrettyRendersExpectedColumns(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, searchUserDefaultConfig())
|
||||
reg.Register(searchUserStub())
|
||||
@@ -617,7 +614,6 @@ func TestSearchUser_Integration_PrettyRendersExpectedColumns(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestSearchUser_Integration_JSONStructuredFields verifies normalized JSON and notices.
|
||||
func TestSearchUser_Integration_JSONStructuredFields(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, searchUserDefaultConfig())
|
||||
reg.Register(searchUserStub())
|
||||
@@ -635,9 +631,6 @@ func TestSearchUser_Integration_JSONStructuredFields(t *testing.T) {
|
||||
if !ok {
|
||||
t.Fatalf("envelope.data: expected object, got %v\nraw=%s", got["data"], stdout.String())
|
||||
}
|
||||
if data["notice"] != "The query is too long and has been truncated to the first 50 characters for search." {
|
||||
t.Fatalf("data.notice = %v", data["notice"])
|
||||
}
|
||||
users, _ := data["users"].([]interface{})
|
||||
if len(users) != 1 {
|
||||
t.Fatalf("users: expected 1, got %d (output=%s)", len(users), stdout.String())
|
||||
@@ -1365,7 +1358,6 @@ func TestSearchUser_Integration_NoAutoPaginationFlags(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestFanout_FilterAppliedToEachQuery verifies shared fanout filters reach every request.
|
||||
func TestFanout_FilterAppliedToEachQuery(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, searchUserDefaultConfig())
|
||||
stub := &httpmock.Stub{
|
||||
@@ -1407,7 +1399,6 @@ func TestFanout_FilterAppliedToEachQuery(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestFanout_PartialFailure_ExitZero verifies partial fanout failures keep notices.
|
||||
func TestFanout_PartialFailure_ExitZero(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, searchUserDefaultConfig())
|
||||
reg.Register(&httpmock.Stub{
|
||||
@@ -1415,7 +1406,6 @@ func TestFanout_PartialFailure_ExitZero(t *testing.T) {
|
||||
BodyFilter: func(b []byte) bool { return strings.Contains(string(b), `"alice"`) },
|
||||
Body: map[string]interface{}{"code": 0, "msg": "ok",
|
||||
"data": map[string]interface{}{
|
||||
"notice": "The query is too long and has been truncated to the first 50 characters for search.",
|
||||
"items": []interface{}{map[string]interface{}{"id": "ou_a"}},
|
||||
"has_more": false,
|
||||
}},
|
||||
@@ -1442,17 +1432,10 @@ func TestFanout_PartialFailure_ExitZero(t *testing.T) {
|
||||
if len(users) != 1 {
|
||||
t.Errorf("users: expected 1 (alice), got %d; stdout=%s", len(users), stdout.String())
|
||||
}
|
||||
if data["notice"] != "The query is too long and has been truncated to the first 50 characters for search." {
|
||||
t.Fatalf("data.notice = %v", data["notice"])
|
||||
}
|
||||
queries := data["queries"].([]interface{})
|
||||
if len(queries) != 2 {
|
||||
t.Fatalf("queries: expected 2, got %d", len(queries))
|
||||
}
|
||||
q0 := queries[0].(map[string]interface{})
|
||||
if q0["notice"] != "The query is too long and has been truncated to the first 50 characters for search." {
|
||||
t.Fatalf("queries[0].notice = %v", q0["notice"])
|
||||
}
|
||||
q1 := queries[1].(map[string]interface{})
|
||||
if !strings.HasPrefix(q1["error"].(string), "HTTP 500") {
|
||||
t.Errorf("queries[1].error: got %q", q1["error"])
|
||||
|
||||
@@ -74,9 +74,6 @@ var DocsSearch = common.Shortcut{
|
||||
"page_token": data["page_token"],
|
||||
"results": normalizedItems,
|
||||
}
|
||||
if notice, _ := data["notice"].(string); notice != "" {
|
||||
resultData["notice"] = notice
|
||||
}
|
||||
|
||||
runtime.OutFormat(resultData, &output.Meta{Count: len(normalizedItems)}, func(w io.Writer) {
|
||||
if len(normalizedItems) == 0 {
|
||||
|
||||
@@ -7,48 +7,8 @@ import (
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/httpmock"
|
||||
)
|
||||
|
||||
// TestDocsSearchExecutePassesThroughNotice verifies docs +search preserves notices.
|
||||
func TestDocsSearchExecutePassesThroughNotice(t *testing.T) {
|
||||
const notice = "The query is too long and has been truncated to the first 50 characters for search."
|
||||
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, docsTestConfigWithAppID("docs-search-notice"))
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/search/v2/doc_wiki/search",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"msg": "ok",
|
||||
"data": map[string]interface{}{
|
||||
"notice": notice,
|
||||
"res_units": []interface{}{},
|
||||
"total": 0,
|
||||
"has_more": false,
|
||||
"page_token": "",
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
if err := mountAndRunDocs(t, DocsSearch, []string{"+search", "--query", "incident", "--format", "json", "--as", "user"}, f, stdout); err != nil {
|
||||
t.Fatalf("DocsSearch.Execute() error = %v", err)
|
||||
}
|
||||
reg.Verify(t)
|
||||
|
||||
var env map[string]interface{}
|
||||
if err := json.Unmarshal(stdout.Bytes(), &env); err != nil {
|
||||
t.Fatalf("json.Unmarshal(stdout) error = %v\nstdout=%s", err, stdout.String())
|
||||
}
|
||||
data, _ := env["data"].(map[string]interface{})
|
||||
if got, _ := data["notice"].(string); got != notice {
|
||||
t.Fatalf("data.notice = %q, want %q; data=%#v", got, notice, data)
|
||||
}
|
||||
}
|
||||
|
||||
// TestAddIsoTimeFieldsSupportsJSONNumber verifies JSON numbers get ISO fields.
|
||||
func TestAddIsoTimeFieldsSupportsJSONNumber(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
|
||||
@@ -121,7 +121,7 @@ const (
|
||||
var DriveAddComment = common.Shortcut{
|
||||
Service: "drive",
|
||||
Command: "+add-comment",
|
||||
Description: "Add a comment to doc/docx/file/sheet/slides/base(bitable); file targets support selected extensions and full comments only",
|
||||
Description: "Add a comment to doc/docx/file/sheet/slides; file targets support selected extensions and full comments only",
|
||||
Risk: "write",
|
||||
Scopes: []string{
|
||||
"drive:drive.metadata:readonly",
|
||||
@@ -131,12 +131,12 @@ var DriveAddComment = common.Shortcut{
|
||||
},
|
||||
AuthTypes: []string{"user", "bot"},
|
||||
Flags: []common.Flag{
|
||||
{Name: "doc", Desc: "document URL/token, file URL/token, sheet/slides/base/bitable URL, or wiki URL that resolves to doc/docx/file/sheet/slides/base(bitable)", Required: true},
|
||||
{Name: "type", Desc: "document type: doc, docx, file, sheet, slides, bitable, base (required when --doc is a bare token; auto-detected for URLs; use bitable as the wire value, base is accepted as a compatibility alias)", Enum: []string{"doc", "docx", "file", "sheet", "slides", "bitable", "base"}},
|
||||
{Name: "doc", Desc: "document URL/token, file URL/token, sheet/slides URL, or wiki URL that resolves to doc/docx/file/sheet/slides", Required: true},
|
||||
{Name: "type", Desc: "document type: doc, docx, file, sheet, slides (required when --doc is a bare token; auto-detected for URLs)", Enum: []string{"doc", "docx", "file", "sheet", "slides"}},
|
||||
{Name: "content", Desc: "reply_elements JSON string", Required: true, Input: []string{common.File, common.Stdin}},
|
||||
{Name: "full-comment", Type: "bool", Desc: "create a full-document comment; also the default when no location is provided"},
|
||||
{Name: "selection-with-ellipsis", Desc: "target content locator (plain text or 'start...end')"},
|
||||
{Name: "block-id", Desc: "for docx: anchor block ID; for sheet: <sheetId>!<cell>; for slides: <slide-block-type>!<xml-id>; for base(bitable): <table-id>!<record-id>!<view-id>"},
|
||||
{Name: "block-id", Desc: "for docx: anchor block ID; for sheet: <sheetId>!<cell> (e.g. a281f9!D6); for slides: <slide-block-type>!<xml-id> (e.g. shape!bPq)"},
|
||||
},
|
||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
docRef, err := parseCommentDocRef(runtime.Str("doc"), runtime.Str("type"))
|
||||
@@ -148,17 +148,6 @@ var DriveAddComment = common.Shortcut{
|
||||
return err
|
||||
}
|
||||
|
||||
if docRef.Kind == "base" {
|
||||
if runtime.Bool("full-comment") {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--full-comment is not applicable for base(bitable) comments; use --block-id <table-id>!<record-id>!<view-id>").WithParam("--full-comment")
|
||||
}
|
||||
if strings.TrimSpace(runtime.Str("selection-with-ellipsis")) != "" {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--selection-with-ellipsis is not applicable for base(bitable) comments; use --block-id <table-id>!<record-id>!<view-id>").WithParam("--selection-with-ellipsis")
|
||||
}
|
||||
_, err := parseBaseCommentAnchor(runtime)
|
||||
return err
|
||||
}
|
||||
|
||||
// Sheet comment validation.
|
||||
if docRef.Kind == "sheet" {
|
||||
blockID := strings.TrimSpace(runtime.Str("block-id"))
|
||||
@@ -199,7 +188,7 @@ var DriveAddComment = common.Shortcut{
|
||||
return validateFileCommentMode(mode, "")
|
||||
}
|
||||
if mode == commentModeLocal && docRef.Kind == "doc" {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "local comments only support docx, sheet, slides, and base(bitable); old doc format only supports full comments")
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "local comments only support docx, sheet, and slides; old doc format only supports full comments")
|
||||
}
|
||||
|
||||
return nil
|
||||
@@ -226,23 +215,6 @@ var DriveAddComment = common.Shortcut{
|
||||
resolvedToken = target.FileToken
|
||||
}
|
||||
|
||||
if resolvedKind == "base" {
|
||||
anchor, err := parseBaseCommentAnchor(runtime)
|
||||
if err != nil {
|
||||
return common.NewDryRunAPI().Set("error", err.Error())
|
||||
}
|
||||
commentBody := buildBaseCommentCreateV2Request(replyElements, anchor)
|
||||
desc := "1-step request: create base(bitable) record-local comment"
|
||||
if isWiki {
|
||||
desc = "2-step orchestration: resolve wiki -> create base(bitable) record-local comment"
|
||||
}
|
||||
return common.NewDryRunAPI().
|
||||
Desc(desc).
|
||||
POST("/open-apis/drive/v1/files/:file_token/new_comments").
|
||||
Body(commentBody).
|
||||
Set("file_token", resolvedToken)
|
||||
}
|
||||
|
||||
// Sheet comment dry-run.
|
||||
if resolvedKind == "sheet" {
|
||||
anchor, _ := parseSheetCellRef(blockID)
|
||||
@@ -380,14 +352,6 @@ var DriveAddComment = common.Shortcut{
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
// Sheet comment: direct URL or token fast path.
|
||||
docRef, _ := parseCommentDocRef(runtime.Str("doc"), runtime.Str("type"))
|
||||
if docRef.Kind == "base" {
|
||||
return executeBaseComment(runtime, resolvedCommentTarget{
|
||||
DocID: docRef.Token,
|
||||
FileToken: docRef.Token,
|
||||
FileType: "base",
|
||||
ResolvedBy: "base",
|
||||
})
|
||||
}
|
||||
if docRef.Kind == "sheet" {
|
||||
return executeSheetComment(runtime, docRef)
|
||||
}
|
||||
@@ -411,9 +375,6 @@ var DriveAddComment = common.Shortcut{
|
||||
if target.FileType == "slides" {
|
||||
return executeSlidesComment(runtime, commentDocRef{Kind: "slides", Token: target.FileToken})
|
||||
}
|
||||
if target.FileType == "base" {
|
||||
return executeBaseComment(runtime, target)
|
||||
}
|
||||
if target.FileType == "file" {
|
||||
return executeFileComment(runtime, target)
|
||||
}
|
||||
@@ -521,12 +482,6 @@ func parseCommentDocRef(input, docType string) (commentDocRef, error) {
|
||||
if token, ok := extractURLToken(raw, "/sheets/"); ok {
|
||||
return commentDocRef{Kind: "sheet", Token: token}, nil
|
||||
}
|
||||
if token, ok := extractURLToken(raw, "/base/"); ok {
|
||||
return commentDocRef{Kind: "base", Token: token}, nil
|
||||
}
|
||||
if token, ok := extractURLToken(raw, "/bitable/"); ok {
|
||||
return commentDocRef{Kind: "base", Token: token}, nil
|
||||
}
|
||||
if token, ok := extractURLToken(raw, "/file/"); ok {
|
||||
return commentDocRef{Kind: "file", Token: token}, nil
|
||||
}
|
||||
@@ -540,7 +495,7 @@ func parseCommentDocRef(input, docType string) (commentDocRef, error) {
|
||||
return commentDocRef{Kind: "doc", Token: token}, nil
|
||||
}
|
||||
if strings.Contains(raw, "://") {
|
||||
return commentDocRef{}, errs.NewValidationError(errs.SubtypeInvalidArgument, "unsupported --doc input %q: use a doc/docx/file/sheet/slides/base/bitable URL, a token with --type, or a wiki URL that resolves to doc/docx/file/sheet/slides/base(bitable)", raw).WithParam("--doc")
|
||||
return commentDocRef{}, errs.NewValidationError(errs.SubtypeInvalidArgument, "unsupported --doc input %q: use a doc/docx/file/sheet/slides URL, a token with --type, or a wiki URL that resolves to doc/docx/file/sheet/slides", raw).WithParam("--doc")
|
||||
}
|
||||
if strings.ContainsAny(raw, "/?#") {
|
||||
return commentDocRef{}, errs.NewValidationError(errs.SubtypeInvalidArgument, "unsupported --doc input %q: use a token with --type, or a wiki URL", raw).WithParam("--doc")
|
||||
@@ -549,10 +504,7 @@ func parseCommentDocRef(input, docType string) (commentDocRef, error) {
|
||||
// Bare token: --type is required.
|
||||
docType = strings.TrimSpace(docType)
|
||||
if docType == "" {
|
||||
return commentDocRef{}, errs.NewValidationError(errs.SubtypeInvalidArgument, "--type is required when --doc is a bare token (allowed values: doc, docx, file, sheet, slides, bitable, base; use bitable as the wire value, base is accepted as a compatibility alias)").WithParam("--type")
|
||||
}
|
||||
if docType == "bitable" || docType == "base" {
|
||||
return commentDocRef{Kind: "base", Token: raw}, nil
|
||||
return commentDocRef{}, errs.NewValidationError(errs.SubtypeInvalidArgument, "--type is required when --doc is a bare token (allowed values: doc, docx, file, sheet, slides)").WithParam("--type")
|
||||
}
|
||||
return commentDocRef{Kind: docType, Token: raw}, nil
|
||||
}
|
||||
@@ -563,11 +515,11 @@ func resolveCommentTarget(ctx context.Context, runtime *common.RuntimeContext, i
|
||||
return resolvedCommentTarget{}, err
|
||||
}
|
||||
|
||||
if docRef.Kind == "docx" || docRef.Kind == "doc" || docRef.Kind == "file" || docRef.Kind == "sheet" || docRef.Kind == "slides" || docRef.Kind == "base" {
|
||||
if docRef.Kind == "docx" || docRef.Kind == "doc" || docRef.Kind == "file" || docRef.Kind == "sheet" || docRef.Kind == "slides" {
|
||||
if mode == commentModeLocal {
|
||||
switch docRef.Kind {
|
||||
case "doc":
|
||||
return resolvedCommentTarget{}, errs.NewValidationError(errs.SubtypeInvalidArgument, "local comments only support docx, sheet, slides, and base(bitable); old doc format only supports full comments")
|
||||
return resolvedCommentTarget{}, errs.NewValidationError(errs.SubtypeInvalidArgument, "local comments only support docx, sheet, and slides; old doc format only supports full comments")
|
||||
case "file":
|
||||
if err := validateFileCommentMode(mode, ""); err != nil {
|
||||
return resolvedCommentTarget{}, err
|
||||
@@ -605,22 +557,6 @@ func resolveCommentTarget(ctx context.Context, runtime *common.RuntimeContext, i
|
||||
if objType == "slides" && strings.TrimSpace(runtime.Str("selection-with-ellipsis")) != "" {
|
||||
return resolvedCommentTarget{}, errs.NewValidationError(errs.SubtypeInvalidArgument, "wiki resolved to %q, but --selection-with-ellipsis is not applicable for slide comments; use --block-id <slide-block-type>!<xml-id>", objType)
|
||||
}
|
||||
if objType == "bitable" || objType == "base" {
|
||||
if runtime.Bool("full-comment") {
|
||||
return resolvedCommentTarget{}, errs.NewValidationError(errs.SubtypeInvalidArgument, "wiki resolved to %q, but --full-comment is not applicable for base(bitable) comments; use --block-id <table-id>!<record-id>!<view-id>", objType).WithParam("--full-comment")
|
||||
}
|
||||
if strings.TrimSpace(runtime.Str("selection-with-ellipsis")) != "" {
|
||||
return resolvedCommentTarget{}, errs.NewValidationError(errs.SubtypeInvalidArgument, "wiki resolved to %q, but --selection-with-ellipsis is not applicable for base(bitable) comments; use --block-id <table-id>!<record-id>!<view-id>", objType).WithParam("--selection-with-ellipsis")
|
||||
}
|
||||
fmt.Fprintf(runtime.IO().ErrOut, "Resolved wiki to base: %s\n", common.MaskToken(objToken))
|
||||
return resolvedCommentTarget{
|
||||
DocID: objToken,
|
||||
FileToken: objToken,
|
||||
FileType: "base",
|
||||
ResolvedBy: "wiki",
|
||||
WikiToken: docRef.Token,
|
||||
}, nil
|
||||
}
|
||||
if objType == "sheet" {
|
||||
// Sheet comments are handled via the sheet fast path in Execute.
|
||||
fmt.Fprintf(runtime.IO().ErrOut, "Resolved wiki to %s: %s\n", objType, common.MaskToken(objToken))
|
||||
@@ -656,10 +592,10 @@ func resolveCommentTarget(ctx context.Context, runtime *common.RuntimeContext, i
|
||||
}, nil
|
||||
}
|
||||
if mode == commentModeLocal && objType != "docx" {
|
||||
return resolvedCommentTarget{}, errs.NewValidationError(errs.SubtypeInvalidArgument, "wiki resolved to %q, but local comments only support docx, sheet, slides, and base(bitable); for sheet use --block-id <sheetId>!<cell>, for slides use --block-id <slide-block-type>!<xml-id>, for base use --block-id <table-id>!<record-id>!<view-id>", objType)
|
||||
return resolvedCommentTarget{}, errs.NewValidationError(errs.SubtypeInvalidArgument, "wiki resolved to %q, but local comments only support docx, sheet, and slides; for sheet use --block-id <sheetId>!<cell>, for slides use --block-id <slide-block-type>!<xml-id>", objType)
|
||||
}
|
||||
if mode == commentModeFull && objType != "docx" && objType != "doc" {
|
||||
return resolvedCommentTarget{}, errs.NewValidationError(errs.SubtypeInvalidArgument, "wiki resolved to %q, but comments only support doc/docx/file/sheet/slides/base(bitable)", objType)
|
||||
return resolvedCommentTarget{}, errs.NewValidationError(errs.SubtypeInvalidArgument, "wiki resolved to %q, but comments only support doc/docx/file/sheet/slides", objType)
|
||||
}
|
||||
|
||||
fmt.Fprintf(runtime.IO().ErrOut, "Resolved wiki to %s: %s\n", objType, common.MaskToken(objToken))
|
||||
@@ -851,12 +787,6 @@ type sheetAnchor struct {
|
||||
Row int
|
||||
}
|
||||
|
||||
type baseAnchor struct {
|
||||
BlockID string
|
||||
BaseRecordID string
|
||||
BaseViewID string
|
||||
}
|
||||
|
||||
func buildCommentCreateV2Request(fileType, blockID, slideBlockType string, replyElements []map[string]interface{}, sheet *sheetAnchor) map[string]interface{} {
|
||||
body := map[string]interface{}{
|
||||
"file_type": fileType,
|
||||
@@ -883,18 +813,6 @@ func buildCommentCreateV2Request(fileType, blockID, slideBlockType string, reply
|
||||
return body
|
||||
}
|
||||
|
||||
func buildBaseCommentCreateV2Request(replyElements []map[string]interface{}, anchor baseAnchor) map[string]interface{} {
|
||||
return map[string]interface{}{
|
||||
"file_type": "bitable",
|
||||
"reply_elements": replyElements,
|
||||
"anchor": map[string]interface{}{
|
||||
"block_id": anchor.BlockID,
|
||||
"base_record_id": anchor.BaseRecordID,
|
||||
"base_view_id": anchor.BaseViewID,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func anchorBlockIDForDryRun(blockID string) string {
|
||||
if strings.TrimSpace(blockID) != "" {
|
||||
return strings.TrimSpace(blockID)
|
||||
@@ -902,26 +820,6 @@ func anchorBlockIDForDryRun(blockID string) string {
|
||||
return "<anchor_block_id>"
|
||||
}
|
||||
|
||||
func parseBaseCommentAnchor(runtime *common.RuntimeContext) (baseAnchor, error) {
|
||||
blockID := strings.TrimSpace(runtime.Str("block-id"))
|
||||
if blockID == "" {
|
||||
return baseAnchor{}, errs.NewValidationError(errs.SubtypeInvalidArgument, "--block-id is required for base(bitable) record-local comments (format: <table-id>!<record-id>!<view-id>, e.g. tbl9mp6fj9kDKHQV!recBIBgGmb!vewc46MG1R)").WithParam("--block-id")
|
||||
}
|
||||
return parseBaseBlockRef(blockID)
|
||||
}
|
||||
|
||||
func parseBaseBlockRef(blockID string) (baseAnchor, error) {
|
||||
parts := strings.Split(strings.TrimSpace(blockID), "!")
|
||||
if len(parts) != 3 || strings.TrimSpace(parts[0]) == "" || strings.TrimSpace(parts[1]) == "" || strings.TrimSpace(parts[2]) == "" {
|
||||
return baseAnchor{}, errs.NewValidationError(errs.SubtypeInvalidArgument, "base(bitable) record-local comments require --block-id in <table-id>!<record-id>!<view-id> format, e.g. tbl9mp6fj9kDKHQV!recBIBgGmb!vewc46MG1R").WithParam("--block-id")
|
||||
}
|
||||
return baseAnchor{
|
||||
BlockID: strings.TrimSpace(parts[0]),
|
||||
BaseRecordID: strings.TrimSpace(parts[1]),
|
||||
BaseViewID: strings.TrimSpace(parts[2]),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func parseSlidesBlockRef(blockID string) (string, string, error) {
|
||||
blockID = strings.TrimSpace(blockID)
|
||||
if blockID == "" {
|
||||
@@ -1132,53 +1030,6 @@ func executeSheetComment(runtime *common.RuntimeContext, docRef commentDocRef) e
|
||||
return nil
|
||||
}
|
||||
|
||||
func executeBaseComment(runtime *common.RuntimeContext, target resolvedCommentTarget) error {
|
||||
replyElements, err := parseCommentReplyElements(runtime.Str("content"))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
anchor, err := parseBaseCommentAnchor(runtime)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
requestPath := fmt.Sprintf("/open-apis/drive/v1/files/%s/new_comments", validate.EncodePathSegment(target.FileToken))
|
||||
requestBody := buildBaseCommentCreateV2Request(replyElements, anchor)
|
||||
|
||||
fmt.Fprintf(runtime.IO().ErrOut, "Creating base(bitable) record-local comment in %s (table=%s, record=%s, view=%s)\n",
|
||||
common.MaskToken(target.FileToken), anchor.BlockID, anchor.BaseRecordID, anchor.BaseViewID)
|
||||
|
||||
data, err := runtime.CallAPITyped("POST", requestPath, nil, requestBody)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
out := map[string]interface{}{
|
||||
"file_token": target.FileToken,
|
||||
"file_type": "bitable",
|
||||
"resolved_by": target.ResolvedBy,
|
||||
"comment_mode": "base_record",
|
||||
"base_block_id": anchor.BlockID,
|
||||
"base_record_id": anchor.BaseRecordID,
|
||||
"base_view_id": anchor.BaseViewID,
|
||||
}
|
||||
if commentID := data["comment_id"]; commentID != nil {
|
||||
out["comment_id"] = commentID
|
||||
}
|
||||
if replyID := data["reply_id"]; replyID != nil {
|
||||
out["reply_id"] = replyID
|
||||
}
|
||||
if createdAt := firstPresentValue(data, "created_at", "create_time"); createdAt != nil {
|
||||
out["created_at"] = createdAt
|
||||
}
|
||||
if target.WikiToken != "" {
|
||||
out["wiki_token"] = target.WikiToken
|
||||
}
|
||||
|
||||
runtime.Out(out, nil)
|
||||
return nil
|
||||
}
|
||||
|
||||
func executeFileComment(runtime *common.RuntimeContext, target resolvedCommentTarget) error {
|
||||
replyElements, err := parseCommentReplyElements(runtime.Str("content"))
|
||||
if err != nil {
|
||||
|
||||
@@ -133,20 +133,6 @@ func TestParseCommentDocRef(t *testing.T) {
|
||||
wantKind: "file",
|
||||
wantToken: "fileToken",
|
||||
},
|
||||
{
|
||||
name: "raw token with type bitable",
|
||||
input: "baseToken",
|
||||
docType: "bitable",
|
||||
wantKind: "base",
|
||||
wantToken: "baseToken",
|
||||
},
|
||||
{
|
||||
name: "raw token with type base alias",
|
||||
input: "baseToken",
|
||||
docType: "base",
|
||||
wantKind: "base",
|
||||
wantToken: "baseToken",
|
||||
},
|
||||
{
|
||||
name: "raw token without type",
|
||||
input: "xxxxxx",
|
||||
@@ -170,18 +156,6 @@ func TestParseCommentDocRef(t *testing.T) {
|
||||
wantKind: "file",
|
||||
wantToken: "boxcn123",
|
||||
},
|
||||
{
|
||||
name: "base url",
|
||||
input: "https://example.larksuite.com/base/baseToken123?table=tbl1",
|
||||
wantKind: "base",
|
||||
wantToken: "baseToken123",
|
||||
},
|
||||
{
|
||||
name: "bitable url",
|
||||
input: "https://example.larksuite.com/bitable/baseToken456?table=tbl1",
|
||||
wantKind: "base",
|
||||
wantToken: "baseToken456",
|
||||
},
|
||||
{
|
||||
name: "unsupported url",
|
||||
input: "https://example.com/not-a-doc",
|
||||
@@ -752,35 +726,6 @@ func TestBuildCommentCreateV2RequestSheetOverridesBlockID(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildBaseCommentCreateV2Request(t *testing.T) {
|
||||
t.Parallel()
|
||||
replyElements := []map[string]interface{}{
|
||||
{"type": "text", "text": "base comment"},
|
||||
}
|
||||
got := buildBaseCommentCreateV2Request(replyElements, baseAnchor{
|
||||
BlockID: "tbl9mp6fj9kDKHQV",
|
||||
BaseRecordID: "recBIBgGmb",
|
||||
BaseViewID: "vewc46MG1R",
|
||||
})
|
||||
|
||||
if got["file_type"] != "bitable" {
|
||||
t.Fatalf("expected file_type bitable, got %#v", got["file_type"])
|
||||
}
|
||||
anchor, ok := got["anchor"].(map[string]interface{})
|
||||
if !ok {
|
||||
t.Fatalf("expected anchor map, got %#v", got["anchor"])
|
||||
}
|
||||
if anchor["block_id"] != "tbl9mp6fj9kDKHQV" {
|
||||
t.Fatalf("expected block_id tbl9mp6fj9kDKHQV, got %#v", anchor["block_id"])
|
||||
}
|
||||
if anchor["base_record_id"] != "recBIBgGmb" {
|
||||
t.Fatalf("expected base_record_id recBIBgGmb, got %#v", anchor["base_record_id"])
|
||||
}
|
||||
if anchor["base_view_id"] != "vewc46MG1R" {
|
||||
t.Fatalf("expected base_view_id vewc46MG1R, got %#v", anchor["base_view_id"])
|
||||
}
|
||||
}
|
||||
|
||||
// ── Sheet cell ref parsing tests ────────────────────────────────────────────
|
||||
|
||||
func TestParseSheetCellRef(t *testing.T) {
|
||||
@@ -1040,78 +985,6 @@ func TestFileCommentValidateRejectsSelectionWithEllipsis(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestBaseCommentValidateMissingBlockID(t *testing.T) {
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, driveTestConfig())
|
||||
err := mountAndRunDrive(t, DriveAddComment, []string{
|
||||
"+add-comment",
|
||||
"--doc", "https://example.larksuite.com/base/baseToken",
|
||||
"--content", `[{"type":"text","text":"test"}]`,
|
||||
"--as", "user",
|
||||
}, f, stdout)
|
||||
if err == nil || !strings.Contains(err.Error(), "--block-id is required") {
|
||||
t.Fatalf("expected block-id required error, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBaseCommentValidateMalformedBlockID(t *testing.T) {
|
||||
cases := []string{
|
||||
"tbl9mp6fj9kDKHQV",
|
||||
"tbl9mp6fj9kDKHQV!recBIBgGmb",
|
||||
"tbl9mp6fj9kDKHQV!!vewc46MG1R",
|
||||
}
|
||||
for _, blockID := range cases {
|
||||
t.Run(blockID, func(t *testing.T) {
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, driveTestConfig())
|
||||
err := mountAndRunDrive(t, DriveAddComment, []string{
|
||||
"+add-comment",
|
||||
"--doc", "https://example.larksuite.com/base/baseToken",
|
||||
"--content", `[{"type":"text","text":"test"}]`,
|
||||
"--block-id", blockID,
|
||||
"--as", "user",
|
||||
}, f, stdout)
|
||||
if err == nil || !strings.Contains(err.Error(), "<table-id>!<record-id>!<view-id>") {
|
||||
t.Fatalf("expected block-id format error, got: %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestBaseCommentValidateRejectsIncompatibleFlags(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
args []string
|
||||
wantErr string
|
||||
}{
|
||||
{
|
||||
name: "full comment",
|
||||
args: []string{"--full-comment"},
|
||||
wantErr: "--full-comment is not applicable for base(bitable) comments",
|
||||
},
|
||||
{
|
||||
name: "selection",
|
||||
args: []string{"--selection-with-ellipsis", "some text"},
|
||||
wantErr: "--selection-with-ellipsis is not applicable for base(bitable) comments",
|
||||
},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, driveTestConfig())
|
||||
args := []string{
|
||||
"+add-comment",
|
||||
"--doc", "https://example.larksuite.com/base/baseToken",
|
||||
"--content", `[{"type":"text","text":"test"}]`,
|
||||
"--block-id", "tbl9mp6fj9kDKHQV!recBIBgGmb!vewc46MG1R",
|
||||
"--as", "user",
|
||||
}
|
||||
args = append(args, tc.args...)
|
||||
err := mountAndRunDrive(t, DriveAddComment, args, f, stdout)
|
||||
if err == nil || !strings.Contains(err.Error(), tc.wantErr) {
|
||||
t.Fatalf("expected %q error, got: %v", tc.wantErr, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// ── Slides comment execute tests ────────────────────────────────────────────
|
||||
|
||||
func TestSlidesCommentExecuteSuccess(t *testing.T) {
|
||||
@@ -1322,87 +1195,6 @@ func TestSheetCommentViaWikiMissingBlockID(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestBaseCommentExecuteSuccess(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
|
||||
createStub := &httpmock.Stub{
|
||||
Method: "POST", URL: "/open-apis/drive/v1/files/baseToken/new_comments",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0, "msg": "success",
|
||||
"data": map[string]interface{}{
|
||||
"comment_id": "baseComment123",
|
||||
"reply_id": "baseReply123",
|
||||
"created_at": 1700000000,
|
||||
},
|
||||
},
|
||||
}
|
||||
reg.Register(createStub)
|
||||
err := mountAndRunDrive(t, DriveAddComment, []string{
|
||||
"+add-comment",
|
||||
"--doc", "https://example.larksuite.com/base/baseToken",
|
||||
"--content", `[{"type":"text","text":"请看这条记录"}]`,
|
||||
"--block-id", "tbl9mp6fj9kDKHQV!recBIBgGmb!vewc46MG1R",
|
||||
"--as", "user",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
var requestBody map[string]interface{}
|
||||
if err := json.Unmarshal(createStub.CapturedBody, &requestBody); err != nil {
|
||||
t.Fatalf("failed to decode captured body: %v\nbody:\n%s", err, string(createStub.CapturedBody))
|
||||
}
|
||||
if got := mustStringField(t, requestBody, "file_type", "request.file_type"); got != "bitable" {
|
||||
t.Fatalf("request file_type = %q, want bitable", got)
|
||||
}
|
||||
anchor := mustMapValue(t, requestBody["anchor"], "request.anchor")
|
||||
if got := mustStringField(t, anchor, "block_id", "request.anchor.block_id"); got != "tbl9mp6fj9kDKHQV" {
|
||||
t.Fatalf("request block_id = %q, want tbl9mp6fj9kDKHQV", got)
|
||||
}
|
||||
if got := mustStringField(t, anchor, "base_record_id", "request.anchor.base_record_id"); got != "recBIBgGmb" {
|
||||
t.Fatalf("request base_record_id = %q, want recBIBgGmb", got)
|
||||
}
|
||||
if got := mustStringField(t, anchor, "base_view_id", "request.anchor.base_view_id"); got != "vewc46MG1R" {
|
||||
t.Fatalf("request base_view_id = %q, want vewc46MG1R", got)
|
||||
}
|
||||
|
||||
out := decodeJSONMap(t, stdout.String())
|
||||
data := mustMapValue(t, out["data"], "data")
|
||||
if got := mustStringField(t, data, "file_type", "data.file_type"); got != "bitable" {
|
||||
t.Fatalf("stdout file_type = %q, want bitable\nstdout:\n%s", got, stdout.String())
|
||||
}
|
||||
if got := mustStringField(t, data, "comment_mode", "data.comment_mode"); got != "base_record" {
|
||||
t.Fatalf("stdout comment_mode = %q, want base_record\nstdout:\n%s", got, stdout.String())
|
||||
}
|
||||
if got := mustStringField(t, data, "reply_id", "data.reply_id"); got != "baseReply123" {
|
||||
t.Fatalf("stdout reply_id = %q, want baseReply123\nstdout:\n%s", got, stdout.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestBaseCommentExecuteBareToken(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST", URL: "/open-apis/drive/v1/files/baseBareToken/new_comments",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0, "msg": "success",
|
||||
"data": map[string]interface{}{"comment_id": "baseBareComment"},
|
||||
},
|
||||
})
|
||||
err := mountAndRunDrive(t, DriveAddComment, []string{
|
||||
"+add-comment",
|
||||
"--doc", "baseBareToken",
|
||||
"--type", "bitable",
|
||||
"--content", `[{"type":"text","text":"ok"}]`,
|
||||
"--block-id", "tbl9mp6fj9kDKHQV!recBIBgGmb!vewc46MG1R",
|
||||
"--as", "user",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if !strings.Contains(stdout.String(), "baseBareComment") {
|
||||
t.Fatalf("stdout missing comment_id: %s", stdout.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestFileCommentExecuteSuccess(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
|
||||
reg.Register(&httpmock.Stub{
|
||||
@@ -1641,40 +1433,6 @@ func TestDryRunSlidesDirectURL(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestDryRunBaseDirectURL(t *testing.T) {
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, driveTestConfig())
|
||||
err := mountAndRunDrive(t, DriveAddComment, []string{
|
||||
"+add-comment",
|
||||
"--doc", "https://example.larksuite.com/base/baseToken",
|
||||
"--content", `[{"type":"text","text":"test"}]`,
|
||||
"--block-id", "tbl9mp6fj9kDKHQV!recBIBgGmb!vewc46MG1R",
|
||||
"--dry-run", "--as", "user",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if !strings.Contains(stdout.String(), "record-local comment") {
|
||||
t.Fatalf("dry-run output missing record-local comment: %s", stdout.String())
|
||||
}
|
||||
out := decodeJSONMap(t, stdout.String())
|
||||
api := mustSliceValue(t, out["api"], "api")
|
||||
call := mustMapValue(t, api[0], "api[0]")
|
||||
body := mustMapValue(t, call["body"], "api[0].body")
|
||||
anchor := mustMapValue(t, body["anchor"], "api[0].body.anchor")
|
||||
if got := mustStringField(t, body, "file_type", "api[0].body.file_type"); got != "bitable" {
|
||||
t.Fatalf("dry-run body.file_type = %q, want bitable\nstdout:\n%s", got, stdout.String())
|
||||
}
|
||||
if got := mustStringField(t, anchor, "block_id", "api[0].body.anchor.block_id"); got != "tbl9mp6fj9kDKHQV" {
|
||||
t.Fatalf("dry-run body.anchor.block_id = %q, want tbl9mp6fj9kDKHQV\nstdout:\n%s", got, stdout.String())
|
||||
}
|
||||
if got := mustStringField(t, anchor, "base_record_id", "api[0].body.anchor.base_record_id"); got != "recBIBgGmb" {
|
||||
t.Fatalf("dry-run body.anchor.base_record_id = %q, want recBIBgGmb\nstdout:\n%s", got, stdout.String())
|
||||
}
|
||||
if got := mustStringField(t, anchor, "base_view_id", "api[0].body.anchor.base_view_id"); got != "vewc46MG1R" {
|
||||
t.Fatalf("dry-run body.anchor.base_view_id = %q, want vewc46MG1R\nstdout:\n%s", got, stdout.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestDryRunWikiResolvesToSlides(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
|
||||
reg.Register(&httpmock.Stub{
|
||||
@@ -1878,92 +1636,25 @@ func TestResolveWikiToDocxFullComment(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveWikiToBaseComment(t *testing.T) {
|
||||
for _, objType := range []string{"bitable", "base"} {
|
||||
t.Run(objType, func(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET", URL: "/open-apis/wiki/v2/spaces/get_node",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0, "msg": "success",
|
||||
"data": map[string]interface{}{
|
||||
"node": map[string]interface{}{"obj_type": objType, "obj_token": "bitToken"},
|
||||
},
|
||||
},
|
||||
})
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST", URL: "/open-apis/drive/v1/files/bitToken/new_comments",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0, "msg": "success",
|
||||
"data": map[string]interface{}{"comment_id": "wikiBaseComment", "reply_id": "wikiBaseReply"},
|
||||
},
|
||||
})
|
||||
err := mountAndRunDrive(t, DriveAddComment, []string{
|
||||
"+add-comment",
|
||||
"--doc", "https://example.larksuite.com/wiki/wikiToken",
|
||||
"--content", `[{"type":"text","text":"test"}]`,
|
||||
"--block-id", "tbl9mp6fj9kDKHQV!recBIBgGmb!vewc46MG1R",
|
||||
"--as", "user",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if !strings.Contains(stdout.String(), "wikiBaseComment") {
|
||||
t.Fatalf("stdout missing comment_id: %s", stdout.String())
|
||||
}
|
||||
out := decodeJSONMap(t, stdout.String())
|
||||
data := mustMapValue(t, out["data"], "data")
|
||||
if got := mustStringField(t, data, "file_type", "data.file_type"); got != "bitable" {
|
||||
t.Fatalf("stdout file_type = %q, want bitable\nstdout:\n%s", got, stdout.String())
|
||||
}
|
||||
if got := mustStringField(t, data, "wiki_token", "data.wiki_token"); got != "wikiToken" {
|
||||
t.Fatalf("stdout wiki_token = %q, want wikiToken\nstdout:\n%s", got, stdout.String())
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveWikiToBaseRejectsIncompatibleFlags(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
args []string
|
||||
wantErr string
|
||||
}{
|
||||
{
|
||||
name: "full comment",
|
||||
args: []string{"--full-comment"},
|
||||
wantErr: "--full-comment is not applicable for base(bitable) comments",
|
||||
func TestResolveWikiToUnsupportedType(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET", URL: "/open-apis/wiki/v2/spaces/get_node",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0, "msg": "success",
|
||||
"data": map[string]interface{}{
|
||||
"node": map[string]interface{}{"obj_type": "bitable", "obj_token": "bitToken"},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "selection",
|
||||
args: []string{"--selection-with-ellipsis", "some text"},
|
||||
wantErr: "--selection-with-ellipsis is not applicable for base(bitable) comments",
|
||||
},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET", URL: "/open-apis/wiki/v2/spaces/get_node",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0, "msg": "success",
|
||||
"data": map[string]interface{}{
|
||||
"node": map[string]interface{}{"obj_type": "bitable", "obj_token": "bitToken"},
|
||||
},
|
||||
},
|
||||
})
|
||||
args := []string{
|
||||
"+add-comment",
|
||||
"--doc", "https://example.larksuite.com/wiki/wikiToken",
|
||||
"--content", `[{"type":"text","text":"test"}]`,
|
||||
"--as", "user",
|
||||
}
|
||||
args = append(args, tc.args...)
|
||||
err := mountAndRunDrive(t, DriveAddComment, args, f, stdout)
|
||||
if err == nil || !strings.Contains(err.Error(), tc.wantErr) {
|
||||
t.Fatalf("expected %q error, got: %v", tc.wantErr, err)
|
||||
}
|
||||
})
|
||||
})
|
||||
err := mountAndRunDrive(t, DriveAddComment, []string{
|
||||
"+add-comment",
|
||||
"--doc", "https://example.larksuite.com/wiki/wikiToken",
|
||||
"--content", `[{"type":"text","text":"test"}]`,
|
||||
"--as", "user",
|
||||
}, f, stdout)
|
||||
if err == nil || !strings.Contains(err.Error(), "only support doc/docx/file/sheet/slides") {
|
||||
t.Fatalf("expected unsupported type error, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2044,7 +1735,7 @@ func TestDocOldFormatLocalCommentRejected(t *testing.T) {
|
||||
"--block-id", "blk_123",
|
||||
"--as", "user",
|
||||
}, f, stdout)
|
||||
if err == nil || !strings.Contains(err.Error(), "only support docx, sheet, slides, and base(bitable)") {
|
||||
if err == nil || !strings.Contains(err.Error(), "only support docx, sheet, and slides") {
|
||||
t.Fatalf("expected local comment rejection for old doc, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,7 +5,6 @@ package drive
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
@@ -16,24 +15,6 @@ import (
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
// wrapExportContextErr converts a context cancellation / deadline error into a
|
||||
// typed errs.NetworkError so the cobra layer sees a typed envelope (with cause
|
||||
// preserved for errors.Is) instead of an untyped context.Canceled /
|
||||
// context.DeadlineExceeded escaping as a plain string. CR-flagged hole on the
|
||||
// poll loop: returning ctx.Err() directly bypassed the typed-error contract.
|
||||
func wrapExportContextErr(err error) error {
|
||||
if err == nil {
|
||||
return nil
|
||||
}
|
||||
subtype := errs.SubtypeNetworkTransport
|
||||
msg := "drive +export polling cancelled: %s"
|
||||
if errors.Is(err, context.DeadlineExceeded) {
|
||||
subtype = errs.SubtypeNetworkTimeout
|
||||
msg = "drive +export polling deadline exceeded: %s"
|
||||
}
|
||||
return errs.NewNetworkError(subtype, msg, err).WithCause(err)
|
||||
}
|
||||
|
||||
// DriveExport exports Drive-native documents to local files and falls back to
|
||||
// a follow-up command when the async export task does not finish in time.
|
||||
var DriveExport = common.Shortcut{
|
||||
@@ -59,302 +40,236 @@ var DriveExport = common.Shortcut{
|
||||
{Name: "overwrite", Type: "bool", Desc: "overwrite existing output file"},
|
||||
},
|
||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
return ValidateExport(exportParamsFromFlags(runtime))
|
||||
return validateDriveExportSpec(driveExportSpec{
|
||||
Token: runtime.Str("token"),
|
||||
DocType: runtime.Str("doc-type"),
|
||||
FileExtension: runtime.Str("file-extension"),
|
||||
SubID: runtime.Str("sub-id"),
|
||||
OnlySchema: runtime.Bool("only-schema"),
|
||||
})
|
||||
},
|
||||
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
return PlanExportDryRun(runtime, exportParamsFromFlags(runtime))
|
||||
},
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
return RunExport(ctx, runtime, exportParamsFromFlags(runtime))
|
||||
},
|
||||
}
|
||||
spec := driveExportSpec{
|
||||
Token: runtime.Str("token"),
|
||||
DocType: runtime.Str("doc-type"),
|
||||
FileExtension: runtime.Str("file-extension"),
|
||||
SubID: runtime.Str("sub-id"),
|
||||
OnlySchema: runtime.Bool("only-schema"),
|
||||
}
|
||||
// Markdown export is a special case: docx markdown comes from the V2
|
||||
// docs_ai fetch API directly instead of the Drive export task API.
|
||||
if spec.FileExtension == "markdown" {
|
||||
apiPath := fmt.Sprintf("/open-apis/docs_ai/v1/documents/%s/fetch", validate.EncodePathSegment(spec.Token))
|
||||
dr := common.NewDryRunAPI().
|
||||
Desc("2-step orchestration: fetch docx markdown -> write local file").
|
||||
POST(apiPath).
|
||||
Body(map[string]interface{}{
|
||||
"format": "markdown",
|
||||
}).
|
||||
Set("output_dir", runtime.Str("output-dir"))
|
||||
if name := strings.TrimSpace(runtime.Str("file-name")); name != "" {
|
||||
dr.Set("file_name", ensureExportFileExtension(sanitizeExportFileName(name, spec.Token), spec.FileExtension))
|
||||
}
|
||||
return dr
|
||||
}
|
||||
|
||||
// ExportParams holds the user-facing inputs for an export flow, decoupled from
|
||||
// cobra flags so other command groups (e.g. sheets +workbook-export) can reuse
|
||||
// the drive export implementation. An empty OutputDir means "create the export
|
||||
// task and poll, but do not download" — callers that only need the ready file
|
||||
// token / status get it back without writing a local file.
|
||||
type ExportParams struct {
|
||||
Token string
|
||||
DocType string
|
||||
FileExtension string
|
||||
SubID string
|
||||
OnlySchema bool
|
||||
OutputDir string
|
||||
FileName string
|
||||
Overwrite bool
|
||||
}
|
||||
body := map[string]interface{}{
|
||||
"token": spec.Token,
|
||||
"type": spec.DocType,
|
||||
"file_extension": spec.FileExtension,
|
||||
}
|
||||
if strings.TrimSpace(spec.SubID) != "" {
|
||||
body["sub_id"] = spec.SubID
|
||||
}
|
||||
if spec.OnlySchema {
|
||||
body["only_schema"] = true
|
||||
}
|
||||
|
||||
func (p ExportParams) spec() driveExportSpec {
|
||||
return driveExportSpec{
|
||||
Token: p.Token,
|
||||
DocType: p.DocType,
|
||||
FileExtension: p.FileExtension,
|
||||
SubID: p.SubID,
|
||||
OnlySchema: p.OnlySchema,
|
||||
}
|
||||
}
|
||||
|
||||
// exportParamsFromFlags reads the standard drive +export flag set.
|
||||
func exportParamsFromFlags(runtime *common.RuntimeContext) ExportParams {
|
||||
// drive +export always downloads; an empty --output-dir historically means
|
||||
// the current directory (saveContentToOutputDir maps "" -> "."), so normalize
|
||||
// it here to keep behavior identical and stay off the export-only ("" => skip
|
||||
// download) path that only sheets +workbook-export uses.
|
||||
outputDir := runtime.Str("output-dir")
|
||||
if outputDir == "" {
|
||||
outputDir = "."
|
||||
}
|
||||
return ExportParams{
|
||||
Token: runtime.Str("token"),
|
||||
DocType: runtime.Str("doc-type"),
|
||||
FileExtension: runtime.Str("file-extension"),
|
||||
SubID: runtime.Str("sub-id"),
|
||||
OnlySchema: runtime.Bool("only-schema"),
|
||||
OutputDir: outputDir,
|
||||
FileName: strings.TrimSpace(runtime.Str("file-name")),
|
||||
Overwrite: runtime.Bool("overwrite"),
|
||||
}
|
||||
}
|
||||
|
||||
// ValidateExport runs the CLI-level export constraint checks.
|
||||
func ValidateExport(p ExportParams) error {
|
||||
return validateDriveExportSpec(p.spec())
|
||||
}
|
||||
|
||||
// PlanExportDryRun builds the dry-run plan for an export without performing I/O.
|
||||
func PlanExportDryRun(runtime *common.RuntimeContext, p ExportParams) *common.DryRunAPI {
|
||||
spec := p.spec()
|
||||
// Markdown export is a special case: docx markdown comes from the V2
|
||||
// docs_ai fetch API directly instead of the Drive export task API.
|
||||
if spec.FileExtension == "markdown" {
|
||||
apiPath := fmt.Sprintf("/open-apis/docs_ai/v1/documents/%s/fetch", validate.EncodePathSegment(spec.Token))
|
||||
dr := common.NewDryRunAPI().
|
||||
Desc("2-step orchestration: fetch docx markdown -> write local file").
|
||||
POST(apiPath).
|
||||
Body(map[string]interface{}{
|
||||
"format": "markdown",
|
||||
}).
|
||||
Set("output_dir", p.OutputDir)
|
||||
if name := strings.TrimSpace(p.FileName); name != "" {
|
||||
Desc("3-step orchestration: create export task -> limited polling -> download file").
|
||||
POST("/open-apis/drive/v1/export_tasks").
|
||||
Body(body).
|
||||
Set("output_dir", runtime.Str("output-dir"))
|
||||
if name := strings.TrimSpace(runtime.Str("file-name")); name != "" {
|
||||
dr.Set("file_name", ensureExportFileExtension(sanitizeExportFileName(name, spec.Token), spec.FileExtension))
|
||||
}
|
||||
return dr
|
||||
}
|
||||
|
||||
body := map[string]interface{}{
|
||||
"token": spec.Token,
|
||||
"type": spec.DocType,
|
||||
"file_extension": spec.FileExtension,
|
||||
}
|
||||
if strings.TrimSpace(spec.SubID) != "" {
|
||||
body["sub_id"] = spec.SubID
|
||||
}
|
||||
if spec.OnlySchema {
|
||||
body["only_schema"] = true
|
||||
}
|
||||
|
||||
dr := common.NewDryRunAPI().
|
||||
Desc("3-step orchestration: create export task -> limited polling -> download file").
|
||||
POST("/open-apis/drive/v1/export_tasks").
|
||||
Body(body).
|
||||
Set("output_dir", p.OutputDir)
|
||||
if name := strings.TrimSpace(p.FileName); name != "" {
|
||||
dr.Set("file_name", ensureExportFileExtension(sanitizeExportFileName(name, spec.Token), spec.FileExtension))
|
||||
}
|
||||
return dr
|
||||
}
|
||||
|
||||
// RunExport drives create export task -> bounded poll -> optional download. It
|
||||
// is the shared core behind both drive +export and sheets +workbook-export. An
|
||||
// empty p.OutputDir skips the download step and returns the ready file token.
|
||||
func RunExport(ctx context.Context, runtime *common.RuntimeContext, p ExportParams) error {
|
||||
spec := p.spec()
|
||||
outputDir := p.OutputDir
|
||||
preferredFileName := strings.TrimSpace(p.FileName)
|
||||
overwrite := p.Overwrite
|
||||
|
||||
// Markdown export bypasses the async export task and writes the fetched
|
||||
// markdown content directly to disk. Uses the V2 docs_ai fetch API for
|
||||
// higher-quality Lark-flavored Markdown output.
|
||||
if spec.FileExtension == "markdown" {
|
||||
fmt.Fprintf(runtime.IO().ErrOut, "Exporting docx as markdown: %s\n", common.MaskToken(spec.Token))
|
||||
apiPath := fmt.Sprintf("/open-apis/docs_ai/v1/documents/%s/fetch", validate.EncodePathSegment(spec.Token))
|
||||
data, err := runtime.CallAPITyped(
|
||||
"POST",
|
||||
apiPath,
|
||||
nil,
|
||||
map[string]interface{}{
|
||||
"format": "markdown",
|
||||
},
|
||||
)
|
||||
if err != nil {
|
||||
return err
|
||||
},
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
spec := driveExportSpec{
|
||||
Token: runtime.Str("token"),
|
||||
DocType: runtime.Str("doc-type"),
|
||||
FileExtension: runtime.Str("file-extension"),
|
||||
SubID: runtime.Str("sub-id"),
|
||||
OnlySchema: runtime.Bool("only-schema"),
|
||||
}
|
||||
outputDir := runtime.Str("output-dir")
|
||||
preferredFileName := strings.TrimSpace(runtime.Str("file-name"))
|
||||
overwrite := runtime.Bool("overwrite")
|
||||
|
||||
// Extract content from the V2 response: data.document.content
|
||||
doc, ok := data["document"].(map[string]interface{})
|
||||
if !ok {
|
||||
return errs.NewInternalError(errs.SubtypeInvalidResponse, "invalid markdown fetch response: missing document object")
|
||||
}
|
||||
content, ok := doc["content"].(string)
|
||||
if !ok {
|
||||
return errs.NewInternalError(errs.SubtypeInvalidResponse, "invalid markdown fetch response: missing document.content")
|
||||
}
|
||||
|
||||
fileName := preferredFileName
|
||||
if fileName == "" {
|
||||
// Prefer the remote title for the exported file name, but still fall
|
||||
// back to the token if metadata is empty.
|
||||
title, err := common.FetchDriveMetaTitle(runtime, spec.Token, spec.DocType)
|
||||
// Markdown export bypasses the async export task and writes the fetched
|
||||
// markdown content directly to disk. Uses the V2 docs_ai fetch API for
|
||||
// higher-quality Lark-flavored Markdown output.
|
||||
if spec.FileExtension == "markdown" {
|
||||
fmt.Fprintf(runtime.IO().ErrOut, "Exporting docx as markdown: %s\n", common.MaskToken(spec.Token))
|
||||
apiPath := fmt.Sprintf("/open-apis/docs_ai/v1/documents/%s/fetch", validate.EncodePathSegment(spec.Token))
|
||||
data, err := runtime.CallAPITyped(
|
||||
"POST",
|
||||
apiPath,
|
||||
nil,
|
||||
map[string]interface{}{
|
||||
"format": "markdown",
|
||||
},
|
||||
)
|
||||
if err != nil {
|
||||
fmt.Fprintf(runtime.IO().ErrOut, "Title lookup failed, using token as filename: %v\n", err)
|
||||
title = spec.Token
|
||||
return err
|
||||
}
|
||||
fileName = title
|
||||
}
|
||||
fileName = ensureExportFileExtension(sanitizeExportFileName(fileName, spec.Token), spec.FileExtension)
|
||||
savedPath, err := saveContentToOutputDir(runtime.FileIO(), outputDir, fileName, []byte(content), overwrite)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
runtime.Out(map[string]interface{}{
|
||||
"token": spec.Token,
|
||||
"doc_type": spec.DocType,
|
||||
"file_extension": spec.FileExtension,
|
||||
"file_name": filepath.Base(savedPath),
|
||||
"saved_path": savedPath,
|
||||
"size_bytes": len(content),
|
||||
}, nil)
|
||||
return nil
|
||||
}
|
||||
|
||||
ticket, err := createDriveExportTask(runtime, spec)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
fmt.Fprintf(runtime.IO().ErrOut, "Created export task: %s\n", ticket)
|
||||
|
||||
var lastStatus driveExportStatus
|
||||
var lastPollErr error
|
||||
hasObservedStatus := false
|
||||
// Keep the command responsive by polling for a bounded window. If the task
|
||||
// is still running after that, return a resume command instead of blocking.
|
||||
for attempt := 1; attempt <= driveExportPollAttempts; attempt++ {
|
||||
if attempt > 1 {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return wrapExportContextErr(ctx.Err())
|
||||
case <-time.After(driveExportPollInterval):
|
||||
// Extract content from the V2 response: data.document.content
|
||||
doc, ok := data["document"].(map[string]interface{})
|
||||
if !ok {
|
||||
return errs.NewInternalError(errs.SubtypeInvalidResponse, "invalid markdown fetch response: missing document object")
|
||||
}
|
||||
}
|
||||
if err := ctx.Err(); err != nil {
|
||||
return wrapExportContextErr(err)
|
||||
}
|
||||
|
||||
status, err := getDriveExportStatus(runtime, spec.Token, ticket)
|
||||
if err != nil {
|
||||
// Treat polling failures as transient so short-lived backend hiccups
|
||||
// do not immediately fail an otherwise healthy export task.
|
||||
lastPollErr = err
|
||||
fmt.Fprintf(runtime.IO().ErrOut, "Export status attempt %d/%d failed: %v\n", attempt, driveExportPollAttempts, err)
|
||||
continue
|
||||
}
|
||||
lastStatus = status
|
||||
hasObservedStatus = true
|
||||
|
||||
if status.Ready() {
|
||||
fmt.Fprintf(runtime.IO().ErrOut, "Export task completed: %s\n", common.MaskToken(status.FileToken))
|
||||
|
||||
// Export-only mode: caller wants the ready file token / metadata but
|
||||
// no local download (e.g. sheets +workbook-export without an output
|
||||
// path). Skip the download and return the status envelope.
|
||||
if strings.TrimSpace(outputDir) == "" {
|
||||
runtime.Out(map[string]interface{}{
|
||||
"ticket": ticket,
|
||||
"token": spec.Token,
|
||||
"doc_type": spec.DocType,
|
||||
"file_extension": spec.FileExtension,
|
||||
"file_token": status.FileToken,
|
||||
"file_name": status.FileName,
|
||||
"file_size": status.FileSize,
|
||||
"ready": true,
|
||||
"downloaded": false,
|
||||
}, nil)
|
||||
return nil
|
||||
content, ok := doc["content"].(string)
|
||||
if !ok {
|
||||
return errs.NewInternalError(errs.SubtypeInvalidResponse, "invalid markdown fetch response: missing document.content")
|
||||
}
|
||||
|
||||
fileName := preferredFileName
|
||||
if fileName == "" {
|
||||
fileName = status.FileName
|
||||
// Prefer the remote title for the exported file name, but still fall
|
||||
// back to the token if metadata is empty.
|
||||
title, err := common.FetchDriveMetaTitle(runtime, spec.Token, spec.DocType)
|
||||
if err != nil {
|
||||
fmt.Fprintf(runtime.IO().ErrOut, "Title lookup failed, using token as filename: %v\n", err)
|
||||
title = spec.Token
|
||||
}
|
||||
fileName = title
|
||||
}
|
||||
fileName = ensureExportFileExtension(sanitizeExportFileName(fileName, spec.Token), spec.FileExtension)
|
||||
out, err := downloadDriveExportFile(ctx, runtime, status.FileToken, outputDir, fileName, overwrite)
|
||||
savedPath, err := saveContentToOutputDir(runtime.FileIO(), outputDir, fileName, []byte(content), overwrite)
|
||||
if err != nil {
|
||||
recoveryCommand := driveExportDownloadCommand(status.FileToken, fileName, outputDir, overwrite)
|
||||
hint := fmt.Sprintf(
|
||||
"the export artifact is already ready (ticket=%s, file_token=%s)\nretry download with: %s",
|
||||
ticket,
|
||||
status.FileToken,
|
||||
recoveryCommand,
|
||||
)
|
||||
return appendDriveExportRecoveryHint(err, hint)
|
||||
return err
|
||||
}
|
||||
out["ticket"] = ticket
|
||||
out["doc_type"] = spec.DocType
|
||||
out["file_extension"] = spec.FileExtension
|
||||
runtime.Out(out, nil)
|
||||
|
||||
runtime.Out(map[string]interface{}{
|
||||
"token": spec.Token,
|
||||
"doc_type": spec.DocType,
|
||||
"file_extension": spec.FileExtension,
|
||||
"file_name": filepath.Base(savedPath),
|
||||
"saved_path": savedPath,
|
||||
"size_bytes": len(content),
|
||||
}, nil)
|
||||
return nil
|
||||
}
|
||||
|
||||
if status.Failed() {
|
||||
msg := strings.TrimSpace(status.JobErrorMsg)
|
||||
if msg == "" {
|
||||
msg = status.StatusLabel()
|
||||
ticket, err := createDriveExportTask(runtime, spec)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
fmt.Fprintf(runtime.IO().ErrOut, "Created export task: %s\n", ticket)
|
||||
|
||||
var lastStatus driveExportStatus
|
||||
var lastPollErr error
|
||||
hasObservedStatus := false
|
||||
// Keep the command responsive by polling for a bounded window. If the task
|
||||
// is still running after that, return a resume command instead of blocking.
|
||||
for attempt := 1; attempt <= driveExportPollAttempts; attempt++ {
|
||||
if attempt > 1 {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return ctx.Err()
|
||||
case <-time.After(driveExportPollInterval):
|
||||
}
|
||||
}
|
||||
return errs.NewAPIError(errs.SubtypeServerError, "export task failed: %s (ticket=%s)", msg, ticket)
|
||||
if err := ctx.Err(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
status, err := getDriveExportStatus(runtime, spec.Token, ticket)
|
||||
if err != nil {
|
||||
// Treat polling failures as transient so short-lived backend hiccups
|
||||
// do not immediately fail an otherwise healthy export task.
|
||||
lastPollErr = err
|
||||
fmt.Fprintf(runtime.IO().ErrOut, "Export status attempt %d/%d failed: %v\n", attempt, driveExportPollAttempts, err)
|
||||
continue
|
||||
}
|
||||
lastStatus = status
|
||||
hasObservedStatus = true
|
||||
|
||||
if status.Ready() {
|
||||
fmt.Fprintf(runtime.IO().ErrOut, "Export task completed: %s\n", common.MaskToken(status.FileToken))
|
||||
fileName := preferredFileName
|
||||
if fileName == "" {
|
||||
fileName = status.FileName
|
||||
}
|
||||
fileName = ensureExportFileExtension(sanitizeExportFileName(fileName, spec.Token), spec.FileExtension)
|
||||
out, err := downloadDriveExportFile(ctx, runtime, status.FileToken, outputDir, fileName, overwrite)
|
||||
if err != nil {
|
||||
recoveryCommand := driveExportDownloadCommand(status.FileToken, fileName, outputDir, overwrite)
|
||||
hint := fmt.Sprintf(
|
||||
"the export artifact is already ready (ticket=%s, file_token=%s)\nretry download with: %s",
|
||||
ticket,
|
||||
status.FileToken,
|
||||
recoveryCommand,
|
||||
)
|
||||
return appendDriveExportRecoveryHint(err, hint)
|
||||
}
|
||||
out["ticket"] = ticket
|
||||
out["doc_type"] = spec.DocType
|
||||
out["file_extension"] = spec.FileExtension
|
||||
runtime.Out(out, nil)
|
||||
return nil
|
||||
}
|
||||
|
||||
if status.Failed() {
|
||||
msg := strings.TrimSpace(status.JobErrorMsg)
|
||||
if msg == "" {
|
||||
msg = status.StatusLabel()
|
||||
}
|
||||
return errs.NewAPIError(errs.SubtypeServerError, "export task failed: %s (ticket=%s)", msg, ticket)
|
||||
}
|
||||
|
||||
fmt.Fprintf(runtime.IO().ErrOut, "Export status %d/%d: %s\n", attempt, driveExportPollAttempts, status.StatusLabel())
|
||||
}
|
||||
|
||||
fmt.Fprintf(runtime.IO().ErrOut, "Export status %d/%d: %s\n", attempt, driveExportPollAttempts, status.StatusLabel())
|
||||
}
|
||||
nextCommand := driveExportTaskResultCommand(ticket, spec.Token)
|
||||
if !hasObservedStatus && lastPollErr != nil {
|
||||
hint := fmt.Sprintf(
|
||||
"the export task was created but every status poll failed (ticket=%s)\nretry status lookup with: %s",
|
||||
ticket,
|
||||
nextCommand,
|
||||
)
|
||||
return appendDriveExportRecoveryHint(lastPollErr, hint)
|
||||
}
|
||||
|
||||
nextCommand := driveExportTaskResultCommand(ticket, spec.Token)
|
||||
if !hasObservedStatus && lastPollErr != nil {
|
||||
hint := fmt.Sprintf(
|
||||
"the export task was created but every status poll failed (ticket=%s)\nretry status lookup with: %s",
|
||||
ticket,
|
||||
nextCommand,
|
||||
)
|
||||
return appendDriveExportRecoveryHint(lastPollErr, hint)
|
||||
}
|
||||
|
||||
failed := false
|
||||
var jobStatus interface{}
|
||||
jobStatusLabel := "unknown"
|
||||
if hasObservedStatus {
|
||||
failed = lastStatus.Failed()
|
||||
jobStatus = lastStatus.JobStatus
|
||||
jobStatusLabel = lastStatus.StatusLabel()
|
||||
}
|
||||
// Return the last observed status so callers can resume from a known task
|
||||
// state instead of losing all progress information on timeout.
|
||||
result := map[string]interface{}{
|
||||
"ticket": ticket,
|
||||
"token": spec.Token,
|
||||
"doc_type": spec.DocType,
|
||||
"file_extension": spec.FileExtension,
|
||||
"ready": false,
|
||||
"failed": failed,
|
||||
"job_status": jobStatus,
|
||||
"job_status_label": jobStatusLabel,
|
||||
"timed_out": true,
|
||||
"next_command": nextCommand,
|
||||
}
|
||||
if preferredFileName != "" {
|
||||
result["file_name"] = ensureExportFileExtension(sanitizeExportFileName(preferredFileName, spec.Token), spec.FileExtension)
|
||||
}
|
||||
runtime.Out(result, nil)
|
||||
fmt.Fprintf(runtime.IO().ErrOut, "Export task is still in progress. Continue with: %s\n", nextCommand)
|
||||
return nil
|
||||
failed := false
|
||||
var jobStatus interface{}
|
||||
jobStatusLabel := "unknown"
|
||||
if hasObservedStatus {
|
||||
failed = lastStatus.Failed()
|
||||
jobStatus = lastStatus.JobStatus
|
||||
jobStatusLabel = lastStatus.StatusLabel()
|
||||
}
|
||||
// Return the last observed status so callers can resume from a known task
|
||||
// state instead of losing all progress information on timeout.
|
||||
result := map[string]interface{}{
|
||||
"ticket": ticket,
|
||||
"token": spec.Token,
|
||||
"doc_type": spec.DocType,
|
||||
"file_extension": spec.FileExtension,
|
||||
"ready": false,
|
||||
"failed": failed,
|
||||
"job_status": jobStatus,
|
||||
"job_status_label": jobStatusLabel,
|
||||
"timed_out": true,
|
||||
"next_command": nextCommand,
|
||||
}
|
||||
if preferredFileName != "" {
|
||||
result["file_name"] = ensureExportFileExtension(sanitizeExportFileName(preferredFileName, spec.Token), spec.FileExtension)
|
||||
}
|
||||
runtime.Out(result, nil)
|
||||
fmt.Fprintf(runtime.IO().ErrOut, "Export task is still in progress. Continue with: %s\n", nextCommand)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
@@ -5,7 +5,6 @@ package drive
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
@@ -498,72 +497,6 @@ func TestDriveExportAsyncSuccess(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestDriveExportEmptyOutputDirDownloadsToCwd guards the export refactor: an
|
||||
// explicit empty --output-dir must still download to the current directory
|
||||
// (normalized to "."), not trigger the export-only no-download path that the
|
||||
// shared RunExport core uses for sheets +workbook-export.
|
||||
func TestDriveExportEmptyOutputDirDownloadsToCwd(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/drive/v1/export_tasks",
|
||||
Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{"ticket": "tk_e"}},
|
||||
})
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/drive/v1/export_tasks/tk_e",
|
||||
Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{
|
||||
"result": map[string]interface{}{
|
||||
"job_status": 0, "file_token": "box_e", "file_name": "report",
|
||||
"file_extension": "pdf", "type": "docx", "file_size": 3,
|
||||
},
|
||||
}},
|
||||
})
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/drive/v1/export_tasks/file/box_e/download",
|
||||
Status: 200,
|
||||
RawBody: []byte("pdf"),
|
||||
Headers: http.Header{
|
||||
"Content-Type": []string{"application/pdf"},
|
||||
"Content-Disposition": []string{`attachment; filename="report.pdf"`},
|
||||
},
|
||||
})
|
||||
|
||||
tmpDir := t.TempDir()
|
||||
withDriveWorkingDir(t, tmpDir)
|
||||
|
||||
prevAttempts, prevInterval := driveExportPollAttempts, driveExportPollInterval
|
||||
driveExportPollAttempts, driveExportPollInterval = 1, 0
|
||||
t.Cleanup(func() {
|
||||
driveExportPollAttempts, driveExportPollInterval = prevAttempts, prevInterval
|
||||
})
|
||||
|
||||
err := mountAndRunDrive(t, DriveExport, []string{
|
||||
"+export",
|
||||
"--token", "docx123",
|
||||
"--doc-type", "docx",
|
||||
"--file-extension", "pdf",
|
||||
"--output-dir", "",
|
||||
"--as", "bot",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
// Empty --output-dir must still write to cwd, not skip the download.
|
||||
data, err := os.ReadFile(filepath.Join(tmpDir, "report.pdf"))
|
||||
if err != nil {
|
||||
t.Fatalf("empty --output-dir should still download to cwd: %v", err)
|
||||
}
|
||||
if string(data) != "pdf" {
|
||||
t.Fatalf("downloaded content = %q", string(data))
|
||||
}
|
||||
if strings.Contains(stdout.String(), `"downloaded": false`) {
|
||||
t.Fatalf("export-only path must not trigger for drive +export: %s", stdout.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestDriveExportAsyncUsesProvidedFileName(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
|
||||
reg.Register(&httpmock.Stub{
|
||||
@@ -1101,37 +1034,3 @@ func TestDriveTaskResultExportIncludesReadyFlags(t *testing.T) {
|
||||
t.Fatalf("stdout missing job_status_label: %s", stdout.String())
|
||||
}
|
||||
}
|
||||
|
||||
// TestWrapExportContextErr verifies the export poll loop's typed wrapping for
|
||||
// context cancellation / deadline. Previously the poll loop returned ctx.Err()
|
||||
// directly so an untyped context.Canceled would escape as a plain string at
|
||||
// the command layer, bypassing the typed-error contract.
|
||||
func TestWrapExportContextErr(t *testing.T) {
|
||||
if err := wrapExportContextErr(nil); err != nil {
|
||||
t.Errorf("wrapExportContextErr(nil) = %v, want nil", err)
|
||||
}
|
||||
|
||||
cancelled := wrapExportContextErr(context.Canceled)
|
||||
var netErrCancel *errs.NetworkError
|
||||
if !errors.As(cancelled, &netErrCancel) {
|
||||
t.Fatalf("wrapExportContextErr(Canceled) = %T, want *errs.NetworkError", cancelled)
|
||||
}
|
||||
if netErrCancel.Subtype != errs.SubtypeNetworkTransport {
|
||||
t.Errorf("Canceled subtype = %q, want %q", netErrCancel.Subtype, errs.SubtypeNetworkTransport)
|
||||
}
|
||||
if !errors.Is(cancelled, context.Canceled) {
|
||||
t.Error("wrapExportContextErr should preserve context.Canceled via errors.Is")
|
||||
}
|
||||
|
||||
deadline := wrapExportContextErr(context.DeadlineExceeded)
|
||||
var netErrDeadline *errs.NetworkError
|
||||
if !errors.As(deadline, &netErrDeadline) {
|
||||
t.Fatalf("wrapExportContextErr(DeadlineExceeded) = %T, want *errs.NetworkError", deadline)
|
||||
}
|
||||
if netErrDeadline.Subtype != errs.SubtypeNetworkTimeout {
|
||||
t.Errorf("DeadlineExceeded subtype = %q, want %q", netErrDeadline.Subtype, errs.SubtypeNetworkTimeout)
|
||||
}
|
||||
if !errors.Is(deadline, context.DeadlineExceeded) {
|
||||
t.Error("wrapExportContextErr should preserve context.DeadlineExceeded via errors.Is")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -35,164 +35,132 @@ var DriveImport = common.Shortcut{
|
||||
{Name: "target-token", Desc: "existing token to import data into (only for type=bitable); when set, data is mounted into this bitable instead of creating a new one"},
|
||||
},
|
||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
return ValidateImport(importParamsFromFlags(runtime))
|
||||
return validateDriveImportSpec(driveImportSpec{
|
||||
FilePath: runtime.Str("file"),
|
||||
DocType: strings.ToLower(runtime.Str("type")),
|
||||
FolderToken: runtime.Str("folder-token"),
|
||||
Name: runtime.Str("name"),
|
||||
TargetToken: runtime.Str("target-token"),
|
||||
})
|
||||
},
|
||||
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
return PlanImportDryRun(runtime, importParamsFromFlags(runtime))
|
||||
spec := driveImportSpec{
|
||||
FilePath: runtime.Str("file"),
|
||||
DocType: strings.ToLower(runtime.Str("type")),
|
||||
FolderToken: runtime.Str("folder-token"),
|
||||
Name: runtime.Str("name"),
|
||||
TargetToken: runtime.Str("target-token"),
|
||||
}
|
||||
fileSize, err := preflightDriveImportFile(runtime.FileIO(), &spec)
|
||||
if err != nil {
|
||||
return common.NewDryRunAPI().Set("error", err.Error())
|
||||
}
|
||||
if valErr := validateDriveImportSpec(spec); valErr != nil {
|
||||
return common.NewDryRunAPI().Set("error", valErr.Error())
|
||||
}
|
||||
|
||||
dry := common.NewDryRunAPI()
|
||||
dry.Desc("Upload file (single-part or multipart) -> create import task -> poll status")
|
||||
|
||||
appendDriveImportFolderTokenWikiCheckDryRun(dry, spec)
|
||||
appendDriveImportUploadDryRun(dry, spec, fileSize)
|
||||
|
||||
dry.POST("/open-apis/drive/v1/import_tasks").
|
||||
Desc("[2] Create import task").
|
||||
Body(spec.CreateTaskBody("<file_token>"))
|
||||
|
||||
dry.GET("/open-apis/drive/v1/import_tasks/:ticket").
|
||||
Desc("[3] Poll import task result").
|
||||
Set("ticket", "<ticket>")
|
||||
if runtime.IsBot() {
|
||||
dry.Desc("After the import result returns the final cloud document target in bot mode, the CLI will also try to grant the current CLI user full_access (可管理权限) on it.")
|
||||
}
|
||||
|
||||
return dry
|
||||
},
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
return RunImport(ctx, runtime, importParamsFromFlags(runtime))
|
||||
spec := driveImportSpec{
|
||||
FilePath: runtime.Str("file"),
|
||||
DocType: strings.ToLower(runtime.Str("type")),
|
||||
FolderToken: runtime.Str("folder-token"),
|
||||
Name: runtime.Str("name"),
|
||||
TargetToken: runtime.Str("target-token"),
|
||||
}
|
||||
if _, err := preflightDriveImportFile(runtime.FileIO(), &spec); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := rejectDriveImportWikiFolderToken(runtime, spec.FolderToken); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Step 1: Upload file as media
|
||||
fileToken, uploadErr := uploadMediaForImport(ctx, runtime, spec.FilePath, spec.SourceFileName(), spec.DocType)
|
||||
if uploadErr != nil {
|
||||
return uploadErr
|
||||
}
|
||||
|
||||
fmt.Fprintf(runtime.IO().ErrOut, "Creating import task for %s as %s...\n", spec.TargetFileName(), spec.DocType)
|
||||
|
||||
// Step 2: Create import task
|
||||
ticket, err := createDriveImportTask(runtime, spec, fileToken)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Step 3: Poll task
|
||||
fmt.Fprintf(runtime.IO().ErrOut, "Polling import task %s...\n", ticket)
|
||||
|
||||
status, ready, err := pollDriveImportTask(runtime, ticket)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Some intermediate responses omit the final type, so fall back to the
|
||||
// requested type to keep the output shape stable.
|
||||
resultType := status.DocType
|
||||
if resultType == "" {
|
||||
resultType = spec.DocType
|
||||
}
|
||||
out := map[string]interface{}{
|
||||
"ticket": ticket,
|
||||
"type": resultType,
|
||||
"ready": ready,
|
||||
"job_status": status.JobStatus,
|
||||
"job_status_label": status.StatusLabel(),
|
||||
}
|
||||
if status.Token != "" {
|
||||
out["token"] = status.Token
|
||||
}
|
||||
if statusURL := strings.TrimSpace(status.URL); statusURL != "" {
|
||||
out["url"] = statusURL
|
||||
} else if status.Token != "" {
|
||||
if u := common.BuildResourceURL(runtime.Config.Brand, normalizeDriveImportKindForURL(resultType, spec.DocType), status.Token); u != "" {
|
||||
out["url"] = u
|
||||
}
|
||||
}
|
||||
if status.JobErrorMsg != "" {
|
||||
out["job_error_msg"] = status.JobErrorMsg
|
||||
}
|
||||
if status.Extra != nil {
|
||||
out["extra"] = status.Extra
|
||||
}
|
||||
if !ready {
|
||||
nextCommand := driveImportTaskResultCommand(ticket)
|
||||
fmt.Fprintf(runtime.IO().ErrOut, "Import task is still in progress. Continue with: %s\n", nextCommand)
|
||||
out["timed_out"] = true
|
||||
out["next_command"] = nextCommand
|
||||
}
|
||||
if ready {
|
||||
if grant := common.AutoGrantCurrentUserDrivePermission(runtime, common.GetString(out, "token"), resultType); grant != nil {
|
||||
out["permission_grant"] = grant
|
||||
}
|
||||
}
|
||||
|
||||
runtime.Out(out, nil)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
// ImportParams holds the user-facing inputs for an import flow, decoupled from
|
||||
// cobra flags so other command groups (e.g. sheets +workbook-import) can reuse
|
||||
// the drive import implementation without taking a dependency on a --type flag.
|
||||
type ImportParams struct {
|
||||
File string
|
||||
DocType string
|
||||
FolderToken string
|
||||
Name string
|
||||
TargetToken string
|
||||
}
|
||||
|
||||
func (p ImportParams) spec() driveImportSpec {
|
||||
return driveImportSpec{
|
||||
FilePath: p.File,
|
||||
DocType: strings.ToLower(p.DocType),
|
||||
FolderToken: p.FolderToken,
|
||||
Name: p.Name,
|
||||
TargetToken: p.TargetToken,
|
||||
}
|
||||
}
|
||||
|
||||
// importParamsFromFlags reads the standard drive +import flag set.
|
||||
func importParamsFromFlags(runtime *common.RuntimeContext) ImportParams {
|
||||
return ImportParams{
|
||||
File: runtime.Str("file"),
|
||||
DocType: runtime.Str("type"),
|
||||
FolderToken: runtime.Str("folder-token"),
|
||||
Name: runtime.Str("name"),
|
||||
TargetToken: runtime.Str("target-token"),
|
||||
}
|
||||
}
|
||||
|
||||
// ValidateImport runs the CLI-level compatibility checks for an import.
|
||||
func ValidateImport(p ImportParams) error {
|
||||
return validateDriveImportSpec(p.spec())
|
||||
}
|
||||
|
||||
// PlanImportDryRun builds the dry-run plan (upload -> create task -> poll) for
|
||||
// an import without performing any network or file I/O beyond a local stat.
|
||||
func PlanImportDryRun(runtime *common.RuntimeContext, p ImportParams) *common.DryRunAPI {
|
||||
spec := p.spec()
|
||||
fileSize, err := preflightDriveImportFile(runtime.FileIO(), &spec)
|
||||
if err != nil {
|
||||
return common.NewDryRunAPI().Set("error", err.Error())
|
||||
}
|
||||
if valErr := validateDriveImportSpec(spec); valErr != nil {
|
||||
return common.NewDryRunAPI().Set("error", valErr.Error())
|
||||
}
|
||||
|
||||
dry := common.NewDryRunAPI()
|
||||
dry.Desc("Upload file (single-part or multipart) -> create import task -> poll status")
|
||||
|
||||
appendDriveImportFolderTokenWikiCheckDryRun(dry, spec)
|
||||
appendDriveImportUploadDryRun(dry, spec, fileSize)
|
||||
|
||||
dry.POST("/open-apis/drive/v1/import_tasks").
|
||||
Desc("[2] Create import task").
|
||||
Body(spec.CreateTaskBody("<file_token>"))
|
||||
|
||||
dry.GET("/open-apis/drive/v1/import_tasks/:ticket").
|
||||
Desc("[3] Poll import task result").
|
||||
Set("ticket", "<ticket>")
|
||||
if runtime.IsBot() {
|
||||
dry.Desc("After the import result returns the final cloud document target in bot mode, the CLI will also try to grant the current CLI user full_access (可管理权限) on it.")
|
||||
}
|
||||
|
||||
return dry
|
||||
}
|
||||
|
||||
// RunImport executes the full import flow: upload media -> create import task ->
|
||||
// bounded poll, then writes the result envelope to the runtime output. It is
|
||||
// the shared core behind both drive +import and sheets +workbook-import.
|
||||
func RunImport(ctx context.Context, runtime *common.RuntimeContext, p ImportParams) error {
|
||||
spec := p.spec()
|
||||
if _, err := preflightDriveImportFile(runtime.FileIO(), &spec); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := rejectDriveImportWikiFolderToken(runtime, spec.FolderToken); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Step 1: Upload file as media
|
||||
fileToken, uploadErr := uploadMediaForImport(ctx, runtime, spec.FilePath, spec.SourceFileName(), spec.DocType)
|
||||
if uploadErr != nil {
|
||||
return uploadErr
|
||||
}
|
||||
|
||||
fmt.Fprintf(runtime.IO().ErrOut, "Creating import task for %s as %s...\n", spec.TargetFileName(), spec.DocType)
|
||||
|
||||
// Step 2: Create import task
|
||||
ticket, err := createDriveImportTask(runtime, spec, fileToken)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Step 3: Poll task
|
||||
fmt.Fprintf(runtime.IO().ErrOut, "Polling import task %s...\n", ticket)
|
||||
|
||||
status, ready, err := pollDriveImportTask(runtime, ticket)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Some intermediate responses omit the final type, so fall back to the
|
||||
// requested type to keep the output shape stable.
|
||||
resultType := status.DocType
|
||||
if resultType == "" {
|
||||
resultType = spec.DocType
|
||||
}
|
||||
out := map[string]interface{}{
|
||||
"ticket": ticket,
|
||||
"type": resultType,
|
||||
"ready": ready,
|
||||
"job_status": status.JobStatus,
|
||||
"job_status_label": status.StatusLabel(),
|
||||
}
|
||||
if status.Token != "" {
|
||||
out["token"] = status.Token
|
||||
}
|
||||
if statusURL := strings.TrimSpace(status.URL); statusURL != "" {
|
||||
out["url"] = statusURL
|
||||
} else if status.Token != "" {
|
||||
if u := common.BuildResourceURL(runtime.Config.Brand, normalizeDriveImportKindForURL(resultType, spec.DocType), status.Token); u != "" {
|
||||
out["url"] = u
|
||||
}
|
||||
}
|
||||
if status.JobErrorMsg != "" {
|
||||
out["job_error_msg"] = status.JobErrorMsg
|
||||
}
|
||||
if status.Extra != nil {
|
||||
out["extra"] = status.Extra
|
||||
}
|
||||
if !ready {
|
||||
nextCommand := driveImportTaskResultCommand(ticket)
|
||||
fmt.Fprintf(runtime.IO().ErrOut, "Import task is still in progress. Continue with: %s\n", nextCommand)
|
||||
out["timed_out"] = true
|
||||
out["next_command"] = nextCommand
|
||||
}
|
||||
if ready {
|
||||
if grant := common.AutoGrantCurrentUserDrivePermission(runtime, common.GetString(out, "token"), resultType); grant != nil {
|
||||
out["permission_grant"] = grant
|
||||
}
|
||||
}
|
||||
|
||||
runtime.Out(out, nil)
|
||||
return nil
|
||||
}
|
||||
|
||||
func preflightDriveImportFile(fio fileio.FileIO, spec *driveImportSpec) (int64, error) {
|
||||
// Keep dry-run and execution aligned on path normalization, file existence,
|
||||
// and format-specific size limits before planning the upload path.
|
||||
|
||||
@@ -149,9 +149,6 @@ var DriveSearch = common.Shortcut{
|
||||
"page_token": data["page_token"],
|
||||
"results": normalizedItems,
|
||||
}
|
||||
if notice, _ := data["notice"].(string); notice != "" {
|
||||
resultData["notice"] = notice
|
||||
}
|
||||
|
||||
runtime.OutFormat(resultData, &output.Meta{Count: len(normalizedItems)}, func(w io.Writer) {
|
||||
renderDriveSearchTable(w, data, normalizedItems)
|
||||
|
||||
@@ -14,49 +14,12 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/errclass"
|
||||
"github.com/larksuite/cli/internal/httpmock"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
)
|
||||
|
||||
// TestDriveSearchExecutePassesThroughNotice verifies drive +search preserves notices.
|
||||
func TestDriveSearchExecutePassesThroughNotice(t *testing.T) {
|
||||
const notice = "The query is too long and has been truncated to the first 50 characters for search."
|
||||
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/search/v2/doc_wiki/search",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"msg": "ok",
|
||||
"data": map[string]interface{}{
|
||||
"notice": notice,
|
||||
"res_units": []interface{}{},
|
||||
"total": 0,
|
||||
"has_more": false,
|
||||
"page_token": "",
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
if err := mountAndRunDrive(t, DriveSearch, []string{"+search", "--query", "incident", "--format", "json", "--as", "user"}, f, stdout); err != nil {
|
||||
t.Fatalf("DriveSearch.Execute() error = %v", err)
|
||||
}
|
||||
reg.Verify(t)
|
||||
|
||||
var env map[string]interface{}
|
||||
if err := json.Unmarshal(stdout.Bytes(), &env); err != nil {
|
||||
t.Fatalf("json.Unmarshal(stdout) error = %v\nstdout=%s", err, stdout.String())
|
||||
}
|
||||
data, _ := env["data"].(map[string]interface{})
|
||||
if got, _ := data["notice"].(string); got != notice {
|
||||
t.Fatalf("data.notice = %q, want %q; data=%#v", got, notice, data)
|
||||
}
|
||||
}
|
||||
|
||||
// TestClampOpenedTimeWindow covers opened-time clamping and slice notices.
|
||||
// TestClampOpenedTimeWindow covers the 3-month / 1-year boundary logic that
|
||||
// narrows --opened-since / --opened-until and generates the multi-slice notice.
|
||||
func TestClampOpenedTimeWindow(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
|
||||
@@ -26,7 +26,9 @@ func mustMarshalDryRun(t *testing.T, v interface{}) string {
|
||||
return string(b)
|
||||
}
|
||||
|
||||
// newTestRuntimeContext builds a RuntimeContext with string and bool test flags.
|
||||
// newTestRuntimeContext builds a *common.RuntimeContext backed by a cobra
|
||||
// command whose flags are populated from the provided string and bool maps,
|
||||
// for unit-testing shortcut bodies, validators, and dry-run shapes.
|
||||
func newTestRuntimeContext(t *testing.T, stringFlags map[string]string, boolFlags map[string]bool) *common.RuntimeContext {
|
||||
t.Helper()
|
||||
|
||||
@@ -57,38 +59,9 @@ func newTestRuntimeContext(t *testing.T, stringFlags map[string]string, boolFlag
|
||||
return &common.RuntimeContext{Cmd: cmd}
|
||||
}
|
||||
|
||||
// newChatSearchTestRuntimeContext builds a chat-search RuntimeContext with typed flags.
|
||||
func newChatSearchTestRuntimeContext(t *testing.T, stringFlags map[string]string, boolFlags map[string]bool) *common.RuntimeContext {
|
||||
t.Helper()
|
||||
|
||||
cmd := &cobra.Command{Use: "test"}
|
||||
cmd.Flags().Int("page-size", 20, "")
|
||||
for name := range stringFlags {
|
||||
if name == "page-size" {
|
||||
continue
|
||||
}
|
||||
cmd.Flags().String(name, "", "")
|
||||
}
|
||||
for name := range boolFlags {
|
||||
cmd.Flags().Bool(name, false, "")
|
||||
}
|
||||
if err := cmd.ParseFlags(nil); err != nil {
|
||||
t.Fatalf("ParseFlags() error = %v", err)
|
||||
}
|
||||
for name, val := range stringFlags {
|
||||
if err := cmd.Flags().Set(name, val); err != nil {
|
||||
t.Fatalf("Flags().Set(%q) error = %v", name, err)
|
||||
}
|
||||
}
|
||||
for name, val := range boolFlags {
|
||||
if err := cmd.Flags().Set(name, map[bool]string{true: "true", false: "false"}[val]); err != nil {
|
||||
t.Fatalf("Flags().Set(%q) error = %v", name, err)
|
||||
}
|
||||
}
|
||||
return &common.RuntimeContext{Cmd: cmd}
|
||||
}
|
||||
|
||||
// newMessagesSearchTestRuntimeContext builds a messages-search RuntimeContext.
|
||||
// newMessagesSearchTestRuntimeContext is the messages-search variant of
|
||||
// newTestRuntimeContext: registers the search-specific --page-size flag
|
||||
// before applying caller-provided values.
|
||||
func newMessagesSearchTestRuntimeContext(t *testing.T, stringFlags map[string]string, boolFlags map[string]bool) *common.RuntimeContext {
|
||||
t.Helper()
|
||||
|
||||
@@ -258,7 +231,6 @@ func TestIsMediaKey(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestShortcutValidateBranches covers direct shortcut validation branches.
|
||||
func TestShortcutValidateBranches(t *testing.T) {
|
||||
|
||||
t.Run("ImChatCreate valid", func(t *testing.T) {
|
||||
@@ -325,7 +297,7 @@ func TestShortcutValidateBranches(t *testing.T) {
|
||||
})
|
||||
|
||||
t.Run("ImChatSearch invalid page size", func(t *testing.T) {
|
||||
runtime := newChatSearchTestRuntimeContext(t, map[string]string{
|
||||
runtime := newTestRuntimeContext(t, map[string]string{
|
||||
"query": "ok",
|
||||
"page-size": "0",
|
||||
}, nil)
|
||||
@@ -335,13 +307,12 @@ func TestShortcutValidateBranches(t *testing.T) {
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("ImChatSearch allows long query for server-side notice", func(t *testing.T) {
|
||||
runtime := newChatSearchTestRuntimeContext(t, map[string]string{
|
||||
"query": strings.Repeat("q", 81),
|
||||
"page-size": "20",
|
||||
t.Run("ImChatSearch query too long", func(t *testing.T) {
|
||||
runtime := newTestRuntimeContext(t, map[string]string{
|
||||
"query": strings.Repeat("q", 65),
|
||||
}, nil)
|
||||
err := ImChatSearch.Validate(context.Background(), runtime)
|
||||
if err != nil {
|
||||
if err == nil || !strings.Contains(err.Error(), "--query exceeds the maximum of 64 characters") {
|
||||
t.Fatalf("ImChatSearch.Validate() error = %v", err)
|
||||
}
|
||||
})
|
||||
@@ -636,7 +607,6 @@ func TestShortcutValidateBranches(t *testing.T) {
|
||||
})
|
||||
}
|
||||
|
||||
// TestMessagesSearchPaginationConfig verifies page-all and page-limit behavior.
|
||||
func TestMessagesSearchPaginationConfig(t *testing.T) {
|
||||
t.Run("default single page", func(t *testing.T) {
|
||||
runtime := newMessagesSearchTestRuntimeContext(t, nil, nil)
|
||||
@@ -680,7 +650,8 @@ func TestMessagesSearchPaginationConfig(t *testing.T) {
|
||||
})
|
||||
}
|
||||
|
||||
// TestShortcutDryRunShapes verifies shortcut dry-run API paths and payloads.
|
||||
// TestShortcutDryRunShapes verifies that each shortcut's DryRun function
|
||||
// produces the expected API path, query parameters, and request body.
|
||||
func TestShortcutDryRunShapes(t *testing.T) {
|
||||
t.Run("ImChatCreate dry run includes params and body", func(t *testing.T) {
|
||||
cmd := &cobra.Command{Use: "test"}
|
||||
@@ -703,19 +674,19 @@ func TestShortcutDryRunShapes(t *testing.T) {
|
||||
})
|
||||
|
||||
t.Run("ImChatSearch dry run includes built params", func(t *testing.T) {
|
||||
runtime := newChatSearchTestRuntimeContext(t, map[string]string{
|
||||
runtime := newTestRuntimeContext(t, map[string]string{
|
||||
"query": "team-alpha",
|
||||
"page-size": "50",
|
||||
"page-token": "next_page",
|
||||
}, nil)
|
||||
got := mustMarshalDryRun(t, ImChatSearch.DryRun(context.Background(), runtime))
|
||||
if !strings.Contains(got, `"/open-apis/im/v2/chats/search"`) || !strings.Contains(got, `"page_size":50`) || !strings.Contains(got, `"query":"\"team-alpha\""`) {
|
||||
if !strings.Contains(got, `"/open-apis/im/v2/chats/search"`) || !strings.Contains(got, `"page_size":20`) || !strings.Contains(got, `"query":"\"team-alpha\""`) {
|
||||
t.Fatalf("ImChatSearch.DryRun() = %s", got)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("ImChatSearch dry run still works with --exclude-muted set", func(t *testing.T) {
|
||||
runtime := newChatSearchTestRuntimeContext(t, map[string]string{
|
||||
runtime := newTestRuntimeContext(t, map[string]string{
|
||||
"query": "team-alpha",
|
||||
}, map[string]bool{
|
||||
"exclude-muted": true,
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,993 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package convertlib
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestConvertInteractiveEventContent(t *testing.T) {
|
||||
// invalid JSON → fallback
|
||||
if got := ConvertInteractiveEventContent("not-json", nil); got != "[interactive card]" {
|
||||
t.Fatalf("invalid JSON = %q, want [interactive card]", got)
|
||||
}
|
||||
// missing user_dsl → fallback
|
||||
if got := ConvertInteractiveEventContent(`{"other":"field"}`, nil); got != "[interactive card]" {
|
||||
t.Fatalf("missing user_dsl = %q, want [interactive card]", got)
|
||||
}
|
||||
// empty user_dsl → fallback
|
||||
if got := ConvertInteractiveEventContent(`{"user_dsl":""}`, nil); got != "[interactive card]" {
|
||||
t.Fatalf("empty user_dsl = %q, want [interactive card]", got)
|
||||
}
|
||||
// user_dsl that is not a string (wrong type) → fallback
|
||||
if got := ConvertInteractiveEventContent(`{"user_dsl":123}`, nil); got != "[interactive card]" {
|
||||
t.Fatalf("non-string user_dsl = %q, want [interactive card]", got)
|
||||
}
|
||||
// valid user-2 card → <card> output
|
||||
userDsl := `{"schema":"2.0","header":{"title":{"tag":"plain_text","content":"Hello"}},"body":{"elements":[{"tag":"markdown","content":"world"}]}}`
|
||||
rawContent := `{"user_dsl":"` + strings.ReplaceAll(userDsl, `"`, `\"`) + `"}`
|
||||
got := ConvertInteractiveEventContent(rawContent, nil)
|
||||
if !strings.HasPrefix(got, `<card title="Hello">`) {
|
||||
t.Fatalf("valid card = %q, want prefix <card title=\"Hello\">", got)
|
||||
}
|
||||
if !strings.Contains(got, "world") {
|
||||
t.Fatalf("valid card = %q, want to contain 'world'", got)
|
||||
}
|
||||
}
|
||||
|
||||
func makeMentionCard(mdContent string) string {
|
||||
obj := map[string]interface{}{
|
||||
"schema": "2.0",
|
||||
"header": map[string]interface{}{
|
||||
"title": map[string]interface{}{"tag": "plain_text", "content": "T"},
|
||||
},
|
||||
"body": map[string]interface{}{
|
||||
"elements": []interface{}{
|
||||
map[string]interface{}{"tag": "markdown", "content": mdContent},
|
||||
},
|
||||
},
|
||||
}
|
||||
dslBytes, _ := json.Marshal(obj)
|
||||
raw, _ := json.Marshal(map[string]interface{}{"user_dsl": string(dslBytes)})
|
||||
return string(raw)
|
||||
}
|
||||
|
||||
func TestConvertInteractiveEventContentMentions(t *testing.T) {
|
||||
mentions := []interface{}{
|
||||
map[string]interface{}{
|
||||
"key": "@_user_1",
|
||||
"name": "test-user",
|
||||
"id": map[string]interface{}{"open_id": "fake-uid-001"},
|
||||
},
|
||||
}
|
||||
|
||||
// quoted attrs: mention_key="key"
|
||||
got := ConvertInteractiveEventContent(makeMentionCard(`hi <at mention_key="@_user_1">n</at> done`), mentions)
|
||||
if !strings.Contains(got, "@test-user(fake-uid-001)") {
|
||||
t.Fatalf("quoted mention_key not resolved, got: %s", got)
|
||||
}
|
||||
|
||||
// unquoted attrs (real Lark format): <at id=ou_xxx mention_key=@_user_1></at>
|
||||
got = ConvertInteractiveEventContent(makeMentionCard(`hi <at id=fake-uid-001 mention_key=@_user_1></at> done`), mentions)
|
||||
if !strings.Contains(got, "@test-user(fake-uid-001)") {
|
||||
t.Fatalf("unquoted mention_key not resolved, got: %s", got)
|
||||
}
|
||||
|
||||
// mentions_key variant (unquoted)
|
||||
got = ConvertInteractiveEventContent(makeMentionCard(`hi <at mentions_key=@_user_1></at> done`), mentions)
|
||||
if !strings.Contains(got, "@test-user(fake-uid-001)") {
|
||||
t.Fatalf("unquoted mentions_key not resolved, got: %s", got)
|
||||
}
|
||||
|
||||
// degradation 1: no mention_key/mentions_key attr → fall back to @id (unquoted)
|
||||
got = ConvertInteractiveEventContent(makeMentionCard(`hi <at id=fake-uid-001></at> done`), mentions)
|
||||
if !strings.Contains(got, "@fake-uid-001") {
|
||||
t.Fatalf("no mention_key unquoted: expected @id fallback, got: %s", got)
|
||||
}
|
||||
|
||||
// degradation 2: mention_key not found in mentions → fall back to @id
|
||||
got = ConvertInteractiveEventContent(makeMentionCard(`hi <at id=fake-uid-001 mention_key=@_unknown></at> done`), mentions)
|
||||
if !strings.Contains(got, "@fake-uid-001") {
|
||||
t.Fatalf("key not in mentions: expected @id fallback, got: %s", got)
|
||||
}
|
||||
|
||||
// multi-mention: ids=id1,id2,id3 mentions_key=k1,,k3
|
||||
// k1 hits → @name(id1), k2 empty → @id2 fallback, k3 not found → @id3 fallback
|
||||
got = ConvertInteractiveEventContent(
|
||||
makeMentionCard(`<at ids=fake-uid-001,fake-uid-002,fake-uid-003 mentions_key=@_user_1,,@_unknown></at>`),
|
||||
mentions,
|
||||
)
|
||||
want := "@test-user(fake-uid-001)@fake-uid-002@fake-uid-003"
|
||||
if !strings.Contains(got, want) {
|
||||
t.Fatalf("multi-mention unquoted: want %q in output, got: %s", want, got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUserDslConverterSchema(t *testing.T) {
|
||||
c := &userDslConverter{}
|
||||
|
||||
// user-2.ts: schema field present, header at root, body.elements
|
||||
schema2 := cardObj{
|
||||
"schema": "2.0",
|
||||
"header": cardObj{
|
||||
"title": cardObj{"tag": "plain_text", "content": "Schema2 Title"},
|
||||
"subtitle": cardObj{"tag": "plain_text", "content": "Sub"},
|
||||
},
|
||||
"body": cardObj{
|
||||
"elements": []interface{}{
|
||||
cardObj{"tag": "markdown", "content": "body text"},
|
||||
},
|
||||
},
|
||||
}
|
||||
got := c.convert(schema2)
|
||||
if got != "<card title=\"Schema2 Title\" subtitle=\"Sub\">\nbody text\n</card>" {
|
||||
t.Fatalf("schema2 = %q", got)
|
||||
}
|
||||
|
||||
// user-1.ts: no schema field, i18n_header.zh_cn, elements at root
|
||||
schema1 := cardObj{
|
||||
"i18n_header": cardObj{
|
||||
"zh_cn": cardObj{
|
||||
"title": cardObj{"tag": "plain_text", "content": "Schema1 Title"},
|
||||
},
|
||||
},
|
||||
"elements": []interface{}{
|
||||
cardObj{"tag": "hr"},
|
||||
},
|
||||
}
|
||||
got = c.convert(schema1)
|
||||
if got != "<card title=\"Schema1 Title\">\n---\n</card>" {
|
||||
t.Fatalf("schema1 = %q", got)
|
||||
}
|
||||
|
||||
// user-1.ts: no schema, direct header (real Lark event format)
|
||||
schema1Direct := cardObj{
|
||||
"header": cardObj{
|
||||
"title": cardObj{"tag": "plain_text", "content": "Direct Header Title"},
|
||||
"subtitle": cardObj{"tag": "plain_text", "content": "Direct Sub"},
|
||||
},
|
||||
"elements": []interface{}{
|
||||
cardObj{"tag": "markdown", "content": "direct body"},
|
||||
},
|
||||
}
|
||||
got = c.convert(schema1Direct)
|
||||
if got != "<card title=\"Direct Header Title\" subtitle=\"Direct Sub\">\ndirect body\n</card>" {
|
||||
t.Fatalf("schema1 direct header = %q", got)
|
||||
}
|
||||
|
||||
// no header, no elements → fallback
|
||||
got = c.convert(cardObj{})
|
||||
if got != "[interactive card]" {
|
||||
t.Fatalf("empty card = %q, want [interactive card]", got)
|
||||
}
|
||||
|
||||
// card with title only → valid (not "[interactive card]")
|
||||
titleOnly := cardObj{
|
||||
"schema": "2.0",
|
||||
"header": cardObj{"title": cardObj{"tag": "plain_text", "content": "TitleOnly"}},
|
||||
"body": cardObj{"elements": []interface{}{}},
|
||||
}
|
||||
got = c.convert(titleOnly)
|
||||
if !strings.Contains(got, "TitleOnly") {
|
||||
t.Fatalf("title-only card = %q, want to contain TitleOnly", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUserDslConverterDispatch(t *testing.T) {
|
||||
c := &userDslConverter{}
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
elem cardObj
|
||||
want string
|
||||
contains string
|
||||
}{
|
||||
{
|
||||
name: "plain_text",
|
||||
elem: cardObj{"tag": "plain_text", "content": "hello"},
|
||||
want: "hello",
|
||||
},
|
||||
{
|
||||
name: "markdown",
|
||||
elem: cardObj{"tag": "markdown", "content": "**bold**"},
|
||||
want: "**bold**",
|
||||
},
|
||||
{
|
||||
name: "hr",
|
||||
elem: cardObj{"tag": "hr"},
|
||||
want: "---",
|
||||
},
|
||||
{
|
||||
name: "br",
|
||||
elem: cardObj{"tag": "br"},
|
||||
want: "\n",
|
||||
},
|
||||
{
|
||||
name: "img with img_key",
|
||||
elem: cardObj{
|
||||
"tag": "img",
|
||||
"img_key": "img_v3_abc",
|
||||
"alt": cardObj{"tag": "plain_text", "content": "Banner"},
|
||||
},
|
||||
want: "🖼️ Banner(img_key:img_v3_abc)",
|
||||
},
|
||||
{
|
||||
name: "img_combination",
|
||||
elem: cardObj{
|
||||
"tag": "img_combination",
|
||||
"img_list": []interface{}{
|
||||
cardObj{"img_key": "k1"},
|
||||
cardObj{"img_key": "k2"},
|
||||
},
|
||||
},
|
||||
want: "🖼️ 2 image(s)(keys:k1,k2)",
|
||||
},
|
||||
{
|
||||
name: "button with behaviors default_url",
|
||||
elem: cardObj{
|
||||
"tag": "button",
|
||||
"text": cardObj{"tag": "plain_text", "content": "Open"},
|
||||
"behaviors": []interface{}{
|
||||
cardObj{"type": "open_url", "default_url": "https://example.com"},
|
||||
},
|
||||
},
|
||||
want: "[Open](https://example.com)",
|
||||
},
|
||||
{
|
||||
name: "button disabled",
|
||||
elem: cardObj{
|
||||
"tag": "button",
|
||||
"text": cardObj{"tag": "plain_text", "content": "Nope"},
|
||||
"disabled": true,
|
||||
},
|
||||
want: "[Nope ✗]",
|
||||
},
|
||||
{
|
||||
name: "button no url",
|
||||
elem: cardObj{
|
||||
"tag": "button",
|
||||
"text": cardObj{"tag": "plain_text", "content": "Submit"},
|
||||
},
|
||||
want: "[Submit]",
|
||||
},
|
||||
{
|
||||
name: "action wrapper (user-1 style)",
|
||||
elem: cardObj{
|
||||
"tag": "action",
|
||||
"actions": []interface{}{
|
||||
cardObj{"tag": "button", "text": cardObj{"tag": "plain_text", "content": "A"}},
|
||||
cardObj{"tag": "button", "text": cardObj{"tag": "plain_text", "content": "B"}},
|
||||
},
|
||||
},
|
||||
want: "[A] [B]",
|
||||
},
|
||||
{
|
||||
name: "overflow",
|
||||
elem: cardObj{
|
||||
"tag": "overflow",
|
||||
"options": []interface{}{
|
||||
cardObj{"text": cardObj{"tag": "plain_text", "content": "Edit"}},
|
||||
cardObj{"text": cardObj{"tag": "plain_text", "content": "Delete"}},
|
||||
},
|
||||
},
|
||||
want: "⋮ Edit, Delete",
|
||||
},
|
||||
{
|
||||
name: "select_static no selection",
|
||||
elem: cardObj{
|
||||
"tag": "select_static",
|
||||
"options": []interface{}{
|
||||
cardObj{"text": cardObj{"tag": "plain_text", "content": "Option1"}, "value": "1"},
|
||||
cardObj{"text": cardObj{"tag": "plain_text", "content": "Option2"}, "value": "2"},
|
||||
},
|
||||
},
|
||||
want: "{Option1 / Option2 ▼}",
|
||||
},
|
||||
{
|
||||
name: "select_static with initial_option",
|
||||
elem: cardObj{
|
||||
"tag": "select_static",
|
||||
"initial_option": "2",
|
||||
"options": []interface{}{
|
||||
cardObj{"text": cardObj{"tag": "plain_text", "content": "Option1"}, "value": "1"},
|
||||
cardObj{"text": cardObj{"tag": "plain_text", "content": "Option2"}, "value": "2"},
|
||||
},
|
||||
},
|
||||
want: "{Option1 / ✓Option2}",
|
||||
},
|
||||
{
|
||||
name: "multi_select_static with selected_values",
|
||||
elem: cardObj{
|
||||
"tag": "multi_select_static",
|
||||
"selected_values": []interface{}{"A"},
|
||||
"options": []interface{}{
|
||||
cardObj{"text": cardObj{"tag": "plain_text", "content": "OptA"}, "value": "A"},
|
||||
cardObj{"text": cardObj{"tag": "plain_text", "content": "OptB"}, "value": "B"},
|
||||
},
|
||||
},
|
||||
want: "{✓OptA / OptB}(multi)",
|
||||
},
|
||||
{
|
||||
name: "select_person no options no selection shows placeholder",
|
||||
elem: cardObj{
|
||||
"tag": "select_person",
|
||||
"placeholder": cardObj{"tag": "plain_text", "content": "请选择"},
|
||||
},
|
||||
want: "{请选择 ▼}",
|
||||
},
|
||||
{
|
||||
name: "select_person with initial_option synthesizes from ID",
|
||||
elem: cardObj{
|
||||
"tag": "select_person",
|
||||
"initial_option": "fake-open-id-001",
|
||||
},
|
||||
want: "{✓fake-open-id-001}",
|
||||
},
|
||||
{
|
||||
name: "multi_select_person with selected_values shows IDs and multi",
|
||||
elem: cardObj{
|
||||
"tag": "multi_select_person",
|
||||
"selected_values": []interface{}{"fake-open-id-001", "fake-open-id-002"},
|
||||
},
|
||||
want: "{✓fake-open-id-001 / ✓fake-open-id-002}(multi)",
|
||||
},
|
||||
{
|
||||
name: "multi_select_person no selection shows placeholder",
|
||||
elem: cardObj{
|
||||
"tag": "multi_select_person",
|
||||
"placeholder": cardObj{"tag": "plain_text", "content": "添加人员"},
|
||||
},
|
||||
want: "{添加人员 ▼}(multi)",
|
||||
},
|
||||
{
|
||||
name: "input with default_value",
|
||||
elem: cardObj{
|
||||
"tag": "input",
|
||||
"label": cardObj{"tag": "plain_text", "content": "Reason"},
|
||||
"default_value": "prefilled",
|
||||
},
|
||||
want: "Reason: prefilled___",
|
||||
},
|
||||
{
|
||||
name: "input with placeholder",
|
||||
elem: cardObj{
|
||||
"tag": "input",
|
||||
"placeholder": cardObj{"tag": "plain_text", "content": "Type here"},
|
||||
},
|
||||
want: "Type here_____",
|
||||
},
|
||||
{
|
||||
name: "date_picker with initial_date",
|
||||
elem: cardObj{
|
||||
"tag": "date_picker",
|
||||
"initial_date": "2026-01-01",
|
||||
},
|
||||
want: "📅 2026-01-01",
|
||||
},
|
||||
{
|
||||
name: "date_picker placeholder",
|
||||
elem: cardObj{
|
||||
"tag": "date_picker",
|
||||
"placeholder": cardObj{"tag": "plain_text", "content": "Pick date"},
|
||||
},
|
||||
want: "📅 Pick date",
|
||||
},
|
||||
{
|
||||
name: "picker_time with initial_time",
|
||||
elem: cardObj{
|
||||
"tag": "picker_time",
|
||||
"initial_time": "14:30",
|
||||
},
|
||||
want: "🕐 14:30",
|
||||
},
|
||||
{
|
||||
name: "checker unchecked",
|
||||
elem: cardObj{
|
||||
"tag": "checker",
|
||||
"text": cardObj{"tag": "plain_text", "content": "Task A"},
|
||||
},
|
||||
want: "[ ] Task A",
|
||||
},
|
||||
{
|
||||
name: "checker checked",
|
||||
elem: cardObj{
|
||||
"tag": "checker",
|
||||
"checked": true,
|
||||
"text": cardObj{"tag": "plain_text", "content": "Task B"},
|
||||
},
|
||||
want: "[x] Task B",
|
||||
},
|
||||
{
|
||||
name: "chart with chart_spec",
|
||||
elem: cardObj{
|
||||
"tag": "chart",
|
||||
"chart_spec": cardObj{
|
||||
"title": cardObj{"text": "Sales"},
|
||||
"type": "bar",
|
||||
"xField": "month",
|
||||
"yField": "value",
|
||||
"data": cardObj{"values": []interface{}{
|
||||
cardObj{"month": "Jan", "value": float64(10)},
|
||||
cardObj{"month": "Feb", "value": float64(20)},
|
||||
}},
|
||||
},
|
||||
},
|
||||
want: "📊 Sales (Bar chart)\nSummary: Jan:10, Feb:20",
|
||||
},
|
||||
{
|
||||
name: "chart with compound xField array",
|
||||
elem: cardObj{
|
||||
"tag": "chart",
|
||||
"chart_spec": cardObj{
|
||||
"title": cardObj{"text": "Sales"},
|
||||
"type": "bar",
|
||||
"xField": []interface{}{"month", "category"},
|
||||
"yField": "value",
|
||||
"data": cardObj{"values": []interface{}{
|
||||
cardObj{"month": "Jan", "category": "A", "value": float64(10)},
|
||||
cardObj{"month": "Feb", "category": "B", "value": float64(20)},
|
||||
}},
|
||||
},
|
||||
},
|
||||
want: "📊 Sales (Bar chart)\nSummary: Jan:10, Feb:20",
|
||||
},
|
||||
{
|
||||
name: "chart no custom title uses type name",
|
||||
elem: cardObj{
|
||||
"tag": "chart",
|
||||
"chart_spec": cardObj{
|
||||
"type": "pie",
|
||||
"categoryField": "label",
|
||||
"valueField": "val",
|
||||
"data": cardObj{"values": []interface{}{
|
||||
cardObj{"label": "A", "val": float64(1)},
|
||||
}},
|
||||
},
|
||||
},
|
||||
want: "📊 Pie chart\nSummary: A:1",
|
||||
},
|
||||
{
|
||||
name: "chart vchart array data format",
|
||||
elem: cardObj{
|
||||
"tag": "chart",
|
||||
"chart_spec": cardObj{
|
||||
"type": "bar",
|
||||
"xField": "x",
|
||||
"yField": "y",
|
||||
"data": []interface{}{
|
||||
cardObj{"id": "s1", "values": []interface{}{
|
||||
cardObj{"x": "Jan", "y": float64(5)},
|
||||
}},
|
||||
cardObj{"id": "s2", "values": []interface{}{
|
||||
cardObj{"x": "Feb", "y": float64(8)},
|
||||
}},
|
||||
},
|
||||
},
|
||||
},
|
||||
want: "📊 Bar chart\nSummary: Jan:5, Feb:8",
|
||||
},
|
||||
{
|
||||
name: "text_tag",
|
||||
elem: cardObj{
|
||||
"tag": "text_tag",
|
||||
"text": cardObj{"tag": "plain_text", "content": "新功能"},
|
||||
},
|
||||
want: "「新功能」",
|
||||
},
|
||||
{
|
||||
name: "avatar with user_id",
|
||||
elem: cardObj{"tag": "avatar", "user_id": "fake-open-id-001"},
|
||||
want: "👤(id:fake-open-id-001)",
|
||||
},
|
||||
{
|
||||
name: "avatar no user_id",
|
||||
elem: cardObj{"tag": "avatar"},
|
||||
want: "👤",
|
||||
},
|
||||
{
|
||||
name: "select_img no selection",
|
||||
elem: cardObj{
|
||||
"tag": "select_img",
|
||||
"options": []interface{}{
|
||||
cardObj{"value": "v1", "img_key": "img_k1"},
|
||||
cardObj{"value": "v2", "img_key": "img_k2"},
|
||||
},
|
||||
},
|
||||
want: "{🖼️ Image 1(v1)(img_key:img_k1) / 🖼️ Image 2(v2)(img_key:img_k2)}",
|
||||
},
|
||||
{
|
||||
name: "select_img with selected",
|
||||
elem: cardObj{
|
||||
"tag": "select_img",
|
||||
"selected_values": []interface{}{"v1"},
|
||||
"options": []interface{}{
|
||||
cardObj{"value": "v1", "img_key": "img_k1"},
|
||||
cardObj{"value": "v2", "img_key": "img_k2"},
|
||||
},
|
||||
},
|
||||
want: "{✓🖼️ Image 1(v1)(img_key:img_k1) / 🖼️ Image 2(v2)(img_key:img_k2)}",
|
||||
},
|
||||
{
|
||||
name: "repeat delegates to elements",
|
||||
elem: cardObj{
|
||||
"tag": "repeat",
|
||||
"elements": []interface{}{
|
||||
cardObj{"tag": "markdown", "content": "item A"},
|
||||
cardObj{"tag": "markdown", "content": "item B"},
|
||||
},
|
||||
},
|
||||
want: "item A\nitem B",
|
||||
},
|
||||
{
|
||||
name: "audio with file_key",
|
||||
elem: cardObj{"tag": "audio", "file_key": "file_abc123"},
|
||||
want: "🎵 Audio(key:file_abc123)",
|
||||
},
|
||||
{
|
||||
name: "audio fallback audio_id",
|
||||
elem: cardObj{"tag": "audio", "audio_id": "audio_xyz"},
|
||||
want: "🎵 Audio(key:audio_xyz)",
|
||||
},
|
||||
{
|
||||
name: "video with file_key",
|
||||
elem: cardObj{"tag": "video", "file_key": "video_abc"},
|
||||
want: "🎬 Video(key:video_abc)",
|
||||
},
|
||||
{
|
||||
name: "custom_icon returns empty",
|
||||
elem: cardObj{"tag": "custom_icon", "img_key": "some_key"},
|
||||
want: "",
|
||||
},
|
||||
{
|
||||
name: "standard_icon returns empty",
|
||||
elem: cardObj{"tag": "standard_icon", "token": "alarm_outlined"},
|
||||
want: "",
|
||||
},
|
||||
{
|
||||
name: "button disabled with disabled_tips",
|
||||
elem: cardObj{
|
||||
"tag": "button",
|
||||
"text": cardObj{"tag": "plain_text", "content": "Submit"},
|
||||
"disabled": true,
|
||||
"disabled_tips": cardObj{"tag": "plain_text", "content": "Not allowed"},
|
||||
},
|
||||
want: "[Submit ✗](tips:\"Not allowed\")",
|
||||
},
|
||||
{
|
||||
name: "button with confirm",
|
||||
elem: cardObj{
|
||||
"tag": "button",
|
||||
"text": cardObj{"tag": "plain_text", "content": "Delete"},
|
||||
"confirm": cardObj{
|
||||
"title": cardObj{"tag": "plain_text", "content": "确认"},
|
||||
"text": cardObj{"tag": "plain_text", "content": "不可撤销"},
|
||||
},
|
||||
},
|
||||
want: "[Delete](confirm:\"确认: 不可撤销\")",
|
||||
},
|
||||
{
|
||||
name: "overflow with url",
|
||||
elem: cardObj{
|
||||
"tag": "overflow",
|
||||
"options": []interface{}{
|
||||
cardObj{"text": cardObj{"tag": "plain_text", "content": "Open"}, "url": "https://example.com"},
|
||||
cardObj{"text": cardObj{"tag": "plain_text", "content": "Copy"}, "value": "copy"},
|
||||
},
|
||||
},
|
||||
want: "⋮ [Open](https://example.com), Copy(copy)",
|
||||
},
|
||||
{
|
||||
name: "select_static with initial_index",
|
||||
elem: cardObj{
|
||||
"tag": "select_static",
|
||||
"initial_index": float64(1),
|
||||
"options": []interface{}{
|
||||
cardObj{"text": cardObj{"tag": "plain_text", "content": "First"}, "value": "a"},
|
||||
cardObj{"text": cardObj{"tag": "plain_text", "content": "Second"}, "value": "b"},
|
||||
},
|
||||
},
|
||||
want: "{First / ✓Second}",
|
||||
},
|
||||
{
|
||||
name: "div text with notation size",
|
||||
elem: cardObj{
|
||||
"tag": "div",
|
||||
"text": cardObj{
|
||||
"tag": "plain_text",
|
||||
"content": "小字注释",
|
||||
"text_size": "notation",
|
||||
},
|
||||
},
|
||||
want: "📝 小字注释",
|
||||
},
|
||||
{
|
||||
name: "form",
|
||||
elem: cardObj{
|
||||
"tag": "form",
|
||||
"elements": []interface{}{
|
||||
cardObj{"tag": "markdown", "content": "fill this"},
|
||||
},
|
||||
},
|
||||
want: "<form>\nfill this\n</form>",
|
||||
},
|
||||
{
|
||||
name: "collapsible_panel collapsed",
|
||||
elem: cardObj{
|
||||
"tag": "collapsible_panel",
|
||||
"expanded": false,
|
||||
"header": cardObj{"title": cardObj{"tag": "plain_text", "content": "Details"}},
|
||||
"elements": []interface{}{
|
||||
cardObj{"tag": "markdown", "content": "inner"},
|
||||
},
|
||||
},
|
||||
want: "▶ Details\n inner\n▲",
|
||||
},
|
||||
{
|
||||
name: "collapsible_panel expanded",
|
||||
elem: cardObj{
|
||||
"tag": "collapsible_panel",
|
||||
"expanded": true,
|
||||
"header": cardObj{"title": cardObj{"tag": "plain_text", "content": "Details"}},
|
||||
"elements": []interface{}{
|
||||
cardObj{"tag": "markdown", "content": "inner"},
|
||||
},
|
||||
},
|
||||
want: "▼ Details\n inner\n▲",
|
||||
},
|
||||
{
|
||||
name: "interactive_container with behaviors",
|
||||
elem: cardObj{
|
||||
"tag": "interactive_container",
|
||||
"behaviors": []interface{}{
|
||||
cardObj{"type": "open_url", "default_url": "https://example.com"},
|
||||
},
|
||||
"elements": []interface{}{
|
||||
cardObj{"tag": "markdown", "content": "Click here"},
|
||||
},
|
||||
},
|
||||
want: "<clickable url=\"https://example.com\">\nClick here\n</clickable>",
|
||||
},
|
||||
{
|
||||
name: "interactive_container no url",
|
||||
elem: cardObj{
|
||||
"tag": "interactive_container",
|
||||
"elements": []interface{}{
|
||||
cardObj{"tag": "markdown", "content": "No link"},
|
||||
},
|
||||
},
|
||||
want: "<clickable>\nNo link\n</clickable>",
|
||||
},
|
||||
{
|
||||
name: "column_set with buttons → space-joined",
|
||||
elem: cardObj{
|
||||
"tag": "column_set",
|
||||
"columns": []interface{}{
|
||||
cardObj{"tag": "column", "elements": []interface{}{
|
||||
cardObj{"tag": "button", "text": cardObj{"tag": "plain_text", "content": "X"}},
|
||||
}},
|
||||
cardObj{"tag": "column", "elements": []interface{}{
|
||||
cardObj{"tag": "button", "text": cardObj{"tag": "plain_text", "content": "Y"}},
|
||||
}},
|
||||
},
|
||||
},
|
||||
want: "[X] [Y]",
|
||||
},
|
||||
{
|
||||
name: "person",
|
||||
elem: cardObj{"tag": "person", "user_id": "fake-open-id-002"},
|
||||
want: "fake-open-id-002",
|
||||
},
|
||||
{
|
||||
name: "unknown tag fallback to content",
|
||||
elem: cardObj{"tag": "mystery", "content": "mystery content"},
|
||||
want: "mystery content",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got := c.convertElement(tt.elem, 0)
|
||||
if tt.contains != "" {
|
||||
if !strings.Contains(got, tt.contains) {
|
||||
t.Fatalf("convertElement(%s) = %q, want to contain %q", tt.name, got, tt.contains)
|
||||
}
|
||||
return
|
||||
}
|
||||
if got != tt.want {
|
||||
t.Fatalf("convertElement(%s) = %q, want %q", tt.name, got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestUserDslExtractButtonURL(t *testing.T) {
|
||||
c := &userDslConverter{}
|
||||
|
||||
// direct url field wins first
|
||||
got := c.extractButtonURL(cardObj{
|
||||
"url": "https://example.com/direct",
|
||||
"multi_url": cardObj{"url": "https://example.com/multi"},
|
||||
"behaviors": []interface{}{
|
||||
cardObj{"type": "open_url", "default_url": "https://example.com/behavior"},
|
||||
},
|
||||
})
|
||||
if got != "https://example.com/direct" {
|
||||
t.Fatalf("direct url = %q, want https://example.com/direct", got)
|
||||
}
|
||||
|
||||
// multi_url.url when no direct url
|
||||
got = c.extractButtonURL(cardObj{
|
||||
"multi_url": cardObj{"url": "https://example.com/multi"},
|
||||
"behaviors": []interface{}{
|
||||
cardObj{"type": "open_url", "default_url": "https://example.com/behavior"},
|
||||
},
|
||||
})
|
||||
if got != "https://example.com/multi" {
|
||||
t.Fatalf("multi_url = %q, want https://example.com/multi", got)
|
||||
}
|
||||
|
||||
// behaviors default_url as last resort
|
||||
got = c.extractButtonURL(cardObj{
|
||||
"behaviors": []interface{}{
|
||||
cardObj{"type": "open_url", "default_url": "https://example.com/behavior"},
|
||||
},
|
||||
})
|
||||
if got != "https://example.com/behavior" {
|
||||
t.Fatalf("behaviors = %q, want https://example.com/behavior", got)
|
||||
}
|
||||
|
||||
// non-open_url behavior is ignored
|
||||
got = c.extractButtonURL(cardObj{
|
||||
"behaviors": []interface{}{
|
||||
cardObj{"type": "callback", "default_url": "https://example.com/callback"},
|
||||
},
|
||||
})
|
||||
if got != "" {
|
||||
t.Fatalf("non-open_url = %q, want empty", got)
|
||||
}
|
||||
|
||||
// no url anywhere → empty
|
||||
got = c.extractButtonURL(cardObj{"text": cardObj{"content": "No URL"}})
|
||||
if got != "" {
|
||||
t.Fatalf("no url = %q, want empty", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUserDslExtractTableCellValue(t *testing.T) {
|
||||
c := &userDslConverter{}
|
||||
|
||||
// nil
|
||||
if got := c.extractUserDslTableCellValue(nil); got != "" {
|
||||
t.Fatalf("nil = %q, want empty", got)
|
||||
}
|
||||
// string
|
||||
if got := c.extractUserDslTableCellValue("hello"); got != "hello" {
|
||||
t.Fatalf("string = %q, want 'hello'", got)
|
||||
}
|
||||
// float64 integer
|
||||
if got := c.extractUserDslTableCellValue(float64(42)); got != "42" {
|
||||
t.Fatalf("int float = %q, want '42'", got)
|
||||
}
|
||||
// float64 decimal
|
||||
if got := c.extractUserDslTableCellValue(float64(3.14)); got != "3.14" {
|
||||
t.Fatalf("float = %q, want '3.14'", got)
|
||||
}
|
||||
// []interface{} with text tags → 「text」 format
|
||||
got := c.extractUserDslTableCellValue([]interface{}{
|
||||
cardObj{"text": "S2", "color": "blue"},
|
||||
cardObj{"text": "M1", "color": "red"},
|
||||
})
|
||||
if got != "「S2」 「M1」" {
|
||||
t.Fatalf("tag array = %q, want '「S2」 「M1」'", got)
|
||||
}
|
||||
// cardObj with content field
|
||||
got = c.extractUserDslTableCellValue(cardObj{"content": "cell content"})
|
||||
if got != "cell content" {
|
||||
t.Fatalf("cardObj with content = %q, want 'cell content'", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUserDslConvertTable(t *testing.T) {
|
||||
c := &userDslConverter{}
|
||||
|
||||
got := c.convertTable(cardObj{
|
||||
"columns": []interface{}{
|
||||
cardObj{"display_name": "客户名称", "name": "customer_name"},
|
||||
cardObj{"display_name": "规模", "name": "scale"},
|
||||
cardObj{"display_name": "金额", "name": "arr"},
|
||||
},
|
||||
"rows": []interface{}{
|
||||
cardObj{
|
||||
"customer_name": "飞书科技",
|
||||
"scale": []interface{}{cardObj{"text": "S2", "color": "blue"}},
|
||||
"arr": float64(16800),
|
||||
},
|
||||
},
|
||||
})
|
||||
want := "| 客户名称 | 规模 | 金额 |\n|------|------|------|\n| 飞书科技 | 「S2」 | 16800 |"
|
||||
if got != want {
|
||||
t.Fatalf("convertTable() = %q, want %q", got, want)
|
||||
}
|
||||
|
||||
// no columns → empty
|
||||
if got := c.convertTable(cardObj{}); got != "" {
|
||||
t.Fatalf("no columns = %q, want empty", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLarkMdMentionResolution(t *testing.T) {
|
||||
mentions := []interface{}{
|
||||
map[string]interface{}{
|
||||
"key": "@_user_1",
|
||||
"name": "test-user",
|
||||
"id": map[string]interface{}{"open_id": "fake-uid-001"},
|
||||
},
|
||||
}
|
||||
|
||||
// lark_md in div.text — the real Lark event format (C01 case)
|
||||
card := map[string]interface{}{
|
||||
"elements": []interface{}{
|
||||
map[string]interface{}{
|
||||
"tag": "div",
|
||||
"text": map[string]interface{}{
|
||||
"tag": "lark_md",
|
||||
"content": "Hello <at id=fake-uid-001></at> check this.",
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
dslBytes, _ := json.Marshal(card)
|
||||
raw, _ := json.Marshal(map[string]interface{}{"user_dsl": string(dslBytes)})
|
||||
got := ConvertInteractiveEventContent(string(raw), mentions)
|
||||
if strings.Contains(got, "<at") {
|
||||
t.Fatalf("div.text lark_md: raw <at> tag not resolved, got: %s", got)
|
||||
}
|
||||
if !strings.Contains(got, "@fake-uid-001") {
|
||||
t.Fatalf("div.text lark_md: @id not in output, got: %s", got)
|
||||
}
|
||||
|
||||
// lark_md in note.elements (C02 case)
|
||||
card = map[string]interface{}{
|
||||
"elements": []interface{}{
|
||||
map[string]interface{}{
|
||||
"tag": "note",
|
||||
"elements": []interface{}{
|
||||
map[string]interface{}{
|
||||
"tag": "lark_md",
|
||||
"content": "Note: <at id=fake-uid-001></at> check.",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
dslBytes, _ = json.Marshal(card)
|
||||
raw, _ = json.Marshal(map[string]interface{}{"user_dsl": string(dslBytes)})
|
||||
got = ConvertInteractiveEventContent(string(raw), mentions)
|
||||
if strings.Contains(got, "<at") {
|
||||
t.Fatalf("note lark_md: raw <at> tag not resolved, got: %s", got)
|
||||
}
|
||||
if !strings.Contains(got, "@fake-uid-001") {
|
||||
t.Fatalf("note lark_md: @id not in output, got: %s", got)
|
||||
}
|
||||
|
||||
// mention_key resolution via mentions map
|
||||
card = map[string]interface{}{
|
||||
"elements": []interface{}{
|
||||
map[string]interface{}{
|
||||
"tag": "div",
|
||||
"text": map[string]interface{}{
|
||||
"tag": "lark_md",
|
||||
"content": `Hi <at mention_key="@_user_1">n</at> done.`,
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
dslBytes, _ = json.Marshal(card)
|
||||
raw, _ = json.Marshal(map[string]interface{}{"user_dsl": string(dslBytes)})
|
||||
got = ConvertInteractiveEventContent(string(raw), mentions)
|
||||
if !strings.Contains(got, "@test-user(fake-uid-001)") {
|
||||
t.Fatalf("div.text lark_md mention_key: want @test-user(fake-uid-001), got: %s", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConvertUserDslCardEndToEnd(t *testing.T) {
|
||||
// user-2.ts format — matches structure of docs/user-dsl/user-example-2.json
|
||||
schema2JSON := `{
|
||||
"schema": "2.0",
|
||||
"header": {
|
||||
"title": {"tag": "plain_text", "content": "飞书卡片组件展示"},
|
||||
"template": "blue"
|
||||
},
|
||||
"body": {
|
||||
"elements": [
|
||||
{"tag": "markdown", "content": "### 基础文本"},
|
||||
{"tag": "hr"},
|
||||
{
|
||||
"tag": "img",
|
||||
"img_key": "img_v3_02122_abc",
|
||||
"alt": {"tag": "plain_text", "content": "示例图片"}
|
||||
},
|
||||
{
|
||||
"tag": "button",
|
||||
"text": {"tag": "plain_text", "content": "主要按钮"},
|
||||
"behaviors": [{"type": "open_url", "default_url": "https://example.com"}]
|
||||
},
|
||||
{
|
||||
"tag": "table",
|
||||
"columns": [
|
||||
{"display_name": "名称", "name": "name"},
|
||||
{"display_name": "数值", "name": "value"}
|
||||
],
|
||||
"rows": [
|
||||
{"name": "项目A", "value": 100},
|
||||
{"name": "项目B", "value": 200}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}`
|
||||
|
||||
got := convertUserDslCard(schema2JSON, nil)
|
||||
|
||||
if !strings.HasPrefix(got, `<card title="飞书卡片组件展示">`) {
|
||||
t.Fatalf("e2e schema2: missing card title prefix, got: %s", got)
|
||||
}
|
||||
if !strings.Contains(got, "### 基础文本") {
|
||||
t.Fatal("e2e schema2: missing markdown content")
|
||||
}
|
||||
if !strings.Contains(got, "---") {
|
||||
t.Fatal("e2e schema2: missing hr")
|
||||
}
|
||||
if !strings.Contains(got, "🖼️ 示例图片(img_key:img_v3_02122_abc)") {
|
||||
t.Fatalf("e2e schema2: missing image, got: %s", got)
|
||||
}
|
||||
if !strings.Contains(got, "[主要按钮](https://example.com)") {
|
||||
t.Fatalf("e2e schema2: missing button, got: %s", got)
|
||||
}
|
||||
if !strings.Contains(got, "| 名称 | 数值 |") {
|
||||
t.Fatal("e2e schema2: missing table header")
|
||||
}
|
||||
if !strings.Contains(got, "| 项目A | 100 |") {
|
||||
t.Fatalf("e2e schema2: missing table row, got: %s", got)
|
||||
}
|
||||
if !strings.HasSuffix(got, "</card>") {
|
||||
t.Fatalf("e2e schema2: missing </card> suffix, got: %s", got)
|
||||
}
|
||||
|
||||
// user-1.ts format
|
||||
schema1JSON := `{
|
||||
"i18n_header": {
|
||||
"zh_cn": {
|
||||
"title": {"tag": "plain_text", "content": "Schema1 卡片"},
|
||||
"template": "blue"
|
||||
}
|
||||
},
|
||||
"elements": [
|
||||
{"tag": "markdown", "content": "Hello **World**"},
|
||||
{
|
||||
"tag": "action",
|
||||
"actions": [
|
||||
{
|
||||
"tag": "button",
|
||||
"text": {"tag": "plain_text", "content": "跳转"},
|
||||
"behaviors": [{"type": "open_url", "default_url": "https://example.com"}]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}`
|
||||
|
||||
got = convertUserDslCard(schema1JSON, nil)
|
||||
if !strings.HasPrefix(got, `<card title="Schema1 卡片">`) {
|
||||
t.Fatalf("e2e schema1: missing card title, got: %s", got)
|
||||
}
|
||||
if !strings.Contains(got, "Hello **World**") {
|
||||
t.Fatal("e2e schema1: missing markdown")
|
||||
}
|
||||
if !strings.Contains(got, "[跳转](https://example.com)") {
|
||||
t.Fatalf("e2e schema1: missing button, got: %s", got)
|
||||
}
|
||||
}
|
||||
@@ -122,7 +122,7 @@ func TestExtractPostBlocksText(t *testing.T) {
|
||||
}
|
||||
|
||||
got := extractPostBlocksText(blocks)
|
||||
want := "hello @Alice [docs](https://example.com)\n"
|
||||
want := "hello @Alice [docs](https://example.com)\n[Image: img_123]"
|
||||
if got != want {
|
||||
t.Fatalf("extractPostBlocksText() = %q, want %q", got, want)
|
||||
}
|
||||
|
||||
@@ -39,16 +39,16 @@ func (postConverter) Convert(ctx *ConvertContext) string {
|
||||
if title, _ := body["title"].(string); title != "" {
|
||||
parts = append(parts, title)
|
||||
}
|
||||
// Prefer content_v2 blocks; fallback to content blocks
|
||||
blocks := selectContentBlocks(body)
|
||||
for _, para := range blocks {
|
||||
elems, _ := para.([]interface{})
|
||||
var line strings.Builder
|
||||
for _, el := range elems {
|
||||
elem, _ := el.(map[string]interface{})
|
||||
line.WriteString(renderPostElem(elem))
|
||||
if blocks, _ := body["content"].([]interface{}); len(blocks) > 0 {
|
||||
for _, para := range blocks {
|
||||
elems, _ := para.([]interface{})
|
||||
var line strings.Builder
|
||||
for _, el := range elems {
|
||||
elem, _ := el.(map[string]interface{})
|
||||
line.WriteString(renderPostElem(elem))
|
||||
}
|
||||
parts = append(parts, line.String())
|
||||
}
|
||||
parts = append(parts, line.String())
|
||||
}
|
||||
|
||||
result := strings.TrimSpace(strings.Join(parts, "\n"))
|
||||
@@ -58,17 +58,6 @@ func (postConverter) Convert(ctx *ConvertContext) string {
|
||||
return ResolveMentionKeys(result, ctx.MentionMap)
|
||||
}
|
||||
|
||||
// selectContentBlocks returns content_v2 blocks when present and non-empty;
|
||||
// otherwise falls back to content blocks. This implements the content_v2
|
||||
// priority rule for post messages.
|
||||
func selectContentBlocks(body map[string]interface{}) []interface{} {
|
||||
if v2, ok := body["content_v2"].([]interface{}); ok && len(v2) > 0 {
|
||||
return v2
|
||||
}
|
||||
blocks, _ := body["content"].([]interface{})
|
||||
return blocks
|
||||
}
|
||||
|
||||
func unwrapPostLocale(parsed map[string]interface{}) map[string]interface{} {
|
||||
if _, ok := parsed["content"]; ok {
|
||||
return parsed
|
||||
@@ -125,14 +114,10 @@ func renderPostElem(el map[string]interface{}) string {
|
||||
var rendered string
|
||||
switch {
|
||||
case userId == "@_all" || userId == "all":
|
||||
rendered = `<at user_id="all"></at>`
|
||||
rendered = "@all"
|
||||
default:
|
||||
if name, _ := el["user_name"].(string); name != "" {
|
||||
if userId != "" && strings.HasPrefix(userId, "ou") {
|
||||
rendered = fmt.Sprintf(`<at user_id="%s">%s</at>`, userId, name)
|
||||
} else {
|
||||
rendered = "@" + name
|
||||
}
|
||||
rendered = "@" + name
|
||||
} else {
|
||||
rendered = "@" + userId
|
||||
}
|
||||
@@ -153,7 +138,7 @@ func renderPostElem(el map[string]interface{}) string {
|
||||
case "img":
|
||||
key, _ := el["image_key"].(string)
|
||||
if key != "" {
|
||||
return fmt.Sprintf("", key)
|
||||
return fmt.Sprintf("[Image: %s]", key)
|
||||
}
|
||||
return "[Image]"
|
||||
case "media":
|
||||
|
||||
@@ -93,13 +93,9 @@ func TestRenderPostElem(t *testing.T) {
|
||||
}{
|
||||
{name: "text", el: map[string]interface{}{"tag": "text", "text": "hello"}, want: "hello"},
|
||||
{name: "link", el: map[string]interface{}{"tag": "a", "text": "doc", "href": "https://example.com"}, want: "[doc](https://example.com)"},
|
||||
{name: "mention all", el: map[string]interface{}{"tag": "at", "user_id": "@_all"}, want: `<at user_id="all"></at>`},
|
||||
{name: "mention user with id", el: map[string]interface{}{"tag": "at", "user_id": "ou_user_1", "user_name": "Alice"}, want: `<at user_id="ou_user_1">Alice</at>`},
|
||||
{name: "mention user name only", el: map[string]interface{}{"tag": "at", "user_name": "Alice"}, want: "@Alice"},
|
||||
{name: "mention user id only", el: map[string]interface{}{"tag": "at", "user_id": "@_user_1"}, want: "@@_user_1"},
|
||||
{name: "image", el: map[string]interface{}{"tag": "img", "image_key": "img_123"}, want: ""},
|
||||
{name: "image no key", el: map[string]interface{}{"tag": "img"}, want: "[Image]"},
|
||||
{name: "md text", el: map[string]interface{}{"tag": "md", "text": "##### 标题\n\n<at user_id=\"ou_xxx\">Alice</at> 你好"}, want: "##### 标题\n\n<at user_id=\"ou_xxx\">Alice</at> 你好"},
|
||||
{name: "mention all", el: map[string]interface{}{"tag": "at", "user_id": "@_all"}, want: "@all"},
|
||||
{name: "mention user", el: map[string]interface{}{"tag": "at", "user_name": "Alice"}, want: "@Alice"},
|
||||
{name: "image", el: map[string]interface{}{"tag": "img", "image_key": "img_123"}, want: "[Image: img_123]"},
|
||||
{name: "media", el: map[string]interface{}{"tag": "media", "file_key": "file_123"}, want: "[Media: file_123]"},
|
||||
{name: "code block", el: map[string]interface{}{"tag": "code_block", "language": "go", "text": "fmt.Println(1)"}, want: "\n```go\nfmt.Println(1)\n```\n"},
|
||||
{name: "hr", el: map[string]interface{}{"tag": "hr"}, want: "\n---\n"},
|
||||
@@ -148,87 +144,3 @@ func TestRenderPostElemEmotionStyleMd(t *testing.T) {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestSelectContentBlocks(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
body map[string]interface{}
|
||||
want int
|
||||
}{
|
||||
{
|
||||
name: "content_v2 present and non-empty",
|
||||
body: map[string]interface{}{
|
||||
"content": []interface{}{[]interface{}{map[string]interface{}{"tag": "text", "text": "old"}}},
|
||||
"content_v2": []interface{}{[]interface{}{map[string]interface{}{"tag": "md", "text": "new"}}},
|
||||
},
|
||||
want: 1,
|
||||
},
|
||||
{
|
||||
name: "content_v2 empty array",
|
||||
body: map[string]interface{}{
|
||||
"content": []interface{}{[]interface{}{map[string]interface{}{"tag": "text", "text": "old"}}},
|
||||
"content_v2": []interface{}{},
|
||||
},
|
||||
want: 1,
|
||||
},
|
||||
{
|
||||
name: "content_v2 nil",
|
||||
body: map[string]interface{}{
|
||||
"content": []interface{}{[]interface{}{map[string]interface{}{"tag": "text", "text": "old"}}},
|
||||
},
|
||||
want: 1,
|
||||
},
|
||||
{
|
||||
name: "content_v2 wrong type",
|
||||
body: map[string]interface{}{
|
||||
"content": []interface{}{[]interface{}{map[string]interface{}{"tag": "text", "text": "old"}}},
|
||||
"content_v2": "not_an_array",
|
||||
},
|
||||
want: 1,
|
||||
},
|
||||
{
|
||||
name: "both missing",
|
||||
body: map[string]interface{}{},
|
||||
want: 0,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got := selectContentBlocks(tt.body)
|
||||
if len(got) != tt.want {
|
||||
t.Fatalf("selectContentBlocks() len = %d, want %d", len(got), tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestPostConverterConvertContentV2(t *testing.T) {
|
||||
// AC-M1-H1: content_v2 present → use content_v2 blocks (md passthrough)
|
||||
ctx := &ConvertContext{
|
||||
RawContent: `{"content_v2":[[{"tag":"md","text":"##### 标题\n\n<at user_id=\"ou_xxx\">Alice</at> 你好"}]],"content":[[{"tag":"text","text":"old path"}]]}`,
|
||||
}
|
||||
want := "##### 标题\n\n<at user_id=\"ou_xxx\">Alice</at> 你好"
|
||||
if got := (postConverter{}).Convert(ctx); got != want {
|
||||
t.Fatalf("postConverter.Convert(content_v2) = %q, want %q", got, want)
|
||||
}
|
||||
|
||||
// AC-M1-H2: no content_v2 → use content blocks with new at/img format
|
||||
ctx2 := &ConvertContext{
|
||||
RawContent: `{"content":[[{"tag":"at","user_id":"ou_xxx","user_name":"Bob"},{"tag":"text","text":" "},{"tag":"img","image_key":"img_123"}]]}`,
|
||||
Mentions: []interface{}{map[string]interface{}{"key": "ou_xxx", "id": "ou_bob", "name": "Bob"}},
|
||||
}
|
||||
want2 := `<at user_id="ou_xxx">Bob</at> `
|
||||
if got := (postConverter{}).Convert(ctx2); got != want2 {
|
||||
t.Fatalf("postConverter.Convert(content) = %q, want %q", got, want2)
|
||||
}
|
||||
|
||||
// AC-M1-E1: content_v2 empty → fallback to content
|
||||
ctx3 := &ConvertContext{
|
||||
RawContent: `{"content_v2":[],"content":[[{"tag":"text","text":"fallback path"}]]}`,
|
||||
}
|
||||
want3 := "fallback path"
|
||||
if got := (postConverter{}).Convert(ctx3); got != want3 {
|
||||
t.Fatalf("postConverter.Convert(empty content_v2) = %q, want %q", got, want3)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -29,7 +29,7 @@ var ImChatSearch = common.Shortcut{
|
||||
AuthTypes: []string{"user", "bot"},
|
||||
HasFormat: true,
|
||||
Flags: []common.Flag{
|
||||
{Name: "query", Desc: "search keyword (server may return data.notice for overly long input)"},
|
||||
{Name: "query", Desc: "search keyword (max 64 chars)"},
|
||||
{Name: "search-types", Desc: "chat types, comma-separated (private, external, public_joined, public_not_joined)"},
|
||||
{Name: "chat-modes", Desc: "filter by chat mode, comma-separated (group, topic)"},
|
||||
{Name: "member-ids", Desc: "filter by member open_ids, comma-separated"},
|
||||
@@ -50,7 +50,7 @@ var ImChatSearch = common.Shortcut{
|
||||
Params(params).
|
||||
Body(body)
|
||||
},
|
||||
// Validate enforces query/member-ids presence, search-types
|
||||
// Validate enforces query/member-ids presence, --query rune cap, search-types
|
||||
// enum, --member-ids count and format, and --page-size bounds.
|
||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
query := runtime.Str("query")
|
||||
@@ -58,6 +58,9 @@ var ImChatSearch = common.Shortcut{
|
||||
if query == "" && memberIDs == "" {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--query and --member-ids cannot both be empty; provide at least one (e.g. --query \"team-name\" or --member-ids \"ou_xxx\")")
|
||||
}
|
||||
if query != "" && len([]rune(query)) > 64 {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--query exceeds the maximum of 64 characters (got %d)", len([]rune(query))).WithParam("--query")
|
||||
}
|
||||
if st := runtime.Str("search-types"); st != "" {
|
||||
allowed := map[string]struct{}{
|
||||
"private": {},
|
||||
@@ -148,9 +151,6 @@ var ImChatSearch = common.Shortcut{
|
||||
"has_more": hasMore,
|
||||
"page_token": pageToken,
|
||||
}
|
||||
if notice, _ := resData["notice"].(string); notice != "" {
|
||||
outData["notice"] = notice
|
||||
}
|
||||
if mfOut.Meta.Applied != "" {
|
||||
outData["filter"] = MuteFilterMetaToMap(mfOut.Meta)
|
||||
}
|
||||
|
||||
@@ -91,7 +91,7 @@ var ImMessagesSearch = common.Shortcut{
|
||||
return err
|
||||
}
|
||||
|
||||
rawItems, hasMore, nextPageToken, truncatedByLimit, pageLimit, notice, err := searchMessages(runtime, req)
|
||||
rawItems, hasMore, nextPageToken, truncatedByLimit, pageLimit, err := searchMessages(runtime, req)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -103,9 +103,6 @@ var ImMessagesSearch = common.Shortcut{
|
||||
"has_more": hasMore,
|
||||
"page_token": nextPageToken,
|
||||
}
|
||||
if notice != "" {
|
||||
outData["notice"] = notice
|
||||
}
|
||||
runtime.OutFormat(outData, nil, func(w io.Writer) {
|
||||
fmt.Fprintln(w, "No matching messages found.")
|
||||
})
|
||||
@@ -134,9 +131,6 @@ var ImMessagesSearch = common.Shortcut{
|
||||
"page_token": nextPageToken,
|
||||
"note": "failed to fetch message details, returning ID list only",
|
||||
}
|
||||
if notice != "" {
|
||||
outData["notice"] = notice
|
||||
}
|
||||
runtime.OutFormat(outData, nil, func(w io.Writer) {
|
||||
fmt.Fprintf(w, "Found %d messages (failed to fetch details):\n", len(messageIds))
|
||||
for _, id := range messageIds {
|
||||
@@ -212,9 +206,6 @@ var ImMessagesSearch = common.Shortcut{
|
||||
"has_more": hasMore,
|
||||
"page_token": nextPageToken,
|
||||
}
|
||||
if notice != "" {
|
||||
outData["notice"] = notice
|
||||
}
|
||||
runtime.OutFormat(outData, nil, func(w io.Writer) {
|
||||
if len(enriched) == 0 {
|
||||
fmt.Fprintln(w, "No matching messages found.")
|
||||
@@ -386,7 +377,6 @@ func buildMessagesSearchRequest(runtime *common.RuntimeContext) (*messagesSearch
|
||||
}, nil
|
||||
}
|
||||
|
||||
// messagesSearchPaginationConfig derives auto-pagination mode and page limit.
|
||||
func messagesSearchPaginationConfig(runtime *common.RuntimeContext) (autoPaginate bool, pageLimit int) {
|
||||
autoPaginate = runtime.Bool("page-all")
|
||||
if runtime.Cmd != nil && runtime.Cmd.Flags().Changed("page-limit") {
|
||||
@@ -402,8 +392,7 @@ func messagesSearchPaginationConfig(runtime *common.RuntimeContext) (autoPaginat
|
||||
return autoPaginate, pageLimit
|
||||
}
|
||||
|
||||
// searchMessages fetches message search pages and returns the first server notice.
|
||||
func searchMessages(runtime *common.RuntimeContext, req *messagesSearchRequest) ([]interface{}, bool, string, bool, int, string, error) {
|
||||
func searchMessages(runtime *common.RuntimeContext, req *messagesSearchRequest) ([]interface{}, bool, string, bool, int, error) {
|
||||
autoPaginate, pageLimit := messagesSearchPaginationConfig(runtime)
|
||||
pageToken := ""
|
||||
if tokens := req.params["page_token"]; len(tokens) > 0 {
|
||||
@@ -421,7 +410,6 @@ func searchMessages(runtime *common.RuntimeContext, req *messagesSearchRequest)
|
||||
lastPageToken string
|
||||
truncatedByLimit bool
|
||||
pageCount int
|
||||
notice string
|
||||
)
|
||||
|
||||
for {
|
||||
@@ -435,12 +423,9 @@ func searchMessages(runtime *common.RuntimeContext, req *messagesSearchRequest)
|
||||
|
||||
searchData, err := runtime.DoAPIJSONTyped(http.MethodPost, "/open-apis/im/v1/messages/search", params, req.body)
|
||||
if err != nil {
|
||||
return nil, false, "", false, pageLimit, "", err
|
||||
return nil, false, "", false, pageLimit, err
|
||||
}
|
||||
|
||||
if notice == "" {
|
||||
notice, _ = searchData["notice"].(string)
|
||||
}
|
||||
items, _ := searchData["items"].([]interface{})
|
||||
allItems = append(allItems, items...)
|
||||
lastHasMore, lastPageToken = common.PaginationMeta(searchData)
|
||||
@@ -456,10 +441,9 @@ func searchMessages(runtime *common.RuntimeContext, req *messagesSearchRequest)
|
||||
pageToken = lastPageToken
|
||||
}
|
||||
|
||||
return allItems, lastHasMore, lastPageToken, truncatedByLimit, pageLimit, notice, nil
|
||||
return allItems, lastHasMore, lastPageToken, truncatedByLimit, pageLimit, nil
|
||||
}
|
||||
|
||||
// batchMGetMessages fetches message details in API-sized batches.
|
||||
func batchMGetMessages(runtime *common.RuntimeContext, messageIds []string) ([]interface{}, error) {
|
||||
var items []interface{}
|
||||
for _, batch := range chunkStrings(messageIds, messagesSearchMGetBatchSize) {
|
||||
@@ -473,7 +457,6 @@ func batchMGetMessages(runtime *common.RuntimeContext, messageIds []string) ([]i
|
||||
return items, nil
|
||||
}
|
||||
|
||||
// batchQueryChatContexts fetches chat metadata best-effort for message rows.
|
||||
func batchQueryChatContexts(runtime *common.RuntimeContext, chatIds []string) map[string]map[string]interface{} {
|
||||
chatContexts := map[string]map[string]interface{}{}
|
||||
// Best-effort: a failed chunk only loses its own entries.
|
||||
@@ -483,7 +466,6 @@ func batchQueryChatContexts(runtime *common.RuntimeContext, chatIds []string) ma
|
||||
return chatContexts
|
||||
}
|
||||
|
||||
// chunkStrings splits a string slice into fixed-size batches.
|
||||
func chunkStrings(items []string, chunkSize int) [][]string {
|
||||
if len(items) == 0 || chunkSize <= 0 {
|
||||
return nil
|
||||
|
||||
@@ -1,129 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package im
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
// TestImChatSearchExecutePassesThroughNotice verifies chat search notice output.
|
||||
func TestImChatSearchExecutePassesThroughNotice(t *testing.T) {
|
||||
const notice = "The query is too long and has been truncated to the first 50 characters for search."
|
||||
longQuery := strings.Repeat("q", 81)
|
||||
|
||||
runtime := newBotShortcutRuntime(t, shortcutRoundTripFunc(func(req *http.Request) (*http.Response, error) {
|
||||
if !strings.Contains(req.URL.Path, "/open-apis/im/v2/chats/search") {
|
||||
return nil, fmt.Errorf("unexpected request: %s", req.URL.String())
|
||||
}
|
||||
var body map[string]interface{}
|
||||
if err := json.NewDecoder(req.Body).Decode(&body); err != nil {
|
||||
return nil, fmt.Errorf("decode request body: %w", err)
|
||||
}
|
||||
if got, _ := body["query"].(string); got != longQuery {
|
||||
return nil, fmt.Errorf("body.query = %q, want %q", got, longQuery)
|
||||
}
|
||||
return shortcutJSONResponse(200, map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{
|
||||
"notice": notice,
|
||||
"items": []interface{}{},
|
||||
"total": 0,
|
||||
"has_more": false,
|
||||
"page_token": "",
|
||||
},
|
||||
}), nil
|
||||
}))
|
||||
runtime.Cmd = newChatSearchNoticeTestCommand(t, longQuery)
|
||||
runtime.Format = "json"
|
||||
|
||||
if err := ImChatSearch.Execute(context.Background(), runtime); err != nil {
|
||||
t.Fatalf("ImChatSearch.Execute() error = %v", err)
|
||||
}
|
||||
|
||||
data := decodeShortcutData(t, runtime)
|
||||
if got, _ := data["notice"].(string); got != notice {
|
||||
t.Fatalf("data.notice = %q, want %q; data=%#v", got, notice, data)
|
||||
}
|
||||
}
|
||||
|
||||
// TestImMessagesSearchExecutePassesThroughNotice verifies message search notice output.
|
||||
func TestImMessagesSearchExecutePassesThroughNotice(t *testing.T) {
|
||||
const notice = "The query is too long and has been truncated to the first 50 characters for search."
|
||||
|
||||
runtime := newMessagesSearchRuntime(t, map[string]string{
|
||||
"query": "incident",
|
||||
}, nil, shortcutRoundTripFunc(func(req *http.Request) (*http.Response, error) {
|
||||
if !strings.Contains(req.URL.Path, "/open-apis/im/v1/messages/search") {
|
||||
return nil, fmt.Errorf("unexpected request: %s", req.URL.String())
|
||||
}
|
||||
return shortcutJSONResponse(200, map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{
|
||||
"notice": notice,
|
||||
"items": []interface{}{},
|
||||
"has_more": false,
|
||||
"page_token": "",
|
||||
},
|
||||
}), nil
|
||||
}))
|
||||
runtime.Format = "json"
|
||||
|
||||
if err := ImMessagesSearch.Execute(context.Background(), runtime); err != nil {
|
||||
t.Fatalf("ImMessagesSearch.Execute() error = %v", err)
|
||||
}
|
||||
|
||||
data := decodeShortcutData(t, runtime)
|
||||
if got, _ := data["notice"].(string); got != notice {
|
||||
t.Fatalf("data.notice = %q, want %q; data=%#v", got, notice, data)
|
||||
}
|
||||
}
|
||||
|
||||
// newChatSearchNoticeTestCommand builds a typed chat-search command for notice tests.
|
||||
func newChatSearchNoticeTestCommand(t *testing.T, query string) *cobra.Command {
|
||||
t.Helper()
|
||||
|
||||
cmd := &cobra.Command{Use: "test"}
|
||||
for _, name := range []string{"query", "search-types", "member-ids", "sort-by", "page-token"} {
|
||||
cmd.Flags().String(name, "", "")
|
||||
}
|
||||
for _, name := range []string{"is-manager", "disable-search-by-user", "exclude-muted"} {
|
||||
cmd.Flags().Bool(name, false, "")
|
||||
}
|
||||
cmd.Flags().Int("page-size", 20, "")
|
||||
if err := cmd.ParseFlags(nil); err != nil {
|
||||
t.Fatalf("ParseFlags() error = %v", err)
|
||||
}
|
||||
if err := cmd.Flags().Set("query", query); err != nil {
|
||||
t.Fatalf("Flags().Set(query) error = %v", err)
|
||||
}
|
||||
return cmd
|
||||
}
|
||||
|
||||
// decodeShortcutData extracts the JSON envelope data object from shortcut output.
|
||||
func decodeShortcutData(t *testing.T, runtime *common.RuntimeContext) map[string]interface{} {
|
||||
t.Helper()
|
||||
|
||||
out, ok := runtime.Factory.IOStreams.Out.(*bytes.Buffer)
|
||||
if !ok {
|
||||
t.Fatalf("stdout buffer has type %T", runtime.Factory.IOStreams.Out)
|
||||
}
|
||||
var env map[string]interface{}
|
||||
if err := json.Unmarshal(out.Bytes(), &env); err != nil {
|
||||
t.Fatalf("json.Unmarshal(stdout) error = %v\nstdout=%s", err, out.String())
|
||||
}
|
||||
data, ok := env["data"].(map[string]interface{})
|
||||
if !ok {
|
||||
t.Fatalf("envelope data missing or wrong type: %#v", env)
|
||||
}
|
||||
return data
|
||||
}
|
||||
@@ -159,7 +159,6 @@ var MailTriage = common.Shortcut{
|
||||
var messages []map[string]interface{}
|
||||
var hasMore bool
|
||||
var nextPageToken string
|
||||
var notice string
|
||||
|
||||
useSearch, err := resolveTriagePath(parsed, query, filter)
|
||||
if err != nil {
|
||||
@@ -190,9 +189,6 @@ var MailTriage = common.Shortcut{
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if notice == "" {
|
||||
notice, _ = searchData["notice"].(string)
|
||||
}
|
||||
pageMessages := buildTriageMessagesFromSearchItems(searchData["items"])
|
||||
messages = append(messages, pageMessages...)
|
||||
pageHasMore, _ := searchData["has_more"].(bool)
|
||||
@@ -286,14 +282,8 @@ var MailTriage = common.Shortcut{
|
||||
"has_more": hasMore,
|
||||
"page_token": nextPageToken,
|
||||
}
|
||||
if notice != "" {
|
||||
outData["notice"] = notice
|
||||
}
|
||||
output.PrintJson(runtime.IO().Out, outData)
|
||||
default: // "table"
|
||||
if notice != "" {
|
||||
fmt.Fprintf(runtime.IO().ErrOut, "notice: %s\n", notice)
|
||||
}
|
||||
if len(messages) == 0 {
|
||||
fmt.Fprintln(runtime.IO().ErrOut, "No messages found.")
|
||||
return nil
|
||||
@@ -770,7 +760,13 @@ func buildListParams(runtime *common.RuntimeContext, mailboxID string, f triageF
|
||||
params["folder_id"] = folderIDFromFilter
|
||||
}
|
||||
} else {
|
||||
params["folder_id"] = folderIDFromFilter
|
||||
resolved, err := resolveFolderID(runtime, mailboxID, folderIDFromFilter)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if resolved != "" {
|
||||
params["folder_id"] = resolved
|
||||
}
|
||||
}
|
||||
} else if folderFromFilter != "" {
|
||||
if dryRun {
|
||||
@@ -780,7 +776,13 @@ func buildListParams(runtime *common.RuntimeContext, mailboxID string, f triageF
|
||||
params["folder_id"] = folderFromFilter
|
||||
}
|
||||
} else {
|
||||
params["folder_id"] = folderFromFilter
|
||||
resolved, err := resolveFolderName(runtime, mailboxID, folderFromFilter)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if resolved != "" {
|
||||
params["folder_id"] = resolved
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -799,7 +801,13 @@ func buildListParams(runtime *common.RuntimeContext, mailboxID string, f triageF
|
||||
params["label_id"] = labelIDFromFilter
|
||||
}
|
||||
} else {
|
||||
params["label_id"] = labelIDFromFilter
|
||||
resolved, err := resolveLabelID(runtime, mailboxID, labelIDFromFilter)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if resolved != "" {
|
||||
params["label_id"] = resolved
|
||||
}
|
||||
}
|
||||
} else if labelFromFilter != "" {
|
||||
if dryRun {
|
||||
@@ -809,7 +817,13 @@ func buildListParams(runtime *common.RuntimeContext, mailboxID string, f triageF
|
||||
params["label_id"] = labelFromFilter
|
||||
}
|
||||
} else {
|
||||
params["label_id"] = labelFromFilter
|
||||
resolved, err := resolveLabelName(runtime, mailboxID, labelFromFilter)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if resolved != "" {
|
||||
params["label_id"] = resolved
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -12,7 +12,6 @@ import (
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/auth"
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/httpmock"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
@@ -975,11 +974,7 @@ func TestBuildListParamsDryRunOnlyUnread(t *testing.T) {
|
||||
func TestBuildListParamsDryRunFolderAlias(t *testing.T) {
|
||||
rt := runtimeForMailTriageTest(t, nil)
|
||||
f := triageFilter{Folder: "sent"}
|
||||
resolved, err := resolveListFilter(rt, "me", f, true)
|
||||
if err != nil {
|
||||
t.Fatalf("resolveListFilter: %v", err)
|
||||
}
|
||||
got, err := buildListParams(rt, "me", resolved, 20, "", true)
|
||||
got, err := buildListParams(rt, "me", f, 20, "", true)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -988,30 +983,10 @@ func TestBuildListParamsDryRunFolderAlias(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildListParamsDryRunCustomFolderPreservesInput(t *testing.T) {
|
||||
rt := runtimeForMailTriageTest(t, nil)
|
||||
f := triageFilter{Folder: "team-folder"}
|
||||
resolved, err := resolveListFilter(rt, "me", f, true)
|
||||
if err != nil {
|
||||
t.Fatalf("resolveListFilter: %v", err)
|
||||
}
|
||||
got, err := buildListParams(rt, "me", resolved, 20, "", true)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got["folder_id"] != "team-folder" {
|
||||
t.Fatalf("expected dry-run folder_id=team-folder, got %v", got["folder_id"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildListParamsDryRunLabelAlias(t *testing.T) {
|
||||
rt := runtimeForMailTriageTest(t, nil)
|
||||
f := triageFilter{Label: "flagged"}
|
||||
resolved, err := resolveListFilter(rt, "me", f, true)
|
||||
if err != nil {
|
||||
t.Fatalf("resolveListFilter: %v", err)
|
||||
}
|
||||
got, err := buildListParams(rt, "me", resolved, 10, "", true)
|
||||
got, err := buildListParams(rt, "me", f, 10, "", true)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -1020,25 +995,6 @@ func TestBuildListParamsDryRunLabelAlias(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildListParamsDryRunCustomLabelPreservesInput(t *testing.T) {
|
||||
rt := runtimeForMailTriageTest(t, nil)
|
||||
f := triageFilter{Label: "custom-label"}
|
||||
resolved, err := resolveListFilter(rt, "me", f, true)
|
||||
if err != nil {
|
||||
t.Fatalf("resolveListFilter: %v", err)
|
||||
}
|
||||
got, err := buildListParams(rt, "me", resolved, 10, "", true)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, ok := got["folder_id"]; ok {
|
||||
t.Fatalf("folder_id should not be set when label is specified, got %v", got["folder_id"])
|
||||
}
|
||||
if got["label_id"] != "custom-label" {
|
||||
t.Fatalf("expected dry-run label_id=custom-label, got %v", got["label_id"])
|
||||
}
|
||||
}
|
||||
|
||||
// --- buildSearchParams additional coverage ---
|
||||
|
||||
func TestBuildSearchParamsAllFilterFields(t *testing.T) {
|
||||
@@ -1522,16 +1478,14 @@ func boolPtr(v bool) *bool { return &v }
|
||||
|
||||
// --- mailbox_id preservation tests ---
|
||||
|
||||
// TestMailTriageStructuredOutputPreservesMailboxID verifies mailbox and notice metadata.
|
||||
func TestMailTriageStructuredOutputPreservesMailboxID(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
mailbox string
|
||||
format string
|
||||
args []string
|
||||
register func(*httpmock.Registry, string)
|
||||
wantCount int
|
||||
wantNotice string
|
||||
name string
|
||||
mailbox string
|
||||
format string
|
||||
args []string
|
||||
register func(*httpmock.Registry, string)
|
||||
wantCount int
|
||||
}{
|
||||
{
|
||||
name: "list json default mailbox",
|
||||
@@ -1568,10 +1522,9 @@ func TestMailTriageStructuredOutputPreservesMailboxID(t *testing.T) {
|
||||
register: func(reg *httpmock.Registry, mailbox string) {
|
||||
registerMailTriageSearchStub(reg, mailbox, []interface{}{
|
||||
mailTriageSearchItem("search_pub_001", "Shared search"),
|
||||
}, false, "", "The query is too long and has been truncated to the first 50 characters for search.")
|
||||
}, false, "")
|
||||
},
|
||||
wantCount: 1,
|
||||
wantNotice: "The query is too long and has been truncated to the first 50 characters for search.",
|
||||
wantCount: 1,
|
||||
},
|
||||
{
|
||||
name: "empty list json keeps top-level mailbox",
|
||||
@@ -1606,9 +1559,6 @@ func TestMailTriageStructuredOutputPreservesMailboxID(t *testing.T) {
|
||||
if data["mailbox_id"] != tt.mailbox {
|
||||
t.Fatalf("top-level mailbox_id mismatch: got %v, want %q", data["mailbox_id"], tt.mailbox)
|
||||
}
|
||||
if tt.wantNotice != "" && data["notice"] != tt.wantNotice {
|
||||
t.Fatalf("notice mismatch: got %v, want %q", data["notice"], tt.wantNotice)
|
||||
}
|
||||
messages := mailTriageMessagesFromOutput(t, data)
|
||||
if len(messages) != tt.wantCount {
|
||||
t.Fatalf("message count mismatch: got %d, want %d", len(messages), tt.wantCount)
|
||||
@@ -1622,7 +1572,6 @@ func TestMailTriageStructuredOutputPreservesMailboxID(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestMailTriageMissingMessageMetadataStillGetsMailboxID verifies fallback rows keep mailbox IDs.
|
||||
func TestMailTriageMissingMessageMetadataStillGetsMailboxID(t *testing.T) {
|
||||
f, stdout, _, reg := mailShortcutTestFactory(t)
|
||||
defer reg.Verify(t)
|
||||
@@ -1655,7 +1604,6 @@ func TestMailTriageMissingMessageMetadataStillGetsMailboxID(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestMailTriageTableOutputPreservesMailboxContext verifies public mailbox table hints.
|
||||
func TestMailTriageTableOutputPreservesMailboxContext(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
@@ -1706,33 +1654,6 @@ func TestMailTriageTableOutputPreservesMailboxContext(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestMailTriageDefaultTableOutputPrintsSearchNoticeToStderr verifies stderr notices.
|
||||
func TestMailTriageDefaultTableOutputPrintsSearchNoticeToStderr(t *testing.T) {
|
||||
const notice = "The query is too long and has been truncated to the first 50 characters for search."
|
||||
|
||||
f, stdout, stderr, reg := mailShortcutTestFactory(t)
|
||||
defer reg.Verify(t)
|
||||
|
||||
registerMailTriageSearchStub(reg, "me", []interface{}{
|
||||
mailTriageSearchItem("msg_search_notice", "Search notice result"),
|
||||
}, false, "", notice)
|
||||
|
||||
if err := runMountedMailShortcut(t, MailTriage, []string{
|
||||
"+triage",
|
||||
"--query", strings.Repeat("q", 81),
|
||||
}, f, stdout); err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
if out := stdout.String(); !strings.Contains(out, "msg_search_notice") {
|
||||
t.Fatalf("stdout should contain table row, got:\n%s", out)
|
||||
}
|
||||
if errOut := stderr.String(); !strings.Contains(errOut, "notice: "+notice) {
|
||||
t.Fatalf("stderr should contain search notice, got:\n%s", errOut)
|
||||
}
|
||||
}
|
||||
|
||||
// decodeMailTriageJSONOutput decodes structured triage output for assertions.
|
||||
func decodeMailTriageJSONOutput(t *testing.T, stdout interface{ Bytes() []byte }) map[string]interface{} {
|
||||
t.Helper()
|
||||
var data map[string]interface{}
|
||||
@@ -1742,7 +1663,6 @@ func decodeMailTriageJSONOutput(t *testing.T, stdout interface{ Bytes() []byte }
|
||||
return data
|
||||
}
|
||||
|
||||
// mailTriageMessagesFromOutput extracts triage messages as object maps.
|
||||
func mailTriageMessagesFromOutput(t *testing.T, data map[string]interface{}) []map[string]interface{} {
|
||||
t.Helper()
|
||||
rawMessages, ok := data["messages"].([]interface{})
|
||||
@@ -1795,8 +1715,7 @@ func registerMailTriageBatchStub(reg *httpmock.Registry, mailbox string, message
|
||||
})
|
||||
}
|
||||
|
||||
// registerMailTriageSearchStub registers a mailbox search response for triage tests.
|
||||
func registerMailTriageSearchStub(reg *httpmock.Registry, mailbox string, items []interface{}, hasMore bool, pageToken string, notices ...string) {
|
||||
func registerMailTriageSearchStub(reg *httpmock.Registry, mailbox string, items []interface{}, hasMore bool, pageToken string) {
|
||||
data := map[string]interface{}{
|
||||
"items": items,
|
||||
"has_more": hasMore,
|
||||
@@ -1804,9 +1723,6 @@ func registerMailTriageSearchStub(reg *httpmock.Registry, mailbox string, items
|
||||
if pageToken != "" {
|
||||
data["page_token"] = pageToken
|
||||
}
|
||||
if len(notices) > 0 && notices[0] != "" {
|
||||
data["notice"] = notices[0]
|
||||
}
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: mailboxPath(mailbox, "search"),
|
||||
@@ -1835,137 +1751,3 @@ func mailTriageSearchItem(messageID, subject string) map[string]interface{} {
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// registerMailTriageFoldersListStub registers a NON-reusable stub for the
|
||||
// mailbox folders list API. Because it is non-reusable, any second hit returns
|
||||
// "httpmock: no stub for GET .../folders" — which is exactly the assertion we
|
||||
// use to prove resolveListFilter runs once and buildListParams does NOT
|
||||
// re-resolve. folderID/folderName is the single custom folder the API reports.
|
||||
func registerMailTriageFoldersListStub(reg *httpmock.Registry, mailbox, folderID, folderName string) {
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: mailboxPath(mailbox, "folders"),
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{
|
||||
"items": []interface{}{
|
||||
map[string]interface{}{
|
||||
"id": folderID,
|
||||
"name": folderName,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// registerMailTriageListPageStub registers one page of the messages list API,
|
||||
// disambiguated from sibling pages by a URL substring unique to that page
|
||||
// (e.g. "page_size=5" for page 1 vs "page_size=2" for page 2). The substring
|
||||
// must NOT depend on query-param ordering: map iteration makes param order
|
||||
// nondeterministic, so prefer a value-only token like "page_size=N" (the N
|
||||
// differs per page because pageSize = maxCount - fetched_so_far). Non-reusable
|
||||
// so reg.Verify catches under- or over-consumption.
|
||||
func registerMailTriageListPageStub(reg *httpmock.Registry, urlSubstring string, items []string, hasMore bool, pageToken string) {
|
||||
data := map[string]interface{}{
|
||||
"items": items,
|
||||
"has_more": hasMore,
|
||||
}
|
||||
if pageToken != "" {
|
||||
data["page_token"] = pageToken
|
||||
}
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: urlSubstring,
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": data,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// TestMailTriageCustomFolderResolvesOnceAcrossListPages is the regression test
|
||||
// for the bug where buildListParams re-called resolveFolderID on every list
|
||||
// page, turning "resolve once" into "1 + page_count" folder-list API calls and
|
||||
// easily tripping rate limits.
|
||||
//
|
||||
// Setup: a custom folder filter that forces resolveListFilter to hit the
|
||||
// folders list API once (to map folder name "team-folder" to folder_id), then two
|
||||
// messages-list pages. The folders list stub is non-reusable, so if
|
||||
// buildListParams re-resolves, the second hit fails with "no stub". The
|
||||
// messages-list stubs are page-specific (disambiguated by page_size in the
|
||||
// URL), so both pages are served and Verify asserts each fired exactly once.
|
||||
func TestMailTriageCustomFolderResolvesOnceAcrossListPages(t *testing.T) {
|
||||
f, stdout, _, reg := mailShortcutTestFactory(t)
|
||||
defer reg.Verify(t)
|
||||
|
||||
// listMailboxFolders (called once by resolveListFilter) gates on the
|
||||
// mail:user_mailbox.folder:read scope, which the default test token does
|
||||
// not carry. Re-store the token with that scope appended so the folders
|
||||
// API call is actually exercised (and thus the non-reusable folders stub
|
||||
// is the load-bearing "exactly once" assertion).
|
||||
const folderScope = "mail:user_mailbox.folder:read"
|
||||
cfg := mailTestConfig()
|
||||
if stored := auth.GetStoredToken(cfg.AppID, cfg.UserOpenId); stored != nil {
|
||||
if !strings.Contains(stored.Scope, folderScope) {
|
||||
stored.Scope = stored.Scope + " " + folderScope
|
||||
if err := auth.SetStoredToken(stored); err != nil {
|
||||
t.Fatalf("re-store token with folder scope: %v", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const (
|
||||
mailbox = "me"
|
||||
folderName = "team-folder"
|
||||
folderID = "fld_custom_team"
|
||||
page2Token = "tok_page2"
|
||||
)
|
||||
// --max 5 with listPageMax=20 → pageSize = 5-0 = 5 on page 1, then 5-3 = 2
|
||||
// on page 2. The page_size query value disambiguates the two list stubs.
|
||||
page1IDs := []string{"msg_a", "msg_b", "msg_c"}
|
||||
page2IDs := []string{"msg_d", "msg_e"}
|
||||
|
||||
// Folders list: registered exactly once, non-reusable. Any second folder
|
||||
// lookup (the bug) fails the test with "no stub for GET .../folders".
|
||||
registerMailTriageFoldersListStub(reg, mailbox, folderID, folderName)
|
||||
// Messages list, page 1: 3 ids, has_more, hands off a page-2 token. The
|
||||
// page_size value (5 = maxCount - 0) is unique to page 1; page 2 uses 2.
|
||||
registerMailTriageListPageStub(reg, "page_size=5", page1IDs, true, page2Token)
|
||||
// Messages list, page 2: 2 ids, terminal.
|
||||
registerMailTriageListPageStub(reg, "page_size=2", page2IDs, false, "")
|
||||
// Batch metadata fetch for all 5 ids.
|
||||
registerMailTriageBatchStub(reg, mailbox, []map[string]interface{}{
|
||||
mailTriageBatchMessage("msg_a", "Subject A"),
|
||||
mailTriageBatchMessage("msg_b", "Subject B"),
|
||||
mailTriageBatchMessage("msg_c", "Subject C"),
|
||||
mailTriageBatchMessage("msg_d", "Subject D"),
|
||||
mailTriageBatchMessage("msg_e", "Subject E"),
|
||||
})
|
||||
|
||||
args := []string{
|
||||
"+triage",
|
||||
"--as", "user",
|
||||
"--mailbox", mailbox,
|
||||
"--filter", `{"folder":"` + folderName + `"}`,
|
||||
"--max", "5",
|
||||
"--format", "json",
|
||||
}
|
||||
if err := runMountedMailShortcut(t, MailTriage, args, f, stdout); err != nil {
|
||||
t.Fatalf("unexpected error running +triage (likely a second folders API call — the bug): %v", err)
|
||||
}
|
||||
|
||||
data := decodeMailTriageJSONOutput(t, stdout)
|
||||
messages := mailTriageMessagesFromOutput(t, data)
|
||||
if len(messages) != 5 {
|
||||
t.Fatalf("expected 5 messages across 2 pages, got %d (stdout=%s)", len(messages), stdout.String())
|
||||
}
|
||||
if got := data["has_more"]; got != false {
|
||||
t.Fatalf("expected has_more=false after exhausting pages, got %v", got)
|
||||
}
|
||||
// All registered stubs (1 folders + 2 list pages + 1 batch_get) are
|
||||
// non-reusable; reg.Verify (deferred above) asserts each was matched
|
||||
// exactly once. Combined with the non-reusable folders stub, this is the
|
||||
// proof that the folders list API was called exactly once across both
|
||||
// pages — the core invariant the fix restores.
|
||||
}
|
||||
|
||||
@@ -308,9 +308,6 @@ var MinutesSearch = common.Shortcut{
|
||||
"has_more": data["has_more"],
|
||||
"page_token": data["page_token"],
|
||||
}
|
||||
if notice, _ := data["notice"].(string); notice != "" {
|
||||
outData["notice"] = notice
|
||||
}
|
||||
|
||||
runtime.OutFormat(outData, &output.Meta{Count: len(rows)}, func(w io.Writer) {
|
||||
if len(rows) == 0 {
|
||||
|
||||
@@ -609,8 +609,6 @@ func TestMinutesSearchExecuteShowsPaginationHintForTableFormat(t *testing.T) {
|
||||
func TestMinutesSearchExecuteJSONCountUsesRenderedRows(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
const notice = "The query is too long and has been truncated to the first 50 characters for search."
|
||||
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, defaultConfig())
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
@@ -619,7 +617,6 @@ func TestMinutesSearchExecuteJSONCountUsesRenderedRows(t *testing.T) {
|
||||
"code": 0,
|
||||
"msg": "ok",
|
||||
"data": map[string]interface{}{
|
||||
"notice": notice,
|
||||
"items": []interface{}{
|
||||
nil,
|
||||
map[string]interface{}{
|
||||
@@ -644,9 +641,6 @@ func TestMinutesSearchExecuteJSONCountUsesRenderedRows(t *testing.T) {
|
||||
reg.Verify(t)
|
||||
|
||||
var envelope struct {
|
||||
Data struct {
|
||||
Notice string `json:"notice"`
|
||||
} `json:"data"`
|
||||
Meta struct {
|
||||
Count int `json:"count"`
|
||||
} `json:"meta"`
|
||||
@@ -657,9 +651,6 @@ func TestMinutesSearchExecuteJSONCountUsesRenderedRows(t *testing.T) {
|
||||
if envelope.Meta.Count != 1 {
|
||||
t.Fatalf("meta.count = %d, want 1", envelope.Meta.Count)
|
||||
}
|
||||
if envelope.Data.Notice != notice {
|
||||
t.Fatalf("data.notice = %q, want %q", envelope.Data.Notice, notice)
|
||||
}
|
||||
}
|
||||
|
||||
// TestMinuteSearchFieldExtractors verifies field extractors read populated metadata correctly.
|
||||
|
||||
@@ -1,385 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package okr
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
// batchCreateKR represents a key result in the batch create input.
|
||||
type batchCreateKR struct {
|
||||
Text string `json:"text"`
|
||||
Mention []string `json:"mention,omitempty"`
|
||||
}
|
||||
|
||||
// batchCreateObjective represents an objective in the batch create input.
|
||||
type batchCreateObjective struct {
|
||||
Text string `json:"text"`
|
||||
Mention []string `json:"mention,omitempty"`
|
||||
KRs []batchCreateKR `json:"krs,omitempty"`
|
||||
}
|
||||
|
||||
// createdObjective tracks a created objective and its KR IDs for output.
|
||||
// KRs are automatically deleted by the backend when the objective is deleted (no need to delete them separately during rollback).
|
||||
type createdObjective struct {
|
||||
ObjectiveID string
|
||||
KRIDs []string // for output response only, not used in rollback
|
||||
}
|
||||
|
||||
// parseBatchCreateInput parses and validates the JSON input.
|
||||
func parseBatchCreateInput(input string) ([]batchCreateObjective, error) {
|
||||
var objectives []batchCreateObjective
|
||||
if err := json.Unmarshal([]byte(input), &objectives); err != nil {
|
||||
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "--input must be valid JSON array: %s", err).WithParam("--input").WithCause(err)
|
||||
}
|
||||
if len(objectives) == 0 {
|
||||
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "--input must contain at least one objective").WithParam("--input")
|
||||
}
|
||||
for i, obj := range objectives {
|
||||
if strings.TrimSpace(obj.Text) == "" {
|
||||
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "objective[%d].text is required and cannot be empty", i).WithParam("--input")
|
||||
}
|
||||
for j, kr := range obj.KRs {
|
||||
if strings.TrimSpace(kr.Text) == "" {
|
||||
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "objective[%d].krs[%d].text is required and cannot be empty", i, j).WithParam("--input")
|
||||
}
|
||||
}
|
||||
}
|
||||
return objectives, nil
|
||||
}
|
||||
|
||||
// buildContentBlock converts text and mentions to a ContentBlock.
|
||||
func buildContentBlock(text string, mentions []string) *ContentBlock {
|
||||
elements := make([]ContentParagraphElement, 0, len(mentions)+1)
|
||||
|
||||
// Add text element
|
||||
textElem := ContentParagraphElement{
|
||||
ParagraphElementType: ParagraphElementTypeTextRun.Ptr(),
|
||||
TextRun: &ContentTextRun{
|
||||
Text: &text,
|
||||
},
|
||||
}
|
||||
elements = append(elements, textElem)
|
||||
|
||||
// Add mention elements
|
||||
for _, mention := range mentions {
|
||||
mentionElem := ContentParagraphElement{
|
||||
ParagraphElementType: ParagraphElementTypeMention.Ptr(),
|
||||
Mention: &ContentMention{
|
||||
UserID: &mention,
|
||||
},
|
||||
}
|
||||
elements = append(elements, mentionElem)
|
||||
}
|
||||
|
||||
return &ContentBlock{
|
||||
Blocks: []ContentBlockElement{
|
||||
{
|
||||
BlockElementType: BlockElementTypeParagraph.Ptr(),
|
||||
Paragraph: &ContentParagraph{
|
||||
Elements: elements,
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// createObjective calls the API to create an objective.
|
||||
func createObjective(ctx context.Context, runtime *common.RuntimeContext, cycleID, userIDType string, obj batchCreateObjective) (string, error) {
|
||||
content := buildContentBlock(obj.Text, obj.Mention)
|
||||
body := map[string]interface{}{
|
||||
"content": content,
|
||||
}
|
||||
queryParams := map[string]interface{}{
|
||||
"cycle_id": cycleID,
|
||||
"user_id_type": userIDType,
|
||||
}
|
||||
|
||||
path := fmt.Sprintf("/open-apis/okr/v2/cycles/%s/objectives", cycleID)
|
||||
data, err := runtime.CallAPITyped("POST", path, queryParams, body)
|
||||
if err != nil {
|
||||
return "", wrapOkrNetworkErr(err, "failed to create objective")
|
||||
}
|
||||
|
||||
objectiveID, ok := data["objective_id"].(string)
|
||||
if !ok {
|
||||
return "", errs.NewInternalError(errs.SubtypeUnknown, "create objective response missing objective_id")
|
||||
}
|
||||
return objectiveID, nil
|
||||
}
|
||||
|
||||
// createKR calls the API to create a key result.
|
||||
func createKR(ctx context.Context, runtime *common.RuntimeContext, objectiveID, userIDType string, kr batchCreateKR) (string, error) {
|
||||
content := buildContentBlock(kr.Text, kr.Mention)
|
||||
body := map[string]interface{}{
|
||||
"content": content,
|
||||
}
|
||||
queryParams := map[string]interface{}{
|
||||
"objective_id": objectiveID,
|
||||
"user_id_type": userIDType,
|
||||
}
|
||||
|
||||
path := fmt.Sprintf("/open-apis/okr/v2/objectives/%s/key_results", objectiveID)
|
||||
data, err := runtime.CallAPITyped("POST", path, queryParams, body)
|
||||
if err != nil {
|
||||
return "", wrapOkrNetworkErr(err, "failed to create key result")
|
||||
}
|
||||
|
||||
krID, ok := data["key_result_id"].(string)
|
||||
if !ok {
|
||||
return "", errs.NewInternalError(errs.SubtypeUnknown, "create key result response missing key_result_id")
|
||||
}
|
||||
return krID, nil
|
||||
}
|
||||
|
||||
// deleteObjective deletes an objective (used for rollback).
|
||||
func deleteObjective(ctx context.Context, runtime *common.RuntimeContext, objectiveID string) error {
|
||||
queryParams := map[string]interface{}{
|
||||
"objective_id": objectiveID,
|
||||
"yes": true,
|
||||
}
|
||||
path := fmt.Sprintf("/open-apis/okr/v2/objectives/%s", objectiveID)
|
||||
_, err := runtime.CallAPITyped("DELETE", path, queryParams, nil)
|
||||
if err != nil {
|
||||
return wrapOkrNetworkErr(err, "failed to delete objective %s during rollback", objectiveID)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// rollback deletes created objectives in reverse order.
|
||||
// KRs are automatically deleted by the backend when the objective is deleted.
|
||||
func rollback(ctx context.Context, runtime *common.RuntimeContext, created []createdObjective) []error {
|
||||
var errsList []error
|
||||
|
||||
// Iterate in reverse order
|
||||
for i := len(created) - 1; i >= 0; i-- {
|
||||
obj := created[i]
|
||||
|
||||
// Delete the objective (backend automatically deletes its KRs)
|
||||
if err := deleteObjective(ctx, runtime, obj.ObjectiveID); err != nil {
|
||||
//nolint:forbidigo // intermediate wrap for rollback error collection; final error is typed via buildRollbackError
|
||||
errsList = append(errsList, fmt.Errorf("objective %s: %w", obj.ObjectiveID, err))
|
||||
}
|
||||
|
||||
// Rate limiting between deletions
|
||||
if i > 0 {
|
||||
time.Sleep(500 * time.Millisecond)
|
||||
}
|
||||
}
|
||||
|
||||
return errsList
|
||||
}
|
||||
|
||||
// OKRBatchCreate batch creates objectives and their key results.
|
||||
var OKRBatchCreate = common.Shortcut{
|
||||
Service: "okr",
|
||||
Command: "+batch-create",
|
||||
Description: "Batch create OKR objectives and key results with rollback on failure",
|
||||
Risk: "write",
|
||||
Scopes: []string{"okr:okr.content:writeonly"},
|
||||
AuthTypes: []string{"user", "bot"},
|
||||
HasFormat: true,
|
||||
Flags: []common.Flag{
|
||||
{Name: "cycle-id", Desc: "OKR cycle ID (int64)", Required: true},
|
||||
{Name: "input", Desc: "JSON array of objectives: [{\"text\":\"...\",\"mention\":[\"...\"],\"krs\":[{\"text\":\"...\",\"mention\":[\"...\"]}]}]", Input: []string{common.File, common.Stdin}, Required: true},
|
||||
{Name: "user-id-type", Default: "open_id", Desc: "user ID type: open_id | union_id | user_id"},
|
||||
},
|
||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
cycleID := runtime.Str("cycle-id")
|
||||
if id, err := strconv.ParseInt(cycleID, 10, 64); err != nil || id <= 0 {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--cycle-id must be a positive int64").WithParam("--cycle-id")
|
||||
}
|
||||
|
||||
input := runtime.Str("input")
|
||||
if err := common.RejectDangerousCharsTyped("--input", input); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := parseBatchCreateInput(input); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
idType := runtime.Str("user-id-type")
|
||||
if idType != "open_id" && idType != "union_id" && idType != "user_id" {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--user-id-type must be one of: open_id | union_id | user_id").WithParam("--user-id-type")
|
||||
}
|
||||
|
||||
return nil
|
||||
},
|
||||
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
cycleID := runtime.Str("cycle-id")
|
||||
userIDType := runtime.Str("user-id-type")
|
||||
objectives, _ := parseBatchCreateInput(runtime.Str("input"))
|
||||
|
||||
apis := common.NewDryRunAPI()
|
||||
|
||||
for i, obj := range objectives {
|
||||
// Objective creation
|
||||
objContent := buildContentBlock(obj.Text, obj.Mention)
|
||||
objBody := map[string]interface{}{
|
||||
"content": objContent,
|
||||
}
|
||||
objParams := map[string]interface{}{
|
||||
"cycle_id": cycleID,
|
||||
"user_id_type": userIDType,
|
||||
}
|
||||
objPath := fmt.Sprintf("/open-apis/okr/v2/cycles/%s/objectives", cycleID)
|
||||
apis = apis.
|
||||
POST(objPath).
|
||||
Params(objParams).
|
||||
Body(objBody).
|
||||
Desc(fmt.Sprintf("Create objective[%d]: %s", i, obj.Text))
|
||||
|
||||
// KR creations
|
||||
for j, kr := range obj.KRs {
|
||||
krContent := buildContentBlock(kr.Text, kr.Mention)
|
||||
krBody := map[string]interface{}{
|
||||
"content": krContent,
|
||||
}
|
||||
krParams := map[string]interface{}{
|
||||
"objective_id": "<objective_id_from_previous_call>",
|
||||
"user_id_type": userIDType,
|
||||
}
|
||||
krPath := "/open-apis/okr/v2/objectives/<objective_id>/key_results"
|
||||
apis = apis.
|
||||
POST(krPath).
|
||||
Params(krParams).
|
||||
Body(krBody).
|
||||
Desc(fmt.Sprintf("Create objective[%d].krs[%d]: %s", i, j, kr.Text))
|
||||
}
|
||||
}
|
||||
|
||||
return apis
|
||||
},
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
cycleID := runtime.Str("cycle-id")
|
||||
userIDType := runtime.Str("user-id-type")
|
||||
objectives, err := parseBatchCreateInput(runtime.Str("input"))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
var created []createdObjective
|
||||
|
||||
for i, obj := range objectives {
|
||||
// Rate limiting between objectives
|
||||
if i > 0 {
|
||||
time.Sleep(500 * time.Millisecond)
|
||||
}
|
||||
|
||||
// Create objective
|
||||
objectiveID, err := createObjective(ctx, runtime, cycleID, userIDType, obj)
|
||||
if err != nil {
|
||||
if len(created) == 0 {
|
||||
return err
|
||||
}
|
||||
rollbackErrs := rollback(ctx, runtime, created)
|
||||
return buildRollbackError(err, rollbackErrs, created)
|
||||
}
|
||||
|
||||
createdObj := createdObjective{
|
||||
ObjectiveID: objectiveID,
|
||||
}
|
||||
|
||||
// Create KRs
|
||||
for j, kr := range obj.KRs {
|
||||
// Rate limiting between KRs
|
||||
if j > 0 {
|
||||
time.Sleep(500 * time.Millisecond)
|
||||
}
|
||||
|
||||
krID, err := createKR(ctx, runtime, objectiveID, userIDType, kr)
|
||||
if err != nil {
|
||||
created = append(created, createdObj)
|
||||
rollbackErrs := rollback(ctx, runtime, created)
|
||||
return buildRollbackError(err, rollbackErrs, created)
|
||||
}
|
||||
|
||||
createdObj.KRIDs = append(createdObj.KRIDs, krID)
|
||||
}
|
||||
|
||||
created = append(created, createdObj)
|
||||
}
|
||||
|
||||
// Build response
|
||||
respCreated := make([]map[string]interface{}, 0, len(created))
|
||||
for _, obj := range created {
|
||||
respCreated = append(respCreated, map[string]interface{}{
|
||||
"objective_id": obj.ObjectiveID,
|
||||
"krs": obj.KRIDs,
|
||||
})
|
||||
}
|
||||
|
||||
result := map[string]interface{}{
|
||||
"ok": true,
|
||||
"data": map[string]interface{}{"created": respCreated},
|
||||
}
|
||||
|
||||
runtime.OutFormat(result, nil, func(w io.Writer) {
|
||||
fmt.Fprintf(w, "Successfully created %d objective(s)\n", len(created))
|
||||
for i, obj := range created {
|
||||
fmt.Fprintf(w, "Objective[%d] ID: %s (%d KR(s))\n", i, obj.ObjectiveID, len(obj.KRIDs))
|
||||
for j, krID := range obj.KRIDs {
|
||||
fmt.Fprintf(w, " KR[%d] ID: %s\n", j, krID)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
// buildRollbackError constructs an error that includes both the original failure
|
||||
// and any rollback failures, with a list of residual IDs that could not be cleaned up.
|
||||
// KRs are automatically deleted by the backend when the objective is deleted, so we only
|
||||
// need to track objective IDs for residual cleanup.
|
||||
func buildRollbackError(originalErr error, rollbackErrs []error, created []createdObjective) error {
|
||||
var residualIDs []string
|
||||
|
||||
// Only collect residual IDs when rollback had failures
|
||||
// If rollback succeeded (len(rollbackErrs) == 0), all objectives were deleted
|
||||
if len(rollbackErrs) > 0 {
|
||||
for _, obj := range created {
|
||||
residualIDs = append(residualIDs, fmt.Sprintf("objective:%s", obj.ObjectiveID))
|
||||
}
|
||||
}
|
||||
|
||||
msg := fmt.Sprintf("batch create failed, rolling back: %v", originalErr)
|
||||
if len(rollbackErrs) > 0 {
|
||||
var rollbackMsgs []string
|
||||
for _, e := range rollbackErrs {
|
||||
rollbackMsgs = append(rollbackMsgs, e.Error())
|
||||
}
|
||||
msg += fmt.Sprintf("; rollback also had %d failure(s): %s", len(rollbackErrs), strings.Join(rollbackMsgs, "; "))
|
||||
}
|
||||
if len(residualIDs) > 0 {
|
||||
msg += fmt.Sprintf("; residual objectives that may need manual cleanup (KRs auto-deleted with objective): %s", strings.Join(residualIDs, ", "))
|
||||
}
|
||||
|
||||
// Preserve the original error's type information if it's already a typed error
|
||||
if prob, ok := errs.ProblemOf(originalErr); ok {
|
||||
switch prob.Category {
|
||||
case errs.CategoryAPI:
|
||||
return errs.NewAPIError(prob.Subtype, "%s", msg).WithCause(originalErr)
|
||||
case errs.CategoryNetwork:
|
||||
return errs.NewNetworkError(prob.Subtype, "%s", msg).WithCause(originalErr)
|
||||
case errs.CategoryValidation:
|
||||
return errs.NewValidationError(prob.Subtype, "%s", msg).WithCause(originalErr)
|
||||
case errs.CategoryInternal:
|
||||
return errs.NewInternalError(prob.Subtype, "%s", msg).WithCause(originalErr)
|
||||
default:
|
||||
return errs.NewInternalError(prob.Subtype, "%s", msg).WithCause(originalErr)
|
||||
}
|
||||
}
|
||||
|
||||
return errs.NewInternalError(errs.SubtypeUnknown, "%s", msg).WithCause(originalErr)
|
||||
}
|
||||
@@ -1,593 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package okr
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/internal/httpmock"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
func batchCreateTestConfig(t *testing.T) *core.CliConfig {
|
||||
t.Helper()
|
||||
return &core.CliConfig{
|
||||
AppID: "test-okr-batch-create",
|
||||
AppSecret: "secret-okr-batch-create",
|
||||
Brand: core.BrandFeishu,
|
||||
}
|
||||
}
|
||||
|
||||
func runBatchCreateShortcut(t *testing.T, f *cmdutil.Factory, stdout *bytes.Buffer, args []string) error {
|
||||
t.Helper()
|
||||
parent := &cobra.Command{Use: "okr"}
|
||||
OKRBatchCreate.Mount(parent, f)
|
||||
parent.SetArgs(args)
|
||||
parent.SilenceErrors = true
|
||||
parent.SilenceUsage = true
|
||||
if stdout != nil {
|
||||
stdout.Reset()
|
||||
}
|
||||
return parent.Execute()
|
||||
}
|
||||
|
||||
const validBatchCreateInput = `[
|
||||
{"text":"Objective 1","mention":["ou_123"],"krs":[{"text":"KR 1.1","mention":["ou_456"]}]},
|
||||
{"text":"Objective 2","krs":[{"text":"KR 2.1"},{"text":"KR 2.2"}]}
|
||||
]`
|
||||
|
||||
// --- Validate tests ---
|
||||
|
||||
func TestBatchCreateValidate_MissingCycleID(t *testing.T) {
|
||||
t.Parallel()
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, batchCreateTestConfig(t))
|
||||
err := runBatchCreateShortcut(t, f, stdout, []string{
|
||||
"+batch-create",
|
||||
"--input", validBatchCreateInput,
|
||||
})
|
||||
// cobra Required:true reports flag name without "--" prefix
|
||||
if err == nil || !strings.Contains(err.Error(), "cycle-id") {
|
||||
t.Fatalf("expected --cycle-id required error, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBatchCreateValidate_InvalidCycleID(t *testing.T) {
|
||||
t.Parallel()
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, batchCreateTestConfig(t))
|
||||
err := runBatchCreateShortcut(t, f, stdout, []string{
|
||||
"+batch-create",
|
||||
"--cycle-id", "abc",
|
||||
"--input", validBatchCreateInput,
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("expected error for invalid --cycle-id")
|
||||
}
|
||||
_, ok := errs.ProblemOf(err)
|
||||
if !ok {
|
||||
t.Fatalf("expected typed error, got: %v", err)
|
||||
}
|
||||
validationErr, ok := err.(*errs.ValidationError)
|
||||
if !ok {
|
||||
t.Fatalf("expected ValidationError, got: %T", err)
|
||||
}
|
||||
if validationErr.Param != "--cycle-id" {
|
||||
t.Fatalf("expected param --cycle-id, got %q", validationErr.Param)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBatchCreateValidate_MissingInput(t *testing.T) {
|
||||
t.Parallel()
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, batchCreateTestConfig(t))
|
||||
err := runBatchCreateShortcut(t, f, stdout, []string{
|
||||
"+batch-create",
|
||||
"--cycle-id", "123",
|
||||
})
|
||||
// cobra Required:true reports flag name without "--" prefix
|
||||
if err == nil || !strings.Contains(err.Error(), "input") {
|
||||
t.Fatalf("expected --input required error, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBatchCreateValidate_InvalidInputJSON(t *testing.T) {
|
||||
t.Parallel()
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, batchCreateTestConfig(t))
|
||||
err := runBatchCreateShortcut(t, f, stdout, []string{
|
||||
"+batch-create",
|
||||
"--cycle-id", "123",
|
||||
"--input", "not-json",
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("expected error for invalid --input JSON")
|
||||
}
|
||||
_, ok := errs.ProblemOf(err)
|
||||
if !ok {
|
||||
t.Fatalf("expected typed error, got: %v", err)
|
||||
}
|
||||
validationErr, ok := err.(*errs.ValidationError)
|
||||
if !ok {
|
||||
t.Fatalf("expected ValidationError, got: %T", err)
|
||||
}
|
||||
if validationErr.Param != "--input" {
|
||||
t.Fatalf("expected param --input, got %q", validationErr.Param)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBatchCreateValidate_EmptyInputArray(t *testing.T) {
|
||||
t.Parallel()
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, batchCreateTestConfig(t))
|
||||
err := runBatchCreateShortcut(t, f, stdout, []string{
|
||||
"+batch-create",
|
||||
"--cycle-id", "123",
|
||||
"--input", "[]",
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("expected error for empty --input array")
|
||||
}
|
||||
_, ok := errs.ProblemOf(err)
|
||||
if !ok {
|
||||
t.Fatalf("expected typed error, got: %v", err)
|
||||
}
|
||||
validationErr, ok := err.(*errs.ValidationError)
|
||||
if !ok {
|
||||
t.Fatalf("expected ValidationError, got: %T", err)
|
||||
}
|
||||
if validationErr.Param != "--input" {
|
||||
t.Fatalf("expected param --input, got %q", validationErr.Param)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBatchCreateValidate_EmptyObjectiveText(t *testing.T) {
|
||||
t.Parallel()
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, batchCreateTestConfig(t))
|
||||
err := runBatchCreateShortcut(t, f, stdout, []string{
|
||||
"+batch-create",
|
||||
"--cycle-id", "123",
|
||||
"--input", `[{"text":"","krs":[{"text":"KR 1"}]}]`,
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("expected error for empty objective text")
|
||||
}
|
||||
_, ok := errs.ProblemOf(err)
|
||||
if !ok {
|
||||
t.Fatalf("expected typed error, got: %v", err)
|
||||
}
|
||||
validationErr, ok := err.(*errs.ValidationError)
|
||||
if !ok {
|
||||
t.Fatalf("expected ValidationError, got: %T", err)
|
||||
}
|
||||
if validationErr.Param != "--input" {
|
||||
t.Fatalf("expected param --input, got %q", validationErr.Param)
|
||||
}
|
||||
if !strings.Contains(err.Error(), "objective[0].text") {
|
||||
t.Fatalf("expected error to mention objective[0].text, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBatchCreateValidate_EmptyKRText(t *testing.T) {
|
||||
t.Parallel()
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, batchCreateTestConfig(t))
|
||||
err := runBatchCreateShortcut(t, f, stdout, []string{
|
||||
"+batch-create",
|
||||
"--cycle-id", "123",
|
||||
"--input", `[{"text":"Obj 1","krs":[{"text":""}]}]`,
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("expected error for empty KR text")
|
||||
}
|
||||
_, ok := errs.ProblemOf(err)
|
||||
if !ok {
|
||||
t.Fatalf("expected typed error, got: %v", err)
|
||||
}
|
||||
validationErr, ok := err.(*errs.ValidationError)
|
||||
if !ok {
|
||||
t.Fatalf("expected ValidationError, got: %T", err)
|
||||
}
|
||||
if validationErr.Param != "--input" {
|
||||
t.Fatalf("expected param --input, got %q", validationErr.Param)
|
||||
}
|
||||
if !strings.Contains(err.Error(), "objective[0].krs[0].text") {
|
||||
t.Fatalf("expected error to mention objective[0].krs[0].text, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBatchCreateValidate_InvalidUserIDType(t *testing.T) {
|
||||
t.Parallel()
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, batchCreateTestConfig(t))
|
||||
err := runBatchCreateShortcut(t, f, stdout, []string{
|
||||
"+batch-create",
|
||||
"--cycle-id", "123",
|
||||
"--input", validBatchCreateInput,
|
||||
"--user-id-type", "invalid",
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("expected error for invalid --user-id-type")
|
||||
}
|
||||
_, ok := errs.ProblemOf(err)
|
||||
if !ok {
|
||||
t.Fatalf("expected typed error, got: %v", err)
|
||||
}
|
||||
validationErr, ok := err.(*errs.ValidationError)
|
||||
if !ok {
|
||||
t.Fatalf("expected ValidationError, got: %T", err)
|
||||
}
|
||||
if validationErr.Param != "--user-id-type" {
|
||||
t.Fatalf("expected param --user-id-type, got %q", validationErr.Param)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBatchCreateValidate_Valid(t *testing.T) {
|
||||
t.Parallel()
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, batchCreateTestConfig(t))
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/okr/v2/cycles/123/objectives",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"msg": "ok",
|
||||
"data": map[string]interface{}{
|
||||
"objective_id": "100",
|
||||
},
|
||||
},
|
||||
})
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/okr/v2/objectives/100/key_results",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"msg": "ok",
|
||||
"data": map[string]interface{}{
|
||||
"key_result_id": "200",
|
||||
},
|
||||
},
|
||||
})
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/okr/v2/cycles/123/objectives",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"msg": "ok",
|
||||
"data": map[string]interface{}{
|
||||
"objective_id": "101",
|
||||
},
|
||||
},
|
||||
})
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/okr/v2/objectives/101/key_results",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"msg": "ok",
|
||||
"data": map[string]interface{}{
|
||||
"key_result_id": "201",
|
||||
},
|
||||
},
|
||||
})
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/okr/v2/objectives/101/key_results",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"msg": "ok",
|
||||
"data": map[string]interface{}{
|
||||
"key_result_id": "202",
|
||||
},
|
||||
},
|
||||
})
|
||||
err := runBatchCreateShortcut(t, f, stdout, []string{
|
||||
"+batch-create",
|
||||
"--cycle-id", "123",
|
||||
"--input", validBatchCreateInput,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// --- DryRun tests ---
|
||||
|
||||
func TestBatchCreateDryRun(t *testing.T) {
|
||||
t.Parallel()
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, batchCreateTestConfig(t))
|
||||
err := runBatchCreateShortcut(t, f, stdout, []string{
|
||||
"+batch-create",
|
||||
"--cycle-id", "123",
|
||||
"--input", validBatchCreateInput,
|
||||
"--dry-run",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
output := stdout.String()
|
||||
if !strings.Contains(output, "/open-apis/okr/v2/cycles/123/objectives") {
|
||||
t.Fatalf("dry-run output should contain objective creation API path, got: %s", output)
|
||||
}
|
||||
if !strings.Contains(output, "POST") {
|
||||
t.Fatalf("dry-run output should contain POST method, got: %s", output)
|
||||
}
|
||||
if !strings.Contains(output, "/open-apis/okr/v2/objectives/") || !strings.Contains(output, "/key_results") {
|
||||
t.Fatalf("dry-run output should contain KR creation API path, got: %s", output)
|
||||
}
|
||||
// Verify content is in the body
|
||||
if !strings.Contains(output, "Objective 1") {
|
||||
t.Fatalf("dry-run output should contain objective text, got: %s", output)
|
||||
}
|
||||
if !strings.Contains(output, "KR 1.1") {
|
||||
t.Fatalf("dry-run output should contain KR text, got: %s", output)
|
||||
}
|
||||
}
|
||||
|
||||
// --- Execute tests ---
|
||||
|
||||
func TestBatchCreateExecute_Success(t *testing.T) {
|
||||
t.Parallel()
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, batchCreateTestConfig(t))
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/okr/v2/cycles/123/objectives",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"msg": "ok",
|
||||
"data": map[string]interface{}{
|
||||
"objective_id": "100",
|
||||
},
|
||||
},
|
||||
})
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/okr/v2/objectives/100/key_results",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"msg": "ok",
|
||||
"data": map[string]interface{}{
|
||||
"key_result_id": "200",
|
||||
},
|
||||
},
|
||||
})
|
||||
err := runBatchCreateShortcut(t, f, stdout, []string{
|
||||
"+batch-create",
|
||||
"--cycle-id", "123",
|
||||
"--input", `[{"text":"Obj 1","krs":[{"text":"KR 1"}]}]`,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
// Verify output
|
||||
data := decodeEnvelope(t, stdout)
|
||||
ok, _ := data["ok"].(bool)
|
||||
if !ok {
|
||||
t.Fatal("expected ok=true in output")
|
||||
}
|
||||
dataField, _ := data["data"].(map[string]interface{})
|
||||
created, _ := dataField["created"].([]interface{})
|
||||
if len(created) != 1 {
|
||||
t.Fatalf("expected 1 created objective, got %d", len(created))
|
||||
}
|
||||
obj, _ := created[0].(map[string]interface{})
|
||||
if obj["objective_id"] != "100" {
|
||||
t.Fatalf("expected objective_id=100, got %v", obj["objective_id"])
|
||||
}
|
||||
krs, _ := obj["krs"].([]interface{})
|
||||
if len(krs) != 1 || krs[0] != "200" {
|
||||
t.Fatalf("expected krs=[200], got %v", krs)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBatchCreateExecute_APIErrorOnObjective(t *testing.T) {
|
||||
t.Parallel()
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, batchCreateTestConfig(t))
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/okr/v2/cycles/123/objectives",
|
||||
Status: 400,
|
||||
Body: map[string]interface{}{
|
||||
"code": 1001001,
|
||||
"msg": "invalid parameters",
|
||||
},
|
||||
})
|
||||
err := runBatchCreateShortcut(t, f, stdout, []string{
|
||||
"+batch-create",
|
||||
"--cycle-id", "123",
|
||||
"--input", `[{"text":"Obj 1"}]`,
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("expected error for API failure")
|
||||
}
|
||||
// Should be a typed error from the API
|
||||
prob, ok := errs.ProblemOf(err)
|
||||
if !ok {
|
||||
t.Fatalf("expected typed error, got: %v", err)
|
||||
}
|
||||
if prob.Category != "api" {
|
||||
t.Fatalf("expected api category, got %q", prob.Category)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBatchCreateExecute_APIErrorOnKR_TriggersRollback(t *testing.T) {
|
||||
t.Parallel()
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, batchCreateTestConfig(t))
|
||||
// First objective creation succeeds
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/okr/v2/cycles/123/objectives",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"msg": "ok",
|
||||
"data": map[string]interface{}{
|
||||
"objective_id": "100",
|
||||
},
|
||||
},
|
||||
})
|
||||
// KR creation fails
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/okr/v2/objectives/100/key_results",
|
||||
Status: 400,
|
||||
Body: map[string]interface{}{
|
||||
"code": 1001001,
|
||||
"msg": "invalid parameters",
|
||||
},
|
||||
})
|
||||
// Rollback: delete the created objective
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "DELETE",
|
||||
URL: "/open-apis/okr/v2/objectives/100",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"msg": "ok",
|
||||
"data": map[string]interface{}{
|
||||
"objective_id": "100",
|
||||
},
|
||||
},
|
||||
})
|
||||
err := runBatchCreateShortcut(t, f, stdout, []string{
|
||||
"+batch-create",
|
||||
"--cycle-id", "123",
|
||||
"--input", `[{"text":"Obj 1","krs":[{"text":"KR 1"}]}]`,
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("expected error for KR creation failure")
|
||||
}
|
||||
// Error should mention rollback
|
||||
if !strings.Contains(err.Error(), "rolling back") && !strings.Contains(err.Error(), "rollback") {
|
||||
t.Fatalf("expected error to mention rollback, got: %v", err)
|
||||
}
|
||||
// Assert typed error metadata
|
||||
prob, ok := errs.ProblemOf(err)
|
||||
if !ok {
|
||||
t.Fatalf("expected typed error, got: %v", err)
|
||||
}
|
||||
if prob.Category != errs.CategoryAPI {
|
||||
t.Fatalf("expected api category (preserved from original error), got %q", prob.Category)
|
||||
}
|
||||
// Assert cause preservation
|
||||
var apiErr *errs.APIError
|
||||
if !errors.As(err, &apiErr) {
|
||||
t.Fatalf("expected error to wrap APIError, got: %T", err)
|
||||
}
|
||||
if !errors.Is(err, apiErr) {
|
||||
t.Fatal("expected errors.Is to find the wrapped APIError")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBatchCreateExecute_RollbackDeleteFails(t *testing.T) {
|
||||
t.Parallel()
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, batchCreateTestConfig(t))
|
||||
// Objective creation succeeds
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/okr/v2/cycles/123/objectives",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"msg": "ok",
|
||||
"data": map[string]interface{}{
|
||||
"objective_id": "100",
|
||||
},
|
||||
},
|
||||
})
|
||||
// KR creation fails
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/okr/v2/objectives/100/key_results",
|
||||
Status: 400,
|
||||
Body: map[string]interface{}{
|
||||
"code": 1001001,
|
||||
"msg": "invalid parameters",
|
||||
},
|
||||
})
|
||||
// Rollback delete also fails
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "DELETE",
|
||||
URL: "/open-apis/okr/v2/objectives/100",
|
||||
Status: 500,
|
||||
Body: map[string]interface{}{
|
||||
"code": 9999999,
|
||||
"msg": "internal error",
|
||||
},
|
||||
})
|
||||
err := runBatchCreateShortcut(t, f, stdout, []string{
|
||||
"+batch-create",
|
||||
"--cycle-id", "123",
|
||||
"--input", `[{"text":"Obj 1","krs":[{"text":"KR 1"}]}]`,
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("expected error for KR creation failure")
|
||||
}
|
||||
// Error should mention residual resources
|
||||
if !strings.Contains(err.Error(), "residual") && !strings.Contains(err.Error(), "manual cleanup") {
|
||||
t.Fatalf("expected error to mention residual resources, got: %v", err)
|
||||
}
|
||||
if !strings.Contains(err.Error(), "objective:100") {
|
||||
t.Fatalf("expected error to list residual objective ID, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// --- Unit tests for helper functions ---
|
||||
|
||||
func TestParseBatchCreateInput_Valid(t *testing.T) {
|
||||
t.Parallel()
|
||||
input := `[{"text":"Obj 1","mention":["ou_123"],"krs":[{"text":"KR 1","mention":["ou_456"]}]}]`
|
||||
objs, err := parseBatchCreateInput(input)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if len(objs) != 1 {
|
||||
t.Fatalf("expected 1 objective, got %d", len(objs))
|
||||
}
|
||||
if objs[0].Text != "Obj 1" {
|
||||
t.Fatalf("expected text 'Obj 1', got %q", objs[0].Text)
|
||||
}
|
||||
if len(objs[0].Mention) != 1 || objs[0].Mention[0] != "ou_123" {
|
||||
t.Fatalf("expected mention ['ou_123'], got %v", objs[0].Mention)
|
||||
}
|
||||
if len(objs[0].KRs) != 1 {
|
||||
t.Fatalf("expected 1 KR, got %d", len(objs[0].KRs))
|
||||
}
|
||||
if objs[0].KRs[0].Text != "KR 1" {
|
||||
t.Fatalf("expected KR text 'KR 1', got %q", objs[0].KRs[0].Text)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildContentBlock(t *testing.T) {
|
||||
t.Parallel()
|
||||
cb := buildContentBlock("Test text", []string{"ou_123", "ou_456"})
|
||||
if cb == nil {
|
||||
t.Fatal("expected non-nil ContentBlock")
|
||||
}
|
||||
if len(cb.Blocks) != 1 {
|
||||
t.Fatalf("expected 1 block, got %d", len(cb.Blocks))
|
||||
}
|
||||
block := cb.Blocks[0]
|
||||
if block.BlockElementType == nil || *block.BlockElementType != BlockElementTypeParagraph {
|
||||
t.Fatalf("expected paragraph block type")
|
||||
}
|
||||
if block.Paragraph == nil {
|
||||
t.Fatal("expected non-nil paragraph")
|
||||
}
|
||||
// Should have 3 elements: 1 text + 2 mentions
|
||||
if len(block.Paragraph.Elements) != 3 {
|
||||
t.Fatalf("expected 3 paragraph elements, got %d", len(block.Paragraph.Elements))
|
||||
}
|
||||
// First element should be textRun
|
||||
if block.Paragraph.Elements[0].ParagraphElementType == nil ||
|
||||
*block.Paragraph.Elements[0].ParagraphElementType != ParagraphElementTypeTextRun {
|
||||
t.Fatal("expected first element to be textRun")
|
||||
}
|
||||
if block.Paragraph.Elements[0].TextRun == nil || *block.Paragraph.Elements[0].TextRun.Text != "Test text" {
|
||||
t.Fatalf("expected text 'Test text', got %v", block.Paragraph.Elements[0].TextRun)
|
||||
}
|
||||
// Second and third should be mentions
|
||||
for i := 1; i <= 2; i++ {
|
||||
if block.Paragraph.Elements[i].ParagraphElementType == nil ||
|
||||
*block.Paragraph.Elements[i].ParagraphElementType != ParagraphElementTypeMention {
|
||||
t.Fatalf("expected element %d to be mention", i)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,178 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package okr
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"math"
|
||||
"strconv"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
// parseIndicatorValue parses and validates the indicator value.
|
||||
func parseIndicatorValue(valueStr string) (float64, error) {
|
||||
value, err := strconv.ParseFloat(valueStr, 64)
|
||||
if err != nil {
|
||||
return 0, errs.NewValidationError(errs.SubtypeInvalidArgument, "--value must be a number between -99999999999 and 99999999999").WithParam("--value").WithCause(err)
|
||||
}
|
||||
if math.IsNaN(value) || math.IsInf(value, 0) || value < -99999999999 || value > 99999999999 {
|
||||
return 0, errs.NewValidationError(errs.SubtypeInvalidArgument, "--value must be a number between -99999999999 and 99999999999").WithParam("--value")
|
||||
}
|
||||
return value, nil
|
||||
}
|
||||
|
||||
// fetchIndicatorID fetches the indicator ID for an objective or key result.
|
||||
// The indicators.list API returns a single indicator object (not a list),
|
||||
// which always exists (may be a default empty indicator).
|
||||
func fetchIndicatorID(ctx context.Context, runtime *common.RuntimeContext, level string, id string) (string, error) {
|
||||
var path string
|
||||
var params map[string]interface{}
|
||||
|
||||
if level == "objective" {
|
||||
path = fmt.Sprintf("/open-apis/okr/v2/objectives/%s/indicators", id)
|
||||
params = map[string]interface{}{"page_size": 100}
|
||||
} else {
|
||||
path = fmt.Sprintf("/open-apis/okr/v2/key_results/%s/indicators", id)
|
||||
params = map[string]interface{}{"page_size": 100}
|
||||
}
|
||||
|
||||
data, err := runtime.CallAPITyped("GET", path, params, nil)
|
||||
if err != nil {
|
||||
return "", wrapOkrNetworkErr(err, "failed to fetch indicators")
|
||||
}
|
||||
|
||||
// Parse response to get indicator ID
|
||||
// Response format: {"indicator": {"id": "...", ...}} (single object, not a list)
|
||||
indicator, ok := data["indicator"].(map[string]interface{})
|
||||
if !ok {
|
||||
return "", errs.NewInternalError(errs.SubtypeUnknown, "indicator field not found in response")
|
||||
}
|
||||
|
||||
indicatorID, ok := indicator["id"].(string)
|
||||
if !ok || indicatorID == "" {
|
||||
return "", errs.NewInternalError(errs.SubtypeUnknown, "indicator ID not found or empty")
|
||||
}
|
||||
|
||||
return indicatorID, nil
|
||||
}
|
||||
|
||||
// OKRIndicatorUpdate updates the current value of an indicator for an objective or key result.
|
||||
var OKRIndicatorUpdate = common.Shortcut{
|
||||
Service: "okr",
|
||||
Command: "+indicator-update",
|
||||
Description: "Update the indicator current value for an objective or key result",
|
||||
Risk: "write",
|
||||
Scopes: []string{"okr:okr.content:writeonly"},
|
||||
AuthTypes: []string{"user", "bot"},
|
||||
HasFormat: true,
|
||||
Flags: []common.Flag{
|
||||
{Name: "level", Desc: "level to update: objective | key-result, Required.", Enum: []string{"objective", "key-result"}},
|
||||
{Name: "id", Desc: "objective or key result ID (int64), Required."},
|
||||
{Name: "value", Desc: "new current value for the indicator (number), Required."},
|
||||
},
|
||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
level := runtime.Str("level")
|
||||
if level == "" {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--level is required").WithParam("--level")
|
||||
}
|
||||
if level != "objective" && level != "key-result" {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--level must be one of: objective | key-result").WithParam("--level")
|
||||
}
|
||||
|
||||
id := runtime.Str("id")
|
||||
if id == "" {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--id is required").WithParam("--id")
|
||||
}
|
||||
if _, err := strconv.ParseInt(id, 10, 64); err != nil {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--id must be a valid int64").WithParam("--id")
|
||||
}
|
||||
if runtime.Str("value") == "" {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--value is required").WithParam("--value")
|
||||
}
|
||||
if _, err := parseIndicatorValue(runtime.Str("value")); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
},
|
||||
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
level := runtime.Str("level")
|
||||
id := runtime.Str("id")
|
||||
value, _ := parseIndicatorValue(runtime.Str("value"))
|
||||
|
||||
apis := common.NewDryRunAPI()
|
||||
|
||||
var listPath string
|
||||
if level == "objective" {
|
||||
listPath = fmt.Sprintf("/open-apis/okr/v2/objectives/%s/indicators", id)
|
||||
} else {
|
||||
listPath = fmt.Sprintf("/open-apis/okr/v2/key_results/%s/indicators", id)
|
||||
}
|
||||
|
||||
// First API: fetch indicator list
|
||||
apis = apis.
|
||||
GET(listPath).
|
||||
Params(map[string]interface{}{"page_size": 100}).
|
||||
Desc(fmt.Sprintf("Fetch indicators for the %s to get indicator ID", level))
|
||||
|
||||
// Second API: patch indicator value
|
||||
patchPath := "/open-apis/okr/v2/indicators/:indicator_id"
|
||||
patchBody := map[string]interface{}{
|
||||
"current_value": value,
|
||||
}
|
||||
apis = apis.
|
||||
PATCH(patchPath).
|
||||
Body(patchBody).
|
||||
Set("indicator_id", "<indicator_id_from_list>").
|
||||
Desc("Update indicator current value")
|
||||
|
||||
return apis
|
||||
},
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
level := runtime.Str("level")
|
||||
id := runtime.Str("id")
|
||||
value, err := parseIndicatorValue(runtime.Str("value"))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Step 1: Fetch indicator ID
|
||||
indicatorID, err := fetchIndicatorID(ctx, runtime, level, id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Step 2: Update indicator value
|
||||
patchPath := fmt.Sprintf("/open-apis/okr/v2/indicators/%s", indicatorID)
|
||||
patchBody := map[string]interface{}{
|
||||
"current_value": value,
|
||||
}
|
||||
|
||||
_, err = runtime.CallAPITyped("PATCH", patchPath, nil, patchBody)
|
||||
if err != nil {
|
||||
return wrapOkrNetworkErr(err, "failed to update indicator value")
|
||||
}
|
||||
|
||||
// Build response
|
||||
result := map[string]interface{}{
|
||||
"indicator_id": indicatorID,
|
||||
"current_value": value,
|
||||
"level": level,
|
||||
"target_id": id,
|
||||
}
|
||||
|
||||
runtime.OutFormat(result, nil, func(w io.Writer) {
|
||||
fmt.Fprintf(w, "Updated Indicator [%s]\n", indicatorID)
|
||||
fmt.Fprintf(w, " Level: %s\n", level)
|
||||
fmt.Fprintf(w, " Target ID: %s\n", id)
|
||||
fmt.Fprintf(w, " Current Value: %v\n", value)
|
||||
})
|
||||
|
||||
return nil
|
||||
},
|
||||
}
|
||||
@@ -1,391 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package okr
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/internal/httpmock"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
func indicatorUpdateTestConfig(t *testing.T) *core.CliConfig {
|
||||
t.Helper()
|
||||
return &core.CliConfig{
|
||||
AppID: "test-okr-indicator-update",
|
||||
AppSecret: "secret-okr-indicator-update",
|
||||
Brand: core.BrandFeishu,
|
||||
}
|
||||
}
|
||||
|
||||
func runIndicatorUpdateShortcut(t *testing.T, f *cmdutil.Factory, stdout *bytes.Buffer, args []string) error {
|
||||
t.Helper()
|
||||
parent := &cobra.Command{Use: "okr"}
|
||||
OKRIndicatorUpdate.Mount(parent, f)
|
||||
parent.SetArgs(args)
|
||||
parent.SilenceErrors = true
|
||||
parent.SilenceUsage = true
|
||||
if stdout != nil {
|
||||
stdout.Reset()
|
||||
}
|
||||
return parent.Execute()
|
||||
}
|
||||
|
||||
// --- Validate tests ---
|
||||
|
||||
func TestIndicatorUpdateValidate_MissingLevel(t *testing.T) {
|
||||
t.Parallel()
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, indicatorUpdateTestConfig(t))
|
||||
err := runIndicatorUpdateShortcut(t, f, stdout, []string{
|
||||
"+indicator-update",
|
||||
"--id", "123",
|
||||
"--value", "50",
|
||||
})
|
||||
// cobra Required:true reports flag name without "--" prefix
|
||||
if err == nil || !strings.Contains(err.Error(), "level") {
|
||||
t.Fatalf("expected --level required error, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestIndicatorUpdateValidate_InvalidLevel(t *testing.T) {
|
||||
t.Parallel()
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, indicatorUpdateTestConfig(t))
|
||||
err := runIndicatorUpdateShortcut(t, f, stdout, []string{
|
||||
"+indicator-update",
|
||||
"--level", "invalid",
|
||||
"--id", "123",
|
||||
"--value", "50",
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("expected error for invalid level")
|
||||
}
|
||||
_, ok := errs.ProblemOf(err)
|
||||
if !ok {
|
||||
t.Fatalf("expected typed error, got: %v", err)
|
||||
}
|
||||
validationErr, ok := err.(*errs.ValidationError)
|
||||
if !ok {
|
||||
t.Fatalf("expected ValidationError, got: %T", err)
|
||||
}
|
||||
if validationErr.Param != "--level" {
|
||||
t.Fatalf("expected param --level, got %q", validationErr.Param)
|
||||
}
|
||||
}
|
||||
|
||||
func TestIndicatorUpdateValidate_MissingID(t *testing.T) {
|
||||
t.Parallel()
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, indicatorUpdateTestConfig(t))
|
||||
err := runIndicatorUpdateShortcut(t, f, stdout, []string{
|
||||
"+indicator-update",
|
||||
"--level", "objective",
|
||||
"--value", "50",
|
||||
})
|
||||
if err == nil || !strings.Contains(err.Error(), "id") {
|
||||
t.Fatalf("expected --id required error, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestIndicatorUpdateValidate_InvalidID(t *testing.T) {
|
||||
t.Parallel()
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, indicatorUpdateTestConfig(t))
|
||||
err := runIndicatorUpdateShortcut(t, f, stdout, []string{
|
||||
"+indicator-update",
|
||||
"--level", "objective",
|
||||
"--id", "not-a-number",
|
||||
"--value", "50",
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("expected error for invalid id")
|
||||
}
|
||||
_, ok := errs.ProblemOf(err)
|
||||
if !ok {
|
||||
t.Fatalf("expected typed error, got: %v", err)
|
||||
}
|
||||
validationErr, ok := err.(*errs.ValidationError)
|
||||
if !ok {
|
||||
t.Fatalf("expected ValidationError, got: %T", err)
|
||||
}
|
||||
if validationErr.Param != "--id" {
|
||||
t.Fatalf("expected param --id, got %q", validationErr.Param)
|
||||
}
|
||||
}
|
||||
|
||||
func TestIndicatorUpdateValidate_MissingValue(t *testing.T) {
|
||||
t.Parallel()
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, indicatorUpdateTestConfig(t))
|
||||
err := runIndicatorUpdateShortcut(t, f, stdout, []string{
|
||||
"+indicator-update",
|
||||
"--level", "objective",
|
||||
"--id", "123",
|
||||
})
|
||||
if err == nil || !strings.Contains(err.Error(), "value") {
|
||||
t.Fatalf("expected --value required error, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestIndicatorUpdateValidate_InvalidValue(t *testing.T) {
|
||||
t.Parallel()
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, indicatorUpdateTestConfig(t))
|
||||
err := runIndicatorUpdateShortcut(t, f, stdout, []string{
|
||||
"+indicator-update",
|
||||
"--level", "objective",
|
||||
"--id", "123",
|
||||
"--value", "not-a-number",
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("expected error for invalid value")
|
||||
}
|
||||
_, ok := errs.ProblemOf(err)
|
||||
if !ok {
|
||||
t.Fatalf("expected typed error, got: %v", err)
|
||||
}
|
||||
validationErr, ok := err.(*errs.ValidationError)
|
||||
if !ok {
|
||||
t.Fatalf("expected ValidationError, got: %T", err)
|
||||
}
|
||||
if validationErr.Param != "--value" {
|
||||
t.Fatalf("expected param --value, got %q", validationErr.Param)
|
||||
}
|
||||
}
|
||||
|
||||
func TestIndicatorUpdateValidate_Valid(t *testing.T) {
|
||||
t.Parallel()
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, indicatorUpdateTestConfig(t))
|
||||
// Mock fetch indicators
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/okr/v2/objectives/123/indicators",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"msg": "ok",
|
||||
"data": map[string]interface{}{
|
||||
"indicator": map[string]interface{}{
|
||||
"id": "ind-456",
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
// Mock patch indicator
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "PATCH",
|
||||
URL: "/open-apis/okr/v2/indicators/ind-456",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"msg": "ok",
|
||||
},
|
||||
})
|
||||
err := runIndicatorUpdateShortcut(t, f, stdout, []string{
|
||||
"+indicator-update",
|
||||
"--level", "objective",
|
||||
"--id", "123",
|
||||
"--value", "75.5",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// --- Execute tests ---
|
||||
|
||||
func TestIndicatorUpdateExecute_Objectives_Success(t *testing.T) {
|
||||
t.Parallel()
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, indicatorUpdateTestConfig(t))
|
||||
// Mock fetch indicators
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/okr/v2/objectives/123/indicators",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"msg": "ok",
|
||||
"data": map[string]interface{}{
|
||||
"indicator": map[string]interface{}{
|
||||
"id": "ind-456",
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
// Mock patch indicator
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "PATCH",
|
||||
URL: "/open-apis/okr/v2/indicators/ind-456",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"msg": "ok",
|
||||
},
|
||||
BodyFilter: func(body []byte) bool {
|
||||
var data map[string]interface{}
|
||||
if err := json.Unmarshal(body, &data); err != nil {
|
||||
return false
|
||||
}
|
||||
val, ok := data["current_value"].(float64)
|
||||
return ok && val == 75.5
|
||||
},
|
||||
})
|
||||
err := runIndicatorUpdateShortcut(t, f, stdout, []string{
|
||||
"+indicator-update",
|
||||
"--level", "objective",
|
||||
"--id", "123",
|
||||
"--value", "75.5",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestIndicatorUpdateExecute_KeyResults_Success(t *testing.T) {
|
||||
t.Parallel()
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, indicatorUpdateTestConfig(t))
|
||||
// Mock fetch indicators
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/okr/v2/key_results/456/indicators",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"msg": "ok",
|
||||
"data": map[string]interface{}{
|
||||
"indicator": map[string]interface{}{
|
||||
"id": "ind-789",
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
// Mock patch indicator
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "PATCH",
|
||||
URL: "/open-apis/okr/v2/indicators/ind-789",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"msg": "ok",
|
||||
},
|
||||
})
|
||||
err := runIndicatorUpdateShortcut(t, f, stdout, []string{
|
||||
"+indicator-update",
|
||||
"--level", "key-result",
|
||||
"--id", "456",
|
||||
"--value", "100",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestIndicatorUpdateExecute_FetchAPIError(t *testing.T) {
|
||||
t.Parallel()
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, indicatorUpdateTestConfig(t))
|
||||
// Mock fetch indicators - API error
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/okr/v2/objectives/123/indicators",
|
||||
Body: map[string]interface{}{
|
||||
"code": 9999,
|
||||
"msg": "fetch error",
|
||||
},
|
||||
})
|
||||
err := runIndicatorUpdateShortcut(t, f, stdout, []string{
|
||||
"+indicator-update",
|
||||
"--level", "objective",
|
||||
"--id", "123",
|
||||
"--value", "50",
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("expected error for fetch API failure")
|
||||
}
|
||||
prob, ok := errs.ProblemOf(err)
|
||||
if !ok {
|
||||
t.Fatalf("expected typed error, got: %v", err)
|
||||
}
|
||||
if prob.Category != errs.CategoryAPI {
|
||||
t.Fatalf("expected CategoryAPI, got %q", prob.Category)
|
||||
}
|
||||
var apiErr *errs.APIError
|
||||
if !errors.As(err, &apiErr) {
|
||||
t.Fatalf("expected error to be *errs.APIError, got: %T", err)
|
||||
}
|
||||
if !errors.Is(err, apiErr) {
|
||||
t.Fatal("errors.Is should find the APIError in the chain")
|
||||
}
|
||||
}
|
||||
|
||||
func TestIndicatorUpdateExecute_PatchAPIError(t *testing.T) {
|
||||
t.Parallel()
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, indicatorUpdateTestConfig(t))
|
||||
// Mock fetch indicators
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/okr/v2/objectives/123/indicators",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"msg": "ok",
|
||||
"data": map[string]interface{}{
|
||||
"indicator": map[string]interface{}{
|
||||
"id": "ind-456",
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
// Mock patch indicator - API error
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "PATCH",
|
||||
URL: "/open-apis/okr/v2/indicators/ind-456",
|
||||
Body: map[string]interface{}{
|
||||
"code": 9999,
|
||||
"msg": "patch error",
|
||||
},
|
||||
})
|
||||
err := runIndicatorUpdateShortcut(t, f, stdout, []string{
|
||||
"+indicator-update",
|
||||
"--level", "objective",
|
||||
"--id", "123",
|
||||
"--value", "50",
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("expected error for patch API failure")
|
||||
}
|
||||
prob, ok := errs.ProblemOf(err)
|
||||
if !ok {
|
||||
t.Fatalf("expected typed error, got: %v", err)
|
||||
}
|
||||
if prob.Category != errs.CategoryAPI {
|
||||
t.Fatalf("expected CategoryAPI, got %q", prob.Category)
|
||||
}
|
||||
var apiErr *errs.APIError
|
||||
if !errors.As(err, &apiErr) {
|
||||
t.Fatalf("expected error to be *errs.APIError, got: %T", err)
|
||||
}
|
||||
if !errors.Is(err, apiErr) {
|
||||
t.Fatal("errors.Is should find the APIError in the chain")
|
||||
}
|
||||
}
|
||||
|
||||
// --- parseIndicatorValue tests ---
|
||||
|
||||
func TestParseIndicatorValue_Valid(t *testing.T) {
|
||||
t.Parallel()
|
||||
tests := []string{"0", "100", "75.5", "-10", "0.001", "99999999999"}
|
||||
for _, v := range tests {
|
||||
result, err := parseIndicatorValue(v)
|
||||
if err != nil {
|
||||
t.Fatalf("expected no error for %q, got: %v", v, err)
|
||||
}
|
||||
_ = result
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseIndicatorValue_Invalid(t *testing.T) {
|
||||
t.Parallel()
|
||||
tests := []string{"", "abc", "1e100000", "100000000000"}
|
||||
for _, v := range tests {
|
||||
_, err := parseIndicatorValue(v)
|
||||
if err == nil {
|
||||
t.Fatalf("expected error for %q", v)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,448 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package okr
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
// reorderItem is the interface for items that have an ID.
|
||||
type reorderItem interface {
|
||||
GetID() string
|
||||
}
|
||||
|
||||
// reorderOp represents a single reorder operation.
|
||||
type reorderOp struct {
|
||||
ID string `json:"id"`
|
||||
Position int32 `json:"position"`
|
||||
}
|
||||
|
||||
// parseReorderOps parses and validates the --ops JSON array.
|
||||
func parseReorderOps(opsStr string) ([]reorderOp, error) {
|
||||
var ops []reorderOp
|
||||
if err := json.Unmarshal([]byte(opsStr), &ops); err != nil {
|
||||
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "--ops must be valid JSON array: %s", err).WithParam("--ops").WithCause(err)
|
||||
}
|
||||
if len(ops) == 0 {
|
||||
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "--ops must contain at least one operation").WithParam("--ops")
|
||||
}
|
||||
|
||||
seen := make(map[string]bool)
|
||||
seenPos := make(map[int32]bool)
|
||||
for i, op := range ops {
|
||||
if strings.TrimSpace(op.ID) == "" {
|
||||
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "ops[%d].id is required and cannot be empty", i).WithParam("--ops")
|
||||
}
|
||||
if op.Position <= 0 {
|
||||
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "ops[%d].position must be a positive integer", i).WithParam("--ops")
|
||||
}
|
||||
if seen[op.ID] {
|
||||
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "duplicate id %q in --ops", op.ID).WithParam("--ops")
|
||||
}
|
||||
if seenPos[op.Position] {
|
||||
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "duplicate position %d in --ops", op.Position).WithParam("--ops")
|
||||
}
|
||||
seen[op.ID] = true
|
||||
seenPos[op.Position] = true
|
||||
}
|
||||
return ops, nil
|
||||
}
|
||||
|
||||
// fetchObjectives fetches all objectives in a cycle.
|
||||
func fetchObjectives(ctx context.Context, runtime *common.RuntimeContext, cycleID string) ([]Objective, error) {
|
||||
queryParams := map[string]interface{}{"page_size": "100"}
|
||||
var objectives []Objective
|
||||
page := 0
|
||||
|
||||
for {
|
||||
if page > 0 {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return nil, ctx.Err()
|
||||
case <-time.After(500 * time.Millisecond):
|
||||
}
|
||||
}
|
||||
page++
|
||||
|
||||
path := fmt.Sprintf("/open-apis/okr/v2/cycles/%s/objectives", cycleID)
|
||||
data, err := runtime.CallAPITyped("GET", path, queryParams, nil)
|
||||
if err != nil {
|
||||
return nil, wrapOkrNetworkErr(err, "failed to fetch objectives")
|
||||
}
|
||||
|
||||
itemsRaw, _ := data["items"].([]interface{})
|
||||
for _, item := range itemsRaw {
|
||||
raw, err := json.Marshal(item)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
var obj Objective
|
||||
if err := json.Unmarshal(raw, &obj); err != nil {
|
||||
continue
|
||||
}
|
||||
objectives = append(objectives, obj)
|
||||
}
|
||||
|
||||
hasMore, pageToken := common.PaginationMeta(data)
|
||||
if !hasMore || pageToken == "" {
|
||||
break
|
||||
}
|
||||
queryParams["page_token"] = pageToken
|
||||
}
|
||||
|
||||
// Sort objectives by position
|
||||
sort.Slice(objectives, func(i, j int) bool {
|
||||
pi := int32(0)
|
||||
if objectives[i].Position != nil {
|
||||
pi = *objectives[i].Position
|
||||
}
|
||||
pj := int32(0)
|
||||
if objectives[j].Position != nil {
|
||||
pj = *objectives[j].Position
|
||||
}
|
||||
return pi < pj
|
||||
})
|
||||
|
||||
return objectives, nil
|
||||
}
|
||||
|
||||
// fetchKeyResults fetches all key results for an objective.
|
||||
func fetchKeyResults(ctx context.Context, runtime *common.RuntimeContext, objectiveID string) ([]KeyResult, error) {
|
||||
queryParams := map[string]interface{}{"page_size": "100"}
|
||||
var keyResults []KeyResult
|
||||
page := 0
|
||||
|
||||
for {
|
||||
if page > 0 {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return nil, ctx.Err()
|
||||
case <-time.After(500 * time.Millisecond):
|
||||
}
|
||||
}
|
||||
page++
|
||||
|
||||
path := fmt.Sprintf("/open-apis/okr/v2/objectives/%s/key_results", objectiveID)
|
||||
data, err := runtime.CallAPITyped("GET", path, queryParams, nil)
|
||||
if err != nil {
|
||||
return nil, wrapOkrNetworkErr(err, "failed to fetch key results")
|
||||
}
|
||||
|
||||
itemsRaw, _ := data["items"].([]interface{})
|
||||
for _, item := range itemsRaw {
|
||||
raw, err := json.Marshal(item)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
var kr KeyResult
|
||||
if err := json.Unmarshal(raw, &kr); err != nil {
|
||||
continue
|
||||
}
|
||||
keyResults = append(keyResults, kr)
|
||||
}
|
||||
|
||||
hasMore, pageToken := common.PaginationMeta(data)
|
||||
if !hasMore || pageToken == "" {
|
||||
break
|
||||
}
|
||||
queryParams["page_token"] = pageToken
|
||||
}
|
||||
|
||||
// Sort key results by position
|
||||
sort.Slice(keyResults, func(i, j int) bool {
|
||||
pi := int32(0)
|
||||
if keyResults[i].Position != nil {
|
||||
pi = *keyResults[i].Position
|
||||
}
|
||||
pj := int32(0)
|
||||
if keyResults[j].Position != nil {
|
||||
pj = *keyResults[j].Position
|
||||
}
|
||||
return pi < pj
|
||||
})
|
||||
|
||||
return keyResults, nil
|
||||
}
|
||||
|
||||
// buildReorderedIDs builds the complete ordered ID list from current items and reorder ops.
|
||||
// Positions are treated as 1-indexed placement keys stored in a map (safe for large values).
|
||||
// Items are first placed at user-specified positions, remaining items fill empty slots
|
||||
// in original order starting from position 1, and final output is sorted by position.
|
||||
func buildReorderedIDs[T reorderItem](items []T, ops []reorderOp, total int) ([]string, error) {
|
||||
// Create a map of ID to current position
|
||||
idToPos := make(map[string]int)
|
||||
for i, item := range items {
|
||||
idToPos[item.GetID()] = i
|
||||
}
|
||||
|
||||
// Validate all ops IDs exist
|
||||
for _, op := range ops {
|
||||
if _, ok := idToPos[op.ID]; !ok {
|
||||
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "id %q not found in current list", op.ID).WithParam("--ops")
|
||||
}
|
||||
}
|
||||
|
||||
// Use map to store position -> ID (1-indexed, safe for large position values)
|
||||
posToID := make(map[int]string)
|
||||
used := make(map[string]bool)
|
||||
for _, op := range ops {
|
||||
posToID[int(op.Position)] = op.ID
|
||||
used[op.ID] = true
|
||||
}
|
||||
|
||||
// Collect unused items in original order
|
||||
var unused []string
|
||||
for _, item := range items {
|
||||
id := item.GetID()
|
||||
if !used[id] {
|
||||
unused = append(unused, id)
|
||||
}
|
||||
}
|
||||
|
||||
// Fill empty slots starting from position 1, in original order
|
||||
unusedIdx := 0
|
||||
for pos := 1; unusedIdx < len(unused); pos++ {
|
||||
if _, occupied := posToID[pos]; !occupied {
|
||||
posToID[pos] = unused[unusedIdx]
|
||||
unusedIdx++
|
||||
}
|
||||
}
|
||||
|
||||
// Collect all positions, sort them, and build result in position order
|
||||
positions := make([]int, 0, len(posToID))
|
||||
for pos := range posToID {
|
||||
positions = append(positions, pos)
|
||||
}
|
||||
sort.Ints(positions)
|
||||
|
||||
result := make([]string, 0, len(positions))
|
||||
for _, pos := range positions {
|
||||
result = append(result, posToID[pos])
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// GetID implements the interface for Objective.
|
||||
func (o Objective) GetID() string { return o.ID }
|
||||
|
||||
// GetID implements the interface for KeyResult.
|
||||
func (k KeyResult) GetID() string { return k.ID }
|
||||
|
||||
// OKRReorder adjusts the position of objectives or key results.
|
||||
var OKRReorder = common.Shortcut{
|
||||
Service: "okr",
|
||||
Command: "+reorder",
|
||||
Description: "Adjust the position (order) of OKR objectives or key results",
|
||||
Risk: "write",
|
||||
Scopes: []string{"okr:okr.content:writeonly"},
|
||||
AuthTypes: []string{"user", "bot"},
|
||||
HasFormat: true,
|
||||
Flags: []common.Flag{
|
||||
{Name: "level", Desc: "level to reorder: objective | key-result, Required.", Enum: []string{"objective", "key-result"}},
|
||||
{Name: "cycle-id", Desc: "OKR cycle ID (int64), Required."},
|
||||
{Name: "objective-id", Desc: "objective ID (required when --level=key-result)"},
|
||||
{Name: "ops", Desc: "JSON array of reorder operations: [{\"id\":\"...\",\"position\":1}], Required.", Input: []string{common.File, common.Stdin}},
|
||||
},
|
||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
level := runtime.Str("level")
|
||||
if strings.TrimSpace(level) == "" {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--level is required").WithParam("--level")
|
||||
}
|
||||
if level != "objective" && level != "key-result" {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--level must be one of: objective | key-result").WithParam("--level")
|
||||
}
|
||||
|
||||
cycleID := runtime.Str("cycle-id")
|
||||
if strings.TrimSpace(cycleID) == "" {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--cycle-id is required").WithParam("--cycle-id")
|
||||
}
|
||||
if id, err := strconv.ParseInt(cycleID, 10, 64); err != nil || id <= 0 {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--cycle-id must be a positive int64").WithParam("--cycle-id")
|
||||
}
|
||||
|
||||
if level == "key-result" {
|
||||
objID := runtime.Str("objective-id")
|
||||
if objID == "" {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--objective-id is required when --level=key-result").WithParam("--objective-id")
|
||||
}
|
||||
if id, err := strconv.ParseInt(objID, 10, 64); err != nil || id <= 0 {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--objective-id must be a positive int64").WithParam("--objective-id")
|
||||
}
|
||||
}
|
||||
|
||||
opsStr := runtime.Str("ops")
|
||||
if strings.TrimSpace(opsStr) == "" {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--ops is required").WithParam("--ops")
|
||||
}
|
||||
if err := common.RejectDangerousCharsTyped("--ops", opsStr); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := parseReorderOps(opsStr); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
},
|
||||
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
level := runtime.Str("level")
|
||||
cycleID := runtime.Str("cycle-id")
|
||||
objectiveID := runtime.Str("objective-id")
|
||||
ops, _ := parseReorderOps(runtime.Str("ops"))
|
||||
|
||||
apis := common.NewDryRunAPI()
|
||||
|
||||
if level == "objective" {
|
||||
// First fetch objectives
|
||||
listParams := map[string]interface{}{
|
||||
"page_size": 100,
|
||||
}
|
||||
listPath := fmt.Sprintf("/open-apis/okr/v2/cycles/%s/objectives", cycleID)
|
||||
apis = apis.
|
||||
GET(listPath).
|
||||
Params(listParams).
|
||||
Desc("Fetch all objectives in the cycle to determine current order")
|
||||
|
||||
// Then reorder
|
||||
reorderParams := map[string]interface{}{
|
||||
"cycle_id": cycleID,
|
||||
}
|
||||
// Build sample body with placeholder IDs
|
||||
objectiveIDs := make([]string, 0, len(ops))
|
||||
for _, op := range ops {
|
||||
objectiveIDs = append(objectiveIDs, op.ID)
|
||||
}
|
||||
reorderBody := map[string]interface{}{
|
||||
"objective_ids": objectiveIDs,
|
||||
}
|
||||
reorderPath := fmt.Sprintf("/open-apis/okr/v2/cycles/%s/objectives_position", cycleID)
|
||||
apis = apis.
|
||||
PUT(reorderPath).
|
||||
Params(reorderParams).
|
||||
Body(reorderBody).
|
||||
Desc("Update objective positions (full list sent, not just changes)")
|
||||
} else {
|
||||
// key-result level
|
||||
listParams := map[string]interface{}{
|
||||
"page_size": 100,
|
||||
}
|
||||
listPath := fmt.Sprintf("/open-apis/okr/v2/objectives/%s/key_results", objectiveID)
|
||||
apis = apis.
|
||||
GET(listPath).
|
||||
Params(listParams).
|
||||
Desc("Fetch all key results for the objective to determine current order")
|
||||
|
||||
reorderParams := map[string]interface{}{
|
||||
"objective_id": objectiveID,
|
||||
}
|
||||
// Build sample body with placeholder IDs
|
||||
keyResultIDs := make([]string, 0, len(ops))
|
||||
for _, op := range ops {
|
||||
keyResultIDs = append(keyResultIDs, op.ID)
|
||||
}
|
||||
reorderBody := map[string]interface{}{
|
||||
"key_result_ids": keyResultIDs,
|
||||
}
|
||||
reorderPath := fmt.Sprintf("/open-apis/okr/v2/objectives/%s/key_results_position", objectiveID)
|
||||
apis = apis.
|
||||
PUT(reorderPath).
|
||||
Params(reorderParams).
|
||||
Body(reorderBody).
|
||||
Desc("Update key result positions (full list sent, not just changes)")
|
||||
}
|
||||
|
||||
return apis
|
||||
},
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
level := runtime.Str("level")
|
||||
cycleID := runtime.Str("cycle-id")
|
||||
objectiveID := runtime.Str("objective-id")
|
||||
ops, err := parseReorderOps(runtime.Str("ops"))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
var reorderedIDs []string
|
||||
var total int
|
||||
|
||||
if level == "objective" {
|
||||
objectives, err := fetchObjectives(ctx, runtime, cycleID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
total = len(objectives)
|
||||
|
||||
reorderedIDs, err = buildReorderedIDs(objectives, ops, total)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Submit reorder
|
||||
params := map[string]interface{}{
|
||||
"cycle_id": cycleID,
|
||||
}
|
||||
body := map[string]interface{}{
|
||||
"objective_ids": reorderedIDs,
|
||||
}
|
||||
path := fmt.Sprintf("/open-apis/okr/v2/cycles/%s/objectives_position", cycleID)
|
||||
_, err = runtime.CallAPITyped("PUT", path, params, body)
|
||||
if err != nil {
|
||||
return wrapOkrNetworkErr(err, "failed to update objective positions")
|
||||
}
|
||||
} else {
|
||||
// key-result level
|
||||
keyResults, err := fetchKeyResults(ctx, runtime, objectiveID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
total = len(keyResults)
|
||||
|
||||
reorderedIDs, err = buildReorderedIDs(keyResults, ops, total)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Submit reorder
|
||||
params := map[string]interface{}{
|
||||
"objective_id": objectiveID,
|
||||
}
|
||||
body := map[string]interface{}{
|
||||
"key_result_ids": reorderedIDs,
|
||||
}
|
||||
path := fmt.Sprintf("/open-apis/okr/v2/objectives/%s/key_results_position", objectiveID)
|
||||
_, err = runtime.CallAPITyped("PUT", path, params, body)
|
||||
if err != nil {
|
||||
return wrapOkrNetworkErr(err, "failed to update key result positions")
|
||||
}
|
||||
}
|
||||
|
||||
// Build response
|
||||
result := map[string]interface{}{
|
||||
"level": level,
|
||||
"cycle_id": cycleID,
|
||||
"total": total,
|
||||
"ordered": reorderedIDs,
|
||||
}
|
||||
|
||||
runtime.OutFormat(result, nil, func(w io.Writer) {
|
||||
fmt.Fprintf(w, "Successfully reordered %d %s(s)\n", total, level)
|
||||
fmt.Fprintln(w, "New order:")
|
||||
for i, id := range reorderedIDs {
|
||||
fmt.Fprintf(w, " Position %d: %s\n", i+1, id)
|
||||
}
|
||||
})
|
||||
|
||||
return nil
|
||||
},
|
||||
}
|
||||
@@ -1,712 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package okr
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/internal/httpmock"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
// testReorderItem implements reorderItem for testing.
|
||||
type testReorderItem struct {
|
||||
id string
|
||||
}
|
||||
|
||||
func (t testReorderItem) GetID() string { return t.id }
|
||||
|
||||
func reorderTestConfig(t *testing.T) *core.CliConfig {
|
||||
t.Helper()
|
||||
return &core.CliConfig{
|
||||
AppID: "test-okr-reorder",
|
||||
AppSecret: "secret-okr-reorder",
|
||||
Brand: core.BrandFeishu,
|
||||
}
|
||||
}
|
||||
|
||||
func runReorderShortcut(t *testing.T, f *cmdutil.Factory, stdout *bytes.Buffer, args []string) error {
|
||||
t.Helper()
|
||||
parent := &cobra.Command{Use: "okr"}
|
||||
OKRReorder.Mount(parent, f)
|
||||
parent.SetArgs(args)
|
||||
parent.SilenceErrors = true
|
||||
parent.SilenceUsage = true
|
||||
if stdout != nil {
|
||||
stdout.Reset()
|
||||
}
|
||||
return parent.Execute()
|
||||
}
|
||||
|
||||
// --- Validate tests ---
|
||||
|
||||
func TestReorderValidate_MissingLevel(t *testing.T) {
|
||||
t.Parallel()
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, reorderTestConfig(t))
|
||||
err := runReorderShortcut(t, f, stdout, []string{
|
||||
"+reorder",
|
||||
"--cycle-id", "123",
|
||||
"--ops", `[{"id":"1","position":1}]`,
|
||||
})
|
||||
// cobra Required:true reports flag name without "--" prefix
|
||||
if err == nil || !strings.Contains(err.Error(), "level") {
|
||||
t.Fatalf("expected --level required error, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReorderValidate_InvalidLevel(t *testing.T) {
|
||||
t.Parallel()
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, reorderTestConfig(t))
|
||||
err := runReorderShortcut(t, f, stdout, []string{
|
||||
"+reorder",
|
||||
"--level", "invalid",
|
||||
"--cycle-id", "123",
|
||||
"--ops", `[{"id":"1","position":1}]`,
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("expected error for invalid --level")
|
||||
}
|
||||
_, ok := errs.ProblemOf(err)
|
||||
if !ok {
|
||||
t.Fatalf("expected typed error, got: %v", err)
|
||||
}
|
||||
validationErr, ok := err.(*errs.ValidationError)
|
||||
if !ok {
|
||||
t.Fatalf("expected ValidationError, got: %T", err)
|
||||
}
|
||||
if validationErr.Param != "--level" {
|
||||
t.Fatalf("expected param --level, got %q", validationErr.Param)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReorderValidate_MissingCycleID(t *testing.T) {
|
||||
t.Parallel()
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, reorderTestConfig(t))
|
||||
err := runReorderShortcut(t, f, stdout, []string{
|
||||
"+reorder",
|
||||
"--level", "objective",
|
||||
"--ops", `[{"id":"1","position":1}]`,
|
||||
})
|
||||
// cobra Required:true reports flag name without "--" prefix
|
||||
if err == nil || !strings.Contains(err.Error(), "cycle-id") {
|
||||
t.Fatalf("expected --cycle-id required error, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReorderValidate_MissingObjectiveIDForKRLevel(t *testing.T) {
|
||||
t.Parallel()
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, reorderTestConfig(t))
|
||||
err := runReorderShortcut(t, f, stdout, []string{
|
||||
"+reorder",
|
||||
"--level", "key-result",
|
||||
"--cycle-id", "123",
|
||||
"--ops", `[{"id":"1","position":1}]`,
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("expected error for missing --objective-id when --level=key-result")
|
||||
}
|
||||
_, ok := errs.ProblemOf(err)
|
||||
if !ok {
|
||||
t.Fatalf("expected typed error, got: %v", err)
|
||||
}
|
||||
validationErr, ok := err.(*errs.ValidationError)
|
||||
if !ok {
|
||||
t.Fatalf("expected ValidationError, got: %T", err)
|
||||
}
|
||||
if validationErr.Param != "--objective-id" {
|
||||
t.Fatalf("expected param --objective-id, got %q", validationErr.Param)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReorderValidate_MissingOps(t *testing.T) {
|
||||
t.Parallel()
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, reorderTestConfig(t))
|
||||
err := runReorderShortcut(t, f, stdout, []string{
|
||||
"+reorder",
|
||||
"--level", "objective",
|
||||
"--cycle-id", "123",
|
||||
})
|
||||
// cobra Required:true reports flag name without "--" prefix
|
||||
if err == nil || !strings.Contains(err.Error(), "ops") {
|
||||
t.Fatalf("expected --ops required error, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReorderValidate_InvalidOpsJSON(t *testing.T) {
|
||||
t.Parallel()
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, reorderTestConfig(t))
|
||||
err := runReorderShortcut(t, f, stdout, []string{
|
||||
"+reorder",
|
||||
"--level", "objective",
|
||||
"--cycle-id", "123",
|
||||
"--ops", "not-json",
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("expected error for invalid --ops JSON")
|
||||
}
|
||||
prob, ok := errs.ProblemOf(err)
|
||||
if !ok {
|
||||
t.Fatalf("expected typed error, got: %v", err)
|
||||
}
|
||||
if prob.Category != errs.CategoryValidation {
|
||||
t.Fatalf("expected CategoryValidation, got %q", prob.Category)
|
||||
}
|
||||
var validationErr *errs.ValidationError
|
||||
if !errors.As(err, &validationErr) {
|
||||
t.Fatalf("expected error to be *errs.ValidationError, got: %T", err)
|
||||
}
|
||||
if !errors.Is(err, validationErr) {
|
||||
t.Fatal("errors.Is should find the ValidationError in the chain")
|
||||
}
|
||||
if validationErr.Param != "--ops" {
|
||||
t.Fatalf("expected param --ops, got %q", validationErr.Param)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReorderValidate_EmptyOpsArray(t *testing.T) {
|
||||
t.Parallel()
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, reorderTestConfig(t))
|
||||
err := runReorderShortcut(t, f, stdout, []string{
|
||||
"+reorder",
|
||||
"--level", "objective",
|
||||
"--cycle-id", "123",
|
||||
"--ops", "[]",
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("expected error for empty --ops array")
|
||||
}
|
||||
_, ok := errs.ProblemOf(err)
|
||||
if !ok {
|
||||
t.Fatalf("expected typed error, got: %v", err)
|
||||
}
|
||||
validationErr, ok := err.(*errs.ValidationError)
|
||||
if !ok {
|
||||
t.Fatalf("expected ValidationError, got: %T", err)
|
||||
}
|
||||
if validationErr.Param != "--ops" {
|
||||
t.Fatalf("expected param --ops, got %q", validationErr.Param)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReorderValidate_DuplicateID(t *testing.T) {
|
||||
t.Parallel()
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, reorderTestConfig(t))
|
||||
err := runReorderShortcut(t, f, stdout, []string{
|
||||
"+reorder",
|
||||
"--level", "objective",
|
||||
"--cycle-id", "123",
|
||||
"--ops", `[{"id":"1","position":1},{"id":"1","position":2}]`,
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("expected error for duplicate id in --ops")
|
||||
}
|
||||
prob, ok := errs.ProblemOf(err)
|
||||
if !ok {
|
||||
t.Fatalf("expected typed error, got: %v", err)
|
||||
}
|
||||
if prob.Category != errs.CategoryValidation {
|
||||
t.Fatalf("expected CategoryValidation, got %q", prob.Category)
|
||||
}
|
||||
var validationErr *errs.ValidationError
|
||||
if !errors.As(err, &validationErr) {
|
||||
t.Fatalf("expected error to be *errs.ValidationError, got: %T", err)
|
||||
}
|
||||
if !errors.Is(err, validationErr) {
|
||||
t.Fatal("errors.Is should find the ValidationError in the chain")
|
||||
}
|
||||
if validationErr.Param != "--ops" {
|
||||
t.Fatalf("expected param --ops, got %q", validationErr.Param)
|
||||
}
|
||||
if !strings.Contains(err.Error(), "duplicate id") {
|
||||
t.Fatalf("expected error to mention duplicate id, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReorderValidate_DuplicatePosition(t *testing.T) {
|
||||
t.Parallel()
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, reorderTestConfig(t))
|
||||
err := runReorderShortcut(t, f, stdout, []string{
|
||||
"+reorder",
|
||||
"--level", "objective",
|
||||
"--cycle-id", "123",
|
||||
"--ops", `[{"id":"1","position":1},{"id":"2","position":1}]`,
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("expected error for duplicate position in --ops")
|
||||
}
|
||||
prob, ok := errs.ProblemOf(err)
|
||||
if !ok {
|
||||
t.Fatalf("expected typed error, got: %v", err)
|
||||
}
|
||||
if prob.Category != errs.CategoryValidation {
|
||||
t.Fatalf("expected CategoryValidation, got %q", prob.Category)
|
||||
}
|
||||
var validationErr *errs.ValidationError
|
||||
if !errors.As(err, &validationErr) {
|
||||
t.Fatalf("expected error to be *errs.ValidationError, got: %T", err)
|
||||
}
|
||||
if !errors.Is(err, validationErr) {
|
||||
t.Fatal("errors.Is should find the ValidationError in the chain")
|
||||
}
|
||||
if validationErr.Param != "--ops" {
|
||||
t.Fatalf("expected param --ops, got %q", validationErr.Param)
|
||||
}
|
||||
if !strings.Contains(err.Error(), "duplicate position") {
|
||||
t.Fatalf("expected error to mention duplicate position, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReorderValidate_NegativePosition(t *testing.T) {
|
||||
t.Parallel()
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, reorderTestConfig(t))
|
||||
err := runReorderShortcut(t, f, stdout, []string{
|
||||
"+reorder",
|
||||
"--level", "objective",
|
||||
"--cycle-id", "123",
|
||||
"--ops", `[{"id":"1","position":0}]`,
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("expected error for position <= 0")
|
||||
}
|
||||
_, ok := errs.ProblemOf(err)
|
||||
if !ok {
|
||||
t.Fatalf("expected typed error, got: %v", err)
|
||||
}
|
||||
validationErr, ok := err.(*errs.ValidationError)
|
||||
if !ok {
|
||||
t.Fatalf("expected ValidationError, got: %T", err)
|
||||
}
|
||||
if validationErr.Param != "--ops" {
|
||||
t.Fatalf("expected param --ops, got %q", validationErr.Param)
|
||||
}
|
||||
}
|
||||
|
||||
// --- DryRun tests ---
|
||||
|
||||
func TestReorderDryRun_Objectives(t *testing.T) {
|
||||
t.Parallel()
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, reorderTestConfig(t))
|
||||
err := runReorderShortcut(t, f, stdout, []string{
|
||||
"+reorder",
|
||||
"--level", "objective",
|
||||
"--cycle-id", "123",
|
||||
"--ops", `[{"id":"1","position":2},{"id":"2","position":1}]`,
|
||||
"--dry-run",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
output := stdout.String()
|
||||
if !strings.Contains(output, "/open-apis/okr/v2/cycles/123/objectives") {
|
||||
t.Fatalf("dry-run output should contain objectives list API path, got: %s", output)
|
||||
}
|
||||
if !strings.Contains(output, "GET") {
|
||||
t.Fatalf("dry-run output should contain GET method for list, got: %s", output)
|
||||
}
|
||||
if !strings.Contains(output, "/open-apis/okr/v2/cycles/123/objectives_position") {
|
||||
t.Fatalf("dry-run output should contain position update API path, got: %s", output)
|
||||
}
|
||||
if !strings.Contains(output, "PUT") {
|
||||
t.Fatalf("dry-run output should contain PUT method for update, got: %s", output)
|
||||
}
|
||||
if !strings.Contains(output, "objective_ids") {
|
||||
t.Fatalf("dry-run output should contain objective_ids in body, got: %s", output)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReorderDryRun_KeyResults(t *testing.T) {
|
||||
t.Parallel()
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, reorderTestConfig(t))
|
||||
err := runReorderShortcut(t, f, stdout, []string{
|
||||
"+reorder",
|
||||
"--level", "key-result",
|
||||
"--cycle-id", "123",
|
||||
"--objective-id", "456",
|
||||
"--ops", `[{"id":"1","position":2},{"id":"2","position":1}]`,
|
||||
"--dry-run",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
output := stdout.String()
|
||||
if !strings.Contains(output, "/open-apis/okr/v2/objectives/456/key_results") {
|
||||
t.Fatalf("dry-run output should contain key_results list API path, got: %s", output)
|
||||
}
|
||||
if !strings.Contains(output, "/open-apis/okr/v2/objectives/456/key_results_position") {
|
||||
t.Fatalf("dry-run output should contain key_results position update API path, got: %s", output)
|
||||
}
|
||||
if !strings.Contains(output, "key_result_ids") {
|
||||
t.Fatalf("dry-run output should contain key_result_ids in body, got: %s", output)
|
||||
}
|
||||
}
|
||||
|
||||
// --- Execute tests ---
|
||||
|
||||
func TestReorderExecute_Objectives_Success(t *testing.T) {
|
||||
t.Parallel()
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, reorderTestConfig(t))
|
||||
// Mock fetch objectives
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/okr/v2/cycles/123/objectives",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"msg": "ok",
|
||||
"data": map[string]interface{}{
|
||||
"items": []interface{}{
|
||||
map[string]interface{}{"id": "1", "position": 1, "cycle_id": "123", "owner": map[string]interface{}{"owner_type": "user"}},
|
||||
map[string]interface{}{"id": "2", "position": 2, "cycle_id": "123", "owner": map[string]interface{}{"owner_type": "user"}},
|
||||
map[string]interface{}{"id": "3", "position": 3, "cycle_id": "123", "owner": map[string]interface{}{"owner_type": "user"}},
|
||||
},
|
||||
"has_more": false,
|
||||
},
|
||||
},
|
||||
})
|
||||
// Mock reorder
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "PUT",
|
||||
URL: "/open-apis/okr/v2/cycles/123/objectives_position",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"msg": "ok",
|
||||
"data": map[string]interface{}{
|
||||
"items": []interface{}{
|
||||
map[string]interface{}{"id": "3", "position": 1},
|
||||
map[string]interface{}{"id": "1", "position": 2},
|
||||
map[string]interface{}{"id": "2", "position": 3},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
err := runReorderShortcut(t, f, stdout, []string{
|
||||
"+reorder",
|
||||
"--level", "objective",
|
||||
"--cycle-id", "123",
|
||||
"--ops", `[{"id":"3","position":1}]`,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
// Verify output
|
||||
data := decodeEnvelope(t, stdout)
|
||||
if data["level"] != "objective" {
|
||||
t.Fatalf("expected level=objective, got %v", data["level"])
|
||||
}
|
||||
if data["cycle_id"] != "123" {
|
||||
t.Fatalf("expected cycle_id=123, got %v", data["cycle_id"])
|
||||
}
|
||||
ordered, _ := data["ordered"].([]interface{})
|
||||
if len(ordered) != 3 {
|
||||
t.Fatalf("expected 3 items in ordered list, got %d", len(ordered))
|
||||
}
|
||||
// First should be 3, then 1, then 2
|
||||
if ordered[0] != "3" || ordered[1] != "1" || ordered[2] != "2" {
|
||||
t.Fatalf("expected ordered [3,1,2], got %v", ordered)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReorderExecute_KeyResults_Success(t *testing.T) {
|
||||
t.Parallel()
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, reorderTestConfig(t))
|
||||
// Mock fetch key results
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/okr/v2/objectives/456/key_results",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"msg": "ok",
|
||||
"data": map[string]interface{}{
|
||||
"items": []interface{}{
|
||||
map[string]interface{}{"id": "kr1", "position": 1, "objective_id": "456", "owner": map[string]interface{}{"owner_type": "user"}},
|
||||
map[string]interface{}{"id": "kr2", "position": 2, "objective_id": "456", "owner": map[string]interface{}{"owner_type": "user"}},
|
||||
},
|
||||
"has_more": false,
|
||||
},
|
||||
},
|
||||
})
|
||||
// Mock reorder
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "PUT",
|
||||
URL: "/open-apis/okr/v2/objectives/456/key_results_position",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"msg": "ok",
|
||||
"data": map[string]interface{}{
|
||||
"items": []interface{}{
|
||||
map[string]interface{}{"id": "kr2", "position": 1},
|
||||
map[string]interface{}{"id": "kr1", "position": 2},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
err := runReorderShortcut(t, f, stdout, []string{
|
||||
"+reorder",
|
||||
"--level", "key-result",
|
||||
"--cycle-id", "123",
|
||||
"--objective-id", "456",
|
||||
"--ops", `[{"id":"kr2","position":1}]`,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
// Verify output
|
||||
data := decodeEnvelope(t, stdout)
|
||||
if data["level"] != "key-result" {
|
||||
t.Fatalf("expected level=key-result, got %v", data["level"])
|
||||
}
|
||||
if data["cycle_id"] != "123" {
|
||||
t.Fatalf("expected cycle_id=123, got %v", data["cycle_id"])
|
||||
}
|
||||
ordered, _ := data["ordered"].([]interface{})
|
||||
if len(ordered) != 2 {
|
||||
t.Fatalf("expected 2 items in ordered list, got %d", len(ordered))
|
||||
}
|
||||
if ordered[0] != "kr2" || ordered[1] != "kr1" {
|
||||
t.Fatalf("expected ordered [kr2,kr1], got %v", ordered)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReorderExecute_PositionOutOfRange(t *testing.T) {
|
||||
t.Parallel()
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, reorderTestConfig(t))
|
||||
// Mock fetch objectives (only 2 items)
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/okr/v2/cycles/123/objectives",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"msg": "ok",
|
||||
"data": map[string]interface{}{
|
||||
"items": []interface{}{
|
||||
map[string]interface{}{"id": "1", "position": 1, "cycle_id": "123", "owner": map[string]interface{}{"owner_type": "user"}},
|
||||
map[string]interface{}{"id": "2", "position": 2, "cycle_id": "123", "owner": map[string]interface{}{"owner_type": "user"}},
|
||||
},
|
||||
"has_more": false,
|
||||
},
|
||||
},
|
||||
})
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "PUT",
|
||||
URL: "/open-apis/okr/v2/cycles/123/objectives_position",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"msg": "ok",
|
||||
},
|
||||
BodyFilter: func(body []byte) bool {
|
||||
var data map[string]interface{}
|
||||
if err := json.Unmarshal(body, &data); err != nil {
|
||||
return false
|
||||
}
|
||||
ids, ok := data["objective_ids"].([]interface{})
|
||||
if !ok || len(ids) != 2 {
|
||||
return false
|
||||
}
|
||||
// position 5 should be clamped to position 2 (last), so order is [2, 1]
|
||||
return ids[0] == "2" && ids[1] == "1"
|
||||
},
|
||||
})
|
||||
err := runReorderShortcut(t, f, stdout, []string{
|
||||
"+reorder",
|
||||
"--level", "objective",
|
||||
"--cycle-id", "123",
|
||||
"--ops", `[{"id":"1","position":5}]`, // position 5 exceeds total of 2, should clamp to last
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error for out-of-range position (should clamp): %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReorderExecute_IDNotFound(t *testing.T) {
|
||||
t.Parallel()
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, reorderTestConfig(t))
|
||||
// Mock fetch objectives
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/okr/v2/cycles/123/objectives",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"msg": "ok",
|
||||
"data": map[string]interface{}{
|
||||
"items": []interface{}{
|
||||
map[string]interface{}{"id": "1", "position": 1, "cycle_id": "123", "owner": map[string]interface{}{"owner_type": "user"}},
|
||||
map[string]interface{}{"id": "2", "position": 2, "cycle_id": "123", "owner": map[string]interface{}{"owner_type": "user"}},
|
||||
},
|
||||
"has_more": false,
|
||||
},
|
||||
},
|
||||
})
|
||||
err := runReorderShortcut(t, f, stdout, []string{
|
||||
"+reorder",
|
||||
"--level", "objective",
|
||||
"--cycle-id", "123",
|
||||
"--ops", `[{"id":"999","position":1}]`, // ID 999 doesn't exist
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("expected error for non-existent ID")
|
||||
}
|
||||
prob, ok := errs.ProblemOf(err)
|
||||
if !ok {
|
||||
t.Fatalf("expected typed error, got: %v", err)
|
||||
}
|
||||
if prob.Category != errs.CategoryValidation {
|
||||
t.Fatalf("expected CategoryValidation, got %q", prob.Category)
|
||||
}
|
||||
var validationErr *errs.ValidationError
|
||||
if !errors.As(err, &validationErr) {
|
||||
t.Fatalf("expected error to be *errs.ValidationError, got: %T", err)
|
||||
}
|
||||
if !errors.Is(err, validationErr) {
|
||||
t.Fatal("errors.Is should find the ValidationError in the chain")
|
||||
}
|
||||
if validationErr.Param != "--ops" {
|
||||
t.Fatalf("expected param --ops, got %q", validationErr.Param)
|
||||
}
|
||||
if !strings.Contains(err.Error(), "not found") {
|
||||
t.Fatalf("expected error to mention not found, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// --- Unit tests for helper functions ---
|
||||
|
||||
func TestParseReorderOps_Valid(t *testing.T) {
|
||||
t.Parallel()
|
||||
ops, err := parseReorderOps(`[{"id":"1","position":2},{"id":"2","position":1}]`)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if len(ops) != 2 {
|
||||
t.Fatalf("expected 2 ops, got %d", len(ops))
|
||||
}
|
||||
if ops[0].ID != "1" || ops[0].Position != 2 {
|
||||
t.Fatalf("expected op[0] = {1,2}, got %+v", ops[0])
|
||||
}
|
||||
if ops[1].ID != "2" || ops[1].Position != 1 {
|
||||
t.Fatalf("expected op[1] = {2,1}, got %+v", ops[1])
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildReorderedIDs(t *testing.T) {
|
||||
t.Parallel()
|
||||
items := []Objective{
|
||||
{ID: "1"},
|
||||
{ID: "2"},
|
||||
{ID: "3"},
|
||||
{ID: "4"},
|
||||
}
|
||||
ops := []reorderOp{
|
||||
{ID: "4", Position: 1},
|
||||
{ID: "2", Position: 3},
|
||||
}
|
||||
result, err := buildReorderedIDs(items, ops, 4)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
// Expected: 4 at pos1, 1 at pos2 (unchanged), 2 at pos3, 3 at pos4
|
||||
expected := []string{"4", "1", "2", "3"}
|
||||
if len(result) != len(expected) {
|
||||
t.Fatalf("expected %d items, got %d", len(expected), len(result))
|
||||
}
|
||||
for i, id := range expected {
|
||||
if result[i] != id {
|
||||
t.Fatalf("expected result[%d] = %q, got %q", i, id, result[i])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildReorderedIDs_SingleClampToEnd(t *testing.T) {
|
||||
t.Parallel()
|
||||
items := []testReorderItem{
|
||||
{id: "1"}, {id: "2"}, {id: "3"}, {id: "4"},
|
||||
}
|
||||
ops := []reorderOp{
|
||||
{ID: "1", Position: 99}, // position 99 exceeds total of 4, should clamp to last
|
||||
}
|
||||
result, err := buildReorderedIDs(items, ops, 4)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
// Expected: 2 at pos1, 3 at pos2, 4 at pos3, 1 at pos4 (clamped)
|
||||
expected := []string{"2", "3", "4", "1"}
|
||||
for i, id := range expected {
|
||||
if result[i] != id {
|
||||
t.Fatalf("expected result[%d] = %q, got %q", i, id, result[i])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildReorderedIDs_MultipleClampToEnd(t *testing.T) {
|
||||
t.Parallel()
|
||||
items := []testReorderItem{
|
||||
{id: "1"}, {id: "2"}, {id: "3"}, {id: "4"}, {id: "5"},
|
||||
}
|
||||
ops := []reorderOp{
|
||||
{ID: "1", Position: 10}, // position 10 exceeds total of 5
|
||||
{ID: "2", Position: 20}, // position 20 exceeds total of 5
|
||||
}
|
||||
result, err := buildReorderedIDs(items, ops, 5)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
// Expected: 3 at pos1, 4 at pos2, 5 at pos3, 1 at pos4 (clamped, pos10 < pos20), 2 at pos5 (clamped)
|
||||
expected := []string{"3", "4", "5", "1", "2"}
|
||||
for i, id := range expected {
|
||||
if result[i] != id {
|
||||
t.Fatalf("expected result[%d] = %q, got %q", i, id, result[i])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildReorderedIDs_MixedClamp(t *testing.T) {
|
||||
t.Parallel()
|
||||
items := []testReorderItem{
|
||||
{id: "1"}, {id: "2"}, {id: "3"}, {id: "4"}, {id: "5"},
|
||||
}
|
||||
ops := []reorderOp{
|
||||
{ID: "5", Position: 1}, // normal position
|
||||
{ID: "1", Position: 99}, // clamped to end
|
||||
{ID: "2", Position: 50}, // clamped to end, but position 50 < 99, so comes before 1
|
||||
}
|
||||
result, err := buildReorderedIDs(items, ops, 5)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
// Expected: 5 at pos1, 3 at pos2, 4 at pos3, 2 at pos4 (clamped pos50), 1 at pos5 (clamped pos99)
|
||||
expected := []string{"5", "3", "4", "2", "1"}
|
||||
for i, id := range expected {
|
||||
if result[i] != id {
|
||||
t.Fatalf("expected result[%d] = %q, got %q", i, id, result[i])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildReorderedIDs_LargePositionSafe(t *testing.T) {
|
||||
t.Parallel()
|
||||
items := []testReorderItem{
|
||||
{id: "1"}, {id: "2"}, {id: "3"},
|
||||
}
|
||||
// Very large position should not cause memory issues with map-based implementation
|
||||
ops := []reorderOp{
|
||||
{ID: "1", Position: 100000000}, // 10^8, would be dangerous with slice
|
||||
}
|
||||
result, err := buildReorderedIDs(items, ops, 3)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
// Expected: 2 at pos1, 3 at pos2, 1 at pos3 (clamped to end)
|
||||
expected := []string{"2", "3", "1"}
|
||||
for i, id := range expected {
|
||||
if result[i] != id {
|
||||
t.Fatalf("expected result[%d] = %q, got %q", i, id, result[i])
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,490 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package okr
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"math"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
// weightItem is the interface for items that have ID and weight.
|
||||
type weightItem interface {
|
||||
GetID() string
|
||||
GetWeight() float64
|
||||
}
|
||||
|
||||
// weightOp represents a single weight assignment.
|
||||
type weightOp struct {
|
||||
ID string `json:"id"`
|
||||
Weight float64 `json:"weight"`
|
||||
}
|
||||
|
||||
// parseWeightOps parses and validates the --weights JSON array.
|
||||
func parseWeightOps(weightsStr string) ([]weightOp, error) {
|
||||
var ops []weightOp
|
||||
if err := json.Unmarshal([]byte(weightsStr), &ops); err != nil {
|
||||
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "--weights must be valid JSON array: %s", err).WithParam("--weights").WithCause(err)
|
||||
}
|
||||
if len(ops) == 0 {
|
||||
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "--weights must contain at least one weight assignment").WithParam("--weights")
|
||||
}
|
||||
|
||||
seen := make(map[string]bool)
|
||||
var sum float64
|
||||
for i, op := range ops {
|
||||
if strings.TrimSpace(op.ID) == "" {
|
||||
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "weights[%d].id is required and cannot be empty", i).WithParam("--weights")
|
||||
}
|
||||
if op.Weight < 0 {
|
||||
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "weights[%d].weight must be non-negative", i).WithParam("--weights")
|
||||
}
|
||||
if op.Weight > 1 {
|
||||
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "weights[%d].weight must be <= 1", i).WithParam("--weights")
|
||||
}
|
||||
// Check for at most 3 decimal places
|
||||
if math.Round(op.Weight*1000)/1000 != op.Weight {
|
||||
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "weights[%d].weight must have at most 3 decimal places", i).WithParam("--weights")
|
||||
}
|
||||
if seen[op.ID] {
|
||||
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "duplicate id %q in --weights", op.ID).WithParam("--weights")
|
||||
}
|
||||
seen[op.ID] = true
|
||||
sum += op.Weight
|
||||
}
|
||||
|
||||
// Sum must be <= 1
|
||||
if sum > 1+1e-9 {
|
||||
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "sum of weights must be <= 1, got %.6f", sum).WithParam("--weights")
|
||||
}
|
||||
|
||||
return ops, nil
|
||||
}
|
||||
|
||||
// formatWeight formats a fixed-point weight value as a json.Number with exactly 3 decimal places.
|
||||
// This ensures precise JSON serialization and avoids float64 precision issues.
|
||||
func formatWeight(fp int64) json.Number {
|
||||
return json.Number(fmt.Sprintf("%d.%03d", fp/1000, fp%1000))
|
||||
}
|
||||
|
||||
// normalizeWeights normalizes weights using fixed-point arithmetic (×1000).
|
||||
// - Specified weights are used as-is (already validated to 3 decimal places).
|
||||
// - Remaining weight (1 - sum_specified) is distributed to unspecified items
|
||||
// proportionally based on their original weights.
|
||||
// - Fixed-point arithmetic ensures exact sum = 1, with residual added to the last item.
|
||||
// - Weights are returned as json.Number to avoid float64 precision issues in JSON serialization.
|
||||
func normalizeWeights[T weightItem](
|
||||
items []T,
|
||||
ops []weightOp,
|
||||
) ([]map[string]interface{}, error) {
|
||||
const scale = 1000 // fixed-point scale for 3 decimal places
|
||||
|
||||
// Build map of specified weights (as fixed-point integers)
|
||||
specified := make(map[string]int64)
|
||||
var specifiedSum int64
|
||||
for _, op := range ops {
|
||||
fp := int64(math.Round(op.Weight * scale))
|
||||
specified[op.ID] = fp
|
||||
specifiedSum += fp
|
||||
}
|
||||
|
||||
// Calculate remaining weight to distribute (as fixed-point)
|
||||
remaining := scale - specifiedSum
|
||||
if remaining < 0 {
|
||||
return nil, errs.NewInternalError(errs.SubtypeUnknown, "weight calculation error: remaining weight is negative")
|
||||
}
|
||||
|
||||
// Collect unspecified items and their original weights
|
||||
type itemWithWeight struct {
|
||||
item T
|
||||
fp int64 // original weight as fixed-point
|
||||
}
|
||||
var unspecified []itemWithWeight
|
||||
var originalUnspecifiedSum int64
|
||||
|
||||
for _, item := range items {
|
||||
id := item.GetID()
|
||||
if _, ok := specified[id]; ok {
|
||||
continue
|
||||
}
|
||||
origWeight := item.GetWeight()
|
||||
if origWeight < 0 {
|
||||
origWeight = 0
|
||||
}
|
||||
fp := int64(math.Round(origWeight * scale))
|
||||
unspecified = append(unspecified, itemWithWeight{item: item, fp: fp})
|
||||
originalUnspecifiedSum += fp
|
||||
}
|
||||
|
||||
// Distribute remaining weight proportionally
|
||||
result := make([]map[string]interface{}, 0, len(items))
|
||||
var resultSum int64
|
||||
|
||||
// First add specified items in original order
|
||||
for _, item := range items {
|
||||
id := item.GetID()
|
||||
if fp, ok := specified[id]; ok {
|
||||
result = append(result, map[string]interface{}{
|
||||
"id": id,
|
||||
"weight": formatWeight(fp),
|
||||
})
|
||||
resultSum += fp
|
||||
}
|
||||
}
|
||||
|
||||
// Then distribute to unspecified items
|
||||
if len(unspecified) > 0 && remaining > 0 {
|
||||
if originalUnspecifiedSum == 0 {
|
||||
// All original weights are zero, distribute evenly
|
||||
perItem := remaining / int64(len(unspecified))
|
||||
residual := remaining - perItem*int64(len(unspecified))
|
||||
|
||||
for i, uw := range unspecified {
|
||||
fp := perItem
|
||||
// Add residual to the last unspecified item
|
||||
if i == len(unspecified)-1 {
|
||||
fp += residual
|
||||
}
|
||||
result = append(result, map[string]interface{}{
|
||||
"id": uw.item.GetID(),
|
||||
"weight": formatWeight(fp),
|
||||
})
|
||||
resultSum += fp
|
||||
}
|
||||
} else {
|
||||
// Distribute proportionally based on original weights
|
||||
var distributed int64
|
||||
for i, uw := range unspecified {
|
||||
var fp int64
|
||||
if i == len(unspecified)-1 {
|
||||
// Last item gets the remainder to ensure exact sum
|
||||
fp = remaining - distributed
|
||||
} else {
|
||||
// Proportional distribution
|
||||
fp = int64(float64(remaining) * float64(uw.fp) / float64(originalUnspecifiedSum))
|
||||
distributed += fp
|
||||
}
|
||||
result = append(result, map[string]interface{}{
|
||||
"id": uw.item.GetID(),
|
||||
"weight": formatWeight(fp),
|
||||
})
|
||||
resultSum += fp
|
||||
}
|
||||
}
|
||||
} else if remaining > 0 {
|
||||
// All items were specified, add residual to the last item
|
||||
if len(result) > 0 {
|
||||
lastIdx := len(result) - 1
|
||||
// Parse current weight as fixed-point and add residual
|
||||
var lastFP int64
|
||||
if lastWeight, ok := result[lastIdx]["weight"].(json.Number); ok {
|
||||
if f, err := lastWeight.Float64(); err == nil {
|
||||
lastFP = int64(math.Round(f * scale))
|
||||
}
|
||||
}
|
||||
result[lastIdx]["weight"] = formatWeight(lastFP + remaining)
|
||||
resultSum += remaining
|
||||
}
|
||||
}
|
||||
|
||||
// Verify sum is exactly 1.0
|
||||
if resultSum != scale {
|
||||
return nil, errs.NewInternalError(errs.SubtypeUnknown,
|
||||
"weight normalization error: sum is %.6f, expected 1.0", float64(resultSum)/scale)
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// GetWeight implements the interface for Objective.
|
||||
func (o Objective) GetWeight() float64 {
|
||||
if o.Weight == nil {
|
||||
return 0
|
||||
}
|
||||
return *o.Weight
|
||||
}
|
||||
|
||||
// GetWeight implements the interface for KeyResult.
|
||||
func (k KeyResult) GetWeight() float64 {
|
||||
if k.Weight == nil {
|
||||
return 0
|
||||
}
|
||||
return *k.Weight
|
||||
}
|
||||
|
||||
// OKRWeight adjusts the weight of objectives or key results.
|
||||
var OKRWeight = common.Shortcut{
|
||||
Service: "okr",
|
||||
Command: "+weight",
|
||||
Description: "Adjust the weight of OKR objectives or key results",
|
||||
Risk: "write",
|
||||
Scopes: []string{"okr:okr.content:writeonly"},
|
||||
AuthTypes: []string{"user", "bot"},
|
||||
HasFormat: true,
|
||||
Flags: []common.Flag{
|
||||
{Name: "level", Desc: "level to adjust: objective | key-result", Enum: []string{"objective", "key-result"}, Required: true},
|
||||
{Name: "cycle-id", Desc: "OKR cycle ID (int64)", Required: true},
|
||||
{Name: "objective-id", Desc: "objective ID (required when --level=key-result)"},
|
||||
{Name: "weights", Desc: "JSON array of weight assignments: [{\"id\":\"...\",\"weight\":0.5}]", Input: []string{common.File, common.Stdin}, Required: true},
|
||||
},
|
||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
level := runtime.Str("level")
|
||||
if level != "objective" && level != "key-result" {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--level must be one of: objective | key-result").WithParam("--level")
|
||||
}
|
||||
|
||||
cycleID := runtime.Str("cycle-id")
|
||||
if id, err := strconv.ParseInt(cycleID, 10, 64); err != nil || id <= 0 {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--cycle-id must be a positive int64").WithParam("--cycle-id")
|
||||
}
|
||||
|
||||
if level == "key-result" {
|
||||
objID := runtime.Str("objective-id")
|
||||
if objID == "" {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--objective-id is required when --level=key-result").WithParam("--objective-id")
|
||||
}
|
||||
if id, err := strconv.ParseInt(objID, 10, 64); err != nil || id <= 0 {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--objective-id must be a positive int64").WithParam("--objective-id")
|
||||
}
|
||||
}
|
||||
|
||||
weightsStr := runtime.Str("weights")
|
||||
if err := common.RejectDangerousCharsTyped("--weights", weightsStr); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := parseWeightOps(weightsStr); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
},
|
||||
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
level := runtime.Str("level")
|
||||
cycleID := runtime.Str("cycle-id")
|
||||
objectiveID := runtime.Str("objective-id")
|
||||
ops, _ := parseWeightOps(runtime.Str("weights"))
|
||||
|
||||
apis := common.NewDryRunAPI()
|
||||
|
||||
if level == "objective" {
|
||||
// First fetch objectives
|
||||
listParams := map[string]interface{}{
|
||||
"page_size": 100,
|
||||
}
|
||||
listPath := fmt.Sprintf("/open-apis/okr/v2/cycles/%s/objectives", cycleID)
|
||||
apis = apis.
|
||||
GET(listPath).
|
||||
Params(listParams).
|
||||
Desc("Fetch all objectives in the cycle to get current weights for normalization")
|
||||
|
||||
// Then update weights
|
||||
weightParams := map[string]interface{}{
|
||||
"cycle_id": cycleID,
|
||||
}
|
||||
// Build sample body
|
||||
objectiveWeights := make([]map[string]interface{}, 0, len(ops))
|
||||
for _, op := range ops {
|
||||
objectiveWeights = append(objectiveWeights, map[string]interface{}{
|
||||
"objective_id": op.ID,
|
||||
"weight": op.Weight,
|
||||
})
|
||||
}
|
||||
weightBody := map[string]interface{}{
|
||||
"objective_weights": objectiveWeights,
|
||||
}
|
||||
weightPath := fmt.Sprintf("/open-apis/okr/v2/cycles/%s/objectives_weight", cycleID)
|
||||
apis = apis.
|
||||
PUT(weightPath).
|
||||
Params(weightParams).
|
||||
Body(weightBody).
|
||||
Desc("Update objective weights (full list sent after normalization)")
|
||||
} else {
|
||||
// key-result level
|
||||
listParams := map[string]interface{}{
|
||||
"page_size": 100,
|
||||
}
|
||||
listPath := fmt.Sprintf("/open-apis/okr/v2/objectives/%s/key_results", objectiveID)
|
||||
apis = apis.
|
||||
GET(listPath).
|
||||
Params(listParams).
|
||||
Desc("Fetch all key results for the objective to get current weights for normalization")
|
||||
|
||||
weightParams := map[string]interface{}{
|
||||
"objective_id": objectiveID,
|
||||
}
|
||||
// Build sample body
|
||||
keyResultWeights := make([]map[string]interface{}, 0, len(ops))
|
||||
for _, op := range ops {
|
||||
keyResultWeights = append(keyResultWeights, map[string]interface{}{
|
||||
"key_result_id": op.ID,
|
||||
"weight": op.Weight,
|
||||
})
|
||||
}
|
||||
weightBody := map[string]interface{}{
|
||||
"key_result_weights": keyResultWeights,
|
||||
}
|
||||
weightPath := fmt.Sprintf("/open-apis/okr/v2/objectives/%s/key_results_weight", objectiveID)
|
||||
apis = apis.
|
||||
PUT(weightPath).
|
||||
Params(weightParams).
|
||||
Body(weightBody).
|
||||
Desc("Update key result weights (full list sent after normalization)")
|
||||
}
|
||||
|
||||
return apis
|
||||
},
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
level := runtime.Str("level")
|
||||
cycleID := runtime.Str("cycle-id")
|
||||
objectiveID := runtime.Str("objective-id")
|
||||
ops, err := parseWeightOps(runtime.Str("weights"))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
var normalizedWeights []map[string]interface{}
|
||||
var total int
|
||||
|
||||
if level == "objective" {
|
||||
objectives, err := fetchObjectives(ctx, runtime, cycleID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
total = len(objectives)
|
||||
|
||||
// Validate all specified IDs exist
|
||||
objIDs := make(map[string]bool)
|
||||
for _, obj := range objectives {
|
||||
objIDs[obj.ID] = true
|
||||
}
|
||||
for _, op := range ops {
|
||||
if !objIDs[op.ID] {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "objective id %q not found in cycle", op.ID).WithParam("--weights")
|
||||
}
|
||||
}
|
||||
|
||||
normalizedWeights, err = normalizeWeights(objectives, ops)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Build position map for sorting
|
||||
posMap := make(map[string]int32)
|
||||
for _, obj := range objectives {
|
||||
if obj.Position != nil {
|
||||
posMap[obj.ID] = *obj.Position
|
||||
}
|
||||
}
|
||||
|
||||
// Submit weight update
|
||||
params := map[string]interface{}{
|
||||
"cycle_id": cycleID,
|
||||
}
|
||||
objectiveWeights := make([]map[string]interface{}, 0, len(normalizedWeights))
|
||||
for _, w := range normalizedWeights {
|
||||
objectiveWeights = append(objectiveWeights, map[string]interface{}{
|
||||
"objective_id": w["id"],
|
||||
"weight": w["weight"],
|
||||
})
|
||||
}
|
||||
// Sort by position to match API requirements
|
||||
sort.Slice(objectiveWeights, func(i, j int) bool {
|
||||
idI := objectiveWeights[i]["objective_id"].(string)
|
||||
idJ := objectiveWeights[j]["objective_id"].(string)
|
||||
return posMap[idI] < posMap[idJ]
|
||||
})
|
||||
body := map[string]interface{}{
|
||||
"objective_weights": objectiveWeights,
|
||||
}
|
||||
path := fmt.Sprintf("/open-apis/okr/v2/cycles/%s/objectives_weight", cycleID)
|
||||
_, err = runtime.CallAPITyped("PUT", path, params, body)
|
||||
if err != nil {
|
||||
return wrapOkrNetworkErr(err, "failed to update objective weights")
|
||||
}
|
||||
} else {
|
||||
// key-result level
|
||||
keyResults, err := fetchKeyResults(ctx, runtime, objectiveID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
total = len(keyResults)
|
||||
|
||||
// Validate all specified IDs exist
|
||||
krIDs := make(map[string]bool)
|
||||
for _, kr := range keyResults {
|
||||
krIDs[kr.ID] = true
|
||||
}
|
||||
for _, op := range ops {
|
||||
if !krIDs[op.ID] {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "key_result id %q not found in objective", op.ID).WithParam("--weights")
|
||||
}
|
||||
}
|
||||
|
||||
normalizedWeights, err = normalizeWeights(keyResults, ops)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Build position map for sorting
|
||||
posMap := make(map[string]int32)
|
||||
for _, kr := range keyResults {
|
||||
if kr.Position != nil {
|
||||
posMap[kr.ID] = *kr.Position
|
||||
}
|
||||
}
|
||||
|
||||
// Submit weight update
|
||||
params := map[string]interface{}{
|
||||
"objective_id": objectiveID,
|
||||
}
|
||||
keyResultWeights := make([]map[string]interface{}, 0, len(normalizedWeights))
|
||||
for _, w := range normalizedWeights {
|
||||
keyResultWeights = append(keyResultWeights, map[string]interface{}{
|
||||
"key_result_id": w["id"],
|
||||
"weight": w["weight"],
|
||||
})
|
||||
}
|
||||
// Sort by position to match API requirements
|
||||
sort.Slice(keyResultWeights, func(i, j int) bool {
|
||||
idI := keyResultWeights[i]["key_result_id"].(string)
|
||||
idJ := keyResultWeights[j]["key_result_id"].(string)
|
||||
return posMap[idI] < posMap[idJ]
|
||||
})
|
||||
body := map[string]interface{}{
|
||||
"key_result_weights": keyResultWeights,
|
||||
}
|
||||
path := fmt.Sprintf("/open-apis/okr/v2/objectives/%s/key_results_weight", objectiveID)
|
||||
_, err = runtime.CallAPITyped("PUT", path, params, body)
|
||||
if err != nil {
|
||||
return wrapOkrNetworkErr(err, "failed to update key result weights")
|
||||
}
|
||||
}
|
||||
|
||||
// Build response
|
||||
result := map[string]interface{}{
|
||||
"level": level,
|
||||
"cycle_id": cycleID,
|
||||
"total": total,
|
||||
"weights": normalizedWeights,
|
||||
}
|
||||
|
||||
runtime.OutFormat(result, nil, func(w io.Writer) {
|
||||
fmt.Fprintf(w, "Successfully updated weights for %d %s(s)\n", total, level)
|
||||
fmt.Fprintln(w, "Weights:")
|
||||
for _, weightEntry := range normalizedWeights {
|
||||
fmt.Fprintf(w, " %s: %v\n", weightEntry["id"], weightEntry["weight"])
|
||||
}
|
||||
})
|
||||
|
||||
return nil
|
||||
},
|
||||
}
|
||||
@@ -1,747 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package okr
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"math"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/internal/httpmock"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
// getWeightFloat extracts a float64 weight from either float64 or json.Number.
|
||||
func getWeightFloat(v interface{}) float64 {
|
||||
switch val := v.(type) {
|
||||
case float64:
|
||||
return val
|
||||
case json.Number:
|
||||
f, _ := val.Float64()
|
||||
return f
|
||||
default:
|
||||
return 0
|
||||
}
|
||||
}
|
||||
|
||||
func weightTestConfig(t *testing.T) *core.CliConfig {
|
||||
t.Helper()
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
return &core.CliConfig{
|
||||
AppID: "test-okr-weight",
|
||||
AppSecret: "secret-okr-weight",
|
||||
Brand: core.BrandFeishu,
|
||||
}
|
||||
}
|
||||
|
||||
func runWeightShortcut(t *testing.T, f *cmdutil.Factory, stdout *bytes.Buffer, args []string) error {
|
||||
t.Helper()
|
||||
parent := &cobra.Command{Use: "okr"}
|
||||
OKRWeight.Mount(parent, f)
|
||||
parent.SetArgs(args)
|
||||
parent.SilenceErrors = true
|
||||
parent.SilenceUsage = true
|
||||
if stdout != nil {
|
||||
stdout.Reset()
|
||||
}
|
||||
return parent.Execute()
|
||||
}
|
||||
|
||||
// --- Validate tests ---
|
||||
|
||||
func TestWeightValidate_MissingLevel(t *testing.T) {
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, weightTestConfig(t))
|
||||
err := runWeightShortcut(t, f, stdout, []string{
|
||||
"+weight",
|
||||
"--cycle-id", "123",
|
||||
"--weights", `[{"id":"1","weight":0.5}]`,
|
||||
})
|
||||
// cobra Required:true reports flag name without "--" prefix
|
||||
if err == nil || !strings.Contains(err.Error(), "level") {
|
||||
t.Fatalf("expected --level required error, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWeightValidate_InvalidLevel(t *testing.T) {
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, weightTestConfig(t))
|
||||
err := runWeightShortcut(t, f, stdout, []string{
|
||||
"+weight",
|
||||
"--level", "invalid",
|
||||
"--cycle-id", "123",
|
||||
"--weights", `[{"id":"1","weight":0.5}]`,
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("expected error for invalid --level")
|
||||
}
|
||||
prob, ok := errs.ProblemOf(err)
|
||||
if !ok {
|
||||
t.Fatalf("expected typed error, got: %v", err)
|
||||
}
|
||||
if prob.Category != errs.CategoryValidation {
|
||||
t.Fatalf("expected CategoryValidation, got %q", prob.Category)
|
||||
}
|
||||
var validationErr *errs.ValidationError
|
||||
if !errors.As(err, &validationErr) {
|
||||
t.Fatalf("expected error to be *errs.ValidationError, got: %T", err)
|
||||
}
|
||||
if !errors.Is(err, validationErr) {
|
||||
t.Fatal("errors.Is should find the ValidationError in the chain")
|
||||
}
|
||||
if validationErr.Param != "--level" {
|
||||
t.Fatalf("expected param --level, got %q", validationErr.Param)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWeightValidate_MissingCycleID(t *testing.T) {
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, weightTestConfig(t))
|
||||
err := runWeightShortcut(t, f, stdout, []string{
|
||||
"+weight",
|
||||
"--level", "objective",
|
||||
"--weights", `[{"id":"1","weight":0.5}]`,
|
||||
})
|
||||
// cobra Required:true reports flag name without "--" prefix
|
||||
if err == nil || !strings.Contains(err.Error(), "cycle-id") {
|
||||
t.Fatalf("expected --cycle-id required error, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWeightValidate_MissingObjectiveIDForKRLevel(t *testing.T) {
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, weightTestConfig(t))
|
||||
err := runWeightShortcut(t, f, stdout, []string{
|
||||
"+weight",
|
||||
"--level", "key-result",
|
||||
"--cycle-id", "123",
|
||||
"--weights", `[{"id":"1","weight":0.5}]`,
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("expected error for missing --objective-id when --level=key-result")
|
||||
}
|
||||
prob, ok := errs.ProblemOf(err)
|
||||
if !ok {
|
||||
t.Fatalf("expected typed error, got: %v", err)
|
||||
}
|
||||
if prob.Category != errs.CategoryValidation {
|
||||
t.Fatalf("expected CategoryValidation, got %q", prob.Category)
|
||||
}
|
||||
var validationErr *errs.ValidationError
|
||||
if !errors.As(err, &validationErr) {
|
||||
t.Fatalf("expected error to be *errs.ValidationError, got: %T", err)
|
||||
}
|
||||
if !errors.Is(err, validationErr) {
|
||||
t.Fatal("errors.Is should find the ValidationError in the chain")
|
||||
}
|
||||
if validationErr.Param != "--objective-id" {
|
||||
t.Fatalf("expected param --objective-id, got %q", validationErr.Param)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWeightValidate_MissingWeights(t *testing.T) {
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, weightTestConfig(t))
|
||||
err := runWeightShortcut(t, f, stdout, []string{
|
||||
"+weight",
|
||||
"--level", "objective",
|
||||
"--cycle-id", "123",
|
||||
})
|
||||
// cobra Required:true reports flag name without "--" prefix
|
||||
if err == nil || !strings.Contains(err.Error(), "weights") {
|
||||
t.Fatalf("expected --weights required error, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWeightValidate_InvalidWeightsJSON(t *testing.T) {
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, weightTestConfig(t))
|
||||
err := runWeightShortcut(t, f, stdout, []string{
|
||||
"+weight",
|
||||
"--level", "objective",
|
||||
"--cycle-id", "123",
|
||||
"--weights", "not-json",
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("expected error for invalid --weights JSON")
|
||||
}
|
||||
prob, ok := errs.ProblemOf(err)
|
||||
if !ok {
|
||||
t.Fatalf("expected typed error, got: %v", err)
|
||||
}
|
||||
if prob.Category != errs.CategoryValidation {
|
||||
t.Fatalf("expected CategoryValidation, got %q", prob.Category)
|
||||
}
|
||||
var validationErr *errs.ValidationError
|
||||
if !errors.As(err, &validationErr) {
|
||||
t.Fatalf("expected error to be *errs.ValidationError, got: %T", err)
|
||||
}
|
||||
if !errors.Is(err, validationErr) {
|
||||
t.Fatal("errors.Is should find the ValidationError in the chain")
|
||||
}
|
||||
if validationErr.Param != "--weights" {
|
||||
t.Fatalf("expected param --weights, got %q", validationErr.Param)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWeightValidate_EmptyWeightsArray(t *testing.T) {
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, weightTestConfig(t))
|
||||
err := runWeightShortcut(t, f, stdout, []string{
|
||||
"+weight",
|
||||
"--level", "objective",
|
||||
"--cycle-id", "123",
|
||||
"--weights", "[]",
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("expected error for empty --weights array")
|
||||
}
|
||||
prob, ok := errs.ProblemOf(err)
|
||||
if !ok {
|
||||
t.Fatalf("expected typed error, got: %v", err)
|
||||
}
|
||||
if prob.Category != errs.CategoryValidation {
|
||||
t.Fatalf("expected CategoryValidation, got %q", prob.Category)
|
||||
}
|
||||
var validationErr *errs.ValidationError
|
||||
if !errors.As(err, &validationErr) {
|
||||
t.Fatalf("expected error to be *errs.ValidationError, got: %T", err)
|
||||
}
|
||||
if !errors.Is(err, validationErr) {
|
||||
t.Fatal("errors.Is should find the ValidationError in the chain")
|
||||
}
|
||||
if validationErr.Param != "--weights" {
|
||||
t.Fatalf("expected param --weights, got %q", validationErr.Param)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWeightValidate_NegativeWeight(t *testing.T) {
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, weightTestConfig(t))
|
||||
err := runWeightShortcut(t, f, stdout, []string{
|
||||
"+weight",
|
||||
"--level", "objective",
|
||||
"--cycle-id", "123",
|
||||
"--weights", `[{"id":"1","weight":-0.1}]`,
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("expected error for negative weight")
|
||||
}
|
||||
prob, ok := errs.ProblemOf(err)
|
||||
if !ok {
|
||||
t.Fatalf("expected typed error, got: %v", err)
|
||||
}
|
||||
if prob.Category != errs.CategoryValidation {
|
||||
t.Fatalf("expected CategoryValidation, got %q", prob.Category)
|
||||
}
|
||||
var validationErr *errs.ValidationError
|
||||
if !errors.As(err, &validationErr) {
|
||||
t.Fatalf("expected error to be *errs.ValidationError, got: %T", err)
|
||||
}
|
||||
if !errors.Is(err, validationErr) {
|
||||
t.Fatal("errors.Is should find the ValidationError in the chain")
|
||||
}
|
||||
if validationErr.Param != "--weights" {
|
||||
t.Fatalf("expected param --weights, got %q", validationErr.Param)
|
||||
}
|
||||
if !strings.Contains(err.Error(), "non-negative") {
|
||||
t.Fatalf("expected error to mention non-negative, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWeightValidate_WeightGreaterThanOne(t *testing.T) {
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, weightTestConfig(t))
|
||||
err := runWeightShortcut(t, f, stdout, []string{
|
||||
"+weight",
|
||||
"--level", "objective",
|
||||
"--cycle-id", "123",
|
||||
"--weights", `[{"id":"1","weight":1.5}]`,
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("expected error for weight > 1")
|
||||
}
|
||||
prob, ok := errs.ProblemOf(err)
|
||||
if !ok {
|
||||
t.Fatalf("expected typed error, got: %v", err)
|
||||
}
|
||||
if prob.Category != errs.CategoryValidation {
|
||||
t.Fatalf("expected CategoryValidation, got %q", prob.Category)
|
||||
}
|
||||
var validationErr *errs.ValidationError
|
||||
if !errors.As(err, &validationErr) {
|
||||
t.Fatalf("expected error to be *errs.ValidationError, got: %T", err)
|
||||
}
|
||||
if !errors.Is(err, validationErr) {
|
||||
t.Fatal("errors.Is should find the ValidationError in the chain")
|
||||
}
|
||||
if validationErr.Param != "--weights" {
|
||||
t.Fatalf("expected param --weights, got %q", validationErr.Param)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWeightValidate_TooManyDecimalPlaces(t *testing.T) {
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, weightTestConfig(t))
|
||||
err := runWeightShortcut(t, f, stdout, []string{
|
||||
"+weight",
|
||||
"--level", "objective",
|
||||
"--cycle-id", "123",
|
||||
"--weights", `[{"id":"1","weight":0.1234}]`,
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("expected error for weight with more than 3 decimal places")
|
||||
}
|
||||
prob, ok := errs.ProblemOf(err)
|
||||
if !ok {
|
||||
t.Fatalf("expected typed error, got: %v", err)
|
||||
}
|
||||
if prob.Category != errs.CategoryValidation {
|
||||
t.Fatalf("expected CategoryValidation, got %q", prob.Category)
|
||||
}
|
||||
var validationErr *errs.ValidationError
|
||||
if !errors.As(err, &validationErr) {
|
||||
t.Fatalf("expected error to be *errs.ValidationError, got: %T", err)
|
||||
}
|
||||
if !errors.Is(err, validationErr) {
|
||||
t.Fatal("errors.Is should find the ValidationError in the chain")
|
||||
}
|
||||
if validationErr.Param != "--weights" {
|
||||
t.Fatalf("expected param --weights, got %q", validationErr.Param)
|
||||
}
|
||||
if !strings.Contains(err.Error(), "3 decimal places") {
|
||||
t.Fatalf("expected error to mention 3 decimal places, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWeightValidate_SumGreaterThanOne(t *testing.T) {
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, weightTestConfig(t))
|
||||
err := runWeightShortcut(t, f, stdout, []string{
|
||||
"+weight",
|
||||
"--level", "objective",
|
||||
"--cycle-id", "123",
|
||||
"--weights", `[{"id":"1","weight":0.6},{"id":"2","weight":0.5}]`,
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("expected error for sum of weights > 1")
|
||||
}
|
||||
prob, ok := errs.ProblemOf(err)
|
||||
if !ok {
|
||||
t.Fatalf("expected typed error, got: %v", err)
|
||||
}
|
||||
if prob.Category != errs.CategoryValidation {
|
||||
t.Fatalf("expected CategoryValidation, got %q", prob.Category)
|
||||
}
|
||||
var validationErr *errs.ValidationError
|
||||
if !errors.As(err, &validationErr) {
|
||||
t.Fatalf("expected error to be *errs.ValidationError, got: %T", err)
|
||||
}
|
||||
if !errors.Is(err, validationErr) {
|
||||
t.Fatal("errors.Is should find the ValidationError in the chain")
|
||||
}
|
||||
if validationErr.Param != "--weights" {
|
||||
t.Fatalf("expected param --weights, got %q", validationErr.Param)
|
||||
}
|
||||
if !strings.Contains(err.Error(), "sum of weights") {
|
||||
t.Fatalf("expected error to mention sum of weights, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWeightValidate_DuplicateID(t *testing.T) {
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, weightTestConfig(t))
|
||||
err := runWeightShortcut(t, f, stdout, []string{
|
||||
"+weight",
|
||||
"--level", "objective",
|
||||
"--cycle-id", "123",
|
||||
"--weights", `[{"id":"1","weight":0.3},{"id":"1","weight":0.4}]`,
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("expected error for duplicate id in --weights")
|
||||
}
|
||||
prob, ok := errs.ProblemOf(err)
|
||||
if !ok {
|
||||
t.Fatalf("expected typed error, got: %v", err)
|
||||
}
|
||||
if prob.Category != errs.CategoryValidation {
|
||||
t.Fatalf("expected CategoryValidation, got %q", prob.Category)
|
||||
}
|
||||
var validationErr *errs.ValidationError
|
||||
if !errors.As(err, &validationErr) {
|
||||
t.Fatalf("expected error to be *errs.ValidationError, got: %T", err)
|
||||
}
|
||||
if !errors.Is(err, validationErr) {
|
||||
t.Fatal("errors.Is should find the ValidationError in the chain")
|
||||
}
|
||||
if validationErr.Param != "--weights" {
|
||||
t.Fatalf("expected param --weights, got %q", validationErr.Param)
|
||||
}
|
||||
if !strings.Contains(err.Error(), "duplicate id") {
|
||||
t.Fatalf("expected error to mention duplicate id, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// --- DryRun tests ---
|
||||
|
||||
func TestWeightDryRun_Objectives(t *testing.T) {
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, weightTestConfig(t))
|
||||
err := runWeightShortcut(t, f, stdout, []string{
|
||||
"+weight",
|
||||
"--level", "objective",
|
||||
"--cycle-id", "123",
|
||||
"--weights", `[{"id":"1","weight":0.5},{"id":"2","weight":0.5}]`,
|
||||
"--dry-run",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
output := stdout.String()
|
||||
if !strings.Contains(output, "/open-apis/okr/v2/cycles/123/objectives") {
|
||||
t.Fatalf("dry-run output should contain objectives list API path, got: %s", output)
|
||||
}
|
||||
if !strings.Contains(output, "GET") {
|
||||
t.Fatalf("dry-run output should contain GET method for list, got: %s", output)
|
||||
}
|
||||
if !strings.Contains(output, "/open-apis/okr/v2/cycles/123/objectives_weight") {
|
||||
t.Fatalf("dry-run output should contain weight update API path, got: %s", output)
|
||||
}
|
||||
if !strings.Contains(output, "PUT") {
|
||||
t.Fatalf("dry-run output should contain PUT method for update, got: %s", output)
|
||||
}
|
||||
if !strings.Contains(output, "objective_weights") {
|
||||
t.Fatalf("dry-run output should contain objective_weights in body, got: %s", output)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWeightDryRun_KeyResults(t *testing.T) {
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, weightTestConfig(t))
|
||||
err := runWeightShortcut(t, f, stdout, []string{
|
||||
"+weight",
|
||||
"--level", "key-result",
|
||||
"--cycle-id", "123",
|
||||
"--objective-id", "456",
|
||||
"--weights", `[{"id":"kr1","weight":0.5},{"id":"kr2","weight":0.5}]`,
|
||||
"--dry-run",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
output := stdout.String()
|
||||
if !strings.Contains(output, "/open-apis/okr/v2/objectives/456/key_results") {
|
||||
t.Fatalf("dry-run output should contain key_results list API path, got: %s", output)
|
||||
}
|
||||
if !strings.Contains(output, "/open-apis/okr/v2/objectives/456/key_results_weight") {
|
||||
t.Fatalf("dry-run output should contain key_results weight update API path, got: %s", output)
|
||||
}
|
||||
if !strings.Contains(output, "key_result_weights") {
|
||||
t.Fatalf("dry-run output should contain key_result_weights in body, got: %s", output)
|
||||
}
|
||||
}
|
||||
|
||||
// --- Execute tests ---
|
||||
|
||||
func TestWeightExecute_Objectives_Success(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, weightTestConfig(t))
|
||||
// Mock fetch objectives
|
||||
w1 := 0.5
|
||||
w2 := 0.5
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/okr/v2/cycles/123/objectives",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"msg": "ok",
|
||||
"data": map[string]interface{}{
|
||||
"items": []interface{}{
|
||||
map[string]interface{}{"id": "1", "weight": &w1, "cycle_id": "123", "owner": map[string]interface{}{"owner_type": "user"}},
|
||||
map[string]interface{}{"id": "2", "weight": &w2, "cycle_id": "123", "owner": map[string]interface{}{"owner_type": "user"}},
|
||||
},
|
||||
"has_more": false,
|
||||
},
|
||||
},
|
||||
})
|
||||
// Mock weight update
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "PUT",
|
||||
URL: "/open-apis/okr/v2/cycles/123/objectives_weight",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"msg": "ok",
|
||||
"data": map[string]interface{}{
|
||||
"items": []interface{}{
|
||||
map[string]interface{}{"id": "1", "weight": 0.7},
|
||||
map[string]interface{}{"id": "2", "weight": 0.3},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
err := runWeightShortcut(t, f, stdout, []string{
|
||||
"+weight",
|
||||
"--level", "objective",
|
||||
"--cycle-id", "123",
|
||||
"--weights", `[{"id":"1","weight":0.7}]`,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
// Verify output
|
||||
data := decodeEnvelope(t, stdout)
|
||||
if data["level"] != "objective" {
|
||||
t.Fatalf("expected level=objective, got %v", data["level"])
|
||||
}
|
||||
if data["cycle_id"] != "123" {
|
||||
t.Fatalf("expected cycle_id=123, got %v", data["cycle_id"])
|
||||
}
|
||||
weights, _ := data["weights"].([]interface{})
|
||||
if len(weights) != 2 {
|
||||
t.Fatalf("expected 2 items in weights list, got %d", len(weights))
|
||||
}
|
||||
|
||||
// Verify sum is exactly 1.0
|
||||
var sum float64
|
||||
for _, w := range weights {
|
||||
wm, _ := w.(map[string]interface{})
|
||||
weightVal := getWeightFloat(wm["weight"])
|
||||
sum += weightVal
|
||||
}
|
||||
if math.Abs(sum-1.0) > 1e-9 {
|
||||
t.Fatalf("expected sum of weights = 1.0, got %.10f", sum)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWeightExecute_KeyResults_Success(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, weightTestConfig(t))
|
||||
// Mock fetch key results
|
||||
w1 := 0.3
|
||||
w2 := 0.3
|
||||
w3 := 0.4
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/okr/v2/objectives/456/key_results",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"msg": "ok",
|
||||
"data": map[string]interface{}{
|
||||
"items": []interface{}{
|
||||
map[string]interface{}{"id": "kr1", "weight": &w1, "objective_id": "456", "owner": map[string]interface{}{"owner_type": "user"}},
|
||||
map[string]interface{}{"id": "kr2", "weight": &w2, "objective_id": "456", "owner": map[string]interface{}{"owner_type": "user"}},
|
||||
map[string]interface{}{"id": "kr3", "weight": &w3, "objective_id": "456", "owner": map[string]interface{}{"owner_type": "user"}},
|
||||
},
|
||||
"has_more": false,
|
||||
},
|
||||
},
|
||||
})
|
||||
// Mock weight update
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "PUT",
|
||||
URL: "/open-apis/okr/v2/objectives/456/key_results_weight",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"msg": "ok",
|
||||
"data": map[string]interface{}{
|
||||
"items": []interface{}{},
|
||||
},
|
||||
},
|
||||
})
|
||||
err := runWeightShortcut(t, f, stdout, []string{
|
||||
"+weight",
|
||||
"--level", "key-result",
|
||||
"--cycle-id", "123",
|
||||
"--objective-id", "456",
|
||||
"--weights", `[{"id":"kr1","weight":0.5}]`,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
// Verify output
|
||||
data := decodeEnvelope(t, stdout)
|
||||
if data["level"] != "key-result" {
|
||||
t.Fatalf("expected level=key-result, got %v", data["level"])
|
||||
}
|
||||
if data["cycle_id"] != "123" {
|
||||
t.Fatalf("expected cycle_id=123, got %v", data["cycle_id"])
|
||||
}
|
||||
weights, _ := data["weights"].([]interface{})
|
||||
|
||||
// Verify sum is exactly 1.0
|
||||
var sum float64
|
||||
for _, w := range weights {
|
||||
wm, _ := w.(map[string]interface{})
|
||||
weightVal := getWeightFloat(wm["weight"])
|
||||
sum += weightVal
|
||||
}
|
||||
if math.Abs(sum-1.0) > 1e-9 {
|
||||
t.Fatalf("expected sum of weights = 1.0, got %.10f", sum)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWeightExecute_IDNotFound(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, weightTestConfig(t))
|
||||
// Mock fetch objectives
|
||||
w1 := 0.5
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/okr/v2/cycles/123/objectives",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"msg": "ok",
|
||||
"data": map[string]interface{}{
|
||||
"items": []interface{}{
|
||||
map[string]interface{}{"id": "1", "weight": &w1, "cycle_id": "123", "owner": map[string]interface{}{"owner_type": "user"}},
|
||||
},
|
||||
"has_more": false,
|
||||
},
|
||||
},
|
||||
})
|
||||
err := runWeightShortcut(t, f, stdout, []string{
|
||||
"+weight",
|
||||
"--level", "objective",
|
||||
"--cycle-id", "123",
|
||||
"--weights", `[{"id":"999","weight":0.5}]`,
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("expected error for non-existent ID")
|
||||
}
|
||||
prob, ok := errs.ProblemOf(err)
|
||||
if !ok {
|
||||
t.Fatalf("expected typed error, got: %v", err)
|
||||
}
|
||||
if prob.Category != errs.CategoryValidation {
|
||||
t.Fatalf("expected CategoryValidation, got %q", prob.Category)
|
||||
}
|
||||
var validationErr *errs.ValidationError
|
||||
if !errors.As(err, &validationErr) {
|
||||
t.Fatalf("expected error to be *errs.ValidationError, got: %T", err)
|
||||
}
|
||||
if !errors.Is(err, validationErr) {
|
||||
t.Fatal("errors.Is should find the ValidationError in the chain")
|
||||
}
|
||||
if validationErr.Param != "--weights" {
|
||||
t.Fatalf("expected param --weights, got %q", validationErr.Param)
|
||||
}
|
||||
if !strings.Contains(err.Error(), "not found") {
|
||||
t.Fatalf("expected error to mention not found, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// --- Unit tests for helper functions ---
|
||||
|
||||
func TestParseWeightOps_Valid(t *testing.T) {
|
||||
ops, err := parseWeightOps(`[{"id":"1","weight":0.3},{"id":"2","weight":0.7}]`)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if len(ops) != 2 {
|
||||
t.Fatalf("expected 2 ops, got %d", len(ops))
|
||||
}
|
||||
if ops[0].ID != "1" || math.Abs(ops[0].Weight-0.3) > 1e-9 {
|
||||
t.Fatalf("expected op[0] = {1,0.3}, got %+v", ops[0])
|
||||
}
|
||||
if ops[1].ID != "2" || math.Abs(ops[1].Weight-0.7) > 1e-9 {
|
||||
t.Fatalf("expected op[1] = {2,0.7}, got %+v", ops[1])
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeWeights_AllSpecified(t *testing.T) {
|
||||
w1 := 0.0
|
||||
w2 := 0.0
|
||||
items := []Objective{
|
||||
{ID: "1", Weight: &w1},
|
||||
{ID: "2", Weight: &w2},
|
||||
}
|
||||
ops := []weightOp{
|
||||
{ID: "1", Weight: 0.3},
|
||||
{ID: "2", Weight: 0.7},
|
||||
}
|
||||
result, err := normalizeWeights(items, ops)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if len(result) != 2 {
|
||||
t.Fatalf("expected 2 results, got %d", len(result))
|
||||
}
|
||||
// Sum should be exactly 1.0
|
||||
var sum float64
|
||||
for _, r := range result {
|
||||
sum += getWeightFloat(r["weight"])
|
||||
}
|
||||
if math.Abs(sum-1.0) > 1e-9 {
|
||||
t.Fatalf("expected sum = 1.0, got %.10f", sum)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeWeights_PartialSpecified_Proportional(t *testing.T) {
|
||||
w1 := 0.5
|
||||
w2 := 0.3
|
||||
w3 := 0.2
|
||||
items := []Objective{
|
||||
{ID: "1", Weight: &w1},
|
||||
{ID: "2", Weight: &w2},
|
||||
{ID: "3", Weight: &w3},
|
||||
}
|
||||
ops := []weightOp{
|
||||
{ID: "1", Weight: 0.4}, // Specify 0.4 for item 1
|
||||
}
|
||||
result, err := normalizeWeights(items, ops)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if len(result) != 3 {
|
||||
t.Fatalf("expected 3 results, got %d", len(result))
|
||||
}
|
||||
// Sum should be exactly 1.0
|
||||
var sum float64
|
||||
for _, r := range result {
|
||||
sum += getWeightFloat(r["weight"])
|
||||
}
|
||||
if math.Abs(sum-1.0) > 1e-9 {
|
||||
t.Fatalf("expected sum = 1.0, got %.10f", sum)
|
||||
}
|
||||
// Item 1 should have weight 0.4
|
||||
var item1Weight float64
|
||||
for _, r := range result {
|
||||
if r["id"] == "1" {
|
||||
item1Weight = getWeightFloat(r["weight"])
|
||||
break
|
||||
}
|
||||
}
|
||||
if math.Abs(item1Weight-0.4) > 1e-9 {
|
||||
t.Fatalf("expected item 1 weight = 0.4, got %.10f", item1Weight)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeWeights_ZeroOriginalWeights(t *testing.T) {
|
||||
w1 := 0.0
|
||||
w2 := 0.0
|
||||
items := []Objective{
|
||||
{ID: "1", Weight: &w1},
|
||||
{ID: "2", Weight: &w2},
|
||||
}
|
||||
ops := []weightOp{
|
||||
{ID: "1", Weight: 0.5},
|
||||
}
|
||||
result, err := normalizeWeights(items, ops)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if len(result) != 2 {
|
||||
t.Fatalf("expected 2 results, got %d", len(result))
|
||||
}
|
||||
// Sum should be exactly 1.0
|
||||
var sum float64
|
||||
for _, r := range result {
|
||||
sum += getWeightFloat(r["weight"])
|
||||
}
|
||||
if math.Abs(sum-1.0) > 1e-9 {
|
||||
t.Fatalf("expected sum = 1.0, got %.10f", sum)
|
||||
}
|
||||
// When original weights are zero, remaining should be distributed evenly
|
||||
var item2Weight float64
|
||||
for _, r := range result {
|
||||
if r["id"] == "2" {
|
||||
item2Weight = getWeightFloat(r["weight"])
|
||||
break
|
||||
}
|
||||
}
|
||||
if math.Abs(item2Weight-0.5) > 1e-9 {
|
||||
t.Fatalf("expected item 2 weight = 0.5 (even distribution), got %.10f", item2Weight)
|
||||
}
|
||||
}
|
||||
@@ -18,9 +18,5 @@ func Shortcuts() []common.Shortcut {
|
||||
OKRUpdateProgressRecord,
|
||||
OKRDeleteProgressRecord,
|
||||
OKRUploadImage,
|
||||
OKRBatchCreate,
|
||||
OKRReorder,
|
||||
OKRWeight,
|
||||
OKRIndicatorUpdate,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -177,18 +177,6 @@ func TestBatchOp_BodyMatchesStandalone(t *testing.T) {
|
||||
args: []string{"--sheet-id", "sh1", "--color", "#FF0000"},
|
||||
subInput: `{"sheet-id":"sh1","color":"#FF0000"}`,
|
||||
},
|
||||
{
|
||||
shortcut: "+sheet-show-gridline",
|
||||
sc: SheetShowGridline,
|
||||
args: []string{"--sheet-id", "sh1"},
|
||||
subInput: `{"sheet-id":"sh1"}`,
|
||||
},
|
||||
{
|
||||
shortcut: "+sheet-hide-gridline",
|
||||
sc: SheetHideGridline,
|
||||
args: []string{"--sheet-id", "sh1"},
|
||||
subInput: `{"sheet-id":"sh1"}`,
|
||||
},
|
||||
{
|
||||
shortcut: "+dropdown-set",
|
||||
sc: DropdownSet,
|
||||
@@ -444,7 +432,12 @@ func TestBatchOp_ErrorEquivalence(t *testing.T) {
|
||||
t, tc.shortcut,
|
||||
append([]string{"--url", testURL, "--dry-run"}, tc.args...),
|
||||
)
|
||||
requireValidation(t, standaloneErr, tc.wantContains)
|
||||
if standaloneErr == nil {
|
||||
t.Fatalf("standalone Validate accepted bad input — expected error containing %q", tc.wantContains)
|
||||
}
|
||||
if !strings.Contains(standaloneErr.Error(), tc.wantContains) {
|
||||
t.Errorf("standalone error = %q, want substring %q", standaloneErr.Error(), tc.wantContains)
|
||||
}
|
||||
|
||||
// Batch path: translate the matching sub-op. The translator wraps
|
||||
// the inner error with "operations[i] (<shortcut>): " — assert the
|
||||
@@ -458,12 +451,17 @@ func TestBatchOp_ErrorEquivalence(t *testing.T) {
|
||||
"input": subInput,
|
||||
}
|
||||
_, batchErr := translateBatchOp(rawOp, testToken, 0)
|
||||
batchVE := requireValidation(t, batchErr, tc.wantContains)
|
||||
if batchErr == nil {
|
||||
t.Fatalf("batch translator accepted bad input — expected error containing %q", tc.wantContains)
|
||||
}
|
||||
if !strings.Contains(batchErr.Error(), tc.wantContains) {
|
||||
t.Errorf("batch error = %q, want substring %q (operations[i] prefix is fine)", batchErr.Error(), tc.wantContains)
|
||||
}
|
||||
// And the wrap context must include the sub-op index + shortcut
|
||||
// name so error reports stay actionable in multi-op batches.
|
||||
wrapHint := "operations[0] (" + tc.subShortcut + "):"
|
||||
if !strings.Contains(batchVE.Message, wrapHint) {
|
||||
t.Errorf("batch error %q missing context prefix %q", batchVE.Message, wrapHint)
|
||||
if !strings.Contains(batchErr.Error(), wrapHint) {
|
||||
t.Errorf("batch error %q missing context prefix %q", batchErr.Error(), wrapHint)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -519,7 +517,12 @@ func TestBatchOp_RejectsWrongScalarType(t *testing.T) {
|
||||
}
|
||||
rawOp := map[string]interface{}{"shortcut": tc.subShortcut, "input": subInput}
|
||||
_, err := translateBatchOp(rawOp, testToken, 0)
|
||||
requireValidation(t, err, tc.wantContains)
|
||||
if err == nil {
|
||||
t.Fatalf("translateBatchOp accepted wrong-typed field; want error containing %q", tc.wantContains)
|
||||
}
|
||||
if !strings.Contains(err.Error(), tc.wantContains) {
|
||||
t.Errorf("error = %q, want substring %q", err.Error(), tc.wantContains)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -577,7 +580,12 @@ func TestBatchOp_GuardsBeyondCobra(t *testing.T) {
|
||||
}
|
||||
rawOp := map[string]interface{}{"shortcut": tc.subShortcut, "input": subInput}
|
||||
_, err := translateBatchOp(rawOp, testToken, 0)
|
||||
requireValidation(t, err, tc.wantContains)
|
||||
if err == nil {
|
||||
t.Fatalf("translateBatchOp accepted bad input; want error containing %q", tc.wantContains)
|
||||
}
|
||||
if !strings.Contains(err.Error(), tc.wantContains) {
|
||||
t.Errorf("error = %q, want substring %q", err.Error(), tc.wantContains)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -708,7 +716,12 @@ func TestBatchOp_RejectsBadSubOpInput(t *testing.T) {
|
||||
"input": subInput,
|
||||
}
|
||||
_, err := translateBatchOp(rawOp, testToken, 0)
|
||||
requireValidation(t, err, tc.wantContains)
|
||||
if err == nil {
|
||||
t.Fatalf("translator accepted bad input — expected error containing %q", tc.wantContains)
|
||||
}
|
||||
if !strings.Contains(err.Error(), tc.wantContains) {
|
||||
t.Errorf("error = %q, want substring %q", err.Error(), tc.wantContains)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -769,7 +782,12 @@ func TestBatchOp_SchemaValidatesSubOps(t *testing.T) {
|
||||
"input": subInput,
|
||||
}
|
||||
_, err := translateBatchOp(rawOp, testToken, 0)
|
||||
requireValidation(t, err, tc.wantContains)
|
||||
if err == nil {
|
||||
t.Fatalf("translator accepted schema-violating sub-op — expected error containing %q", tc.wantContains)
|
||||
}
|
||||
if !strings.Contains(err.Error(), tc.wantContains) {
|
||||
t.Errorf("error = %q, want substring %q", err.Error(), tc.wantContains)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -150,12 +150,6 @@ var batchOpDispatch = map[string]batchOpMapping{
|
||||
return sheetVisibilityInput(fv, t, sid, sn, "unhide")
|
||||
}},
|
||||
"+sheet-set-tab-color": {"modify_workbook_structure", sheetSetTabColorInput},
|
||||
"+sheet-show-gridline": {"modify_workbook_structure", func(fv flagView, t, sid, sn string) (map[string]interface{}, error) {
|
||||
return sheetVisibilityInput(fv, t, sid, sn, "show_gridline")
|
||||
}},
|
||||
"+sheet-hide-gridline": {"modify_workbook_structure", func(fv flagView, t, sid, sn string) (map[string]interface{}, error) {
|
||||
return sheetVisibilityInput(fv, t, sid, sn, "hide_gridline")
|
||||
}},
|
||||
|
||||
// ─── 对象族 CRUD (manage_*_object, operation 区分) ─────────────
|
||||
"+chart-create": {"manage_chart_object", objCreateTranslate(chartSpec)},
|
||||
|
||||
@@ -4,10 +4,12 @@
|
||||
package sheets
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
_ "github.com/larksuite/cli/internal/vfs/localfileio"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
@@ -35,9 +37,18 @@ func TestGuardCSVValueIsNotFilePath(t *testing.T) {
|
||||
|
||||
// Bare value naming an existing file → guarded with a fix-it hint.
|
||||
err := guardCSVValueIsNotFilePath(newCSVGuardRuntime("data.csv"))
|
||||
ve := requireValidation(t, err, "existing file")
|
||||
if !strings.Contains(ve.Message, "@data.csv") {
|
||||
t.Errorf("message should suggest @data.csv, got: %q", ve.Message)
|
||||
if err == nil {
|
||||
t.Fatal("expected guard error when --csv names an existing file")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "existing file") || !strings.Contains(err.Error(), "@data.csv") {
|
||||
t.Errorf("error should flag the file and suggest @data.csv, got: %v", err)
|
||||
}
|
||||
if p, ok := errs.ProblemOf(err); !ok || p.Category != errs.CategoryValidation || p.Subtype != errs.SubtypeInvalidArgument {
|
||||
t.Errorf("problem = %+v, want validation/invalid_argument", p)
|
||||
}
|
||||
var ve *errs.ValidationError
|
||||
if !errors.As(err, &ve) {
|
||||
t.Fatalf("guard error = %T, want *errs.ValidationError", err)
|
||||
}
|
||||
if ve.Param != "--csv" {
|
||||
t.Errorf("param = %q, want --csv", ve.Param)
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
package sheets
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
@@ -43,7 +44,12 @@ func TestCsvPutInput_RejectsStartCellAndRangeTogether(t *testing.T) {
|
||||
"range": "A1:H17",
|
||||
})
|
||||
_, err := csvPutInput(fv, "tok", "sid", "")
|
||||
requireValidation(t, err, "--start-cell and --range are mutually exclusive")
|
||||
if err == nil {
|
||||
t.Fatal("csvPutInput accepted both start-cell and range; want mutual-exclusion error")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "--start-cell and --range are mutually exclusive") {
|
||||
t.Errorf("error = %q, want it to mention start-cell/range mutual exclusion", err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
// With neither --start-cell nor --range explicitly set, csvPutInput rejects the
|
||||
@@ -55,7 +61,12 @@ func TestCsvPutInput_RejectsStartCellAndRangeTogether(t *testing.T) {
|
||||
func TestCsvPutInput_RequiresStartCellOrRange(t *testing.T) {
|
||||
fv := newMapFlagViewForCommand("+csv-put", map[string]interface{}{"csv": "a,b"})
|
||||
_, err := csvPutInput(fv, "tok", "sid", "")
|
||||
requireValidation(t, err, "--start-cell or --range is required")
|
||||
if err == nil {
|
||||
t.Fatal("csvPutInput accepted missing start-cell/range; want a required-flag error")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "--start-cell or --range is required") {
|
||||
t.Errorf("error = %q, want it to mention '--start-cell or --range is required'", err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
// csvPutWriteRangeFromInput surfaces the real paste footprint so agents can see
|
||||
|
||||
@@ -54,7 +54,7 @@
|
||||
"kind": "own",
|
||||
"type": "int",
|
||||
"required": "optional",
|
||||
"desc": "Insert position (0-based); appended to the end when omitted",
|
||||
"desc": "Insert position; appended to the end when omitted",
|
||||
"default": "-1"
|
||||
},
|
||||
{
|
||||
@@ -413,86 +413,6 @@
|
||||
}
|
||||
]
|
||||
},
|
||||
"+sheet-hide-gridline": {
|
||||
"risk": "write",
|
||||
"flags": [
|
||||
{
|
||||
"name": "url",
|
||||
"kind": "public",
|
||||
"type": "string",
|
||||
"required": "xor",
|
||||
"desc": "Spreadsheet URL (XOR with `--spreadsheet-token`)"
|
||||
},
|
||||
{
|
||||
"name": "spreadsheet-token",
|
||||
"kind": "public",
|
||||
"type": "string",
|
||||
"required": "xor",
|
||||
"desc": "Spreadsheet token (XOR with `--url`)"
|
||||
},
|
||||
{
|
||||
"name": "sheet-id",
|
||||
"kind": "public",
|
||||
"type": "string",
|
||||
"required": "xor",
|
||||
"desc": "Sheet reference_id (XOR with `--sheet-name`)"
|
||||
},
|
||||
{
|
||||
"name": "sheet-name",
|
||||
"kind": "public",
|
||||
"type": "string",
|
||||
"required": "xor",
|
||||
"desc": "Sheet name (XOR with `--sheet-id`)"
|
||||
},
|
||||
{
|
||||
"name": "dry-run",
|
||||
"kind": "system",
|
||||
"type": "bool",
|
||||
"required": "optional",
|
||||
"desc": ""
|
||||
}
|
||||
]
|
||||
},
|
||||
"+sheet-show-gridline": {
|
||||
"risk": "write",
|
||||
"flags": [
|
||||
{
|
||||
"name": "url",
|
||||
"kind": "public",
|
||||
"type": "string",
|
||||
"required": "xor",
|
||||
"desc": "Spreadsheet URL (XOR with `--spreadsheet-token`)"
|
||||
},
|
||||
{
|
||||
"name": "spreadsheet-token",
|
||||
"kind": "public",
|
||||
"type": "string",
|
||||
"required": "xor",
|
||||
"desc": "Spreadsheet token (XOR with `--url`)"
|
||||
},
|
||||
{
|
||||
"name": "sheet-id",
|
||||
"kind": "public",
|
||||
"type": "string",
|
||||
"required": "xor",
|
||||
"desc": "Sheet reference_id (XOR with `--sheet-name`)"
|
||||
},
|
||||
{
|
||||
"name": "sheet-name",
|
||||
"kind": "public",
|
||||
"type": "string",
|
||||
"required": "xor",
|
||||
"desc": "Sheet name (XOR with `--sheet-id`)"
|
||||
},
|
||||
{
|
||||
"name": "dry-run",
|
||||
"kind": "system",
|
||||
"type": "bool",
|
||||
"required": "optional",
|
||||
"desc": ""
|
||||
}
|
||||
]
|
||||
},
|
||||
"+workbook-create": {
|
||||
"risk": "write",
|
||||
"flags": [
|
||||
@@ -510,46 +430,28 @@
|
||||
"required": "optional",
|
||||
"desc": "Target folder token; placed at the drive root when omitted"
|
||||
},
|
||||
{
|
||||
"name": "headers",
|
||||
"kind": "own",
|
||||
"type": "string",
|
||||
"required": "optional",
|
||||
"desc": "Header row as a JSON array: `[\"Col A\",\"Col B\"]`",
|
||||
"input": [
|
||||
"file",
|
||||
"stdin"
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "values",
|
||||
"kind": "own",
|
||||
"type": "string",
|
||||
"required": "optional",
|
||||
"desc": "Untyped initial data as one 2D JSON array (`[[\"alice\",95]]`); values are written as-is with their type auto-detected, through the same batched set_cell_range path as --sheets — pair with --styles for number formats, colors, merges, and row/col sizes",
|
||||
"desc": "Initial data as a 2D JSON array: `[[\"alice\",95]]`",
|
||||
"input": [
|
||||
"file",
|
||||
"stdin"
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "sheets",
|
||||
"kind": "own",
|
||||
"type": "string",
|
||||
"required": "optional",
|
||||
"desc": "Typed table payload as JSON (same shape as `+table-put`): a top-level `sheets` array, each item `{name, start_cell?, mode?, header?, allow_overwrite?, columns:[\"colA\",\"colB\",...], data:[[...]], dtypes?:{colA:pandasDtype, ...}, formats?:{colA:numberFormat, ...}}`. Agents typically build it from a DataFrame via `{**json.loads(df.to_json(orient=\"split\")), \"dtypes\": df.dtypes.astype(str).to_dict()}`. Mutually exclusive with --values and --dataframe. Creates the workbook, then writes typed type-faithful data (dates land as real dates, numbers keep precision).",
|
||||
"input": [
|
||||
"file",
|
||||
"stdin"
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "styles",
|
||||
"kind": "own",
|
||||
"type": "string",
|
||||
"required": "optional",
|
||||
"desc": "Initial visual operations as JSON: top-level `{styles:[...]}`. Each item corresponds to one target sheet and must include `name`, plus at least one of `cell_styles` / `row_sizes` / `col_sizes` / `cell_merges`. `cell_styles` entries use +cells-set-style fields with a cell range; row/col sizes use dimension ranges plus type/size; merges use cell ranges plus optional merge_type. With --sheets, styles array length/order/name must match --sheets.sheets. With --values, pass exactly one styles item for the initial sheet (its name is ignored).",
|
||||
"input": [
|
||||
"file",
|
||||
"stdin"
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "dataframe",
|
||||
"kind": "own",
|
||||
"type": "string",
|
||||
"required": "optional",
|
||||
"desc": "Single-sheet typed table from one Arrow IPC file (Feather v2 — what `pandas.DataFrame.to_feather()` writes), mutually exclusive with --values and --sheets. Pass `@<path>` for a file or `-` for binary stdin (same convention as other input flags). Arrow bytes are read raw — no TrimSpace / BOM strip — so the IPC magic survives intact (unlike text input flags). Column types come from the Arrow schema; per-column `number_format` may be set via Arrow field metadata. Creates the workbook and fills its default sheet (`Sheet1` — adopted in place, no empty Sheet1 left behind). For multi-sheet or non-default placement, use `--sheets` instead."
|
||||
},
|
||||
{
|
||||
"name": "dry-run",
|
||||
"kind": "system",
|
||||
@@ -600,7 +502,7 @@
|
||||
"kind": "own",
|
||||
"type": "string",
|
||||
"required": "optional",
|
||||
"desc": "Local save path. When omitted, **only the export task is triggered + polled, the file is NOT downloaded** (returns file_token / status so a later step can resume the download). Pass a concrete path (e.g. `./out.xlsx`) or a directory (`.` keeps the server-provided filename) to download. Note: the equivalent `lark-cli drive +export --doc-type sheet` uses three separate flags (`--output-dir` / `--file-name` / `--overwrite`) and defaults to downloading into the current directory; this wrapper collapses them into a single `--output-path` for ergonomics but defaults to no-download — fall back to `drive +export` if the split flag set fits better."
|
||||
"desc": "Local save path; export is triggered but not downloaded when omitted"
|
||||
},
|
||||
{
|
||||
"name": "dry-run",
|
||||
@@ -611,32 +513,6 @@
|
||||
}
|
||||
]
|
||||
},
|
||||
"+workbook-import": {
|
||||
"risk": "write",
|
||||
"flags": [
|
||||
{
|
||||
"name": "file",
|
||||
"kind": "own",
|
||||
"type": "string",
|
||||
"required": "required",
|
||||
"desc": "Local file path (.xlsx / .xls / .csv)"
|
||||
},
|
||||
{
|
||||
"name": "folder-token",
|
||||
"kind": "own",
|
||||
"type": "string",
|
||||
"required": "optional",
|
||||
"desc": "Target folder token; imported to the cloud drive root when omitted"
|
||||
},
|
||||
{
|
||||
"name": "name",
|
||||
"kind": "own",
|
||||
"type": "string",
|
||||
"required": "optional",
|
||||
"desc": "Imported spreadsheet name; defaults to the local file name without its extension"
|
||||
}
|
||||
]
|
||||
},
|
||||
"+sheet-info": {
|
||||
"risk": "read",
|
||||
"flags": [
|
||||
@@ -1206,8 +1082,9 @@
|
||||
"kind": "own",
|
||||
"type": "int",
|
||||
"required": "optional",
|
||||
"desc": "Max output chars per call; default 500000 (safety cap). Large reads are usually better redirected to a file; only lower it (e.g. 25000) when you want results inline without triggering file offload, paging via has_more",
|
||||
"default": "500000"
|
||||
"desc": "Safety cap; default 200000",
|
||||
"default": "200000",
|
||||
"hidden": true
|
||||
},
|
||||
{
|
||||
"name": "skip-hidden",
|
||||
@@ -1315,8 +1192,9 @@
|
||||
"kind": "own",
|
||||
"type": "int",
|
||||
"required": "optional",
|
||||
"desc": "Max output chars per call; default 500000 (safety cap). Large reads are usually better redirected to a file; only lower it (e.g. 25000) when you want results inline without triggering file offload, paging via has_more",
|
||||
"default": "500000"
|
||||
"desc": "Safety cap; default 200000",
|
||||
"default": "200000",
|
||||
"hidden": true
|
||||
},
|
||||
{
|
||||
"name": "include-row-prefix",
|
||||
@@ -1334,72 +1212,19 @@
|
||||
"desc": "Skip hidden rows and columns; default `false`"
|
||||
},
|
||||
{
|
||||
"name": "dry-run",
|
||||
"kind": "system",
|
||||
"type": "bool",
|
||||
"required": "optional",
|
||||
"desc": "Print the request path and parameters without executing"
|
||||
}
|
||||
]
|
||||
},
|
||||
"+table-get": {
|
||||
"risk": "read",
|
||||
"flags": [
|
||||
{
|
||||
"name": "url",
|
||||
"kind": "public",
|
||||
"type": "string",
|
||||
"required": "xor",
|
||||
"desc": "Spreadsheet URL (XOR with `--spreadsheet-token`)"
|
||||
},
|
||||
{
|
||||
"name": "spreadsheet-token",
|
||||
"kind": "public",
|
||||
"type": "string",
|
||||
"required": "xor",
|
||||
"desc": "Spreadsheet token (XOR with `--url`)"
|
||||
},
|
||||
{
|
||||
"name": "sheet-id",
|
||||
"kind": "own",
|
||||
"type": "string",
|
||||
"required": "optional",
|
||||
"desc": "Read only this sheet (by id); omit to read all sheets"
|
||||
},
|
||||
{
|
||||
"name": "sheet-name",
|
||||
"kind": "own",
|
||||
"type": "string",
|
||||
"required": "optional",
|
||||
"desc": "Read only this sheet (by name); omit to read all sheets"
|
||||
},
|
||||
{
|
||||
"name": "range",
|
||||
"kind": "own",
|
||||
"type": "string",
|
||||
"required": "optional",
|
||||
"desc": "A1 range to read; omit to read each sheet's full used range (spans internal blank rows/columns, not just the A1 current region)"
|
||||
},
|
||||
{
|
||||
"name": "no-header",
|
||||
"name": "rows-json",
|
||||
"kind": "own",
|
||||
"type": "bool",
|
||||
"required": "optional",
|
||||
"desc": "Treat the first row as data instead of a header (columns get positional names col1, col2, ...)"
|
||||
},
|
||||
{
|
||||
"name": "dataframe-out",
|
||||
"kind": "own",
|
||||
"type": "string",
|
||||
"required": "optional",
|
||||
"desc": "Write the typed table as one Arrow IPC file (Feather v2) instead of the default JSON. Pass `@<path>` for a file or `-` for binary stdout (same convention as other binary I/O flags). Mirror of the input-side `--dataframe` on `+table-put` / `+workbook-create` — pandas users round-trip via `df = pd.read_feather(\"x.arrow\")` or `pd.read_feather(io.BytesIO(stdout))`. Single-sheet only: requires `--sheet-id` or `--sheet-name`; whole-workbook reads keep the default JSON path. Column types come from the typed read-back (string/number/date/bool); per-column `number_format` is preserved as Arrow field metadata so the Arrow file can round-trip straight back through `+table-put --dataframe`."
|
||||
"desc": "Return structured rows ({row_number, values:{col→cell}}) instead of CSV text; default false",
|
||||
"default": "false"
|
||||
},
|
||||
{
|
||||
"name": "dry-run",
|
||||
"kind": "system",
|
||||
"type": "bool",
|
||||
"required": "optional",
|
||||
"desc": ""
|
||||
"desc": "Print the request path and parameters without executing"
|
||||
}
|
||||
]
|
||||
},
|
||||
@@ -2024,7 +1849,7 @@
|
||||
"kind": "own",
|
||||
"type": "string",
|
||||
"required": "required",
|
||||
"desc": "RFC 4180 CSV text; values or formulas (a leading = is evaluated as a formula); no styles / comments / images (use +cells-set for those).",
|
||||
"desc": "RFC 4180 CSV text; plain values only (no formulas / styles / comments)",
|
||||
"input": [
|
||||
"file",
|
||||
"stdin"
|
||||
@@ -2055,61 +1880,6 @@
|
||||
}
|
||||
]
|
||||
},
|
||||
"+table-put": {
|
||||
"risk": "write",
|
||||
"flags": [
|
||||
{
|
||||
"name": "url",
|
||||
"kind": "public",
|
||||
"type": "string",
|
||||
"required": "xor",
|
||||
"desc": "Spreadsheet URL to write into (XOR with `--spreadsheet-token`)"
|
||||
},
|
||||
{
|
||||
"name": "spreadsheet-token",
|
||||
"kind": "public",
|
||||
"type": "string",
|
||||
"required": "xor",
|
||||
"desc": "Spreadsheet token to write into (XOR with `--url`)"
|
||||
},
|
||||
{
|
||||
"name": "sheets",
|
||||
"kind": "own",
|
||||
"type": "string",
|
||||
"required": "xor",
|
||||
"desc": "Typed table payload (pandas-DataFrame-shaped) as JSON, XOR with `--dataframe`: a top-level `sheets` array, each item `{name, start_cell?, mode?, header?, allow_overwrite?, columns:[\"colA\",\"colB\",...], data:[[...]], dtypes?:{colA:pandasDtype, ...}, formats?:{colA:numberFormat, ...}}`. Agents typically build it with `{**json.loads(df.to_json(orient=\"split\")), \"dtypes\": df.dtypes.astype(str).to_dict()}`. `dtypes` values are pandas dtype strings (`int64`, `float64`, `Int64`, `bool`, `boolean`, `datetime64[ns]`, `object`, ...); the writer maps them to internal string/number/date/bool — omit `dtypes` and a column writes as text (good for raw CSV-shaped data). `formats[col]` is an Excel number_format string (e.g. `#,##0.00`, `0.0%`, `yyyy-mm`); when absent, date columns default to `yyyy-mm-dd` and string columns to text format (`@`).",
|
||||
"input": [
|
||||
"file",
|
||||
"stdin"
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "dataframe",
|
||||
"kind": "own",
|
||||
"type": "string",
|
||||
"required": "xor",
|
||||
"desc": "Single-sheet typed table from one Arrow IPC file (a.k.a. Feather v2 — what `pandas.DataFrame.to_feather()` writes), XOR with `--sheets`. Pass `@<path>` for a file or `-` for binary stdin (same convention as other input flags). Arrow bytes are read raw — no TrimSpace / BOM strip — so the IPC magic survives intact (unlike text input flags). Column types come from the Arrow schema (int*/uint*/float* → number, date32/date64/timestamp → date, utf8/large_utf8 → string, bool → bool); per-column `number_format` may be set via Arrow field metadata (`pa.field(\"price\", pa.float64(), metadata={b\"number_format\": b\"$#,##0.00\"})`). Writes the sheet at default placement: name `Sheet1` (created when absent), overwrite from A1 with header. For a different sheet name, anchor, mode, or to write multiple sheets, use `--sheets` instead."
|
||||
},
|
||||
{
|
||||
"name": "styles",
|
||||
"kind": "own",
|
||||
"type": "string",
|
||||
"required": "optional",
|
||||
"desc": "Visual operations applied after the typed write, as JSON: top-level `{styles:[...]}`. Each item corresponds to one written sheet and must include `name`, plus at least one of `cell_styles` / `row_sizes` / `col_sizes` / `cell_merges`. `cell_styles` entries use +cells-set-style fields with a cell range; row/col sizes use dimension ranges plus type/size; merges use cell ranges plus optional merge_type. The styles array length/order/name must match the written sheets: with --sheets, match --sheets.sheets; with --dataframe (single sheet named Sheet1), pass exactly one styles item with name `Sheet1`. Run `+table-put --print-schema --flag-name styles` for the full cell_styles field schema.",
|
||||
"input": [
|
||||
"file",
|
||||
"stdin"
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "dry-run",
|
||||
"kind": "system",
|
||||
"type": "bool",
|
||||
"required": "optional",
|
||||
"desc": ""
|
||||
}
|
||||
]
|
||||
},
|
||||
"+cells-clear": {
|
||||
"risk": "high-risk-write",
|
||||
"flags": [
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"schema_version": "3",
|
||||
"schema_version": "2",
|
||||
"flags": {
|
||||
"+batch-update": {
|
||||
"operations": {
|
||||
@@ -44,8 +44,6 @@
|
||||
"+sheet-hide",
|
||||
"+sheet-unhide",
|
||||
"+sheet-set-tab-color",
|
||||
"+sheet-show-gridline",
|
||||
"+sheet-hide-gridline",
|
||||
"+chart-create",
|
||||
"+chart-update",
|
||||
"+chart-delete",
|
||||
@@ -456,7 +454,7 @@
|
||||
"type": "object"
|
||||
},
|
||||
"link": {
|
||||
"description": "超链接地址(type='link' 时必填);@文档 mention(mention_type 非 0)时也必填,传文档 URL(如搜索结果里的文档链接),否则卡片不可点。@人(mention_type=0)不需要传",
|
||||
"description": "超链接地址(仅 type='link' 时必填)",
|
||||
"type": "string"
|
||||
},
|
||||
"mention_token": {
|
||||
@@ -464,21 +462,8 @@
|
||||
"type": "string"
|
||||
},
|
||||
"mention_type": {
|
||||
"description": "@提及类型编号(仅 type='mention' 时可选)。0 或不填=@用户;@文件时按类型取:1=文档 3=电子表格 8=多维表格 11=思维笔记 12=文件 15=旧版幻灯片 16=知识库 22=新版文档 30=幻灯片 38=画板",
|
||||
"type": "number",
|
||||
"enum": [
|
||||
0,
|
||||
1,
|
||||
3,
|
||||
8,
|
||||
11,
|
||||
12,
|
||||
15,
|
||||
16,
|
||||
22,
|
||||
30,
|
||||
38
|
||||
]
|
||||
"description": "@提及类型编号(仅 type='mention' 时可选)",
|
||||
"type": "number"
|
||||
},
|
||||
"notify": {
|
||||
"description": "是否发送通知(仅 type='mention' 时可选,默认 true)",
|
||||
@@ -1745,12 +1730,11 @@
|
||||
},
|
||||
"aggregateType": {
|
||||
"type": "string",
|
||||
"description": "汇总方式,默认为'sum',仅在 aggregate 为 true 时生效。count 只统计数值单元格;counta 统计所有非空单元格(含文本),按文本/分类列统计出现次数(如各类别的数量、频次分布)时用 counta。",
|
||||
"description": "汇总方式,默认为'sum',仅在 aggregate 为 true 时生效",
|
||||
"enum": [
|
||||
"sum",
|
||||
"average",
|
||||
"count",
|
||||
"counta",
|
||||
"min",
|
||||
"max",
|
||||
"median"
|
||||
@@ -1803,7 +1787,11 @@
|
||||
"data"
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"position",
|
||||
"size"
|
||||
]
|
||||
}
|
||||
},
|
||||
"+chart-update": {
|
||||
@@ -2781,12 +2769,11 @@
|
||||
},
|
||||
"aggregateType": {
|
||||
"type": "string",
|
||||
"description": "汇总方式,默认为'sum',仅在 aggregate 为 true 时生效。count 只统计数值单元格;counta 统计所有非空单元格(含文本),按文本/分类列统计出现次数(如各类别的数量、频次分布)时用 counta。",
|
||||
"description": "汇总方式,默认为'sum',仅在 aggregate 为 true 时生效",
|
||||
"enum": [
|
||||
"sum",
|
||||
"average",
|
||||
"count",
|
||||
"counta",
|
||||
"min",
|
||||
"max",
|
||||
"median"
|
||||
@@ -2839,7 +2826,11 @@
|
||||
"data"
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"position",
|
||||
"size"
|
||||
]
|
||||
}
|
||||
},
|
||||
"+cond-format-create": {
|
||||
@@ -6258,744 +6249,6 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"+table-put": {
|
||||
"sheets": {
|
||||
"type": "array",
|
||||
"minItems": 1,
|
||||
"description": "一个或多个子表的 typed 数据,每个数组元素写入一张子表;支持多 DataFrame → 多子表一次写入。整体形状对齐 pandas `df.to_json(orient=\"split\")`:列名走 `columns`、二维取值走 `data`、每列的 pandas dtype 走 `dtypes`、可选的展示格式走 `formats`。一行式用法:`{**json.loads(df.to_json(orient=\"split\")), \"dtypes\": df.dtypes.astype(str).to_dict()}`。",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"name",
|
||||
"columns",
|
||||
"data"
|
||||
],
|
||||
"properties": {
|
||||
"name": {
|
||||
"type": "string",
|
||||
"description": "目标子表名。按名匹配已有子表;不存在则新建该子表。同一次调用内子表名不可重复。"
|
||||
},
|
||||
"start_cell": {
|
||||
"type": "string",
|
||||
"default": "A1",
|
||||
"description": "写入起点单元格(A1 记法,如 \"B2\"),默认 \"A1\"。mode=append 时忽略其行号、仅沿用其列。"
|
||||
},
|
||||
"mode": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"overwrite",
|
||||
"append"
|
||||
],
|
||||
"default": "overwrite",
|
||||
"description": "overwrite(默认):从 start_cell 起写「表头 + 数据」块;append:把数据追加到子表已有数据下方(默认不重复表头)。"
|
||||
},
|
||||
"header": {
|
||||
"type": "boolean",
|
||||
"description": "是否写一行列名表头。省略时按 mode 取默认:overwrite→true、append→false(避免在已有表头下重复);显式给值可覆盖。"
|
||||
},
|
||||
"allow_overwrite": {
|
||||
"type": "boolean",
|
||||
"default": true,
|
||||
"description": "为 false 时,若写入会落在非空单元格则拒写以保护原数据(返回 partial_success)。默认 true。"
|
||||
},
|
||||
"columns": {
|
||||
"type": "array",
|
||||
"minItems": 1,
|
||||
"description": "列名字符串数组,顺序与 `data` 中每行取值一一对应。同一子表内列名不可重复。",
|
||||
"items": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"data": {
|
||||
"type": "array",
|
||||
"description": "数据行;每行是一个数组,长度必须等于 `columns` 数。元素按 `dtypes` 推得的列类型取值(date 列写 ISO yyyy-mm-dd 字符串、number 列写数值、bool 列写布尔、其余写文本),null 表示空单元格。",
|
||||
"items": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": [
|
||||
"string",
|
||||
"number",
|
||||
"boolean",
|
||||
"null"
|
||||
],
|
||||
"description": "单元格值:date→ISO yyyy-mm-dd 字符串;number→数值(json.Number 精度保留);bool→布尔;string→文本;null→空单元格。"
|
||||
}
|
||||
}
|
||||
},
|
||||
"dtypes": {
|
||||
"type": "object",
|
||||
"description": "可选。列名 → pandas dtype 字符串的映射;缺失项默认按 object(string + 文本格式 `@`)处理,所以省略整段时整张表按文本写入(导入 CSV-shaped 数据的最简形态)。dtype 解析规则:`int*` / `uint*` / `Int*` / `UInt*` / `float*` / `Float*` / `complex*` → number(精度保留),`bool` / `boolean` → bool,`datetime64[ns]` / 含时区的 `datetime64[ns, UTC]` 等 → date(默认 `yyyy-mm-dd` 格式),`object` / `string` / `category` / 未识别 → string + 文本格式 `@`(数字样字符串如「00123」不会塌缩成数字)。",
|
||||
"additionalProperties": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"formats": {
|
||||
"type": "object",
|
||||
"description": "可选。列名 → Excel number_format 字符串的映射,覆盖 dtype 自带的默认格式(金额 `#,##0.00`、百分比 `0.0%`、自定义日期 `yyyy-mm` 等)。percent 列的数值尺度由调用方负责(0.0469 配 `0.00%` 显示 4.69%)。",
|
||||
"additionalProperties": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"styles": {
|
||||
"items": {
|
||||
"properties": {
|
||||
"cell_merges": {
|
||||
"description": "单元格合并操作数组;range 使用 A1 单元格范围,merge_type 默认 all。",
|
||||
"items": {
|
||||
"properties": {
|
||||
"merge_type": {
|
||||
"enum": [
|
||||
"all",
|
||||
"rows",
|
||||
"columns"
|
||||
],
|
||||
"type": "string"
|
||||
},
|
||||
"range": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"range"
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
"type": "array"
|
||||
},
|
||||
"cell_styles": {
|
||||
"description": "单元格样式操作数组;每项用 A1 单元格 range 指定范围,字段名与 +cells-set-style 对齐。",
|
||||
"items": {
|
||||
"properties": {
|
||||
"background_color": {
|
||||
"type": "string"
|
||||
},
|
||||
"border_styles": {
|
||||
"type": "object",
|
||||
"description": "边框配置,结构同 +cells-set-style --border-styles。",
|
||||
"properties": {
|
||||
"bottom": {
|
||||
"properties": {
|
||||
"color": {
|
||||
"description": "边框颜色(十六进制,例如 \"#000000\")",
|
||||
"type": "string"
|
||||
},
|
||||
"style": {
|
||||
"description": "边框线型;传 \"none\" 表示清除该方向边框(无边框线)",
|
||||
"enum": [
|
||||
"solid",
|
||||
"dashed",
|
||||
"dotted",
|
||||
"double",
|
||||
"none"
|
||||
],
|
||||
"type": "string"
|
||||
},
|
||||
"weight": {
|
||||
"description": "边框粗细/线宽",
|
||||
"enum": [
|
||||
"thin",
|
||||
"medium",
|
||||
"thick"
|
||||
],
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"type": "object"
|
||||
},
|
||||
"left": {
|
||||
"properties": {
|
||||
"color": {
|
||||
"description": "边框颜色(十六进制,例如 \"#000000\")",
|
||||
"type": "string"
|
||||
},
|
||||
"style": {
|
||||
"description": "边框线型;传 \"none\" 表示清除该方向边框(无边框线)",
|
||||
"enum": [
|
||||
"solid",
|
||||
"dashed",
|
||||
"dotted",
|
||||
"double",
|
||||
"none"
|
||||
],
|
||||
"type": "string"
|
||||
},
|
||||
"weight": {
|
||||
"description": "边框粗细/线宽",
|
||||
"enum": [
|
||||
"thin",
|
||||
"medium",
|
||||
"thick"
|
||||
],
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"type": "object"
|
||||
},
|
||||
"right": {
|
||||
"properties": {
|
||||
"color": {
|
||||
"description": "边框颜色(十六进制,例如 \"#000000\")",
|
||||
"type": "string"
|
||||
},
|
||||
"style": {
|
||||
"description": "边框线型;传 \"none\" 表示清除该方向边框(无边框线)",
|
||||
"enum": [
|
||||
"solid",
|
||||
"dashed",
|
||||
"dotted",
|
||||
"double",
|
||||
"none"
|
||||
],
|
||||
"type": "string"
|
||||
},
|
||||
"weight": {
|
||||
"description": "边框粗细/线宽",
|
||||
"enum": [
|
||||
"thin",
|
||||
"medium",
|
||||
"thick"
|
||||
],
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"type": "object"
|
||||
},
|
||||
"top": {
|
||||
"properties": {
|
||||
"color": {
|
||||
"description": "边框颜色(十六进制,例如 \"#000000\")",
|
||||
"type": "string"
|
||||
},
|
||||
"style": {
|
||||
"description": "边框线型;传 \"none\" 表示清除该方向边框(无边框线)",
|
||||
"enum": [
|
||||
"solid",
|
||||
"dashed",
|
||||
"dotted",
|
||||
"double",
|
||||
"none"
|
||||
],
|
||||
"type": "string"
|
||||
},
|
||||
"weight": {
|
||||
"description": "边框粗细/线宽",
|
||||
"enum": [
|
||||
"thin",
|
||||
"medium",
|
||||
"thick"
|
||||
],
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"type": "object"
|
||||
}
|
||||
}
|
||||
},
|
||||
"font_color": {
|
||||
"type": "string"
|
||||
},
|
||||
"font_line": {
|
||||
"enum": [
|
||||
"none",
|
||||
"underline",
|
||||
"line-through"
|
||||
],
|
||||
"type": "string"
|
||||
},
|
||||
"font_size": {
|
||||
"type": "number"
|
||||
},
|
||||
"font_style": {
|
||||
"enum": [
|
||||
"normal",
|
||||
"italic"
|
||||
],
|
||||
"type": "string"
|
||||
},
|
||||
"font_weight": {
|
||||
"enum": [
|
||||
"normal",
|
||||
"bold"
|
||||
],
|
||||
"type": "string"
|
||||
},
|
||||
"horizontal_alignment": {
|
||||
"enum": [
|
||||
"left",
|
||||
"center",
|
||||
"right"
|
||||
],
|
||||
"type": "string"
|
||||
},
|
||||
"number_format": {
|
||||
"type": "string"
|
||||
},
|
||||
"range": {
|
||||
"description": "A1 单元格范围,必须落在该子表本次写入区域内;例如 A1:B1、B2。",
|
||||
"type": "string"
|
||||
},
|
||||
"vertical_alignment": {
|
||||
"enum": [
|
||||
"top",
|
||||
"middle",
|
||||
"bottom"
|
||||
],
|
||||
"type": "string"
|
||||
},
|
||||
"word_wrap": {
|
||||
"enum": [
|
||||
"overflow",
|
||||
"auto-wrap",
|
||||
"word-clip"
|
||||
],
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"range"
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
"type": "array"
|
||||
},
|
||||
"col_sizes": {
|
||||
"description": "列宽操作数组;range 使用列范围如 A:C,type 为 pixel/standard,pixel 需要 size。",
|
||||
"items": {
|
||||
"properties": {
|
||||
"range": {
|
||||
"type": "string"
|
||||
},
|
||||
"size": {
|
||||
"type": "number"
|
||||
},
|
||||
"type": {
|
||||
"enum": [
|
||||
"pixel",
|
||||
"standard"
|
||||
],
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"range",
|
||||
"type"
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
"type": "array"
|
||||
},
|
||||
"name": {
|
||||
"description": "子表名。--sheets 模式下必须与同位置 --sheets.sheets[].name 一致;--values 模式下建议写 Sheet1(其 name 会被忽略)。",
|
||||
"type": "string"
|
||||
},
|
||||
"row_sizes": {
|
||||
"description": "行高操作数组;range 使用行范围如 1:3,type 为 pixel/standard/auto,pixel 需要 size。",
|
||||
"items": {
|
||||
"properties": {
|
||||
"range": {
|
||||
"type": "string"
|
||||
},
|
||||
"size": {
|
||||
"type": "number"
|
||||
},
|
||||
"type": {
|
||||
"enum": [
|
||||
"pixel",
|
||||
"standard",
|
||||
"auto"
|
||||
],
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"range",
|
||||
"type"
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
"type": "array"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"name"
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
"type": "array"
|
||||
}
|
||||
},
|
||||
"+workbook-create": {
|
||||
"sheets": {
|
||||
"type": "array",
|
||||
"minItems": 1,
|
||||
"description": "一个或多个子表的 typed 数据,每个数组元素写入一张子表;支持多 DataFrame → 多子表一次写入。整体形状对齐 pandas `df.to_json(orient=\"split\")`:列名走 `columns`、二维取值走 `data`、每列的 pandas dtype 走 `dtypes`、可选的展示格式走 `formats`。一行式用法:`{**json.loads(df.to_json(orient=\"split\")), \"dtypes\": df.dtypes.astype(str).to_dict()}`。",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"name",
|
||||
"columns",
|
||||
"data"
|
||||
],
|
||||
"properties": {
|
||||
"name": {
|
||||
"type": "string",
|
||||
"description": "目标子表名。按名匹配已有子表;不存在则新建该子表。同一次调用内子表名不可重复。"
|
||||
},
|
||||
"start_cell": {
|
||||
"type": "string",
|
||||
"default": "A1",
|
||||
"description": "写入起点单元格(A1 记法,如 \"B2\"),默认 \"A1\"。mode=append 时忽略其行号、仅沿用其列。"
|
||||
},
|
||||
"mode": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"overwrite",
|
||||
"append"
|
||||
],
|
||||
"default": "overwrite",
|
||||
"description": "overwrite(默认):从 start_cell 起写「表头 + 数据」块;append:把数据追加到子表已有数据下方(默认不重复表头)。"
|
||||
},
|
||||
"header": {
|
||||
"type": "boolean",
|
||||
"description": "是否写一行列名表头。省略时按 mode 取默认:overwrite→true、append→false(避免在已有表头下重复);显式给值可覆盖。"
|
||||
},
|
||||
"allow_overwrite": {
|
||||
"type": "boolean",
|
||||
"default": true,
|
||||
"description": "为 false 时,若写入会落在非空单元格则拒写以保护原数据(返回 partial_success)。默认 true。"
|
||||
},
|
||||
"columns": {
|
||||
"type": "array",
|
||||
"minItems": 1,
|
||||
"description": "列名字符串数组,顺序与 `data` 中每行取值一一对应。同一子表内列名不可重复。",
|
||||
"items": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"data": {
|
||||
"type": "array",
|
||||
"description": "数据行;每行是一个数组,长度必须等于 `columns` 数。元素按 `dtypes` 推得的列类型取值(date 列写 ISO yyyy-mm-dd 字符串、number 列写数值、bool 列写布尔、其余写文本),null 表示空单元格。",
|
||||
"items": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": [
|
||||
"string",
|
||||
"number",
|
||||
"boolean",
|
||||
"null"
|
||||
],
|
||||
"description": "单元格值:date→ISO yyyy-mm-dd 字符串;number→数值(json.Number 精度保留);bool→布尔;string→文本;null→空单元格。"
|
||||
}
|
||||
}
|
||||
},
|
||||
"dtypes": {
|
||||
"type": "object",
|
||||
"description": "可选。列名 → pandas dtype 字符串的映射;缺失项默认按 object(string + 文本格式 `@`)处理,所以省略整段时整张表按文本写入(导入 CSV-shaped 数据的最简形态)。dtype 解析规则:`int*` / `uint*` / `Int*` / `UInt*` / `float*` / `Float*` / `complex*` → number(精度保留),`bool` / `boolean` → bool,`datetime64[ns]` / 含时区的 `datetime64[ns, UTC]` 等 → date(默认 `yyyy-mm-dd` 格式),`object` / `string` / `category` / 未识别 → string + 文本格式 `@`(数字样字符串如「00123」不会塌缩成数字)。",
|
||||
"additionalProperties": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"formats": {
|
||||
"type": "object",
|
||||
"description": "可选。列名 → Excel number_format 字符串的映射,覆盖 dtype 自带的默认格式(金额 `#,##0.00`、百分比 `0.0%`、自定义日期 `yyyy-mm` 等)。percent 列的数值尺度由调用方负责(0.0469 配 `0.00%` 显示 4.69%)。",
|
||||
"additionalProperties": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"styles": {
|
||||
"items": {
|
||||
"properties": {
|
||||
"cell_merges": {
|
||||
"description": "单元格合并操作数组;range 使用 A1 单元格范围,merge_type 默认 all。",
|
||||
"items": {
|
||||
"properties": {
|
||||
"merge_type": {
|
||||
"enum": [
|
||||
"all",
|
||||
"rows",
|
||||
"columns"
|
||||
],
|
||||
"type": "string"
|
||||
},
|
||||
"range": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"range"
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
"type": "array"
|
||||
},
|
||||
"cell_styles": {
|
||||
"description": "单元格样式操作数组;每项用 A1 单元格 range 指定范围,字段名与 +cells-set-style 对齐。",
|
||||
"items": {
|
||||
"properties": {
|
||||
"background_color": {
|
||||
"type": "string"
|
||||
},
|
||||
"border_styles": {
|
||||
"type": "object",
|
||||
"description": "边框配置,结构同 +cells-set-style --border-styles。",
|
||||
"properties": {
|
||||
"bottom": {
|
||||
"properties": {
|
||||
"color": {
|
||||
"description": "边框颜色(十六进制,例如 \"#000000\")",
|
||||
"type": "string"
|
||||
},
|
||||
"style": {
|
||||
"description": "边框线型;传 \"none\" 表示清除该方向边框(无边框线)",
|
||||
"enum": [
|
||||
"solid",
|
||||
"dashed",
|
||||
"dotted",
|
||||
"double",
|
||||
"none"
|
||||
],
|
||||
"type": "string"
|
||||
},
|
||||
"weight": {
|
||||
"description": "边框粗细/线宽",
|
||||
"enum": [
|
||||
"thin",
|
||||
"medium",
|
||||
"thick"
|
||||
],
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"type": "object"
|
||||
},
|
||||
"left": {
|
||||
"properties": {
|
||||
"color": {
|
||||
"description": "边框颜色(十六进制,例如 \"#000000\")",
|
||||
"type": "string"
|
||||
},
|
||||
"style": {
|
||||
"description": "边框线型;传 \"none\" 表示清除该方向边框(无边框线)",
|
||||
"enum": [
|
||||
"solid",
|
||||
"dashed",
|
||||
"dotted",
|
||||
"double",
|
||||
"none"
|
||||
],
|
||||
"type": "string"
|
||||
},
|
||||
"weight": {
|
||||
"description": "边框粗细/线宽",
|
||||
"enum": [
|
||||
"thin",
|
||||
"medium",
|
||||
"thick"
|
||||
],
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"type": "object"
|
||||
},
|
||||
"right": {
|
||||
"properties": {
|
||||
"color": {
|
||||
"description": "边框颜色(十六进制,例如 \"#000000\")",
|
||||
"type": "string"
|
||||
},
|
||||
"style": {
|
||||
"description": "边框线型;传 \"none\" 表示清除该方向边框(无边框线)",
|
||||
"enum": [
|
||||
"solid",
|
||||
"dashed",
|
||||
"dotted",
|
||||
"double",
|
||||
"none"
|
||||
],
|
||||
"type": "string"
|
||||
},
|
||||
"weight": {
|
||||
"description": "边框粗细/线宽",
|
||||
"enum": [
|
||||
"thin",
|
||||
"medium",
|
||||
"thick"
|
||||
],
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"type": "object"
|
||||
},
|
||||
"top": {
|
||||
"properties": {
|
||||
"color": {
|
||||
"description": "边框颜色(十六进制,例如 \"#000000\")",
|
||||
"type": "string"
|
||||
},
|
||||
"style": {
|
||||
"description": "边框线型;传 \"none\" 表示清除该方向边框(无边框线)",
|
||||
"enum": [
|
||||
"solid",
|
||||
"dashed",
|
||||
"dotted",
|
||||
"double",
|
||||
"none"
|
||||
],
|
||||
"type": "string"
|
||||
},
|
||||
"weight": {
|
||||
"description": "边框粗细/线宽",
|
||||
"enum": [
|
||||
"thin",
|
||||
"medium",
|
||||
"thick"
|
||||
],
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"type": "object"
|
||||
}
|
||||
}
|
||||
},
|
||||
"font_color": {
|
||||
"type": "string"
|
||||
},
|
||||
"font_line": {
|
||||
"enum": [
|
||||
"none",
|
||||
"underline",
|
||||
"line-through"
|
||||
],
|
||||
"type": "string"
|
||||
},
|
||||
"font_size": {
|
||||
"type": "number"
|
||||
},
|
||||
"font_style": {
|
||||
"enum": [
|
||||
"normal",
|
||||
"italic"
|
||||
],
|
||||
"type": "string"
|
||||
},
|
||||
"font_weight": {
|
||||
"enum": [
|
||||
"normal",
|
||||
"bold"
|
||||
],
|
||||
"type": "string"
|
||||
},
|
||||
"horizontal_alignment": {
|
||||
"enum": [
|
||||
"left",
|
||||
"center",
|
||||
"right"
|
||||
],
|
||||
"type": "string"
|
||||
},
|
||||
"number_format": {
|
||||
"type": "string"
|
||||
},
|
||||
"range": {
|
||||
"description": "A1 单元格范围,必须落在该子表本次写入区域内;例如 A1:B1、B2。",
|
||||
"type": "string"
|
||||
},
|
||||
"vertical_alignment": {
|
||||
"enum": [
|
||||
"top",
|
||||
"middle",
|
||||
"bottom"
|
||||
],
|
||||
"type": "string"
|
||||
},
|
||||
"word_wrap": {
|
||||
"enum": [
|
||||
"overflow",
|
||||
"auto-wrap",
|
||||
"word-clip"
|
||||
],
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"range"
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
"type": "array"
|
||||
},
|
||||
"col_sizes": {
|
||||
"description": "列宽操作数组;range 使用列范围如 A:C,type 为 pixel/standard,pixel 需要 size。",
|
||||
"items": {
|
||||
"properties": {
|
||||
"range": {
|
||||
"type": "string"
|
||||
},
|
||||
"size": {
|
||||
"type": "number"
|
||||
},
|
||||
"type": {
|
||||
"enum": [
|
||||
"pixel",
|
||||
"standard"
|
||||
],
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"range",
|
||||
"type"
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
"type": "array"
|
||||
},
|
||||
"name": {
|
||||
"description": "子表名。--sheets 模式下必须与同位置 --sheets.sheets[].name 一致;--values 模式下建议写 Sheet1(其 name 会被忽略)。",
|
||||
"type": "string"
|
||||
},
|
||||
"row_sizes": {
|
||||
"description": "行高操作数组;range 使用行范围如 1:3,type 为 pixel/standard/auto,pixel 需要 size。",
|
||||
"items": {
|
||||
"properties": {
|
||||
"range": {
|
||||
"type": "string"
|
||||
},
|
||||
"size": {
|
||||
"type": "number"
|
||||
},
|
||||
"type": {
|
||||
"enum": [
|
||||
"pixel",
|
||||
"standard",
|
||||
"auto"
|
||||
],
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"range",
|
||||
"type"
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
"type": "array"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"name"
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
"type": "array"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -47,132 +47,19 @@ func TestExecute_WorkbookInfo_ToolError(t *testing.T) {
|
||||
"data": map[string]interface{}{},
|
||||
},
|
||||
}
|
||||
_, _, err := func() (string, string, error) {
|
||||
stdout, stderr, err := func() (string, string, error) {
|
||||
parent, stdout, stderr, reg := newTestRig(t, WorkbookInfo)
|
||||
reg.Register(stub)
|
||||
parent.SetArgs([]string{"+workbook-info", "--url", testURL})
|
||||
err := parent.Execute()
|
||||
return stdout.String(), stderr.String(), err
|
||||
}()
|
||||
p := requireProblem(t, err, errs.CategoryAPI, errs.SubtypeServerError, "")
|
||||
if !strings.Contains(p.Message, "1310201") && !strings.Contains(p.Message, "not found") {
|
||||
t.Errorf("expected error code or message in problem; got message=%q", p.Message)
|
||||
}
|
||||
}
|
||||
|
||||
// TestExecute_WikiURLResolvesToSheet covers the two-step wiki path: a /wiki/
|
||||
// URL is resolved via get_node to its spreadsheet obj_token, which then feeds
|
||||
// the tool invoke. The tool stub is keyed on the resolved obj_token, so the
|
||||
// test would fail if the node_token were used unresolved.
|
||||
func TestExecute_WikiURLResolvesToSheet(t *testing.T) {
|
||||
t.Parallel()
|
||||
getNode := &httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/wiki/v2/spaces/get_node",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"msg": "success",
|
||||
"data": map[string]interface{}{
|
||||
"node": map[string]interface{}{
|
||||
"obj_type": "sheet",
|
||||
"obj_token": testToken,
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
tool := toolOutputStub(testToken, "read", `{"sheets":[{"sheet_id":"sh1","title":"Sheet1","index":0}]}`)
|
||||
out, err := runShortcutWithStubs(t, WorkbookInfo,
|
||||
[]string{"--url", "https://example.feishu.cn/wiki/wikTestNODE"}, getNode, tool)
|
||||
if err != nil {
|
||||
t.Fatalf("execute failed: %v\nout=%s", err, out)
|
||||
}
|
||||
data := decodeEnvelopeData(t, out)
|
||||
if sheets, _ := data["sheets"].([]interface{}); len(sheets) != 1 {
|
||||
t.Fatalf("sheets len = %d, want 1; out=%s", len(sheets), out)
|
||||
}
|
||||
}
|
||||
|
||||
// TestExecute_WikiURLWrongObjType rejects a wiki node that resolves to a
|
||||
// non-spreadsheet obj_type before any tool invoke.
|
||||
func TestExecute_WikiURLWrongObjType(t *testing.T) {
|
||||
t.Parallel()
|
||||
getNode := &httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/wiki/v2/spaces/get_node",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"msg": "success",
|
||||
"data": map[string]interface{}{
|
||||
"node": map[string]interface{}{
|
||||
"obj_type": "docx",
|
||||
"obj_token": "docABC",
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
_, err := runShortcutWithStubs(t, WorkbookInfo,
|
||||
[]string{"--url", "https://example.feishu.cn/wiki/wikTestNODE"}, getNode)
|
||||
requireValidation(t, err, "obj_type")
|
||||
}
|
||||
|
||||
// TestExecute_WikiURLIncompleteNode treats an incomplete get_node response
|
||||
// (missing obj_type/obj_token) as an internal/server error, not a user --url
|
||||
// validation error.
|
||||
func TestExecute_WikiURLIncompleteNode(t *testing.T) {
|
||||
t.Parallel()
|
||||
getNode := &httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/wiki/v2/spaces/get_node",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"msg": "success",
|
||||
"data": map[string]interface{}{
|
||||
"node": map[string]interface{}{},
|
||||
},
|
||||
},
|
||||
}
|
||||
_, err := runShortcutWithStubs(t, WorkbookInfo,
|
||||
[]string{"--url", "https://example.feishu.cn/wiki/wikTestNODE"}, getNode)
|
||||
if err == nil {
|
||||
t.Fatal("want error for incomplete get_node node data")
|
||||
t.Fatalf("expected non-zero code to surface as error; stdout=%s stderr=%s", stdout, stderr)
|
||||
}
|
||||
var ve *errs.ValidationError
|
||||
if errors.As(err, &ve) {
|
||||
t.Fatalf("incomplete-data error classified as validation (%v); want internal", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestExecute_RangeMove_WikiURL guards the transformExecuteFn path: +range-move
|
||||
// and +range-copy use a named Execute helper (not an inline func), so they must
|
||||
// still resolve a /wiki/ URL to the backing spreadsheet token before calling
|
||||
// transform_range. The tool stub is keyed on the resolved obj_token, so an
|
||||
// unresolved node_token would miss it and fail this test.
|
||||
func TestExecute_RangeMove_WikiURL(t *testing.T) {
|
||||
t.Parallel()
|
||||
getNode := &httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/wiki/v2/spaces/get_node",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"msg": "success",
|
||||
"data": map[string]interface{}{
|
||||
"node": map[string]interface{}{
|
||||
"obj_type": "sheet",
|
||||
"obj_token": testToken,
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
tool := toolOutputStub(testToken, "write", `{"updated_range":"A10:B11"}`)
|
||||
out, err := runShortcutWithStubs(t, RangeMove,
|
||||
[]string{
|
||||
"--url", "https://example.feishu.cn/wiki/wikTestNODE",
|
||||
"--sheet-id", testSheetID,
|
||||
"--source-range", "A1:B2",
|
||||
"--target-range", "A10",
|
||||
}, getNode, tool)
|
||||
if err != nil {
|
||||
t.Fatalf("execute failed: %v\nout=%s", err, out)
|
||||
combined := stdout + stderr + err.Error()
|
||||
if !strings.Contains(combined, "1310201") && !strings.Contains(combined, "not found") {
|
||||
t.Errorf("expected error code in envelope; got=%s|%s|%v", stdout, stderr, err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -478,17 +365,14 @@ func TestExecute_WorkbookCreate(t *testing.T) {
|
||||
},
|
||||
},
|
||||
}
|
||||
// The write reads the workbook structure to resolve the default sheet's id
|
||||
// (the create response doesn't echo it). lookupFirstSheetID and
|
||||
// writeTypedSheets' listSheetIDsByName both read it — one reusable stub serves
|
||||
// both. The synthesized sheet is named "Sheet1", matching the default sheet,
|
||||
// so it's adopted in place (no rename).
|
||||
// Initial fill first reads the workbook structure to resolve the default
|
||||
// sheet's id (the create response doesn't echo it), then writes.
|
||||
structure := toolOutputStub("shtcnBRAND", "read", `{"sheets":[{"sheet_id":"shtFirst","sheet_name":"Sheet1","index":0}]}`)
|
||||
structure.Reusable = true
|
||||
fill := toolOutputStub("shtcnBRAND", "write", `{"updated_cells":4}`)
|
||||
out, err := runShortcutWithStubs(t, WorkbookCreate, []string{
|
||||
"--title", "Sales",
|
||||
"--values", `[["Name","Score"],["alice",95]]`,
|
||||
"--headers", `["Name","Score"]`,
|
||||
"--values", `[["alice",95]]`,
|
||||
}, create, structure, fill)
|
||||
if err != nil {
|
||||
t.Fatalf("execute failed: %v\nout=%s", err, out)
|
||||
@@ -498,8 +382,8 @@ func TestExecute_WorkbookCreate(t *testing.T) {
|
||||
if ss["spreadsheet_token"] != "shtcnBRAND" {
|
||||
t.Errorf("spreadsheet_token = %v", ss["spreadsheet_token"])
|
||||
}
|
||||
if sheets, _ := data["sheets"].([]interface{}); len(sheets) != 1 {
|
||||
t.Errorf("sheets summary missing in envelope; got %#v", data["sheets"])
|
||||
if data["initial_fill"] == nil {
|
||||
t.Errorf("initial_fill missing in envelope")
|
||||
}
|
||||
// The fill must target the resolved first sheet, not an empty selector.
|
||||
fillInput := decodeToolInput(t, decodeRawEnvelopeBody(t, fill.CapturedBody), "set_cell_range")
|
||||
@@ -509,13 +393,14 @@ func TestExecute_WorkbookCreate(t *testing.T) {
|
||||
}
|
||||
|
||||
// TestExecute_WorkbookCreate_EmptyArraysSkipFill locks the fix for the nil-map
|
||||
// panic / illegal-range bug: --values '[]' must short-circuit the initial fill
|
||||
// (no structure/fill calls fire) and finish with the spreadsheet created but no
|
||||
// sheets summary — never panic on a nil payload.
|
||||
// panic / illegal-range bug: --values '[]' or --headers '[]' must short-circuit
|
||||
// the initial fill (no structure/fill calls fire) and finish with the
|
||||
// spreadsheet created but no initial_fill — never panic on a nil fill map.
|
||||
func TestExecute_WorkbookCreate_EmptyArraysSkipFill(t *testing.T) {
|
||||
t.Parallel()
|
||||
for _, tc := range []struct{ name, flag, val string }{
|
||||
{"empty values", "--values", "[]"},
|
||||
{"empty headers", "--headers", "[]"},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
@@ -536,8 +421,8 @@ func TestExecute_WorkbookCreate_EmptyArraysSkipFill(t *testing.T) {
|
||||
t.Fatalf("execute failed: %v\nout=%s", err, out)
|
||||
}
|
||||
data := decodeEnvelopeData(t, out)
|
||||
if data["sheets"] != nil {
|
||||
t.Errorf("sheets should be absent for %s %s; got %#v", tc.flag, tc.val, data["sheets"])
|
||||
if data["initial_fill"] != nil {
|
||||
t.Errorf("initial_fill should be absent for %s %s; got %#v", tc.flag, tc.val, data["initial_fill"])
|
||||
}
|
||||
if ss, _ := data["spreadsheet"].(map[string]interface{}); ss["spreadsheet_token"] != "shtNEW" {
|
||||
t.Errorf("spreadsheet_token = %v, want shtNEW", ss["spreadsheet_token"])
|
||||
|
||||
@@ -80,28 +80,3 @@ func flagsFor(command string) []common.Flag {
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// flagAcceptsStdin reports whether the (command, flag) pair declares stdin as
|
||||
// an input source in flag-defs.json. Used to decide whether an "invalid JSON"
|
||||
// error should also steer the caller toward stdin. It runs on an error path,
|
||||
// so it returns false for an unknown command/flag rather than panicking the
|
||||
// way flagsFor does.
|
||||
func flagAcceptsStdin(command, name string) bool {
|
||||
defs, _ := loadFlagDefs()
|
||||
spec, ok := defs[command]
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
for _, df := range spec.Flags {
|
||||
if df.Name != name {
|
||||
continue
|
||||
}
|
||||
for _, in := range df.Input {
|
||||
if in == common.Stdin {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
@@ -75,7 +75,7 @@ var flagDefs = map[string]commandDef{
|
||||
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name (XOR with `--sheet-id`)"},
|
||||
{Name: "range", Kind: "own", Type: "string", Required: "required", Desc: "A1 range, e.g. `A1:F10` (no sheet prefix — use `--sheet-id` / `--sheet-name` to select the sheet)"},
|
||||
{Name: "include", Kind: "own", Type: "string_slice", Required: "optional", Desc: "Comma-separated info categories to include", Enum: []string{"value", "formula", "style", "comment", "data_validation"}},
|
||||
{Name: "max-chars", Kind: "own", Type: "int", Required: "optional", Desc: "Max output chars per call; default 500000 (safety cap). Large reads are usually better redirected to a file; only lower it (e.g. 25000) when you want results inline without triggering file offload, paging via has_more", Default: "500000"},
|
||||
{Name: "max-chars", Kind: "own", Type: "int", Required: "optional", Desc: "Safety cap; default 200000", Default: "200000", Hidden: true},
|
||||
{Name: "skip-hidden", Kind: "own", Type: "bool", Required: "optional", Desc: "Skip hidden rows and columns; default `false`"},
|
||||
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"},
|
||||
},
|
||||
@@ -305,9 +305,10 @@ var flagDefs = map[string]commandDef{
|
||||
{Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id (XOR with `--sheet-name`)"},
|
||||
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name (XOR with `--sheet-id`)"},
|
||||
{Name: "range", Kind: "own", Type: "string", Required: "required", Desc: "A1 range, e.g. `A1:F30` (no sheet prefix — use `--sheet-id` / `--sheet-name` to select the sheet)"},
|
||||
{Name: "max-chars", Kind: "own", Type: "int", Required: "optional", Desc: "Max output chars per call; default 500000 (safety cap). Large reads are usually better redirected to a file; only lower it (e.g. 25000) when you want results inline without triggering file offload, paging via has_more", Default: "500000"},
|
||||
{Name: "max-chars", Kind: "own", Type: "int", Required: "optional", Desc: "Safety cap; default 200000", Default: "200000", Hidden: true},
|
||||
{Name: "include-row-prefix", Kind: "own", Type: "bool", Required: "optional", Desc: "Whether to prefix each row with `[row=N]`; default `true`", Default: "true"},
|
||||
{Name: "skip-hidden", Kind: "own", Type: "bool", Required: "optional", Desc: "Skip hidden rows and columns; default `false`"},
|
||||
{Name: "rows-json", Kind: "own", Type: "bool", Required: "optional", Desc: "Return structured rows ({row_number, values:{col→cell}}) instead of CSV text; default false", Default: "false"},
|
||||
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional", Desc: "Print the request path and parameters without executing"},
|
||||
},
|
||||
},
|
||||
@@ -319,7 +320,7 @@ var flagDefs = map[string]commandDef{
|
||||
{Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id (XOR with `--sheet-name`)"},
|
||||
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name (XOR with `--sheet-id`)"},
|
||||
{Name: "start-cell", Kind: "own", Type: "string", Required: "required", Desc: "Top-left A1 anchor (e.g. `A1`, `B5`; no sheet prefix — use `--sheet-id` / `--sheet-name` to select the sheet); must be a single cell, range notation not accepted; the bottom-right is inferred from CSV row/column counts", Default: "A1"},
|
||||
{Name: "csv", Kind: "own", Type: "string", Required: "required", Desc: "RFC 4180 CSV text; values or formulas (a leading = is evaluated as a formula); no styles / comments / images (use +cells-set for those).", Input: []string{"file", "stdin"}},
|
||||
{Name: "csv", Kind: "own", Type: "string", Required: "required", Desc: "RFC 4180 CSV text; plain values only (no formulas / styles / comments)", Input: []string{"file", "stdin"}},
|
||||
{Name: "allow-overwrite", Kind: "own", Type: "bool", Required: "optional", Desc: "Allow overwriting (default true); set false to error if any target cell is non-empty", Default: "true"},
|
||||
{Name: "range", Kind: "own", Type: "string", Required: "optional", Desc: "alias for --start-cell (parity with +csv-get / +cells-set, which locate with --range); a range like A1:H17 collapses to its top-left cell", Hidden: true},
|
||||
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"},
|
||||
@@ -765,7 +766,7 @@ var flagDefs = map[string]commandDef{
|
||||
{Name: "url", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet URL (XOR with `--spreadsheet-token`)"},
|
||||
{Name: "spreadsheet-token", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet token (XOR with `--url`)"},
|
||||
{Name: "title", Kind: "own", Type: "string", Required: "required", Desc: "New sheet title"},
|
||||
{Name: "index", Kind: "own", Type: "int", Required: "optional", Desc: "Insert position (0-based); appended to the end when omitted", Default: "-1"},
|
||||
{Name: "index", Kind: "own", Type: "int", Required: "optional", Desc: "Insert position; appended to the end when omitted", Default: "-1"},
|
||||
{Name: "row-count", Kind: "own", Type: "int", Required: "optional", Desc: "Initial row count (default 200, max 50000)", Default: "200"},
|
||||
{Name: "col-count", Kind: "own", Type: "int", Required: "optional", Desc: "Initial column count (default 20, max 200)", Default: "20"},
|
||||
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"},
|
||||
@@ -792,16 +793,6 @@ var flagDefs = map[string]commandDef{
|
||||
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"},
|
||||
},
|
||||
},
|
||||
"+sheet-hide-gridline": {
|
||||
Risk: "write",
|
||||
Flags: []flagDef{
|
||||
{Name: "url", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet URL (XOR with `--spreadsheet-token`)"},
|
||||
{Name: "spreadsheet-token", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet token (XOR with `--url`)"},
|
||||
{Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id (XOR with `--sheet-name`)"},
|
||||
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name (XOR with `--sheet-id`)"},
|
||||
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"},
|
||||
},
|
||||
},
|
||||
"+sheet-info": {
|
||||
Risk: "read",
|
||||
Flags: []flagDef{
|
||||
@@ -848,16 +839,6 @@ var flagDefs = map[string]commandDef{
|
||||
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"},
|
||||
},
|
||||
},
|
||||
"+sheet-show-gridline": {
|
||||
Risk: "write",
|
||||
Flags: []flagDef{
|
||||
{Name: "url", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet URL (XOR with `--spreadsheet-token`)"},
|
||||
{Name: "spreadsheet-token", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet token (XOR with `--url`)"},
|
||||
{Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id (XOR with `--sheet-name`)"},
|
||||
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name (XOR with `--sheet-id`)"},
|
||||
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"},
|
||||
},
|
||||
},
|
||||
"+sheet-unhide": {
|
||||
Risk: "write",
|
||||
Flags: []flagDef{
|
||||
@@ -914,39 +895,13 @@ var flagDefs = map[string]commandDef{
|
||||
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"},
|
||||
},
|
||||
},
|
||||
"+table-get": {
|
||||
Risk: "read",
|
||||
Flags: []flagDef{
|
||||
{Name: "url", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet URL (XOR with `--spreadsheet-token`)"},
|
||||
{Name: "spreadsheet-token", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet token (XOR with `--url`)"},
|
||||
{Name: "sheet-id", Kind: "own", Type: "string", Required: "optional", Desc: "Read only this sheet (by id); omit to read all sheets"},
|
||||
{Name: "sheet-name", Kind: "own", Type: "string", Required: "optional", Desc: "Read only this sheet (by name); omit to read all sheets"},
|
||||
{Name: "range", Kind: "own", Type: "string", Required: "optional", Desc: "A1 range to read; omit to read each sheet's full used range (spans internal blank rows/columns, not just the A1 current region)"},
|
||||
{Name: "no-header", Kind: "own", Type: "bool", Required: "optional", Desc: "Treat the first row as data instead of a header (columns get positional names col1, col2, ...)"},
|
||||
{Name: "dataframe-out", Kind: "own", Type: "string", Required: "optional", Desc: "Write the typed table as one Arrow IPC file (Feather v2) instead of the default JSON. Pass `@<path>` for a file or `-` for binary stdout (same convention as other binary I/O flags). Mirror of the input-side `--dataframe` on `+table-put` / `+workbook-create` — pandas users round-trip via `df = pd.read_feather(\"x.arrow\")` or `pd.read_feather(io.BytesIO(stdout))`. Single-sheet only: requires `--sheet-id` or `--sheet-name`; whole-workbook reads keep the default JSON path. Column types come from the typed read-back (string/number/date/bool); per-column `number_format` is preserved as Arrow field metadata so the Arrow file can round-trip straight back through `+table-put --dataframe`."},
|
||||
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"},
|
||||
},
|
||||
},
|
||||
"+table-put": {
|
||||
Risk: "write",
|
||||
Flags: []flagDef{
|
||||
{Name: "url", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet URL to write into (XOR with `--spreadsheet-token`)"},
|
||||
{Name: "spreadsheet-token", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet token to write into (XOR with `--url`)"},
|
||||
{Name: "sheets", Kind: "own", Type: "string", Required: "xor", Desc: "Typed table payload (pandas-DataFrame-shaped) as JSON, XOR with `--dataframe`: a top-level `sheets` array, each item `{name, start_cell?, mode?, header?, allow_overwrite?, columns:[\"colA\",\"colB\",...], data:[[...]], dtypes?:{colA:pandasDtype, ...}, formats?:{colA:numberFormat, ...}}`. Agents typically build it with `{**json.loads(df.to_json(orient=\"split\")), \"dtypes\": df.dtypes.astype(str).to_dict()}`. `dtypes` values are pandas dtype strings (`int64`, `float64`, `Int64`, `bool`, `boolean`, `datetime64[ns]`, `object`, ...); the writer maps them to internal string/number/date/bool — omit `dtypes` and a column writes as text (good for raw CSV-shaped data). `formats[col]` is an Excel number_format string (e.g. `#,##0.00`, `0.0%`, `yyyy-mm`); when absent, date columns default to `yyyy-mm-dd` and string columns to text format (`@`).", Input: []string{"file", "stdin"}},
|
||||
{Name: "dataframe", Kind: "own", Type: "string", Required: "xor", Desc: "Single-sheet typed table from one Arrow IPC file (a.k.a. Feather v2 — what `pandas.DataFrame.to_feather()` writes), XOR with `--sheets`. Pass `@<path>` for a file or `-` for binary stdin (same convention as other input flags). Arrow bytes are read raw — no TrimSpace / BOM strip — so the IPC magic survives intact (unlike text input flags). Column types come from the Arrow schema (int*/uint*/float* → number, date32/date64/timestamp → date, utf8/large_utf8 → string, bool → bool); per-column `number_format` may be set via Arrow field metadata (`pa.field(\"price\", pa.float64(), metadata={b\"number_format\": b\"$#,##0.00\"})`). Writes the sheet at default placement: name `Sheet1` (created when absent), overwrite from A1 with header. For a different sheet name, anchor, mode, or to write multiple sheets, use `--sheets` instead."},
|
||||
{Name: "styles", Kind: "own", Type: "string", Required: "optional", Desc: "Visual operations applied after the typed write, as JSON: top-level `{styles:[...]}`. Each item corresponds to one written sheet and must include `name`, plus at least one of `cell_styles` / `row_sizes` / `col_sizes` / `cell_merges`. `cell_styles` entries use +cells-set-style fields with a cell range; row/col sizes use dimension ranges plus type/size; merges use cell ranges plus optional merge_type. The styles array length/order/name must match the written sheets: with --sheets, match --sheets.sheets; with --dataframe (single sheet named Sheet1), pass exactly one styles item with name `Sheet1`. Run `+table-put --print-schema --flag-name styles` for the full cell_styles field schema.", Input: []string{"file", "stdin"}},
|
||||
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"},
|
||||
},
|
||||
},
|
||||
"+workbook-create": {
|
||||
Risk: "write",
|
||||
Flags: []flagDef{
|
||||
{Name: "title", Kind: "own", Type: "string", Required: "required", Desc: "Spreadsheet title"},
|
||||
{Name: "folder-token", Kind: "own", Type: "string", Required: "optional", Desc: "Target folder token; placed at the drive root when omitted"},
|
||||
{Name: "values", Kind: "own", Type: "string", Required: "optional", Desc: "Untyped initial data as one 2D JSON array (`[[\"alice\",95]]`); values are written as-is with their type auto-detected, through the same batched set_cell_range path as --sheets — pair with --styles for number formats, colors, merges, and row/col sizes", Input: []string{"file", "stdin"}},
|
||||
{Name: "sheets", Kind: "own", Type: "string", Required: "optional", Desc: "Typed table payload as JSON (same shape as `+table-put`): a top-level `sheets` array, each item `{name, start_cell?, mode?, header?, allow_overwrite?, columns:[\"colA\",\"colB\",...], data:[[...]], dtypes?:{colA:pandasDtype, ...}, formats?:{colA:numberFormat, ...}}`. Agents typically build it from a DataFrame via `{**json.loads(df.to_json(orient=\"split\")), \"dtypes\": df.dtypes.astype(str).to_dict()}`. Mutually exclusive with --values and --dataframe. Creates the workbook, then writes typed type-faithful data (dates land as real dates, numbers keep precision).", Input: []string{"file", "stdin"}},
|
||||
{Name: "styles", Kind: "own", Type: "string", Required: "optional", Desc: "Initial visual operations as JSON: top-level `{styles:[...]}`. Each item corresponds to one target sheet and must include `name`, plus at least one of `cell_styles` / `row_sizes` / `col_sizes` / `cell_merges`. `cell_styles` entries use +cells-set-style fields with a cell range; row/col sizes use dimension ranges plus type/size; merges use cell ranges plus optional merge_type. With --sheets, styles array length/order/name must match --sheets.sheets. With --values, pass exactly one styles item for the initial sheet (its name is ignored).", Input: []string{"file", "stdin"}},
|
||||
{Name: "dataframe", Kind: "own", Type: "string", Required: "optional", Desc: "Single-sheet typed table from one Arrow IPC file (Feather v2 — what `pandas.DataFrame.to_feather()` writes), mutually exclusive with --values and --sheets. Pass `@<path>` for a file or `-` for binary stdin (same convention as other input flags). Arrow bytes are read raw — no TrimSpace / BOM strip — so the IPC magic survives intact (unlike text input flags). Column types come from the Arrow schema; per-column `number_format` may be set via Arrow field metadata. Creates the workbook and fills its default sheet (`Sheet1` — adopted in place, no empty Sheet1 left behind). For multi-sheet or non-default placement, use `--sheets` instead."},
|
||||
{Name: "headers", Kind: "own", Type: "string", Required: "optional", Desc: "Header row as a JSON array: `[\"Col A\",\"Col B\"]`", Input: []string{"file", "stdin"}},
|
||||
{Name: "values", Kind: "own", Type: "string", Required: "optional", Desc: "Initial data as a 2D JSON array: `[[\"alice\",95]]`", Input: []string{"file", "stdin"}},
|
||||
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"},
|
||||
},
|
||||
},
|
||||
@@ -957,18 +912,10 @@ var flagDefs = map[string]commandDef{
|
||||
{Name: "spreadsheet-token", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet token (XOR with `--url`)"},
|
||||
{Name: "file-extension", Kind: "own", Type: "string", Required: "optional", Desc: "Export file format; `csv` mode requires `--sheet-id`", Default: "xlsx", Enum: []string{"xlsx", "csv"}},
|
||||
{Name: "sheet-id", Kind: "own", Type: "string", Required: "optional", Desc: "Required only in csv mode: which sheet to export as CSV. This is a `+workbook-export`-specific flag, unrelated to the common four-tuple sheet locator (this shortcut does not accept the common sheet locator)"},
|
||||
{Name: "output-path", Kind: "own", Type: "string", Required: "optional", Desc: "Local save path. When omitted, **only the export task is triggered + polled, the file is NOT downloaded** (returns file_token / status so a later step can resume the download). Pass a concrete path (e.g. `./out.xlsx`) or a directory (`.` keeps the server-provided filename) to download. Note: the equivalent `lark-cli drive +export --doc-type sheet` uses three separate flags (`--output-dir` / `--file-name` / `--overwrite`) and defaults to downloading into the current directory; this wrapper collapses them into a single `--output-path` for ergonomics but defaults to no-download — fall back to `drive +export` if the split flag set fits better."},
|
||||
{Name: "output-path", Kind: "own", Type: "string", Required: "optional", Desc: "Local save path; export is triggered but not downloaded when omitted"},
|
||||
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"},
|
||||
},
|
||||
},
|
||||
"+workbook-import": {
|
||||
Risk: "write",
|
||||
Flags: []flagDef{
|
||||
{Name: "file", Kind: "own", Type: "string", Required: "required", Desc: "Local file path (.xlsx / .xls / .csv)"},
|
||||
{Name: "folder-token", Kind: "own", Type: "string", Required: "optional", Desc: "Target folder token; imported to the cloud drive root when omitted"},
|
||||
{Name: "name", Kind: "own", Type: "string", Required: "optional", Desc: "Imported spreadsheet name; defaults to the local file name without its extension"},
|
||||
},
|
||||
},
|
||||
"+workbook-info": {
|
||||
Risk: "read",
|
||||
Flags: []flagDef{
|
||||
|
||||
@@ -65,9 +65,9 @@ func TestFlagsFor_MapsAllFields(t *testing.T) {
|
||||
if url == nil || url.Required {
|
||||
t.Errorf("+sheet-create --url should not be cobra-required: %+v", url)
|
||||
}
|
||||
// visible + int default
|
||||
// hidden + int default
|
||||
cap := byName("+cells-get", "max-chars")
|
||||
if cap == nil || cap.Hidden || cap.Default != "500000" {
|
||||
if cap == nil || !cap.Hidden || cap.Default != "200000" {
|
||||
t.Errorf("+cells-get --max-chars not mapped: %+v", cap)
|
||||
}
|
||||
// input sources
|
||||
@@ -140,24 +140,3 @@ func TestFlagsFor_EveryRegisteredCommandHasDefs(t *testing.T) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestFlagAcceptsStdin verifies the stdin-capability probe that decides whether
|
||||
// an "invalid JSON" error should also steer the caller toward stdin: a composite
|
||||
// flag (cells) accepts stdin, a plain locator (spreadsheet-token) does not, and
|
||||
// an unknown command/flag returns false without panicking (it runs on an error
|
||||
// path, unlike flagsFor).
|
||||
func TestFlagAcceptsStdin(t *testing.T) {
|
||||
t.Parallel()
|
||||
if !flagAcceptsStdin("+cells-set", "cells") {
|
||||
t.Error("+cells-set --cells should accept stdin")
|
||||
}
|
||||
if flagAcceptsStdin("+cells-set", "spreadsheet-token") {
|
||||
t.Error("--spreadsheet-token should not accept stdin")
|
||||
}
|
||||
if flagAcceptsStdin("+nope", "cells") {
|
||||
t.Error("unknown command should be false (and must not panic)")
|
||||
}
|
||||
if flagAcceptsStdin("+cells-set", "nope") {
|
||||
t.Error("unknown flag should be false")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,8 +9,6 @@ import (
|
||||
"fmt"
|
||||
"sort"
|
||||
"sync"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
)
|
||||
|
||||
// ─── --print-schema runtime introspection ─────────────────────────────
|
||||
@@ -93,7 +91,7 @@ func printFlagSchemaFor(command string) func(flagName string) ([]byte, error) {
|
||||
}
|
||||
entry, ok := idx.Flags[command]
|
||||
if !ok || len(entry) == 0 {
|
||||
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "no JSON Schema registered for %s", command)
|
||||
return nil, fmt.Errorf("no JSON Schema registered for %s", command)
|
||||
}
|
||||
if flagName == "" {
|
||||
flags := make([]string, 0, len(entry))
|
||||
@@ -114,9 +112,7 @@ func printFlagSchemaFor(command string) func(flagName string) ([]byte, error) {
|
||||
flags = append(flags, f)
|
||||
}
|
||||
sort.Strings(flags)
|
||||
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument,
|
||||
"no JSON Schema registered for %s --%s; available: %v", command, flagName, flags).
|
||||
WithParam("--flag-name")
|
||||
return nil, fmt.Errorf("no JSON Schema registered for %s --%s; available: %v", command, flagName, flags)
|
||||
}
|
||||
// Reformat for readability — schema files store compact JSON.
|
||||
var pretty interface{}
|
||||
|
||||
@@ -84,12 +84,12 @@ func TestPrintFlagSchema_NamedFlagReturnsSchemaSubtree(t *testing.T) {
|
||||
func TestPrintFlagSchema_UnknownFlagListsAvailable(t *testing.T) {
|
||||
t.Parallel()
|
||||
_, err := printFlagSchemaFor("+chart-create")("does-not-exist")
|
||||
ve := requireValidation(t, err, "+chart-create")
|
||||
if !strings.Contains(ve.Message, "properties") {
|
||||
t.Errorf("message should list available flags; got %q", ve.Message)
|
||||
if err == nil {
|
||||
t.Fatal("expected error for unknown flag, got nil")
|
||||
}
|
||||
if ve.Param != "--flag-name" {
|
||||
t.Errorf("param = %q, want --flag-name", ve.Param)
|
||||
msg := err.Error()
|
||||
if !strings.Contains(msg, "+chart-create") || !strings.Contains(msg, "properties") {
|
||||
t.Errorf("error should mention shortcut + available flags; got %q", msg)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -63,7 +63,6 @@ func validateParsedJSONFlag(fv flagView, name string, value interface{}) error {
|
||||
var parseJSONFlagSkip = map[string]struct{}{
|
||||
"properties": {},
|
||||
"operations": {},
|
||||
"styles": {},
|
||||
}
|
||||
|
||||
// validateValueAgainstSchema is the (command, flag) → schema → check
|
||||
@@ -94,17 +93,7 @@ func validateValueAgainstSchema(fv flagView, name string, value interface{}) err
|
||||
var schema schemaProperty
|
||||
json.Unmarshal(raw, &schema)
|
||||
if vErr := validateAgainstSchema(value, &schema, ""); vErr != nil {
|
||||
// Composite-JSON shape errors (e.g. +cells-set --cells, chart
|
||||
// --properties) are the highest-frequency usage-layer failure for
|
||||
// sheets, and agents often burn several retries guessing the shape.
|
||||
// Point them straight at --print-schema, which dumps the exact JSON
|
||||
// Schema for this (command, flag) pair. The hint is always actionable:
|
||||
// reaching this branch means entry[name] resolved a schema from the
|
||||
// embedded index, and --print-schema reads that same index, so the
|
||||
// suggested command is guaranteed to print it.
|
||||
return sheetsValidationForFlag(name,
|
||||
"--%s: %s; run `lark-cli sheets %s --print-schema --flag-name %s` to see the expected JSON Schema",
|
||||
name, vErr.Error(), command, name)
|
||||
return sheetsValidationForFlag(name, "--%s: %s", name, vErr.Error())
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -478,9 +478,11 @@ func TestValidateInputAgainstSchema_RealSchema(t *testing.T) {
|
||||
},
|
||||
}
|
||||
err := validateInputAgainstSchema(fv, bad)
|
||||
ve := requireValidation(t, err, "summarize_by")
|
||||
if !strings.Contains(ve.Message, "not in enum") {
|
||||
t.Errorf("error = %q, want enum hint", ve.Message)
|
||||
if err == nil {
|
||||
t.Fatal("expected enum violation, got nil")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "summarize_by") || !strings.Contains(err.Error(), "not in enum") {
|
||||
t.Errorf("error = %q, want summarize_by + enum hint", err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -497,9 +499,11 @@ func TestValidateInputAgainstSchema_RealMinItems(t *testing.T) {
|
||||
},
|
||||
}
|
||||
err := validateInputAgainstSchema(fv, bad)
|
||||
ve := requireValidation(t, err, "values")
|
||||
if !strings.Contains(ve.Message, "minimum is 1") {
|
||||
t.Errorf("error = %q, want minimum-is-1 hint", ve.Message)
|
||||
if err == nil {
|
||||
t.Fatal("expected minItems violation for empty values, got nil")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "values") || !strings.Contains(err.Error(), "minimum is 1") {
|
||||
t.Errorf("error = %q, want values + minimum-is-1 hint", err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -516,9 +520,11 @@ func TestValidateInputAgainstSchema_RealMinimum(t *testing.T) {
|
||||
},
|
||||
}
|
||||
err := validateInputAgainstSchema(fv, bad)
|
||||
ve := requireValidation(t, err, "row")
|
||||
if !strings.Contains(ve.Message, "below minimum") {
|
||||
t.Errorf("error = %q, want below-minimum hint", ve.Message)
|
||||
if err == nil {
|
||||
t.Fatal("expected minimum violation for row:-1, got nil")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "row") || !strings.Contains(err.Error(), "below minimum") {
|
||||
t.Errorf("error = %q, want row + below-minimum hint", err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -548,9 +554,11 @@ func TestValidateInputAgainstSchema_RealAdditionalProperties(t *testing.T) {
|
||||
},
|
||||
}
|
||||
err := validateInputAgainstSchema(fv, bad)
|
||||
ve := requireValidation(t, err, "collapse")
|
||||
if !strings.Contains(ve.Message, `expected type "string"`) {
|
||||
t.Errorf("error = %q, want string-type hint", ve.Message)
|
||||
if err == nil {
|
||||
t.Fatal("expected additionalProperties violation, got nil")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "collapse") || !strings.Contains(err.Error(), `expected type "string"`) {
|
||||
t.Errorf("error = %q, want collapse + string-type hint", err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -579,24 +587,3 @@ func TestValidateInputAgainstSchema_SkipOperations(t *testing.T) {
|
||||
t.Errorf("operations should be skipped; got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestValidateValueAgainstSchema_PrintSchemaHint pins the highest-value
|
||||
// recovery affordance for composite-JSON flags: when the shape is wrong, the
|
||||
// error must point the agent straight at --print-schema (with the right
|
||||
// command + flag) instead of leaving it to guess across retries. +cells-set
|
||||
// --cells expects a 2-D array; a bare string trips the top-level type check.
|
||||
func TestValidateValueAgainstSchema_PrintSchemaHint(t *testing.T) {
|
||||
t.Parallel()
|
||||
fv := mapFlagView{command: "+cells-set"}
|
||||
err := validateValueAgainstSchema(fv, "cells", "not-an-array")
|
||||
// Underlying shape error is preserved (substring callers still match).
|
||||
ve := requireValidation(t, err, `expected type "array"`)
|
||||
// And the actionable --print-schema hint is appended with the exact
|
||||
// command + flag, so a copy-paste fetches the schema for this pair.
|
||||
if !strings.Contains(ve.Message, "lark-cli sheets +cells-set --print-schema --flag-name cells") {
|
||||
t.Errorf("want --print-schema hint with command+flag; got %q", ve.Message)
|
||||
}
|
||||
if ve.Param != "--cells" {
|
||||
t.Errorf("param = %q, want --cells", ve.Param)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -32,6 +32,4 @@ var commandsWithSchema = map[string]struct{}{
|
||||
"+range-sort": {},
|
||||
"+sparkline-create": {},
|
||||
"+sparkline-update": {},
|
||||
"+table-put": {},
|
||||
"+workbook-create": {},
|
||||
}
|
||||
|
||||
@@ -10,8 +10,6 @@ package sheets
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
neturl "net/url"
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
@@ -50,151 +48,46 @@ func sheetsInputStatError(flag string, err error) error {
|
||||
return wrapped
|
||||
}
|
||||
|
||||
// spreadsheetRef classification: a --url / --spreadsheet-token input names a
|
||||
// spreadsheet either directly (a /sheets/ URL or raw token) or indirectly via a
|
||||
// wiki node that must be resolved to its backing spreadsheet at Execute time.
|
||||
const (
|
||||
spreadsheetRefSheet = "sheet"
|
||||
spreadsheetRefWiki = "wiki"
|
||||
)
|
||||
|
||||
// spreadsheetRef is a parsed --url / --spreadsheet-token input. A wiki ref holds
|
||||
// the still-unresolved wiki node_token; resolveSpreadsheetTokenExec turns it
|
||||
// into the real spreadsheet token at Execute time.
|
||||
type spreadsheetRef struct {
|
||||
Kind string // spreadsheetRefSheet | spreadsheetRefWiki
|
||||
Token string
|
||||
}
|
||||
|
||||
// parseSpreadsheetRef applies the public --url / --spreadsheet-token XOR pair and
|
||||
// classifies the input. Network-free, safe to call from Validate and DryRun.
|
||||
//
|
||||
// Recognized --url shapes:
|
||||
// - https://.../sheets/<token> → {sheet, token}
|
||||
// - https://.../spreadsheets/<token> → {sheet, token}
|
||||
// - https://.../wiki/<node_token> → {wiki, node_token} (resolved at Execute)
|
||||
//
|
||||
// A raw --spreadsheet-token is always treated as a spreadsheet token; wiki nodes
|
||||
// only ever arrive as a /wiki/ URL.
|
||||
func parseSpreadsheetRef(runtime *common.RuntimeContext) (spreadsheetRef, error) {
|
||||
// resolveSpreadsheetToken applies the public --url / --spreadsheet-token XOR
|
||||
// pair shared by every sheets canonical shortcut and returns the resolved
|
||||
// token. Network-free, safe to call from Validate and DryRun.
|
||||
func resolveSpreadsheetToken(runtime *common.RuntimeContext) (string, error) {
|
||||
if err := common.ExactlyOneTyped(runtime, "url", "spreadsheet-token"); err != nil {
|
||||
return spreadsheetRef{}, err
|
||||
return "", err
|
||||
}
|
||||
if token := strings.TrimSpace(runtime.Str("spreadsheet-token")); token != "" {
|
||||
if err := validate.RejectControlChars(token, "spreadsheet-token"); err != nil {
|
||||
return spreadsheetRef{}, sheetsValidationCauseForFlag("spreadsheet-token", err)
|
||||
return "", sheetsValidationCauseForFlag("spreadsheet-token", err)
|
||||
}
|
||||
return spreadsheetRef{Kind: spreadsheetRefSheet, Token: token}, nil
|
||||
return token, nil
|
||||
}
|
||||
|
||||
rawURL := strings.TrimSpace(runtime.Str("url"))
|
||||
token, kind, ok := spreadsheetURLToken(rawURL)
|
||||
if !ok {
|
||||
return spreadsheetRef{}, sheetsValidationForFlag("url", "--url must be a spreadsheet URL like https://.../sheets/<token> or a wiki URL like https://.../wiki/<token>")
|
||||
url := strings.TrimSpace(runtime.Str("url"))
|
||||
token := extractSpreadsheetToken(url)
|
||||
if token == "" || token == url {
|
||||
return "", sheetsValidationForFlag("url", "--url must be a spreadsheet URL like https://.../sheets/<token>")
|
||||
}
|
||||
if err := validate.RejectControlChars(token, "url"); err != nil {
|
||||
return spreadsheetRef{}, sheetsValidationCauseForFlag("url", err)
|
||||
return "", sheetsValidationCauseForFlag("url", err)
|
||||
}
|
||||
return spreadsheetRef{Kind: kind, Token: token}, nil
|
||||
return token, nil
|
||||
}
|
||||
|
||||
// spreadsheetURLToken extracts the token and its kind from a Lark URL, matching
|
||||
// only on the URL *path* segment (parsed via net/url). A /wiki/ or /sheets/ that
|
||||
// appears only in the query or fragment (e.g. a redirect or anchor param) never
|
||||
// hijacks classification. Returns ok=false when no known prefix heads the path.
|
||||
func spreadsheetURLToken(rawURL string) (token, kind string, ok bool) {
|
||||
u, err := neturl.Parse(rawURL)
|
||||
if err != nil || u.Path == "" {
|
||||
return "", "", false
|
||||
}
|
||||
for _, m := range []struct {
|
||||
prefix string
|
||||
kind string
|
||||
}{
|
||||
{"/sheets/", spreadsheetRefSheet},
|
||||
{"/spreadsheets/", spreadsheetRefSheet},
|
||||
{"/wiki/", spreadsheetRefWiki},
|
||||
} {
|
||||
if seg, found := pathSegmentAfter(u.Path, m.prefix); found {
|
||||
return seg, m.kind, true
|
||||
// extractSpreadsheetToken pulls the token segment out of a /sheets/<token>
|
||||
// or /spreadsheets/<token> URL. Returns the input unchanged when no known
|
||||
// prefix is present (callers must check token != originalInput).
|
||||
func extractSpreadsheetToken(input string) string {
|
||||
input = strings.TrimSpace(input)
|
||||
for _, prefix := range []string{"/sheets/", "/spreadsheets/"} {
|
||||
if idx := strings.Index(input, prefix); idx >= 0 {
|
||||
token := input[idx+len(prefix):]
|
||||
if idx2 := strings.IndexAny(token, "/?#"); idx2 >= 0 {
|
||||
token = token[:idx2]
|
||||
}
|
||||
return token
|
||||
}
|
||||
}
|
||||
return "", "", false
|
||||
}
|
||||
|
||||
// pathSegmentAfter returns the first path segment after prefix when path begins
|
||||
// with prefix, else ("", false).
|
||||
func pathSegmentAfter(path, prefix string) (string, bool) {
|
||||
if !strings.HasPrefix(path, prefix) {
|
||||
return "", false
|
||||
}
|
||||
rest := path[len(prefix):]
|
||||
if i := strings.IndexByte(rest, '/'); i >= 0 {
|
||||
rest = rest[:i]
|
||||
}
|
||||
rest = strings.TrimSpace(rest)
|
||||
if rest == "" {
|
||||
return "", false
|
||||
}
|
||||
return rest, true
|
||||
}
|
||||
|
||||
// resolveSpreadsheetToken applies the public --url / --spreadsheet-token XOR pair
|
||||
// and returns the resolved token. Network-free, safe to call from Validate and
|
||||
// DryRun.
|
||||
//
|
||||
// A /wiki/ URL yields the still-unresolved wiki node_token: turning it into the
|
||||
// backing spreadsheet token needs a get_node call, which only Execute may make.
|
||||
// Validate/DryRun only need a non-empty, control-char-clean token, so the
|
||||
// node_token passes through unchanged here; Execute paths call
|
||||
// resolveSpreadsheetTokenExec instead.
|
||||
func resolveSpreadsheetToken(runtime *common.RuntimeContext) (string, error) {
|
||||
ref, err := parseSpreadsheetRef(runtime)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return ref.Token, nil
|
||||
}
|
||||
|
||||
// resolveSpreadsheetTokenExec is the Execute-time counterpart of
|
||||
// resolveSpreadsheetToken: it additionally resolves a /wiki/ URL's node_token to
|
||||
// the backing spreadsheet token via wiki get_node, verifying obj_type=sheet.
|
||||
// Non-wiki inputs make no API call. Use this from every sheets Execute hook and
|
||||
// keep resolveSpreadsheetToken in Validate/DryRun so those stay network-free.
|
||||
func resolveSpreadsheetTokenExec(runtime *common.RuntimeContext) (string, error) {
|
||||
ref, err := parseSpreadsheetRef(runtime)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if ref.Kind != spreadsheetRefWiki {
|
||||
return ref.Token, nil
|
||||
}
|
||||
return resolveWikiNodeToSpreadsheetToken(runtime, ref.Token)
|
||||
}
|
||||
|
||||
// resolveWikiNodeToSpreadsheetToken resolves a wiki node_token to the spreadsheet
|
||||
// obj_token it points at, erroring when the node is not a spreadsheet. The
|
||||
// wiki:node:read scope is only needed on this path, so it is enforced here rather
|
||||
// than declared unconditionally on every sheets shortcut.
|
||||
func resolveWikiNodeToSpreadsheetToken(runtime *common.RuntimeContext, nodeToken string) (string, error) {
|
||||
if err := runtime.EnsureScopes([]string{"wiki:node:read"}); err != nil {
|
||||
return "", err
|
||||
}
|
||||
data, err := runtime.CallAPITyped("GET", "/open-apis/wiki/v2/spaces/get_node",
|
||||
map[string]interface{}{"token": nodeToken}, nil)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
node := common.GetMap(data, "node")
|
||||
objType := common.GetString(node, "obj_type")
|
||||
objToken := common.GetString(node, "obj_token")
|
||||
if objType == "" || objToken == "" {
|
||||
return "", errs.NewInternalError(errs.SubtypeInvalidResponse, "wiki get_node returned incomplete node data for %q", nodeToken)
|
||||
}
|
||||
if objType != "sheet" {
|
||||
return "", sheetsValidationForFlag("url", "wiki URL resolves to obj_type=%q, but a spreadsheet (obj_type=sheet) is required", objType)
|
||||
}
|
||||
return objToken, nil
|
||||
return input
|
||||
}
|
||||
|
||||
// resolveSheetSelector validates the --sheet-id / --sheet-name XOR and
|
||||
@@ -348,16 +241,6 @@ func parseJSONFlag(runtime flagView, name string) (interface{}, error) {
|
||||
}
|
||||
var out interface{}
|
||||
if err := json.Unmarshal([]byte(raw), &out); err != nil {
|
||||
// Composite payloads that embed formulas / quotes / commas are the
|
||||
// classic source of this error: inlined into the shell, the JSON gets
|
||||
// mangled (e.g. `\$` → "invalid character in string escape"). For any
|
||||
// flag that accepts stdin, steer the caller there — passing the payload
|
||||
// via `--<flag> - < file` sidesteps shell escaping entirely.
|
||||
if flagAcceptsStdin(runtime.Command(), name) {
|
||||
return nil, sheetsValidationForFlag(name,
|
||||
"--%s: invalid JSON: %v; if the payload contains formulas / quotes / commas, pass it via stdin (`--%s - < file`) so the shell doesn't mangle the JSON",
|
||||
name, err, name).WithCause(err)
|
||||
}
|
||||
return nil, sheetsValidationForFlag(name, "--%s: invalid JSON: %v", name, err).WithCause(err)
|
||||
}
|
||||
// Schema-driven flag validation at the user-input boundary. Skips
|
||||
@@ -442,72 +325,6 @@ func buildCellStyleFromFlags(runtime flagView) map[string]interface{} {
|
||||
return style
|
||||
}
|
||||
|
||||
// cellStyleAliases maps shorthand cell_styles field names that models commonly
|
||||
// hallucinate (Excel / openpyxl / CSS conventions) onto the canonical field
|
||||
// names the backend expects. Only the unambiguous alignment shorthands are
|
||||
// aliased — they are the high-frequency miss; ambiguous guesses (e.g. "color",
|
||||
// "bg_color", "text_align") are intentionally left out so a wrong guess still
|
||||
// surfaces as an error rather than being silently reinterpreted.
|
||||
var cellStyleAliases = []struct{ alias, canonical string }{
|
||||
{"horizontal_align", "horizontal_alignment"},
|
||||
{"halign", "horizontal_alignment"},
|
||||
{"vertical_align", "vertical_alignment"},
|
||||
{"valign", "vertical_alignment"},
|
||||
}
|
||||
|
||||
// normalizeCellStyleAliases renames known shorthand keys in a single
|
||||
// cell_styles map to their canonical equivalents, in place, so a model that
|
||||
// writes e.g. "horizontal_align" instead of "horizontal_alignment" still
|
||||
// applies the style instead of hitting an "unsupported field" error (--styles)
|
||||
// or having the field silently dropped by the backend (typed --cells). If both
|
||||
// the shorthand and its canonical key are present it returns a validation error
|
||||
// rather than picking one. path labels the map for the error message.
|
||||
func normalizeCellStyleAliases(style map[string]interface{}, path string) error {
|
||||
if len(style) == 0 {
|
||||
return nil
|
||||
}
|
||||
for _, a := range cellStyleAliases {
|
||||
v, ok := style[a.alias]
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
if _, exists := style[a.canonical]; exists {
|
||||
return common.ValidationErrorf("%s.%s conflicts with %s; pass only %s", path, a.alias, a.canonical, a.canonical)
|
||||
}
|
||||
style[a.canonical] = v
|
||||
delete(style, a.alias)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// normalizeTypedCellsStyleAliases walks a typed --cells 2D array and applies
|
||||
// normalizeCellStyleAliases to every cell's inline cell_styles object, so the
|
||||
// alignment shorthands are accepted on +cells-set the same as on --styles.
|
||||
// Structure is checked leniently to match the pass-through contract: any
|
||||
// element that isn't the expected shape is skipped, not rejected.
|
||||
func normalizeTypedCellsStyleAliases(cells []interface{}, path string) error {
|
||||
for r, rowRaw := range cells {
|
||||
row, ok := rowRaw.([]interface{})
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
for c, cellRaw := range row {
|
||||
cell, ok := cellRaw.(map[string]interface{})
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
st, ok := cell["cell_styles"].(map[string]interface{})
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
if err := normalizeCellStyleAliases(st, fmt.Sprintf("%s[%d][%d].cell_styles", path, r, c)); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// borderStylesFromFlag parses --border-styles as a JSON object (top/bottom/
|
||||
// left/right with style sub-objects). Returns nil when the flag is empty.
|
||||
func borderStylesFromFlag(runtime flagView) (map[string]interface{}, error) {
|
||||
|
||||
@@ -81,53 +81,6 @@ func runShortcutWithStubs(t *testing.T, sc common.Shortcut, args []string, stubs
|
||||
return stdout.String(), err
|
||||
}
|
||||
|
||||
// requireProblem asserts err carries a typed errs.Problem with the given
|
||||
// category and (optional) subtype, and that its message contains msgContains
|
||||
// (skip the message check by passing ""). Returns the Problem so callers can
|
||||
// drill into the typed envelope's category-specific fields (e.g. cast to
|
||||
// *errs.ValidationError to read .Param / .Params / .Cause).
|
||||
//
|
||||
// Replaces the older "strings.Contains(stdout+stderr+err.Error(), ...)" pattern
|
||||
// across sheets tests: substring on a rendered envelope was brittle (any
|
||||
// message tweak silently broke it) and didn't verify that the typed contract —
|
||||
// category / subtype / cause preservation — held. Per coding guideline
|
||||
// "Error-path tests must assert typed metadata via errs.ProblemOf
|
||||
// (category / subtype / param) and cause preservation, not message substrings
|
||||
// alone."
|
||||
func requireProblem(t *testing.T, err error, wantCategory errs.Category, wantSubtype errs.Subtype, msgContains string) *errs.Problem {
|
||||
t.Helper()
|
||||
if err == nil {
|
||||
t.Fatal("expected error, got nil")
|
||||
}
|
||||
p, ok := errs.ProblemOf(err)
|
||||
if !ok {
|
||||
t.Fatalf("expected typed error carrying errs.Problem, got %T: %v", err, err)
|
||||
}
|
||||
if p.Category != wantCategory {
|
||||
t.Errorf("category = %q, want %q (err=%v)", p.Category, wantCategory, err)
|
||||
}
|
||||
if wantSubtype != "" && p.Subtype != wantSubtype {
|
||||
t.Errorf("subtype = %q, want %q (err=%v)", p.Subtype, wantSubtype, err)
|
||||
}
|
||||
if msgContains != "" && !strings.Contains(p.Message, msgContains) {
|
||||
t.Errorf("message = %q, want containing %q", p.Message, msgContains)
|
||||
}
|
||||
return p
|
||||
}
|
||||
|
||||
// requireValidation is shorthand for the most common case: a typed
|
||||
// CategoryValidation error with SubtypeInvalidArgument. Returns the
|
||||
// *errs.ValidationError so callers can also assert on .Param / .Params / .Cause.
|
||||
func requireValidation(t *testing.T, err error, msgContains string) *errs.ValidationError {
|
||||
t.Helper()
|
||||
requireProblem(t, err, errs.CategoryValidation, errs.SubtypeInvalidArgument, msgContains)
|
||||
var ve *errs.ValidationError
|
||||
if !errors.As(err, &ve) {
|
||||
t.Fatalf("expected *errs.ValidationError, got %T: %v", err, err)
|
||||
}
|
||||
return ve
|
||||
}
|
||||
|
||||
func TestSheetHelpersValidationMetadata(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
@@ -315,52 +268,3 @@ const (
|
||||
testSheetID = "shtSubA"
|
||||
testSheetID2 = "shtSubB"
|
||||
)
|
||||
|
||||
// TestParseSpreadsheetRef locks the network-free classification of
|
||||
// --url / --spreadsheet-token into a sheet token vs an (unresolved) wiki
|
||||
// node_token. The wiki node is resolved later, at Execute time only.
|
||||
func TestParseSpreadsheetRef(t *testing.T) {
|
||||
t.Parallel()
|
||||
mk := func(url, tok string) *common.RuntimeContext {
|
||||
cmd := &cobra.Command{Use: "sheets"}
|
||||
cmd.Flags().String("url", url, "")
|
||||
cmd.Flags().String("spreadsheet-token", tok, "")
|
||||
return common.TestNewRuntimeContext(cmd, testConfig(t))
|
||||
}
|
||||
cases := []struct {
|
||||
name string
|
||||
url string
|
||||
tok string
|
||||
wantKind string
|
||||
wantToken string
|
||||
wantErr bool
|
||||
}{
|
||||
{name: "sheets url", url: "https://x.feishu.cn/sheets/shtABC", wantKind: spreadsheetRefSheet, wantToken: "shtABC"},
|
||||
{name: "spreadsheets url", url: "https://x.feishu.cn/spreadsheets/shtABC", wantKind: spreadsheetRefSheet, wantToken: "shtABC"},
|
||||
{name: "wiki url", url: "https://x.feishu.cn/wiki/wikDEF", wantKind: spreadsheetRefWiki, wantToken: "wikDEF"},
|
||||
{name: "wiki url with query", url: "https://x.feishu.cn/wiki/wikDEF?sheet=xxxxxx", wantKind: spreadsheetRefWiki, wantToken: "wikDEF"},
|
||||
{name: "raw token", tok: "shtRAW", wantKind: spreadsheetRefSheet, wantToken: "shtRAW"},
|
||||
{name: "sheets url with /wiki/ in query stays sheet", url: "https://x.feishu.cn/sheets/shtABC?from=/wiki/wikX", wantKind: spreadsheetRefSheet, wantToken: "shtABC"},
|
||||
{name: "sheets url with /wiki/ in fragment stays sheet", url: "https://x.feishu.cn/sheets/shtABC#/wiki/wikX", wantKind: spreadsheetRefSheet, wantToken: "shtABC"},
|
||||
{name: "docx url unsupported", url: "https://x.feishu.cn/docx/docABC", wantErr: true},
|
||||
{name: "neither provided", wantErr: true},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
ref, err := parseSpreadsheetRef(mk(tc.url, tc.tok))
|
||||
if tc.wantErr {
|
||||
if err == nil {
|
||||
t.Fatalf("want error, got ref=%+v", ref)
|
||||
}
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if ref.Kind != tc.wantKind || ref.Token != tc.wantToken {
|
||||
t.Fatalf("ref = %+v, want {Kind:%s Token:%s}", ref, tc.wantKind, tc.wantToken)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -67,7 +67,7 @@ var BatchUpdate = common.Shortcut{
|
||||
return invokeToolDryRun(token, ToolKindWrite, "batch_update", input)
|
||||
},
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
token, err := resolveSpreadsheetTokenExec(runtime)
|
||||
token, err := resolveSpreadsheetToken(runtime)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -180,7 +180,7 @@ var CellsBatchSetStyle = common.Shortcut{
|
||||
return invokeToolDryRun(token, ToolKindWrite, "batch_update", input)
|
||||
},
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
token, err := resolveSpreadsheetTokenExec(runtime)
|
||||
token, err := resolveSpreadsheetToken(runtime)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -270,7 +270,7 @@ var CellsBatchClear = common.Shortcut{
|
||||
return invokeToolDryRun(token, ToolKindWrite, "batch_update", input)
|
||||
},
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
token, err := resolveSpreadsheetTokenExec(runtime)
|
||||
token, err := resolveSpreadsheetToken(runtime)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -350,7 +350,7 @@ var DropdownUpdate = common.Shortcut{
|
||||
return invokeToolDryRun(token, ToolKindWrite, "batch_update", input)
|
||||
},
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
token, err := resolveSpreadsheetTokenExec(runtime)
|
||||
token, err := resolveSpreadsheetToken(runtime)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -396,7 +396,7 @@ var DropdownDelete = common.Shortcut{
|
||||
return invokeToolDryRun(token, ToolKindWrite, "batch_update", input)
|
||||
},
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
token, err := resolveSpreadsheetTokenExec(runtime)
|
||||
token, err := resolveSpreadsheetToken(runtime)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ package sheets
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
@@ -165,16 +166,18 @@ func TestCellsBatchClear_Guards(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
// sheetless range → prefix guard (shared with the dropdown fan-outs).
|
||||
_, _, err := runShortcutCapturingErr(t, CellsBatchClear, []string{
|
||||
stdout, stderr, err := runShortcutCapturingErr(t, CellsBatchClear, []string{
|
||||
"--url", testURL,
|
||||
"--ranges", `["A1:A10"]`,
|
||||
"--yes",
|
||||
"--dry-run",
|
||||
})
|
||||
requireValidation(t, err, "must include a sheet prefix")
|
||||
if err == nil || !strings.Contains(stdout+stderr+err.Error(), "must include a sheet prefix") {
|
||||
t.Errorf("expected sheet-prefix guard; got=%s|%s|%v", stdout, stderr, err)
|
||||
}
|
||||
|
||||
// missing --yes → confirmation_required (high-risk-write).
|
||||
stdout, stderr, err := runShortcutCapturingErr(t, CellsBatchClear, []string{
|
||||
stdout, stderr, err = runShortcutCapturingErr(t, CellsBatchClear, []string{
|
||||
"--url", testURL,
|
||||
"--ranges", `["sheet1!A1:A10"]`,
|
||||
})
|
||||
@@ -265,32 +268,38 @@ func TestBatchUpdate_ValidationGuards(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
// dropdown-update with sheetless range
|
||||
_, _, err := runShortcutCapturingErr(t, DropdownUpdate, []string{
|
||||
stdout, stderr, err := runShortcutCapturingErr(t, DropdownUpdate, []string{
|
||||
"--url", testURL,
|
||||
"--ranges", `["A2:A5"]`,
|
||||
"--options", `["a"]`,
|
||||
"--dry-run",
|
||||
})
|
||||
requireValidation(t, err, "must include a sheet prefix")
|
||||
if err == nil || !strings.Contains(stdout+stderr+err.Error(), "must include a sheet prefix") {
|
||||
t.Errorf("expected sheet-prefix guard for +dropdown-update; got=%s|%s|%v", stdout, stderr, err)
|
||||
}
|
||||
|
||||
// batch-update with empty operations
|
||||
_, _, err = runShortcutCapturingErr(t, BatchUpdate, []string{
|
||||
stdout, stderr, err = runShortcutCapturingErr(t, BatchUpdate, []string{
|
||||
"--url", testURL,
|
||||
"--operations", `[]`,
|
||||
"--yes",
|
||||
"--dry-run",
|
||||
})
|
||||
requireValidation(t, err, "non-empty JSON array")
|
||||
if err == nil || !strings.Contains(stdout+stderr+err.Error(), "non-empty JSON array") {
|
||||
t.Errorf("expected empty-operations guard; got=%s|%s|%v", stdout, stderr, err)
|
||||
}
|
||||
|
||||
// dropdown-update with non-array --options (object instead) → array guard
|
||||
// (now via schema validator at parseJSONFlag time)
|
||||
_, _, err = runShortcutCapturingErr(t, DropdownUpdate, []string{
|
||||
stdout, stderr, err = runShortcutCapturingErr(t, DropdownUpdate, []string{
|
||||
"--url", testURL,
|
||||
"--ranges", `["sheet1!A1:A2"]`,
|
||||
"--options", `{"not":"array"}`,
|
||||
"--dry-run",
|
||||
})
|
||||
requireValidation(t, err, `expected type "array"`)
|
||||
if err == nil || !strings.Contains(stdout+stderr+err.Error(), `expected type "array"`) {
|
||||
t.Errorf("expected JSON array guard; got=%s|%s|%v", stdout, stderr, err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestValidateDropdownRanges_RejectsMalformedRange locks the up-front sheet!range
|
||||
@@ -313,13 +322,15 @@ func TestValidateDropdownRanges_RejectsMalformedRange(t *testing.T) {
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
_, _, err := runShortcutCapturingErr(t, DropdownUpdate, []string{
|
||||
stdout, stderr, err := runShortcutCapturingErr(t, DropdownUpdate, []string{
|
||||
"--url", testURL,
|
||||
"--ranges", tc.ranges,
|
||||
"--options", `["a"]`,
|
||||
"--dry-run",
|
||||
})
|
||||
requireValidation(t, err, tc.want)
|
||||
if err == nil || !strings.Contains(stdout+stderr+err.Error(), tc.want) {
|
||||
t.Errorf("ranges=%s: expected error containing %q; got=%s|%s|%v", tc.ranges, tc.want, stdout, stderr, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -408,13 +419,18 @@ func TestBatchUpdate_TranslatorRejects(t *testing.T) {
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
_, _, err := runShortcutCapturingErr(t, BatchUpdate, []string{
|
||||
stdout, stderr, err := runShortcutCapturingErr(t, BatchUpdate, []string{
|
||||
"--url", testURL,
|
||||
"--operations", tc.opsJSON,
|
||||
"--yes",
|
||||
"--dry-run",
|
||||
})
|
||||
requireValidation(t, err, tc.wantMatch)
|
||||
if err == nil {
|
||||
t.Fatalf("expected error containing %q; got stdout=%s stderr=%s", tc.wantMatch, stdout, stderr)
|
||||
}
|
||||
if !strings.Contains(stdout+stderr+err.Error(), tc.wantMatch) {
|
||||
t.Errorf("expected error containing %q; got: %s | %s | %v", tc.wantMatch, stdout, stderr, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,621 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package sheets
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/apache/arrow/go/v17/arrow"
|
||||
"github.com/apache/arrow/go/v17/arrow/array"
|
||||
"github.com/apache/arrow/go/v17/arrow/ipc"
|
||||
"github.com/apache/arrow/go/v17/arrow/memory"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/extension/fileio"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
// ─── --dataframe (Arrow IPC / Feather v2 binary input) ────────────────
|
||||
//
|
||||
// --dataframe is the binary-typed twin of --sheets. The wire payload is one
|
||||
// Arrow IPC file (a.k.a. Feather v2 — what `pandas.DataFrame.to_feather()`
|
||||
// writes), single schema, optionally multi-batch. Type / format are read off
|
||||
// the Arrow schema (no separate dtypes/formats maps), and per-column number
|
||||
// format can be set via the field's `number_format` metadata key:
|
||||
//
|
||||
// pa.field("price", pa.float64(), metadata={b"number_format": b"$#,##0.00"})
|
||||
//
|
||||
// One DataFrame writes into one sub-sheet at fixed defaults: name `Sheet1`
|
||||
// (adopted in place by +workbook-create; created when absent by +table-put),
|
||||
// overwrite from A1 with header on, allow_overwrite=true. The shortcut
|
||||
// surface is deliberately the one flag — anything that needs a different
|
||||
// sheet name / anchor / mode / multi-sheet falls back to --sheets, whose
|
||||
// JSON payload already carries every knob.
|
||||
//
|
||||
// Binary IO note: --dataframe bypasses the text-oriented Input resolver
|
||||
// (`runtime.Str("dataframe")` carries a *path*, not file contents). Reading
|
||||
// the Arrow bytes through that resolver would TrimSpace the trailing IPC
|
||||
// magic / corrupt non-UTF8 bytes. Path → FileIO.Open → io.ReadAll keeps the
|
||||
// stream byte-exact. "-" reads from stdin directly.
|
||||
|
||||
// dataframeDefaultSheetName is the sub-sheet name --dataframe writes into.
|
||||
// Matches valuesSheetName so +workbook-create adopts the brand-new
|
||||
// workbook's default sheet in place (no stray empty Sheet1 left behind);
|
||||
// +table-put creates Sheet1 if it doesn't already exist.
|
||||
const dataframeDefaultSheetName = valuesSheetName
|
||||
|
||||
// parseDataframePayload reads the --dataframe path (Arrow IPC file) and
|
||||
// composes a single-sheet tablePayload at the fixed default placement.
|
||||
// Network-free: safe from Validate and DryRun. The resulting tableSheetSpec
|
||||
// rides the same buildSheetMatrix / buildTypedCell path as a --sheets entry,
|
||||
// so downstream is unaware of where the rows came from.
|
||||
func parseDataframePayload(rctx *common.RuntimeContext) (*tablePayload, error) {
|
||||
raw := strings.TrimSpace(rctx.Str("dataframe"))
|
||||
if raw == "" {
|
||||
return nil, common.ValidationErrorf("--dataframe is required")
|
||||
}
|
||||
data, err := readDataframeBytes(rctx, raw)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
spec, err := decodeArrowToSheet(data, dataframeDefaultSheetName)
|
||||
if err != nil {
|
||||
return nil, common.ValidationErrorf("--dataframe: %v", err).WithCause(err)
|
||||
}
|
||||
payload := &tablePayload{Sheets: []tableSheetSpec{spec}}
|
||||
if err := payload.validate(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return payload, nil
|
||||
}
|
||||
|
||||
// dataframeStdinCache holds the bytes read from stdin on the first call so a
|
||||
// later call (Validate → Execute / DryRun) gets the same bytes instead of an
|
||||
// empty stream — stdin is single-shot, but parseDataframePayload runs
|
||||
// multiple times per command invocation. Process-wide is fine: lark-cli is
|
||||
// one-shot (one command per process). Tests reset by setting it back to nil.
|
||||
var dataframeStdinCache []byte
|
||||
|
||||
// Memory caps for --dataframe. The Arrow IPC reader allocates large buffers up
|
||||
// front, and arrowRecordToRows materializes every cell into [][]interface{}, so
|
||||
// an unbounded input could OOM the CLI before the backend's per-write limits
|
||||
// kicked in. The caps mirror the backend's per-sheet hard ceilings (200 cols,
|
||||
// 50000 rows) plus a generous overall byte cap that still fits the worst-case
|
||||
// dense numeric payload (200 × 50000 cells × ~25 bytes Arrow overhead ≈ 250 MB).
|
||||
const (
|
||||
dataframeMaxBytes = 256 * 1024 * 1024 // 256 MiB raw IPC payload
|
||||
dataframeMaxCols = 200 // backend hard ceiling
|
||||
dataframeMaxRows = 50000 // backend hard ceiling
|
||||
)
|
||||
|
||||
// readDataframeBytes resolves --dataframe to raw binary. A literal `@` prefix
|
||||
// is tolerated for symmetry with --sheets (`@/tmp/x.arrow` and `/tmp/x.arrow`
|
||||
// both work). `-` reads stdin verbatim — cached on first call so Validate /
|
||||
// Execute / DryRun all see the same bytes. Bytes are returned untouched: no
|
||||
// TrimSpace, no BOM strip — both would corrupt an Arrow IPC stream.
|
||||
func readDataframeBytes(rctx *common.RuntimeContext, raw string) ([]byte, error) {
|
||||
// readCapped pulls up to dataframeMaxBytes+1 bytes from r so we can detect
|
||||
// "exceeded cap" without allocating the entire oversized payload up front.
|
||||
readCapped := func(r io.Reader) ([]byte, error) {
|
||||
data, err := io.ReadAll(io.LimitReader(r, dataframeMaxBytes+1))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(data) > dataframeMaxBytes {
|
||||
return nil, common.ValidationErrorf(
|
||||
"--dataframe: payload exceeds %d MiB cap (limits CLI memory; the backend per-sheet ceilings are 200 cols × 50000 rows)",
|
||||
dataframeMaxBytes/(1024*1024))
|
||||
}
|
||||
return data, nil
|
||||
}
|
||||
|
||||
if raw == "-" {
|
||||
if dataframeStdinCache != nil {
|
||||
return dataframeStdinCache, nil
|
||||
}
|
||||
// A process has a single stdin: --dataframe is binary and bypasses the
|
||||
// common Input resolver, so we have to share the stdin-consumed flag with
|
||||
// it explicitly. Without this, e.g. `+table-put --dataframe - --styles -`
|
||||
// would be accepted and one of them would silently see an empty stream.
|
||||
if rctx.StdinConsumed() {
|
||||
return nil, common.ValidationErrorf("--dataframe: stdin (-) can only be used by one flag").
|
||||
WithHint("a process has a single stdin, so only one flag per call may use '-'; pass the others as @file (e.g. --styles @/path/to/styles.json)")
|
||||
}
|
||||
ios := rctx.IO()
|
||||
if ios == nil || ios.In == nil {
|
||||
return nil, common.ValidationErrorf("--dataframe: stdin is not available")
|
||||
}
|
||||
data, err := readCapped(ios.In)
|
||||
if err != nil {
|
||||
if errs.IsTyped(err) {
|
||||
return nil, err
|
||||
}
|
||||
return nil, common.ValidationErrorf("--dataframe: read stdin: %v", err).WithCause(err)
|
||||
}
|
||||
if len(data) == 0 {
|
||||
return nil, common.ValidationErrorf("--dataframe: stdin is empty")
|
||||
}
|
||||
dataframeStdinCache = data
|
||||
rctx.MarkStdinConsumed()
|
||||
return data, nil
|
||||
}
|
||||
path := strings.TrimPrefix(raw, "@")
|
||||
fio := rctx.FileIO()
|
||||
if fio == nil {
|
||||
return nil, common.ValidationErrorf("--dataframe: file input is not available in this context")
|
||||
}
|
||||
// Pre-check size via Stat so a multi-GB file is rejected immediately
|
||||
// instead of being streamed all the way to the cap.
|
||||
if info, statErr := fio.Stat(path); statErr == nil && info.Size() > dataframeMaxBytes {
|
||||
return nil, common.ValidationErrorf(
|
||||
"--dataframe: file %q is %d MiB, exceeds %d MiB cap",
|
||||
path, info.Size()/(1024*1024), dataframeMaxBytes/(1024*1024))
|
||||
}
|
||||
f, err := fio.Open(path)
|
||||
if err != nil {
|
||||
return nil, common.ValidationErrorf("--dataframe: %v", err).WithCause(err)
|
||||
}
|
||||
defer f.Close()
|
||||
data, err := readCapped(f)
|
||||
if err != nil {
|
||||
if errs.IsTyped(err) {
|
||||
return nil, err
|
||||
}
|
||||
return nil, common.ValidationErrorf("--dataframe: %v", err).WithCause(err)
|
||||
}
|
||||
if len(data) == 0 {
|
||||
return nil, common.ValidationErrorf("--dataframe: file %q is empty", path)
|
||||
}
|
||||
return data, nil
|
||||
}
|
||||
|
||||
// decodeArrowToSheet reads `data` as an Arrow IPC file (single schema,
|
||||
// possibly multi-batch) and produces a tableSheetSpec with name + columns +
|
||||
// rows filled in. Sheet placement (start_cell / mode / header / overwrite) is
|
||||
// not touched here — parseDataframePayload layers those on from CLI flags.
|
||||
func decodeArrowToSheet(data []byte, sheetName string) (tableSheetSpec, error) {
|
||||
reader, err := ipc.NewFileReader(bytes.NewReader(data))
|
||||
if err != nil {
|
||||
return tableSheetSpec{}, fmt.Errorf("invalid Arrow IPC file (expected pandas df.to_feather output): %w", err) //nolint:forbidigo // intermediate error; the command layer wraps it into a typed --dataframe/--dataframe-out validation error
|
||||
}
|
||||
defer reader.Close()
|
||||
|
||||
schema := reader.Schema()
|
||||
if schema == nil || schema.NumFields() == 0 {
|
||||
return tableSheetSpec{}, fmt.Errorf("Arrow schema has no fields") //nolint:forbidigo // intermediate error; the command layer wraps it into a typed --dataframe/--dataframe-out validation error
|
||||
}
|
||||
|
||||
ncols := schema.NumFields()
|
||||
if ncols > dataframeMaxCols {
|
||||
// Fail fast at the schema layer before allocating per-column slices.
|
||||
// 200 cols matches the backend's per-sheet hard ceiling — anything past
|
||||
// that would error on the first set_cell_range anyway.
|
||||
return tableSheetSpec{}, fmt.Errorf("%d columns exceeds the per-sheet ceiling of %d", ncols, dataframeMaxCols) //nolint:forbidigo // intermediate error; the command layer wraps it into a typed --dataframe/--dataframe-out validation error
|
||||
}
|
||||
cols := make([]tableColumnSpec, ncols)
|
||||
seen := make(map[string]bool, ncols)
|
||||
for i := 0; i < ncols; i++ {
|
||||
f := schema.Field(i)
|
||||
name := f.Name
|
||||
if strings.TrimSpace(name) == "" {
|
||||
return tableSheetSpec{}, fmt.Errorf("column %d has empty name", i) //nolint:forbidigo // intermediate error; the command layer wraps it into a typed --dataframe/--dataframe-out validation error
|
||||
}
|
||||
if seen[name] {
|
||||
return tableSheetSpec{}, fmt.Errorf("duplicate column name %q", name) //nolint:forbidigo // intermediate error; the command layer wraps it into a typed --dataframe/--dataframe-out validation error
|
||||
}
|
||||
seen[name] = true
|
||||
typ, format, err := arrowFieldToTypeFormat(f)
|
||||
if err != nil {
|
||||
return tableSheetSpec{}, fmt.Errorf("column %q: %w", name, err) //nolint:forbidigo // intermediate error; the command layer wraps it into a typed --dataframe/--dataframe-out validation error
|
||||
}
|
||||
cols[i] = tableColumnSpec{Name: name, Type: typ, Format: format}
|
||||
}
|
||||
|
||||
var rows [][]interface{}
|
||||
for b := 0; b < reader.NumRecords(); b++ {
|
||||
rec, err := reader.RecordAt(b)
|
||||
if err != nil {
|
||||
return tableSheetSpec{}, fmt.Errorf("read record batch %d: %w", b, err) //nolint:forbidigo // intermediate error; the command layer wraps it into a typed --dataframe/--dataframe-out validation error
|
||||
}
|
||||
// Reject early during iteration before materializing more rows into the
|
||||
// [][]interface{} buffer — without this, a 1M-row Arrow file would be
|
||||
// fully decoded into memory before the writer's per-batch size check
|
||||
// kicks in.
|
||||
if int64(len(rows))+rec.NumRows() > int64(dataframeMaxRows) {
|
||||
rec.Release()
|
||||
return tableSheetSpec{}, fmt.Errorf("%d rows exceeds the per-sheet ceiling of %d", int64(len(rows))+rec.NumRows(), dataframeMaxRows) //nolint:forbidigo // intermediate error; the command layer wraps it into a typed --dataframe/--dataframe-out validation error
|
||||
}
|
||||
batchRows, err := arrowRecordToRows(rec, cols)
|
||||
rec.Release()
|
||||
if err != nil {
|
||||
return tableSheetSpec{}, err
|
||||
}
|
||||
rows = append(rows, batchRows...)
|
||||
}
|
||||
|
||||
return tableSheetSpec{Name: sheetName, Columns: cols, Rows: rows}, nil
|
||||
}
|
||||
|
||||
// arrowFieldToTypeFormat maps an Arrow field to the internal (type, format)
|
||||
// pair. The field's `number_format` metadata key — when present — sets the
|
||||
// Excel number_format string verbatim; otherwise sensible defaults are
|
||||
// applied per type (`@` text for strings, `yyyy-mm-dd` for dates).
|
||||
func arrowFieldToTypeFormat(f arrow.Field) (typ, format string, err error) {
|
||||
if v, ok := f.Metadata.GetValue("number_format"); ok {
|
||||
format = strings.TrimSpace(v)
|
||||
}
|
||||
switch f.Type.(type) {
|
||||
case *arrow.StringType, *arrow.LargeStringType:
|
||||
if format == "" {
|
||||
format = "@"
|
||||
}
|
||||
return "string", format, nil
|
||||
case *arrow.BooleanType:
|
||||
return "bool", format, nil
|
||||
case *arrow.Date32Type, *arrow.Date64Type, *arrow.TimestampType:
|
||||
if format == "" {
|
||||
format = "yyyy-mm-dd"
|
||||
}
|
||||
return "date", format, nil
|
||||
}
|
||||
if isArrowNumericType(f.Type) {
|
||||
return "number", format, nil
|
||||
}
|
||||
return "", "", fmt.Errorf("unsupported Arrow type %s (want string/number/date/bool)", f.Type.Name()) //nolint:forbidigo // intermediate error; the command layer wraps it into a typed --dataframe/--dataframe-out validation error
|
||||
}
|
||||
|
||||
func isArrowNumericType(t arrow.DataType) bool {
|
||||
switch t.ID() {
|
||||
case arrow.INT8, arrow.INT16, arrow.INT32, arrow.INT64,
|
||||
arrow.UINT8, arrow.UINT16, arrow.UINT32, arrow.UINT64,
|
||||
arrow.FLOAT16, arrow.FLOAT32, arrow.FLOAT64:
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// arrowRecordToRows transposes one column-batch into row-major
|
||||
// [][]interface{} matched to `cols`. Cells are stamped with the same value
|
||||
// shapes buildTypedCell expects from the JSON path: nil for nulls,
|
||||
// json.Number for numerics (precision-preserving), `yyyy-mm-dd` strings for
|
||||
// dates/timestamps, bool for booleans, string for strings.
|
||||
func arrowRecordToRows(rec arrow.Record, cols []tableColumnSpec) ([][]interface{}, error) {
|
||||
if int(rec.NumCols()) != len(cols) {
|
||||
return nil, fmt.Errorf("record has %d cols, schema declared %d", rec.NumCols(), len(cols)) //nolint:forbidigo // intermediate error; the command layer wraps it into a typed --dataframe/--dataframe-out validation error
|
||||
}
|
||||
nrows := int(rec.NumRows())
|
||||
rows := make([][]interface{}, nrows)
|
||||
for r := range rows {
|
||||
rows[r] = make([]interface{}, len(cols))
|
||||
}
|
||||
for c := range cols {
|
||||
arr := rec.Column(c)
|
||||
for r := 0; r < nrows; r++ {
|
||||
if arr.IsNull(r) {
|
||||
continue
|
||||
}
|
||||
v, err := arrowCellValue(arr, r)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("row %d column %q: %w", r, cols[c].Name, err) //nolint:forbidigo // intermediate error; the command layer wraps it into a typed --dataframe/--dataframe-out validation error
|
||||
}
|
||||
rows[r][c] = v
|
||||
}
|
||||
}
|
||||
return rows, nil
|
||||
}
|
||||
|
||||
func arrowCellValue(arr arrow.Array, i int) (interface{}, error) {
|
||||
switch a := arr.(type) {
|
||||
case *array.String:
|
||||
return a.Value(i), nil
|
||||
case *array.LargeString:
|
||||
return a.Value(i), nil
|
||||
case *array.Boolean:
|
||||
return a.Value(i), nil
|
||||
case *array.Int8:
|
||||
return json.Number(strconv.FormatInt(int64(a.Value(i)), 10)), nil
|
||||
case *array.Int16:
|
||||
return json.Number(strconv.FormatInt(int64(a.Value(i)), 10)), nil
|
||||
case *array.Int32:
|
||||
return json.Number(strconv.FormatInt(int64(a.Value(i)), 10)), nil
|
||||
case *array.Int64:
|
||||
return json.Number(strconv.FormatInt(a.Value(i), 10)), nil
|
||||
case *array.Uint8:
|
||||
return json.Number(strconv.FormatUint(uint64(a.Value(i)), 10)), nil
|
||||
case *array.Uint16:
|
||||
return json.Number(strconv.FormatUint(uint64(a.Value(i)), 10)), nil
|
||||
case *array.Uint32:
|
||||
return json.Number(strconv.FormatUint(uint64(a.Value(i)), 10)), nil
|
||||
case *array.Uint64:
|
||||
return json.Number(strconv.FormatUint(a.Value(i), 10)), nil
|
||||
case *array.Float16:
|
||||
return json.Number(strconv.FormatFloat(float64(a.Value(i).Float32()), 'f', -1, 32)), nil
|
||||
case *array.Float32:
|
||||
return json.Number(strconv.FormatFloat(float64(a.Value(i)), 'f', -1, 32)), nil
|
||||
case *array.Float64:
|
||||
return json.Number(strconv.FormatFloat(a.Value(i), 'f', -1, 64)), nil
|
||||
case *array.Date32:
|
||||
// Date32: days since 1970-01-01 (epoch). Multiply to seconds, format
|
||||
// in UTC so timezone offset can't flip the calendar date.
|
||||
t := time.Unix(int64(a.Value(i))*86400, 0).UTC()
|
||||
return t.Format("2006-01-02"), nil
|
||||
case *array.Date64:
|
||||
t := time.UnixMilli(int64(a.Value(i))).UTC()
|
||||
return t.Format("2006-01-02"), nil
|
||||
case *array.Timestamp:
|
||||
ts := int64(a.Value(i))
|
||||
unit := a.DataType().(*arrow.TimestampType).Unit
|
||||
var t time.Time
|
||||
switch unit {
|
||||
case arrow.Second:
|
||||
t = time.Unix(ts, 0).UTC()
|
||||
case arrow.Millisecond:
|
||||
t = time.UnixMilli(ts).UTC()
|
||||
case arrow.Microsecond:
|
||||
t = time.UnixMicro(ts).UTC()
|
||||
case arrow.Nanosecond:
|
||||
t = time.Unix(0, ts).UTC()
|
||||
default:
|
||||
return nil, fmt.Errorf("unsupported timestamp unit %v", unit) //nolint:forbidigo // intermediate error; the command layer wraps it into a typed --dataframe/--dataframe-out validation error
|
||||
}
|
||||
return t.Format("2006-01-02"), nil
|
||||
}
|
||||
return nil, fmt.Errorf("unsupported Arrow array %T", arr) //nolint:forbidigo // intermediate error; the command layer wraps it into a typed --dataframe/--dataframe-out validation error
|
||||
}
|
||||
|
||||
// ─── --dataframe-out (Arrow IPC binary output, mirror of --dataframe) ──
|
||||
//
|
||||
// +table-get's binary read-back: encode one sheet's typed read-back as an
|
||||
// Arrow IPC file (Feather v2), so pandas can `pd.read_feather(path)` /
|
||||
// `pd.read_feather(BytesIO(stdout))` symmetrically with the put side.
|
||||
// Single-sheet only — Arrow IPC carries one schema per file. The JSON path
|
||||
// is unchanged; --dataframe-out swaps the encoder for callers that already
|
||||
// have pandas / pyarrow in their pipeline.
|
||||
|
||||
// encodeSheetMapToArrowIPC turns one readSheetAsSpec output into an Arrow IPC
|
||||
// file blob. Internal column types are recovered from `dtypes` (the wire
|
||||
// proxy for the typed protocol), and per-column `number_format` rides through
|
||||
// as Arrow field metadata so the file feeds straight back into
|
||||
// `+table-put --dataframe`.
|
||||
func encodeSheetMapToArrowIPC(sheet map[string]interface{}) ([]byte, error) {
|
||||
columns, _ := sheet["columns"].([]interface{})
|
||||
if len(columns) == 0 {
|
||||
return nil, fmt.Errorf("sheet has no columns") //nolint:forbidigo // intermediate error; the command layer wraps it into a typed --dataframe/--dataframe-out validation error
|
||||
}
|
||||
dtypes, _ := sheet["dtypes"].(map[string]interface{})
|
||||
formats, _ := sheet["formats"].(map[string]interface{})
|
||||
// `data` arrives as either []interface{} (when the sheet came through a
|
||||
// JSON round-trip / unit-test fixture) or [][]interface{} (the shape
|
||||
// readSheetAsSpec directly emits in production). Accept both — anything
|
||||
// else falls through to a zero-row table.
|
||||
var rawData [][]interface{}
|
||||
switch d := sheet["data"].(type) {
|
||||
case [][]interface{}:
|
||||
rawData = d
|
||||
case []interface{}:
|
||||
rawData = make([][]interface{}, len(d))
|
||||
for i, r := range d {
|
||||
rawData[i], _ = r.([]interface{})
|
||||
}
|
||||
}
|
||||
|
||||
ncols := len(columns)
|
||||
colNames := make([]string, ncols)
|
||||
colTypes := make([]string, ncols)
|
||||
fields := make([]arrow.Field, ncols)
|
||||
for i, c := range columns {
|
||||
name, _ := c.(string)
|
||||
if name == "" {
|
||||
return nil, fmt.Errorf("column %d has empty name", i) //nolint:forbidigo // intermediate error; the command layer wraps it into a typed --dataframe/--dataframe-out validation error
|
||||
}
|
||||
colNames[i] = name
|
||||
dt, _ := dtypes[name].(string)
|
||||
colTypes[i] = dtypeToInternalType(dt)
|
||||
var meta arrow.Metadata
|
||||
if formats != nil {
|
||||
if nf, ok := formats[name].(string); ok && strings.TrimSpace(nf) != "" {
|
||||
meta = arrow.NewMetadata([]string{"number_format"}, []string{nf})
|
||||
}
|
||||
}
|
||||
fields[i] = arrow.Field{
|
||||
Name: name,
|
||||
Type: internalTypeToArrowType(colTypes[i]),
|
||||
Nullable: true,
|
||||
Metadata: meta,
|
||||
}
|
||||
}
|
||||
schema := arrow.NewSchema(fields, nil)
|
||||
|
||||
mem := memory.NewGoAllocator()
|
||||
rb := array.NewRecordBuilder(mem, schema)
|
||||
defer rb.Release()
|
||||
for r, row := range rawData {
|
||||
if len(row) != ncols {
|
||||
return nil, fmt.Errorf("row %d has %d cells, want %d", r, len(row), ncols) //nolint:forbidigo // intermediate error; the command layer wraps it into a typed --dataframe/--dataframe-out validation error
|
||||
}
|
||||
for c := 0; c < ncols; c++ {
|
||||
if err := appendArrowCell(rb.Field(c), colTypes[c], row[c]); err != nil {
|
||||
return nil, fmt.Errorf("row %d column %q: %w", r, colNames[c], err) //nolint:forbidigo // intermediate error; the command layer wraps it into a typed --dataframe/--dataframe-out validation error
|
||||
}
|
||||
}
|
||||
}
|
||||
rec := rb.NewRecord()
|
||||
defer rec.Release()
|
||||
|
||||
var buf bytesWriterSeeker
|
||||
w, err := ipc.NewFileWriter(&buf, ipc.WithSchema(schema), ipc.WithAllocator(mem))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("ipc.NewFileWriter: %w", err) //nolint:forbidigo // intermediate error; the command layer wraps it into a typed --dataframe/--dataframe-out validation error
|
||||
}
|
||||
if err := w.Write(rec); err != nil {
|
||||
return nil, fmt.Errorf("write record: %w", err) //nolint:forbidigo // intermediate error; the command layer wraps it into a typed --dataframe/--dataframe-out validation error
|
||||
}
|
||||
if err := w.Close(); err != nil {
|
||||
return nil, fmt.Errorf("close writer: %w", err) //nolint:forbidigo // intermediate error; the command layer wraps it into a typed --dataframe/--dataframe-out validation error
|
||||
}
|
||||
return buf.buf, nil
|
||||
}
|
||||
|
||||
// dtypeToInternalType inverts typeToDtype so the Arrow encoder can pick an
|
||||
// internal column type from the wire-level dtype string. Unknown / object
|
||||
// falls back to string (lossless: every cell is already typed as such).
|
||||
func dtypeToInternalType(dtype string) string {
|
||||
switch strings.ToLower(strings.TrimSpace(dtype)) {
|
||||
case "float64", "float32", "int64", "int32", "int16", "int8",
|
||||
"uint64", "uint32", "uint16", "uint8":
|
||||
return "number"
|
||||
case "bool", "boolean":
|
||||
return "bool"
|
||||
}
|
||||
if strings.HasPrefix(strings.ToLower(dtype), "datetime") {
|
||||
return "date"
|
||||
}
|
||||
return "string"
|
||||
}
|
||||
|
||||
// internalTypeToArrowType is the put-side dtypeToTypeFormat dual: maps the
|
||||
// internal column type to the Arrow data type the encoder builds a column
|
||||
// with. Numbers go to float64 because +table-get can't tell int from float
|
||||
// from a number_format alone — float64 covers both losslessly for the cell
|
||||
// ranges Lark Sheets accepts.
|
||||
func internalTypeToArrowType(typ string) arrow.DataType {
|
||||
switch typ {
|
||||
case "number":
|
||||
return arrow.PrimitiveTypes.Float64
|
||||
case "date":
|
||||
return arrow.FixedWidthTypes.Date32
|
||||
case "bool":
|
||||
return arrow.FixedWidthTypes.Boolean
|
||||
}
|
||||
return arrow.BinaryTypes.String
|
||||
}
|
||||
|
||||
// appendArrowCell stamps one cell into its column builder. Cell shape matches
|
||||
// what cellToTyped emits on the JSON path: json.Number for numbers, ISO
|
||||
// `yyyy-mm-dd` string for dates, plain string for strings, bool for bools,
|
||||
// nil for empty. Anything off-shape errors so the caller doesn't silently
|
||||
// emit nulls for malformed data.
|
||||
func appendArrowCell(b array.Builder, typ string, v interface{}) error {
|
||||
if v == nil {
|
||||
b.AppendNull()
|
||||
return nil
|
||||
}
|
||||
switch typ {
|
||||
case "string":
|
||||
s, ok := v.(string)
|
||||
if !ok {
|
||||
return fmt.Errorf("string expects string value, got %T", v) //nolint:forbidigo // intermediate error; the command layer wraps it into a typed --dataframe/--dataframe-out validation error
|
||||
}
|
||||
b.(*array.StringBuilder).Append(s)
|
||||
case "number":
|
||||
f, err := arrowNumber(v)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
b.(*array.Float64Builder).Append(f)
|
||||
case "date":
|
||||
s, ok := v.(string)
|
||||
if !ok {
|
||||
return fmt.Errorf("date expects ISO yyyy-mm-dd string, got %T", v) //nolint:forbidigo // intermediate error; the command layer wraps it into a typed --dataframe/--dataframe-out validation error
|
||||
}
|
||||
t, err := time.Parse("2006-01-02", strings.TrimSpace(s))
|
||||
if err != nil {
|
||||
return fmt.Errorf("date parse %q: %w", s, err) //nolint:forbidigo // intermediate error; the command layer wraps it into a typed --dataframe/--dataframe-out validation error
|
||||
}
|
||||
b.(*array.Date32Builder).Append(arrow.Date32FromTime(t))
|
||||
case "bool":
|
||||
bb, ok := v.(bool)
|
||||
if !ok {
|
||||
return fmt.Errorf("bool expects bool, got %T", v) //nolint:forbidigo // intermediate error; the command layer wraps it into a typed --dataframe/--dataframe-out validation error
|
||||
}
|
||||
b.(*array.BooleanBuilder).Append(bb)
|
||||
default:
|
||||
return fmt.Errorf("unsupported internal type %q", typ) //nolint:forbidigo // intermediate error; the command layer wraps it into a typed --dataframe/--dataframe-out validation error
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// arrowNumber converts the number cell shape readSheetAsSpec emits
|
||||
// (json.Number) plus the float fallback to float64 for the Arrow builder.
|
||||
func arrowNumber(v interface{}) (float64, error) {
|
||||
switch n := v.(type) {
|
||||
case json.Number:
|
||||
f, err := n.Float64()
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("number parse %q: %w", n.String(), err) //nolint:forbidigo // intermediate error; the command layer wraps it into a typed --dataframe/--dataframe-out validation error
|
||||
}
|
||||
return f, nil
|
||||
case float64:
|
||||
return n, nil
|
||||
}
|
||||
return 0, fmt.Errorf("number expects numeric value, got %T", v) //nolint:forbidigo // intermediate error; the command layer wraps it into a typed --dataframe/--dataframe-out validation error
|
||||
}
|
||||
|
||||
// bytesWriterSeeker is a 10-line in-memory io.WriteSeeker for
|
||||
// ipc.NewFileWriter, which seeks back to patch a footer offset. Using a
|
||||
// buffer (instead of a temp file or os.Stdout, which isn't seekable) keeps
|
||||
// --dataframe-out's stdout path zero-IO and stays straightforward.
|
||||
type bytesWriterSeeker struct {
|
||||
buf []byte
|
||||
pos int64
|
||||
}
|
||||
|
||||
func (w *bytesWriterSeeker) Write(p []byte) (int, error) {
|
||||
end := w.pos + int64(len(p))
|
||||
if end > int64(len(w.buf)) {
|
||||
w.buf = append(w.buf, make([]byte, end-int64(len(w.buf)))...)
|
||||
}
|
||||
n := copy(w.buf[w.pos:], p)
|
||||
w.pos = end
|
||||
return n, nil
|
||||
}
|
||||
|
||||
func (w *bytesWriterSeeker) Seek(offset int64, whence int) (int64, error) {
|
||||
switch whence {
|
||||
case io.SeekStart:
|
||||
w.pos = offset
|
||||
case io.SeekCurrent:
|
||||
w.pos += offset
|
||||
case io.SeekEnd:
|
||||
w.pos = int64(len(w.buf)) + offset
|
||||
default:
|
||||
return 0, fmt.Errorf("unknown whence %d", whence) //nolint:forbidigo // intermediate error; the command layer wraps it into a typed --dataframe/--dataframe-out validation error
|
||||
}
|
||||
return w.pos, nil
|
||||
}
|
||||
|
||||
// writeDataframeOut dispatches the encoded Arrow bytes to wherever --dataframe-out
|
||||
// points: `-` → process stdout, `@<path>` or plain path → local file. Symmetric
|
||||
// with readDataframeBytes on the input side: same `@` tolerance, same TrimPrefix
|
||||
// semantics, and an absolute path will still get rejected by FileIO's SafePath.
|
||||
func writeDataframeOut(rctx *common.RuntimeContext, raw string, data []byte) error {
|
||||
if raw == "-" {
|
||||
out := rctx.IO()
|
||||
if out == nil || out.Out == nil {
|
||||
return common.ValidationErrorf("--dataframe-out: stdout is not available")
|
||||
}
|
||||
if _, err := out.Out.Write(data); err != nil {
|
||||
return errs.NewInternalError(errs.SubtypeFileIO, "--dataframe-out: write stdout").WithCause(err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
path := strings.TrimPrefix(raw, "@")
|
||||
fio := rctx.FileIO()
|
||||
if fio == nil {
|
||||
return common.ValidationErrorf("--dataframe-out: file output is not available in this context")
|
||||
}
|
||||
// FileIO.Save validates the path via SafeOutputPath (the same sandbox
|
||||
// readDataframeBytes hits on the input side) and writes atomically, so we
|
||||
// don't need an extra ValidatePath call here.
|
||||
if _, err := fio.Save(path, fileio.SaveOptions{ContentLength: int64(len(data))}, bytes.NewReader(data)); err != nil {
|
||||
return errs.NewInternalError(errs.SubtypeFileIO, "--dataframe-out: write %q", path).WithCause(err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -1,421 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package sheets
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/apache/arrow/go/v17/arrow"
|
||||
"github.com/apache/arrow/go/v17/arrow/array"
|
||||
"github.com/apache/arrow/go/v17/arrow/ipc"
|
||||
"github.com/apache/arrow/go/v17/arrow/memory"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
// buildArrowIPC writes one record into a Feather v2 (Arrow IPC file) blob.
|
||||
// Used by the round-trip tests below to stand in for what
|
||||
// `pandas.DataFrame.to_feather(path)` would produce; saves the tests from
|
||||
// depending on a pandas-shaped fixture file.
|
||||
//
|
||||
// ipc.NewFileWriter wants an io.WriteSeeker (it back-patches a footer
|
||||
// offset), so we write to a temp file and read the bytes back — simpler than
|
||||
// re-implementing a seekable in-memory buffer.
|
||||
func buildArrowIPC(t *testing.T, schema *arrow.Schema, build func(b *array.RecordBuilder)) []byte {
|
||||
t.Helper()
|
||||
mem := memory.NewGoAllocator()
|
||||
rb := array.NewRecordBuilder(mem, schema)
|
||||
defer rb.Release()
|
||||
build(rb)
|
||||
rec := rb.NewRecord()
|
||||
defer rec.Release()
|
||||
|
||||
path := filepath.Join(t.TempDir(), "df.arrow")
|
||||
f, err := os.Create(path)
|
||||
if err != nil {
|
||||
t.Fatalf("create temp arrow file: %v", err)
|
||||
}
|
||||
w, err := ipc.NewFileWriter(f, ipc.WithSchema(schema), ipc.WithAllocator(mem))
|
||||
if err != nil {
|
||||
f.Close()
|
||||
t.Fatalf("ipc.NewFileWriter: %v", err)
|
||||
}
|
||||
if err := w.Write(rec); err != nil {
|
||||
t.Fatalf("write record: %v", err)
|
||||
}
|
||||
if err := w.Close(); err != nil {
|
||||
t.Fatalf("close writer: %v", err)
|
||||
}
|
||||
if err := f.Close(); err != nil {
|
||||
t.Fatalf("close file: %v", err)
|
||||
}
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
t.Fatalf("read temp arrow file: %v", err)
|
||||
}
|
||||
return data
|
||||
}
|
||||
|
||||
// TestDataframe_RoundTripCoreTypes pins down the Arrow-schema → internal
|
||||
// (type, format) mapping and the per-cell value shape that buildTypedCell
|
||||
// expects: number cells are json.Number (precision-preserving), date cells
|
||||
// are `yyyy-mm-dd` strings, bool/string come through verbatim. Numbers, dates,
|
||||
// strings, bools, and nulls all in one record so a future Arrow-Go bump can't
|
||||
// quietly regress any one family.
|
||||
func TestDataframe_RoundTripCoreTypes(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
schema := arrow.NewSchema([]arrow.Field{
|
||||
{Name: "name", Type: arrow.BinaryTypes.String},
|
||||
{Name: "qty", Type: arrow.PrimitiveTypes.Int64},
|
||||
{Name: "price", Type: arrow.PrimitiveTypes.Float64, Metadata: arrow.NewMetadata(
|
||||
[]string{"number_format"}, []string{"$#,##0.00"},
|
||||
)},
|
||||
{Name: "active", Type: arrow.FixedWidthTypes.Boolean},
|
||||
{Name: "shipped_on", Type: arrow.FixedWidthTypes.Date32},
|
||||
}, nil)
|
||||
|
||||
jan15 := arrow.Date32FromTime(time.Date(2024, 1, 15, 0, 0, 0, 0, time.UTC))
|
||||
feb02 := arrow.Date32FromTime(time.Date(2024, 2, 2, 0, 0, 0, 0, time.UTC))
|
||||
|
||||
buf := buildArrowIPC(t, schema, func(b *array.RecordBuilder) {
|
||||
b.Field(0).(*array.StringBuilder).AppendValues([]string{"alice", ""}, []bool{true, false})
|
||||
b.Field(1).(*array.Int64Builder).AppendValues([]int64{42, 0}, []bool{true, false})
|
||||
b.Field(2).(*array.Float64Builder).AppendValues([]float64{19.95, 0}, []bool{true, false})
|
||||
b.Field(3).(*array.BooleanBuilder).AppendValues([]bool{true, false}, []bool{true, true})
|
||||
b.Field(4).(*array.Date32Builder).AppendValues([]arrow.Date32{jan15, feb02}, []bool{true, true})
|
||||
})
|
||||
|
||||
spec, err := decodeArrowToSheet(buf, "S1")
|
||||
if err != nil {
|
||||
t.Fatalf("decodeArrowToSheet: %v", err)
|
||||
}
|
||||
if spec.Name != "S1" {
|
||||
t.Errorf("sheet name = %q, want S1", spec.Name)
|
||||
}
|
||||
if len(spec.Columns) != 5 {
|
||||
t.Fatalf("got %d columns, want 5", len(spec.Columns))
|
||||
}
|
||||
want := []struct{ typ, format string }{
|
||||
{"string", "@"},
|
||||
{"number", ""},
|
||||
{"number", "$#,##0.00"},
|
||||
{"bool", ""},
|
||||
{"date", "yyyy-mm-dd"},
|
||||
}
|
||||
for i, w := range want {
|
||||
if spec.Columns[i].Type != w.typ {
|
||||
t.Errorf("columns[%d].Type = %q, want %q", i, spec.Columns[i].Type, w.typ)
|
||||
}
|
||||
if spec.Columns[i].Format != w.format {
|
||||
t.Errorf("columns[%d].Format = %q, want %q", i, spec.Columns[i].Format, w.format)
|
||||
}
|
||||
}
|
||||
|
||||
if len(spec.Rows) != 2 {
|
||||
t.Fatalf("got %d rows, want 2", len(spec.Rows))
|
||||
}
|
||||
// Row 0: every field present, types match what buildTypedCell will accept.
|
||||
row0 := spec.Rows[0]
|
||||
if row0[0] != "alice" {
|
||||
t.Errorf("row0[name] = %#v, want \"alice\"", row0[0])
|
||||
}
|
||||
if n, ok := row0[1].(json.Number); !ok || n.String() != "42" {
|
||||
t.Errorf("row0[qty] = %#v, want json.Number(\"42\")", row0[1])
|
||||
}
|
||||
if n, ok := row0[2].(json.Number); !ok || n.String() != "19.95" {
|
||||
t.Errorf("row0[price] = %#v, want json.Number(\"19.95\")", row0[2])
|
||||
}
|
||||
if row0[3] != true {
|
||||
t.Errorf("row0[active] = %#v, want true", row0[3])
|
||||
}
|
||||
if row0[4] != "2024-01-15" {
|
||||
t.Errorf("row0[shipped_on] = %#v, want \"2024-01-15\"", row0[4])
|
||||
}
|
||||
|
||||
// Row 1: nulls on name/qty/price (despite the buffer values) must become nil
|
||||
// so buildTypedCell paints an empty cell that still carries number_format.
|
||||
row1 := spec.Rows[1]
|
||||
for _, c := range []int{0, 1, 2} {
|
||||
if row1[c] != nil {
|
||||
t.Errorf("row1[%d] = %#v, want nil (null in arrow)", c, row1[c])
|
||||
}
|
||||
}
|
||||
if row1[3] != false {
|
||||
t.Errorf("row1[active] = %#v, want false", row1[3])
|
||||
}
|
||||
if row1[4] != "2024-02-02" {
|
||||
t.Errorf("row1[shipped_on] = %#v, want \"2024-02-02\"", row1[4])
|
||||
}
|
||||
}
|
||||
|
||||
// TestDataframe_Timestamp pins the timestamp → date conversion for the
|
||||
// timestamp[us] case (pandas default for `pd.Timestamp` columns once written
|
||||
// via `to_feather`). Only the calendar date matters for our `yyyy-mm-dd`
|
||||
// landing — guard against TZ drift from the wrong unit pick.
|
||||
func TestDataframe_Timestamp(t *testing.T) {
|
||||
t.Parallel()
|
||||
schema := arrow.NewSchema([]arrow.Field{
|
||||
{Name: "ts", Type: &arrow.TimestampType{Unit: arrow.Microsecond}},
|
||||
}, nil)
|
||||
ts := arrow.Timestamp(time.Date(2024, 6, 12, 14, 30, 0, 0, time.UTC).UnixMicro())
|
||||
buf := buildArrowIPC(t, schema, func(b *array.RecordBuilder) {
|
||||
b.Field(0).(*array.TimestampBuilder).AppendValues([]arrow.Timestamp{ts}, []bool{true})
|
||||
})
|
||||
spec, err := decodeArrowToSheet(buf, "S")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if spec.Columns[0].Type != "date" {
|
||||
t.Errorf("type = %q, want date", spec.Columns[0].Type)
|
||||
}
|
||||
if got := spec.Rows[0][0]; got != "2024-06-12" {
|
||||
t.Errorf("ts = %#v, want \"2024-06-12\"", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestDataframe_EmptySchema rejects an Arrow file whose schema has no fields:
|
||||
// a 0-column "DataFrame" would write a header-less, data-less block that
|
||||
// validates as "writer ran successfully" but produces nothing — the test ties
|
||||
// that off as an explicit error rather than letting it slip through.
|
||||
func TestDataframe_EmptySchema(t *testing.T) {
|
||||
t.Parallel()
|
||||
schema := arrow.NewSchema(nil, nil)
|
||||
buf := buildArrowIPC(t, schema, func(b *array.RecordBuilder) {})
|
||||
_, err := decodeArrowToSheet(buf, "S")
|
||||
if err == nil || !strings.Contains(err.Error(), "no fields") {
|
||||
t.Errorf("err = %v, want 'no fields' error", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestDataframe_DuplicateColumn catches duplicate-name columns at decode
|
||||
// time. Validate already rejects duplicate column names for the JSON path;
|
||||
// the Arrow path mirrors that so the error surfaces with the same shape.
|
||||
func TestDataframe_DuplicateColumn(t *testing.T) {
|
||||
t.Parallel()
|
||||
schema := arrow.NewSchema([]arrow.Field{
|
||||
{Name: "x", Type: arrow.BinaryTypes.String},
|
||||
{Name: "x", Type: arrow.PrimitiveTypes.Int64},
|
||||
}, nil)
|
||||
buf := buildArrowIPC(t, schema, func(b *array.RecordBuilder) {
|
||||
b.Field(0).(*array.StringBuilder).Append("")
|
||||
b.Field(1).(*array.Int64Builder).Append(0)
|
||||
})
|
||||
_, err := decodeArrowToSheet(buf, "S")
|
||||
if err == nil || !strings.Contains(err.Error(), "duplicate") {
|
||||
t.Errorf("err = %v, want duplicate-column error", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestDataframe_BadBytes rejects a non-Arrow blob with a hint pointing at
|
||||
// pandas df.to_feather so users see what producer is expected without having
|
||||
// to grep the docs.
|
||||
func TestDataframe_BadBytes(t *testing.T) {
|
||||
t.Parallel()
|
||||
_, err := decodeArrowToSheet([]byte("not arrow"), "S")
|
||||
if err == nil || !strings.Contains(err.Error(), "Arrow") {
|
||||
t.Errorf("err = %v, want Arrow-decode error", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestDecodeArrowToSheet_RejectsTooManyColumns guards the per-sheet column cap.
|
||||
// Without it a wide Arrow file (e.g. 201+ columns) would allocate a long
|
||||
// tableColumnSpec slice + decode every batch before the backend's 200-column
|
||||
// per-sheet ceiling rejected the first set_cell_range — wasting both CPU and
|
||||
// memory before the failure surfaces. Cap matches the backend hard ceiling.
|
||||
func TestDecodeArrowToSheet_RejectsTooManyColumns(t *testing.T) {
|
||||
t.Parallel()
|
||||
fields := make([]arrow.Field, dataframeMaxCols+1)
|
||||
for i := range fields {
|
||||
fields[i] = arrow.Field{Name: "c" + strings.TrimSpace(string(rune('0'+i%10))) + "_" + strings.Repeat("x", i/10+1), Type: arrow.BinaryTypes.String}
|
||||
}
|
||||
schema := arrow.NewSchema(fields, nil)
|
||||
buf := buildArrowIPC(t, schema, func(b *array.RecordBuilder) {
|
||||
for i := range fields {
|
||||
b.Field(i).(*array.StringBuilder).Append("")
|
||||
}
|
||||
})
|
||||
_, err := decodeArrowToSheet(buf, "S")
|
||||
if err == nil || !strings.Contains(err.Error(), "exceeds the per-sheet ceiling") {
|
||||
t.Errorf("err = %v, want column-cap error", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestReadDataframeBytes_RejectsSecondStdinConsumer covers the case where another
|
||||
// flag (e.g. --styles) has already consumed stdin via the common Input resolver:
|
||||
// since --dataframe bypasses that resolver, the only thing keeping the two from
|
||||
// racing for an empty stream is the explicit StdinConsumed() check in
|
||||
// readDataframeBytes. Without that check, fangshuyu's report holds — both flags
|
||||
// silently accept '-' and one of them sees empty bytes downstream.
|
||||
func TestReadDataframeBytes_RejectsSecondStdinConsumer(t *testing.T) {
|
||||
// process-wide cache must be reset so the test isn't served from a prior run.
|
||||
saved := dataframeStdinCache
|
||||
dataframeStdinCache = nil
|
||||
t.Cleanup(func() { dataframeStdinCache = saved })
|
||||
|
||||
rctx := &common.RuntimeContext{}
|
||||
rctx.MarkStdinConsumed()
|
||||
|
||||
_, err := readDataframeBytes(rctx, "-")
|
||||
requireValidation(t, err, "stdin (-) can only be used by one flag")
|
||||
}
|
||||
|
||||
// TestDataframe_EncodeRoundTrip checks --dataframe-out's encoder against its
|
||||
// own decoder: build a +table-get-shaped sheet map (the same one
|
||||
// readSheetAsSpec emits), encode to Arrow IPC, decode back via the put-side
|
||||
// decoder, and require the column types / formats / row values to match. If
|
||||
// any encoder choice drifts from what the decoder expects, the round-trip
|
||||
// breaks here long before a real put → get round-trip in production would.
|
||||
func TestDataframe_EncodeRoundTrip(t *testing.T) {
|
||||
t.Parallel()
|
||||
sheet := map[string]interface{}{
|
||||
"name": "S1",
|
||||
"columns": []interface{}{"name", "qty", "price", "active", "ts"},
|
||||
"dtypes": map[string]interface{}{
|
||||
"name": "object",
|
||||
"qty": "float64",
|
||||
"price": "float64",
|
||||
"active": "bool",
|
||||
"ts": "datetime64[ns]",
|
||||
},
|
||||
"formats": map[string]interface{}{
|
||||
// `@` is the writer convention for string columns; readSheetAsSpec
|
||||
// strips it via isTextNumberFormat, so an Arrow file built from a
|
||||
// real read won't carry @ either. Keep it absent here to mirror
|
||||
// the production wire shape.
|
||||
"price": "$#,##0.00",
|
||||
},
|
||||
"data": []interface{}{
|
||||
[]interface{}{"alice", json.Number("42"), json.Number("19.95"), true, "2024-01-15"},
|
||||
[]interface{}{"bob", nil, json.Number("8.5"), false, "2024-02-02"},
|
||||
},
|
||||
}
|
||||
blob, err := encodeSheetMapToArrowIPC(sheet)
|
||||
if err != nil {
|
||||
t.Fatalf("encodeSheetMapToArrowIPC: %v", err)
|
||||
}
|
||||
spec, err := decodeArrowToSheet(blob, "S1")
|
||||
if err != nil {
|
||||
t.Fatalf("decodeArrowToSheet: %v", err)
|
||||
}
|
||||
wantTypes := []string{"string", "number", "number", "bool", "date"}
|
||||
wantFormats := []string{"@", "", "$#,##0.00", "", "yyyy-mm-dd"}
|
||||
if len(spec.Columns) != len(wantTypes) {
|
||||
t.Fatalf("got %d columns, want %d", len(spec.Columns), len(wantTypes))
|
||||
}
|
||||
for i, w := range wantTypes {
|
||||
if spec.Columns[i].Type != w {
|
||||
t.Errorf("columns[%d].Type = %q, want %q", i, spec.Columns[i].Type, w)
|
||||
}
|
||||
if spec.Columns[i].Format != wantFormats[i] {
|
||||
t.Errorf("columns[%d].Format = %q, want %q", i, spec.Columns[i].Format, wantFormats[i])
|
||||
}
|
||||
}
|
||||
if len(spec.Rows) != 2 {
|
||||
t.Fatalf("got %d rows, want 2", len(spec.Rows))
|
||||
}
|
||||
if spec.Rows[0][0] != "alice" {
|
||||
t.Errorf("row0[name] = %#v, want alice", spec.Rows[0][0])
|
||||
}
|
||||
if n, ok := spec.Rows[0][1].(json.Number); !ok || n.String() != "42" {
|
||||
t.Errorf("row0[qty] = %#v, want json.Number(\"42\")", spec.Rows[0][1])
|
||||
}
|
||||
if spec.Rows[0][3] != true {
|
||||
t.Errorf("row0[active] = %#v, want true", spec.Rows[0][3])
|
||||
}
|
||||
if spec.Rows[0][4] != "2024-01-15" {
|
||||
t.Errorf("row0[ts] = %#v, want 2024-01-15", spec.Rows[0][4])
|
||||
}
|
||||
// qty is null on row1, must come back as nil (not a zero-valued
|
||||
// json.Number that would later round-trip as 0).
|
||||
if spec.Rows[1][1] != nil {
|
||||
t.Errorf("row1[qty] = %#v, want nil (null arrow cell)", spec.Rows[1][1])
|
||||
}
|
||||
}
|
||||
|
||||
// TestDataframe_EncodeAcceptsBothRowShapes pins the encoder against the two
|
||||
// shapes `sheet["data"]` actually arrives in: `[][]interface{}` from a live
|
||||
// readSheetAsSpec call (production), and `[]interface{}` from a JSON
|
||||
// unmarshal (round-trip / fixtures). Either must produce non-empty Arrow
|
||||
// output — early on the production shape silently fell through the
|
||||
// `[]interface{}` type assertion and we shipped a 0-row Arrow blob.
|
||||
func TestDataframe_EncodeAcceptsBothRowShapes(t *testing.T) {
|
||||
t.Parallel()
|
||||
base := func(data interface{}) map[string]interface{} {
|
||||
return map[string]interface{}{
|
||||
"name": "S",
|
||||
"columns": []interface{}{"city"},
|
||||
"dtypes": map[string]interface{}{"city": "object"},
|
||||
"data": data,
|
||||
}
|
||||
}
|
||||
for label, data := range map[string]interface{}{
|
||||
"production [][]interface{}": [][]interface{}{{"BJ"}, {"SH"}},
|
||||
"unmarshal []interface{}": []interface{}{[]interface{}{"BJ"}, []interface{}{"SH"}},
|
||||
} {
|
||||
blob, err := encodeSheetMapToArrowIPC(base(data))
|
||||
if err != nil {
|
||||
t.Errorf("%s: encode: %v", label, err)
|
||||
continue
|
||||
}
|
||||
spec, err := decodeArrowToSheet(blob, "S")
|
||||
if err != nil {
|
||||
t.Errorf("%s: decode: %v", label, err)
|
||||
continue
|
||||
}
|
||||
if len(spec.Rows) != 2 {
|
||||
t.Errorf("%s: got %d rows, want 2", label, len(spec.Rows))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestDataframe_DtypeToInternalType pins the inverse of typeToDtype so
|
||||
// readSheetAsSpec's dtype labels recover the right internal type. Covers the
|
||||
// dtype families +table-get emits today plus the safe fallback for unknown
|
||||
// labels (string, lossless).
|
||||
func TestDataframe_DtypeToInternalType(t *testing.T) {
|
||||
t.Parallel()
|
||||
cases := map[string]string{
|
||||
"float64": "number",
|
||||
"int64": "number",
|
||||
"Int64": "number",
|
||||
"bool": "bool",
|
||||
"boolean": "bool",
|
||||
"datetime64[ns]": "date",
|
||||
"datetime64[ms]": "date",
|
||||
"object": "string",
|
||||
"": "string",
|
||||
"weird-new-dtype": "string",
|
||||
}
|
||||
for in, want := range cases {
|
||||
if got := dtypeToInternalType(in); got != want {
|
||||
t.Errorf("dtypeToInternalType(%q) = %q, want %q", in, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestDataframe_BytesWriterSeeker confirms the in-memory WriteSeeker handles
|
||||
// the Seek-and-overwrite pattern ipc.NewFileWriter uses to patch the footer
|
||||
// offset: write some bytes, seek back to the middle, overwrite, end up with
|
||||
// the buffer reflecting the overwritten bytes (not a tail-extended duplicate).
|
||||
func TestDataframe_BytesWriterSeeker(t *testing.T) {
|
||||
t.Parallel()
|
||||
var w bytesWriterSeeker
|
||||
if _, err := w.Write([]byte("hello world")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := w.Seek(6, 0); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := w.Write([]byte("WORLD")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := string(w.buf); got != "hello WORLD" {
|
||||
t.Errorf("buf = %q, want \"hello WORLD\"", got)
|
||||
}
|
||||
}
|
||||
@@ -7,7 +7,6 @@ import (
|
||||
"context"
|
||||
"fmt"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
@@ -50,20 +49,6 @@ type objectCRUDSpec struct {
|
||||
// right nesting level.
|
||||
enhanceCreateInput func(rt flagView, input map[string]interface{})
|
||||
enhanceUpdateInput func(rt flagView, input map[string]interface{})
|
||||
// validateCreateInput, when set, runs after enhanceCreateInput to
|
||||
// enforce cross-flag / cross-field, create-only constraints JSON
|
||||
// Schema can't express. Two uses today:
|
||||
// - pivot rejects --target-position vs --range when both carry
|
||||
// non-default values — they map to the same wire field and
|
||||
// conflicting values are ambiguous (needs raw flags via rt).
|
||||
// - cond-format requires every properties.attrs entry to match the
|
||||
// sibling rule_type's shape (see validateCondFormatAttrs); a
|
||||
// colorScale rule fed cellIs-shaped attrs writes a color-less
|
||||
// segment that breaks the sheet on open (inspects input only).
|
||||
// It is the create-path twin of validateUpdateInput; the same scope
|
||||
// notes apply. Validators that only inspect the wire input can ignore
|
||||
// the rt argument.
|
||||
validateCreateInput func(rt flagView, input map[string]interface{}) error
|
||||
// validateUpdateInput, when set, runs after enhanceUpdateInput to
|
||||
// enforce *cross-field, update-only* constraints JSON Schema can't
|
||||
// express (e.g. sparkline requires properties.sparklines[i] to
|
||||
@@ -155,7 +140,7 @@ func newObjectCreateShortcut(spec objectCRUDSpec) common.Shortcut {
|
||||
return dr
|
||||
},
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
token, err := resolveSpreadsheetTokenExec(runtime)
|
||||
token, err := resolveSpreadsheetToken(runtime)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -205,11 +190,6 @@ func objectCreateInput(runtime flagView, token, sheetID, sheetName string, spec
|
||||
if spec.enhanceCreateInput != nil {
|
||||
spec.enhanceCreateInput(runtime, input)
|
||||
}
|
||||
if spec.validateCreateInput != nil {
|
||||
if err := spec.validateCreateInput(runtime, input); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
if err := validateInputAgainstSchema(runtime, input); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -244,7 +224,7 @@ func newObjectUpdateShortcut(spec objectCRUDSpec) common.Shortcut {
|
||||
return invokeToolDryRun(token, ToolKindWrite, spec.toolName, input)
|
||||
},
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
token, err := resolveSpreadsheetTokenExec(runtime)
|
||||
token, err := resolveSpreadsheetToken(runtime)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -328,7 +308,7 @@ func newObjectDeleteShortcut(spec objectCRUDSpec) common.Shortcut {
|
||||
return invokeToolDryRun(token, ToolKindWrite, spec.toolName, input)
|
||||
},
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
token, err := resolveSpreadsheetTokenExec(runtime)
|
||||
token, err := resolveSpreadsheetToken(runtime)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -401,6 +381,9 @@ var pivotSpec = objectCRUDSpec{
|
||||
},
|
||||
createWarn: pivotPlacementWarn,
|
||||
enhanceCreateInput: func(rt flagView, input map[string]interface{}) {
|
||||
if v := strings.TrimSpace(rt.Str("target-position")); v != "" && v != "A1" {
|
||||
input["target_position"] = v
|
||||
}
|
||||
props, _ := input["properties"].(map[string]interface{})
|
||||
if props == nil {
|
||||
return
|
||||
@@ -408,26 +391,10 @@ var pivotSpec = objectCRUDSpec{
|
||||
if v := strings.TrimSpace(rt.Str("source")); v != "" {
|
||||
props["source"] = v
|
||||
}
|
||||
// --target-position 与 --range 都映射到 properties.range;
|
||||
// --target-position 优先,未给(或为默认值 A1)时回落到 --range。
|
||||
// 互斥校验在 validateCreateInput 里做。
|
||||
if v := strings.TrimSpace(rt.Str("target-position")); v != "" && v != "A1" {
|
||||
props["range"] = v
|
||||
} else if v := strings.TrimSpace(rt.Str("range")); v != "" {
|
||||
if v := strings.TrimSpace(rt.Str("range")); v != "" {
|
||||
props["range"] = v
|
||||
}
|
||||
},
|
||||
// --target-position 与 --range 落到同一 wire 字段(properties.range),
|
||||
// 同时给非默认值时无法判断意图——按 --target-sheet-id / --target-sheet-name
|
||||
// 的处理方式,CLI 端直接拒绝(优于静默丢弃其一)。
|
||||
validateCreateInput: func(rt flagView, _ map[string]interface{}) error {
|
||||
pos := strings.TrimSpace(rt.Str("target-position"))
|
||||
rng := strings.TrimSpace(rt.Str("range"))
|
||||
if pos != "" && pos != "A1" && rng != "" {
|
||||
return common.ValidationErrorf("--target-position and --range are mutually exclusive (both map to properties.range; pass only one)")
|
||||
}
|
||||
return nil
|
||||
},
|
||||
}
|
||||
var PivotCreate = newObjectCreateShortcut(pivotSpec)
|
||||
var PivotUpdate = newObjectUpdateShortcut(pivotSpec)
|
||||
@@ -520,118 +487,7 @@ var condFormatSpec = objectCRUDSpec{
|
||||
idField: "conditional_format_id",
|
||||
enhanceCreateInput: condFormatEnhance,
|
||||
enhanceUpdateInput: condFormatEnhance,
|
||||
// validateCondFormatAttrs only inspects the wire input, so the create
|
||||
// hook ignores rt; the update hook (func(input)) calls it directly.
|
||||
validateCreateInput: func(_ flagView, input map[string]interface{}) error {
|
||||
return validateCondFormatAttrs(input)
|
||||
},
|
||||
validateUpdateInput: validateCondFormatAttrs,
|
||||
}
|
||||
|
||||
// condFormatAttrsRequired maps each conditional-format rule_type to the
|
||||
// keys every properties.attrs entry must carry for that rule. It mirrors
|
||||
// the per-rule attrs contract the tool's manage_conditional_format_object
|
||||
// converter reads (byted-sheet ai-tools manage-conditional-format-object.ts):
|
||||
// that converter maps each attrs entry *blindly by rule_type*, so a
|
||||
// colorScale rule fed cellIs-shaped attrs ({compare_type,value}) silently
|
||||
// yields a color-less color-scale segment — dirty data that crashes the
|
||||
// frontend on snapshot deserialization (the 5005 "can't open" report this
|
||||
// validator was added for).
|
||||
//
|
||||
// JSON Schema can't catch this: properties.attrs.items is a oneOf over all
|
||||
// nine shapes, and the validator accepts an entry as soon as *any* branch
|
||||
// matches — blind to the sibling rule_type. {compare_type,value} matches
|
||||
// the cellIs branch regardless of whether rule_type says colorScale.
|
||||
//
|
||||
// Rule types absent from the map (duplicateValues, uniqueValues,
|
||||
// containsBlanks, notContainsBlanks) carry no attrs, so nothing to check.
|
||||
// Counts (dataBar==2, colorScale 2–3, iconSet ordering) stay the tool's
|
||||
// job — it already rejects those with actionable messages; the gap this
|
||||
// closes is per-entry *shape*, which the tool does not check.
|
||||
var condFormatAttrsRequired = map[string][]string{
|
||||
"cellIs": {"compare_type", "value"},
|
||||
"containsText": {"compare_type", "text"},
|
||||
"timePeriod": {"operator", "time_period"},
|
||||
"dataBar": {"color", "value_type"},
|
||||
"colorScale": {"value_type", "color"},
|
||||
"rank": {"is_bottom", "value_type"},
|
||||
"aboveAverage": {"operator"},
|
||||
"expression": {"formula"},
|
||||
"iconSet": {"icon_type", "value_type", "operator"},
|
||||
}
|
||||
|
||||
// validateCondFormatAttrs enforces that every properties.attrs entry
|
||||
// matches the shape required by the sibling properties.rule_type. Shared
|
||||
// by create and update. On update, rule_type may be omitted (the caller is
|
||||
// editing style only and the existing rule's type governs the attrs shape,
|
||||
// which the CLI can't see); in that case validation is deferred to the
|
||||
// server. Missing/empty attrs is likewise left to the tool, which already
|
||||
// reports "attrs are required for rule_type: X" clearly.
|
||||
func validateCondFormatAttrs(input map[string]interface{}) error {
|
||||
props, _ := input["properties"].(map[string]interface{})
|
||||
if props == nil {
|
||||
return nil
|
||||
}
|
||||
ruleType, _ := props["rule_type"].(string)
|
||||
ruleType = strings.TrimSpace(ruleType)
|
||||
if ruleType == "" {
|
||||
return nil
|
||||
}
|
||||
required, ok := condFormatAttrsRequired[ruleType]
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
attrs, ok := props["attrs"].([]interface{})
|
||||
if !ok {
|
||||
// Missing attrs, or a non-array shape the schema check already
|
||||
// flagged — nothing for this cross-field rule to add.
|
||||
return nil
|
||||
}
|
||||
for i, entryRaw := range attrs {
|
||||
entry, ok := entryRaw.(map[string]interface{})
|
||||
if !ok {
|
||||
continue // schema validation owns per-entry type errors.
|
||||
}
|
||||
for _, key := range required {
|
||||
if v, has := entry[key]; !has || condAttrIsBlank(v) {
|
||||
return common.ValidationErrorf(
|
||||
"--properties: attrs[%d] is missing %q, which rule_type %q requires on every entry (expected keys %s; got %s). "+
|
||||
"A common cause is reusing another rule's attrs shape — e.g. cellIs-style {compare_type,value} under a colorScale rule, which writes a color-less segment that breaks the sheet on open.",
|
||||
i, key, ruleType, strings.Join(required, "+"), condAttrPresentKeys(entry))
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// condAttrIsBlank treats a present-but-empty string (after trimming) as
|
||||
// missing. The crash-causing case is an empty `color`, but an empty value
|
||||
// for any required key is never meaningful in these branches, so the rule
|
||||
// is uniform. Non-string values (numbers, booleans) count as present.
|
||||
func condAttrIsBlank(v interface{}) bool {
|
||||
if v == nil {
|
||||
return true
|
||||
}
|
||||
if s, ok := v.(string); ok {
|
||||
return strings.TrimSpace(s) == ""
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// condAttrPresentKeys lists the keys actually present on an attrs entry,
|
||||
// sorted, for the "got ..." half of the error message.
|
||||
func condAttrPresentKeys(entry map[string]interface{}) string {
|
||||
if len(entry) == 0 {
|
||||
return "{}"
|
||||
}
|
||||
keys := make([]string, 0, len(entry))
|
||||
for k := range entry {
|
||||
keys = append(keys, k)
|
||||
}
|
||||
sort.Strings(keys)
|
||||
return "{" + strings.Join(keys, ",") + "}"
|
||||
}
|
||||
|
||||
var CondFormatCreate = newObjectCreateShortcut(condFormatSpec)
|
||||
var CondFormatUpdate = newObjectUpdateShortcut(condFormatSpec)
|
||||
var CondFormatDelete = newObjectDeleteShortcut(condFormatSpec)
|
||||
@@ -876,7 +732,7 @@ func newFloatImageWriteShortcut(command, description, op string, withIDFlag, isH
|
||||
return invokeToolDryRun(token, ToolKindWrite, "manage_float_image_object", input)
|
||||
},
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
token, err := resolveSpreadsheetTokenExec(runtime)
|
||||
token, err := resolveSpreadsheetToken(runtime)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -1026,7 +882,7 @@ var FilterCreate = common.Shortcut{
|
||||
return invokeToolDryRun(token, ToolKindWrite, "manage_filter_object", input)
|
||||
},
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
token, err := resolveSpreadsheetTokenExec(runtime)
|
||||
token, err := resolveSpreadsheetToken(runtime)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -1101,7 +957,7 @@ var FilterUpdate = common.Shortcut{
|
||||
return invokeToolDryRun(token, ToolKindWrite, "manage_filter_object", input)
|
||||
},
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
token, err := resolveSpreadsheetTokenExec(runtime)
|
||||
token, err := resolveSpreadsheetToken(runtime)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -1169,7 +1025,7 @@ var FilterDelete = common.Shortcut{
|
||||
return invokeToolDryRun(token, ToolKindWrite, "manage_filter_object", input)
|
||||
},
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
token, err := resolveSpreadsheetTokenExec(runtime)
|
||||
token, err := resolveSpreadsheetToken(runtime)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -4,12 +4,9 @@
|
||||
package sheets
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"sort"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
@@ -140,24 +137,25 @@ func TestObjectCRUDShortcuts_DryRun(t *testing.T) {
|
||||
// covered separately in the +pivot-create empty-selector / mutex
|
||||
// tests below.
|
||||
{
|
||||
name: "+pivot-create with placement / source / target-position flags",
|
||||
name: "+pivot-create with placement / source / range flags",
|
||||
sc: PivotCreate,
|
||||
args: []string{
|
||||
"--url", testURL, "--target-sheet-id", testSheetID,
|
||||
"--properties", `{"rows":[{"field":"A"}]}`,
|
||||
"--source", "Sheet1!A1:F1000",
|
||||
"--range", "F1",
|
||||
"--target-position", "B5",
|
||||
},
|
||||
toolName: "manage_pivot_table_object",
|
||||
wantInput: map[string]interface{}{
|
||||
"excel_id": testToken,
|
||||
"sheet_id": testSheetID,
|
||||
"operation": "create",
|
||||
"excel_id": testToken,
|
||||
"sheet_id": testSheetID,
|
||||
"operation": "create",
|
||||
"target_position": "B5",
|
||||
"properties": map[string]interface{}{
|
||||
"rows": []interface{}{map[string]interface{}{"field": "A"}},
|
||||
"source": "Sheet1!A1:F1000",
|
||||
// --target-position 映射到 properties.range。
|
||||
"range": "B5",
|
||||
"range": "F1",
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -204,7 +202,7 @@ func TestObjectCRUDShortcuts_DryRun(t *testing.T) {
|
||||
args: []string{
|
||||
"--url", testURL, "--sheet-id", testSheetID,
|
||||
"--rule-id", "ruleA",
|
||||
"--properties", `{"attrs":[{"compare_type":"greaterThan","value":"100"}],"style":{"back_color":"#FFD7D7"}}`,
|
||||
"--properties", `{"attrs":[{"operator":"greaterThan","value":"100"}],"style":{"back_color":"#FFD7D7"}}`,
|
||||
"--rule-type", "cellIs",
|
||||
"--ranges", `["A1:A100"]`,
|
||||
},
|
||||
@@ -216,7 +214,7 @@ func TestObjectCRUDShortcuts_DryRun(t *testing.T) {
|
||||
"conditional_format_id": "ruleA",
|
||||
"properties": map[string]interface{}{
|
||||
"rule_type": "cellIs",
|
||||
"attrs": []interface{}{map[string]interface{}{"compare_type": "greaterThan", "value": "100"}},
|
||||
"attrs": []interface{}{map[string]interface{}{"operator": "greaterThan", "value": "100"}},
|
||||
"style": map[string]interface{}{"back_color": "#FFD7D7"},
|
||||
"ranges": []interface{}{"A1:A100"},
|
||||
},
|
||||
@@ -473,18 +471,24 @@ func TestPivotCreate_SheetSelectorSemantics(t *testing.T) {
|
||||
|
||||
t.Run("both set is rejected", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
_, _, err := runShortcutCapturingErr(t, PivotCreate, []string{
|
||||
_, stderr, err := runShortcutCapturingErr(t, PivotCreate, []string{
|
||||
"--url", testURL,
|
||||
"--target-sheet-id", testSheetID,
|
||||
"--target-sheet-name", "Sheet1",
|
||||
"--properties", `{"rows":[{"field":"A"}]}`,
|
||||
"--source", "Sheet1!A1:F1000",
|
||||
})
|
||||
ve := requireValidation(t, err, "mutually exclusive")
|
||||
if err == nil {
|
||||
t.Fatalf("expected CLI to reject both --target-sheet-id and --target-sheet-name set; stderr=%s", stderr)
|
||||
}
|
||||
combined := stderr + err.Error()
|
||||
if !strings.Contains(combined, "mutually exclusive") {
|
||||
t.Errorf("expected error to say 'mutually exclusive'; got=%s|%v", stderr, err)
|
||||
}
|
||||
// 错误信息必须用真实的 flag 名(target-*),否则模型按消息提示去
|
||||
// 改 --sheet-id 还是错的。
|
||||
if !strings.Contains(ve.Message, "--target-sheet-id") {
|
||||
t.Errorf("expected error to quote --target-sheet-id flag name; got message=%q", ve.Message)
|
||||
if !strings.Contains(combined, "--target-sheet-id") {
|
||||
t.Errorf("expected error to quote --target-sheet-id flag name; got=%s|%v", stderr, err)
|
||||
}
|
||||
})
|
||||
|
||||
@@ -503,49 +507,6 @@ func TestPivotCreate_SheetSelectorSemantics(t *testing.T) {
|
||||
})
|
||||
}
|
||||
|
||||
// TestPivotCreate_TargetPositionRangeMutex regresses the "--target-position
|
||||
// and --range cannot both be set" guardrail on +pivot-create. They map to
|
||||
// the same wire field (properties.range), so two non-default values are
|
||||
// ambiguous; the CLI rejects up front (mirrors the --target-sheet-id /
|
||||
// --target-sheet-name mutex). --target-position=A1 is the documented default
|
||||
// and is treated as "not set" — pairing it with --range still works.
|
||||
func TestPivotCreate_TargetPositionRangeMutex(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
t.Run("both non-default values rejected", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
_, _, err := runShortcutCapturingErr(t, PivotCreate, []string{
|
||||
"--url", testURL,
|
||||
"--target-sheet-id", testSheetID,
|
||||
"--properties", `{"rows":[{"field":"A"}]}`,
|
||||
"--source", "Sheet1!A1:F1000",
|
||||
"--target-position", "B5",
|
||||
"--range", "F1",
|
||||
})
|
||||
ve := requireValidation(t, err, "mutually exclusive")
|
||||
if !strings.Contains(ve.Message, "--target-position") || !strings.Contains(ve.Message, "--range") {
|
||||
t.Errorf("expected error to quote both --target-position and --range; got message=%q", ve.Message)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("default A1 with --range is accepted (range wins)", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
body := parseDryRunBody(t, PivotCreate, []string{
|
||||
"--url", testURL,
|
||||
"--target-sheet-id", testSheetID,
|
||||
"--properties", `{"rows":[{"field":"A"}]}`,
|
||||
"--source", "Sheet1!A1:F1000",
|
||||
"--target-position", "A1",
|
||||
"--range", "F1",
|
||||
})
|
||||
input := decodeToolInput(t, body, "manage_pivot_table_object")
|
||||
props, _ := input["properties"].(map[string]interface{})
|
||||
if got, _ := props["range"].(string); got != "F1" {
|
||||
t.Errorf("properties.range = %q, want %q", got, "F1")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// TestPivotCreate_SchemaValidates exercises the schema-driven
|
||||
// validator wired into objectCreateInput. The pivot create schema
|
||||
// doesn't constrain rows/columns/values to be present (the backend
|
||||
@@ -557,27 +518,35 @@ func TestPivotCreate_SchemaValidates(t *testing.T) {
|
||||
|
||||
t.Run("rejects wrong type for rows", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
_, _, err := runShortcutCapturingErr(t, PivotCreate, []string{
|
||||
_, stderr, err := runShortcutCapturingErr(t, PivotCreate, []string{
|
||||
"--url", testURL,
|
||||
"--properties", `{"rows":"not-an-array"}`,
|
||||
"--source", "Sheet1!A1:F1000",
|
||||
"--dry-run",
|
||||
})
|
||||
ve := requireValidation(t, err, "rows")
|
||||
if !strings.Contains(ve.Message, "array") {
|
||||
t.Errorf("expected error to mention array; got message=%q", ve.Message)
|
||||
if err == nil {
|
||||
t.Fatalf("expected schema validator to reject rows=string; stderr=%s", stderr)
|
||||
}
|
||||
combined := stderr + err.Error()
|
||||
if !strings.Contains(combined, "rows") || !strings.Contains(combined, "array") {
|
||||
t.Errorf("expected error to mention rows/array; got=%s|%v", stderr, err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("rejects out-of-enum summarize_by", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
_, _, err := runShortcutCapturingErr(t, PivotCreate, []string{
|
||||
_, stderr, err := runShortcutCapturingErr(t, PivotCreate, []string{
|
||||
"--url", testURL,
|
||||
"--properties", `{"values":[{"field":"A","summarize_by":"BOGUS"}]}`,
|
||||
"--source", "Sheet1!A1:F1000",
|
||||
"--dry-run",
|
||||
})
|
||||
requireValidation(t, err, "summarize_by")
|
||||
if err == nil {
|
||||
t.Fatalf("expected schema validator to reject summarize_by=BOGUS; stderr=%s", stderr)
|
||||
}
|
||||
if !strings.Contains(stderr+err.Error(), "summarize_by") {
|
||||
t.Errorf("expected error to mention summarize_by; got=%s|%v", stderr, err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("schema-conformant input is accepted", func(t *testing.T) {
|
||||
@@ -611,8 +580,14 @@ func TestObjectCreate_RequiresSheetSelector(t *testing.T) {
|
||||
for _, tt := range cases {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
_, _, err := runShortcutCapturingErr(t, tt.sc, tt.args)
|
||||
requireValidation(t, err, "specify at least one of --sheet-id or --sheet-name")
|
||||
_, stderr, err := runShortcutCapturingErr(t, tt.sc, tt.args)
|
||||
if err == nil {
|
||||
t.Fatalf("expected CLI to reject empty sheet selector for +%s-create; stderr=%s", tt.name, stderr)
|
||||
}
|
||||
combined := stderr + err.Error()
|
||||
if !strings.Contains(combined, "specify at least one of --sheet-id or --sheet-name") {
|
||||
t.Errorf("expected 'specify at least one of --sheet-id or --sheet-name'; got=%s|%v", stderr, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -623,184 +598,19 @@ func TestObjectCreate_RequiresSheetSelector(t *testing.T) {
|
||||
// +sparkline-list, before any server call goes out.
|
||||
func TestSparklineUpdate_MissingSparklineID(t *testing.T) {
|
||||
t.Parallel()
|
||||
_, _, err := runShortcutCapturingErr(t, SparklineUpdate, []string{
|
||||
_, stderr, err := runShortcutCapturingErr(t, SparklineUpdate, []string{
|
||||
"--url", testURL, "--sheet-id", testSheetID, "--group-id", "grpA",
|
||||
"--properties", `{"sparklines":[{"source":"Sheet1!A1:A10"}]}`,
|
||||
})
|
||||
ve := requireValidation(t, err, "missing sparkline_id")
|
||||
if !strings.Contains(ve.Message, "+sparkline-list") {
|
||||
t.Errorf("expected error to point at +sparkline-list; got message=%q", ve.Message)
|
||||
if err == nil {
|
||||
t.Fatalf("expected CLI to reject missing sparkline_id; stderr=%s", stderr)
|
||||
}
|
||||
}
|
||||
|
||||
// TestCondFormatAttrs_ShapeMatchesRuleType regresses the cross-field
|
||||
// guard that rejects attrs whose shape doesn't match the sibling
|
||||
// rule_type — the gap behind the "缺 color 的 colorScale 脏数据导致表格
|
||||
// 打不开" report: a colorScale rule fed cellIs-shaped attrs
|
||||
// ({compare_type,value}, no color) passed both the CLI's per-entry oneOf
|
||||
// schema check and the tool, writing a color-less segment that crashed
|
||||
// the frontend on open. The check covers create and update symmetrically.
|
||||
func TestCondFormatAttrs_ShapeMatchesRuleType(t *testing.T) {
|
||||
t.Parallel()
|
||||
cases := []struct {
|
||||
name string
|
||||
sc common.Shortcut
|
||||
args []string
|
||||
wantErr bool
|
||||
wantMsg string // substring expected in the error, when wantErr
|
||||
}{
|
||||
{
|
||||
name: "colorScale fed cellIs-shaped attrs (missing color) is rejected",
|
||||
sc: CondFormatCreate,
|
||||
args: []string{
|
||||
"--url", testURL, "--sheet-id", testSheetID,
|
||||
"--rule-type", "colorScale", "--ranges", `["C1:C10"]`,
|
||||
"--properties", `{"style":{},"attrs":[{"compare_type":"greaterThan","value":"0"},{"compare_type":"lessThan","value":"100"}]}`, "--dry-run",
|
||||
},
|
||||
wantErr: true,
|
||||
wantMsg: "colorScale",
|
||||
},
|
||||
{
|
||||
name: "colorScale with empty color string is rejected",
|
||||
sc: CondFormatCreate,
|
||||
args: []string{
|
||||
"--url", testURL, "--sheet-id", testSheetID,
|
||||
"--rule-type", "colorScale", "--ranges", `["C1:C10"]`,
|
||||
"--properties", `{"style":{},"attrs":[{"value_type":"minValue","color":""},{"value_type":"maxValue","color":"#FF0000"}]}`, "--dry-run",
|
||||
},
|
||||
wantErr: true,
|
||||
wantMsg: `"color"`,
|
||||
},
|
||||
{
|
||||
name: "well-formed colorScale attrs pass",
|
||||
sc: CondFormatCreate,
|
||||
args: []string{
|
||||
"--url", testURL, "--sheet-id", testSheetID,
|
||||
"--rule-type", "colorScale", "--ranges", `["C1:C10"]`,
|
||||
"--properties", `{"style":{},"attrs":[{"value_type":"minValue","color":"#FFFFFF"},{"value_type":"maxValue","color":"#FF0000"}]}`, "--dry-run",
|
||||
},
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "update path is guarded too (colorScale + cellIs attrs)",
|
||||
sc: CondFormatUpdate,
|
||||
args: []string{
|
||||
"--url", testURL, "--sheet-id", testSheetID, "--rule-id", "ruleA",
|
||||
"--rule-type", "colorScale", "--ranges", `["C1:C10"]`,
|
||||
"--properties", `{"style":{},"attrs":[{"compare_type":"greaterThan","value":"0"}]}`, "--dry-run",
|
||||
},
|
||||
wantErr: true,
|
||||
wantMsg: "colorScale",
|
||||
},
|
||||
combined := stderr + err.Error()
|
||||
if !strings.Contains(combined, "missing sparkline_id") {
|
||||
t.Errorf("expected error to mention missing sparkline_id; got=%s|%v", stderr, err)
|
||||
}
|
||||
for _, tt := range cases {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
_, stderr, err := runShortcutCapturingErr(t, tt.sc, tt.args)
|
||||
if tt.wantErr {
|
||||
requireValidation(t, err, tt.wantMsg)
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
t.Fatalf("expected acceptance (dry-run); got err=%v stderr=%s", err, stderr)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestCondFormatAttrsRequired_MatchesSchemaOneOf guards against drift
|
||||
// between the hand-maintained condFormatAttrsRequired table (the source
|
||||
// validateCondFormatAttrs enforces) and the embedded flag-schemas.json
|
||||
// attrs oneOf (the authoritative shape contract synced from the spec
|
||||
// repo). The cross-field validator only works if its per-rule_type
|
||||
// required keys mirror the schema branches; if a future schema sync adds
|
||||
// or drops a required key on any branch without updating the table, the
|
||||
// CLI would silently under- or over-validate. They share no compile-time
|
||||
// link, so this test is the only thing pinning them together.
|
||||
//
|
||||
// The schema oneOf branches are NOT labeled by rule_type (that's the whole
|
||||
// point — rule_type is a sibling field the per-entry oneOf can't see), so
|
||||
// we can't match branch→rule_type. We instead compare the *multiset* of
|
||||
// required-key sets: every branch's required array must appear as some
|
||||
// table entry's value and vice versa. This catches any added/dropped
|
||||
// required key (real drift); it tolerates only a relabeling between two
|
||||
// branches that happen to share an identical required set (dataBar and
|
||||
// colorScale both require {color,value_type}), which is harmless here.
|
||||
func TestCondFormatAttrsRequired_MatchesSchemaOneOf(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
// multiset key: required keys sorted + joined, so order within a
|
||||
// branch's required array doesn't matter.
|
||||
keyOf := func(req []string) string {
|
||||
s := append([]string(nil), req...)
|
||||
sort.Strings(s)
|
||||
return strings.Join(s, "+")
|
||||
}
|
||||
|
||||
tableMS := map[string]int{}
|
||||
for _, req := range condFormatAttrsRequired {
|
||||
tableMS[keyOf(req)]++
|
||||
}
|
||||
|
||||
schemaMS := func(t *testing.T, command string) map[string]int {
|
||||
idx, err := loadFlagSchemas()
|
||||
if err != nil {
|
||||
t.Fatalf("loadFlagSchemas: %v", err)
|
||||
}
|
||||
raw, ok := idx.Flags[command]["properties"]
|
||||
if !ok {
|
||||
t.Fatalf("no embedded schema for %s --properties", command)
|
||||
}
|
||||
var schema map[string]interface{}
|
||||
if err := json.Unmarshal(raw, &schema); err != nil {
|
||||
t.Fatalf("unmarshal %s properties schema: %v", command, err)
|
||||
}
|
||||
dig := func(m map[string]interface{}, key string) map[string]interface{} {
|
||||
next, _ := m[key].(map[string]interface{})
|
||||
if next == nil {
|
||||
t.Fatalf("%s: missing %q while navigating to attrs oneOf", command, key)
|
||||
}
|
||||
return next
|
||||
}
|
||||
attrs := dig(dig(schema, "properties"), "attrs")
|
||||
items := dig(attrs, "items")
|
||||
oneOf, ok := items["oneOf"].([]interface{})
|
||||
if !ok || len(oneOf) == 0 {
|
||||
t.Fatalf("%s: attrs.items.oneOf is missing or empty", command)
|
||||
}
|
||||
ms := map[string]int{}
|
||||
for i, branchRaw := range oneOf {
|
||||
branch, ok := branchRaw.(map[string]interface{})
|
||||
if !ok {
|
||||
t.Fatalf("%s: oneOf[%d] is not an object", command, i)
|
||||
}
|
||||
reqRaw, _ := branch["required"].([]interface{})
|
||||
req := make([]string, 0, len(reqRaw))
|
||||
for _, r := range reqRaw {
|
||||
if s, ok := r.(string); ok {
|
||||
req = append(req, s)
|
||||
}
|
||||
}
|
||||
ms[keyOf(req)]++
|
||||
}
|
||||
return ms
|
||||
}
|
||||
|
||||
for _, command := range []string{"+cond-format-create", "+cond-format-update"} {
|
||||
got := schemaMS(t, command)
|
||||
if len(got) != len(tableMS) {
|
||||
t.Errorf("%s: schema oneOf has %d distinct required-sets, table has %d", command, len(got), len(tableMS))
|
||||
}
|
||||
for k, n := range tableMS {
|
||||
if got[k] != n {
|
||||
t.Errorf("%s: required-set %q appears %d× in schema but %d× in condFormatAttrsRequired — table drifted from schema; re-sync the table", command, k, got[k], n)
|
||||
}
|
||||
}
|
||||
for k, n := range got {
|
||||
if tableMS[k] != n {
|
||||
t.Errorf("%s: schema branch with required-set %q (×%d) has no matching condFormatAttrsRequired entry — add it to the table", command, k, n)
|
||||
}
|
||||
}
|
||||
if !strings.Contains(combined, "+sparkline-list") {
|
||||
t.Errorf("expected error to point at +sparkline-list; got=%s|%v", stderr, err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -815,13 +625,18 @@ func TestCondFormatAttrsRequired_MatchesSchemaOneOf(t *testing.T) {
|
||||
// create still mandates one of --image / --image-token / --image-uri.
|
||||
func TestFloatImageCreate_RequiresImageSource(t *testing.T) {
|
||||
t.Parallel()
|
||||
_, _, err := runShortcutCapturingErr(t, FloatImageCreate, []string{
|
||||
_, stderr, err := runShortcutCapturingErr(t, FloatImageCreate, []string{
|
||||
"--url", testURL, "--sheet-id", testSheetID,
|
||||
"--image-name", "x.png",
|
||||
"--position-row", "0", "--position-col", "A",
|
||||
"--size-width", "10", "--size-height", "10",
|
||||
})
|
||||
requireValidation(t, err, "one of --image, --image-token, or --image-uri is required")
|
||||
if err == nil {
|
||||
t.Fatalf("expected CLI to require an image source on create; stderr=%s", stderr)
|
||||
}
|
||||
if combined := stderr + err.Error(); !strings.Contains(combined, "one of --image, --image-token, or --image-uri is required") {
|
||||
t.Errorf("expected error to require an image source; got=%s|%v", stderr, err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestObjectDelete_AllHighRisk asserts every delete shortcut blocks
|
||||
@@ -844,8 +659,14 @@ func TestObjectDelete_AllHighRisk(t *testing.T) {
|
||||
for _, tt := range cases {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
_, _, err := runShortcutCapturingErr(t, tt.sc, tt.args)
|
||||
requireProblem(t, err, errs.CategoryConfirmation, errs.SubtypeConfirmationRequired, "")
|
||||
stdout, stderr, err := runShortcutCapturingErr(t, tt.sc, tt.args)
|
||||
if err == nil {
|
||||
t.Fatalf("expected confirmation_required; stdout=%s stderr=%s", stdout, stderr)
|
||||
}
|
||||
combined := stdout + stderr + err.Error()
|
||||
if !strings.Contains(combined, "confirmation_required") && !strings.Contains(combined, "requires confirmation") {
|
||||
t.Errorf("expected confirmation gate; got=%s|%s|%v", stdout, stderr, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -57,7 +57,7 @@ func newObjectListShortcut(spec objectListSpec) common.Shortcut {
|
||||
return invokeToolDryRun(token, ToolKindRead, spec.toolName, objectListInput(runtime, token, sheetID, sheetName, spec))
|
||||
},
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
token, err := resolveSpreadsheetTokenExec(runtime)
|
||||
token, err := resolveSpreadsheetToken(runtime)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -45,7 +45,7 @@ var CellsClear = common.Shortcut{
|
||||
return invokeToolDryRun(token, ToolKindWrite, "clear_cell_range", input)
|
||||
},
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
token, err := resolveSpreadsheetTokenExec(runtime)
|
||||
token, err := resolveSpreadsheetToken(runtime)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -163,7 +163,7 @@ func newMergeShortcut(command, desc, op string, withMergeType bool) common.Short
|
||||
return invokeToolDryRun(token, ToolKindWrite, "merge_cells", input)
|
||||
},
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
token, err := resolveSpreadsheetTokenExec(runtime)
|
||||
token, err := resolveSpreadsheetToken(runtime)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -239,7 +239,7 @@ var RowsResize = common.Shortcut{
|
||||
return invokeToolDryRun(token, ToolKindWrite, "resize_range", input)
|
||||
},
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
token, err := resolveSpreadsheetTokenExec(runtime)
|
||||
token, err := resolveSpreadsheetToken(runtime)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -279,7 +279,7 @@ var ColsResize = common.Shortcut{
|
||||
return invokeToolDryRun(token, ToolKindWrite, "resize_range", input)
|
||||
},
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
token, err := resolveSpreadsheetTokenExec(runtime)
|
||||
token, err := resolveSpreadsheetToken(runtime)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -451,7 +451,7 @@ var RangeFill = common.Shortcut{
|
||||
return invokeToolDryRun(token, ToolKindWrite, "transform_range", input)
|
||||
},
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
token, err := resolveSpreadsheetTokenExec(runtime)
|
||||
token, err := resolveSpreadsheetToken(runtime)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -490,7 +490,7 @@ var RangeSort = common.Shortcut{
|
||||
return invokeToolDryRun(token, ToolKindWrite, "transform_range", input)
|
||||
},
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
token, err := resolveSpreadsheetTokenExec(runtime)
|
||||
token, err := resolveSpreadsheetToken(runtime)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -540,7 +540,7 @@ func transformDryRunFn(op string, withPasteType, _ bool) func(context.Context, *
|
||||
|
||||
func transformExecuteFn(op string, withPasteType, _ bool) func(context.Context, *common.RuntimeContext) error {
|
||||
return func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
token, err := resolveSpreadsheetTokenExec(runtime)
|
||||
token, err := resolveSpreadsheetToken(runtime)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -287,11 +287,16 @@ func TestRangeSort_RejectsMalformedKeys(t *testing.T) {
|
||||
for _, c := range cases {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
_, _, err := runShortcutCapturingErr(t, RangeSort, []string{
|
||||
stdout, stderr, err := runShortcutCapturingErr(t, RangeSort, []string{
|
||||
"--url", testURL, "--sheet-id", testSheetID,
|
||||
"--range", "A1:E10", "--sort-keys", c.keys, "--dry-run",
|
||||
})
|
||||
requireValidation(t, err, c.want)
|
||||
if err == nil {
|
||||
t.Fatalf("expected validation error; stdout=%s stderr=%s", stdout, stderr)
|
||||
}
|
||||
if !strings.Contains(stdout+stderr+err.Error(), c.want) {
|
||||
t.Errorf("want substring %q in error; got stdout=%s stderr=%s err=%v", c.want, stdout, stderr, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -344,8 +349,13 @@ func TestResize_TypeAndSizeGuards(t *testing.T) {
|
||||
for _, tt := range cases {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
_, _, err := runShortcutCapturingErr(t, tt.sc, append(tt.args, "--dry-run"))
|
||||
requireValidation(t, err, tt.want)
|
||||
stdout, stderr, err := runShortcutCapturingErr(t, tt.sc, append(tt.args, "--dry-run"))
|
||||
if err == nil {
|
||||
t.Fatalf("expected validation error; stdout=%s stderr=%s", stdout, stderr)
|
||||
}
|
||||
if !strings.Contains(stdout+stderr+err.Error(), tt.want) {
|
||||
t.Errorf("expected %q; got=%s|%s|%v", tt.want, stdout, stderr, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,6 +5,8 @@ package sheets
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/csv"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
@@ -57,7 +59,7 @@ var CellsGet = common.Shortcut{
|
||||
return invokeToolDryRun(token, ToolKindRead, "get_cell_ranges", cellsGetInput(runtime, token, sheetID, sheetName))
|
||||
},
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
token, err := resolveSpreadsheetTokenExec(runtime)
|
||||
token, err := resolveSpreadsheetToken(runtime)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -150,7 +152,7 @@ var CsvGet = common.Shortcut{
|
||||
return invokeToolDryRun(token, ToolKindRead, "get_range_as_csv", csvGetInput(runtime, token, sheetID, sheetName))
|
||||
},
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
token, err := resolveSpreadsheetTokenExec(runtime)
|
||||
token, err := resolveSpreadsheetToken(runtime)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -162,7 +164,12 @@ var CsvGet = common.Shortcut{
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !runtime.Bool("include-row-prefix") {
|
||||
switch {
|
||||
case runtime.Bool("rows-json"):
|
||||
// --rows-json reshapes the CSV response into structured rows
|
||||
// ({row_number, values:{col→cell}}); see assembleRowsJSON.
|
||||
out = assembleRowsJSON(out, strings.TrimSpace(runtime.Str("range")))
|
||||
case !runtime.Bool("include-row-prefix"):
|
||||
out = stripRowPrefixFromCsvOutput(out)
|
||||
}
|
||||
runtime.Out(out, nil)
|
||||
@@ -212,6 +219,141 @@ func stripRowPrefixFromCsvOutput(out interface{}) interface{} {
|
||||
return m
|
||||
}
|
||||
|
||||
// rowPrefixRe matches the leading "[row=N] " (or "[row=N],") annotation that
|
||||
// the tool prepends to the first physical line of each logical CSV record.
|
||||
var rowPrefixRe = regexp.MustCompile(`^\[row=(\d+)\][ ,]?`)
|
||||
|
||||
// assembleRowsJSON reshapes the tool's annotated_csv string into structured
|
||||
// rows so callers never have to regex-parse "[row=N]" or RFC-4180 CSV by hand:
|
||||
//
|
||||
// {
|
||||
// "range": "A1:K3380",
|
||||
// "current_region": "...", // passthrough, if the tool returned it
|
||||
// "rows": [{"row_number":1,"values":{"A":"姓名", ..., "K":"时间差_分钟"}},
|
||||
// {"row_number":2,"values":{"A":"张三", ..., "K":"8.5"}}, ...]
|
||||
// }
|
||||
//
|
||||
// Every logical row is emitted, including the first — no row is assumed to be a
|
||||
// header, since sheet data is not always tabular. Each cell is keyed by its
|
||||
// column letter (from the tool's col_indices when present, else derived from the
|
||||
// requested range's start column). On any parsing trouble it returns the
|
||||
// original output unchanged.
|
||||
func assembleRowsJSON(out interface{}, requestedRange string) interface{} {
|
||||
m, ok := out.(map[string]interface{})
|
||||
if !ok {
|
||||
return out
|
||||
}
|
||||
csvStr, ok := m["annotated_csv"].(string)
|
||||
if !ok {
|
||||
return out
|
||||
}
|
||||
|
||||
// Group physical lines into logical records by [row=N] boundaries; lines
|
||||
// without a prefix are embedded-newline continuations of the current record.
|
||||
type logicalRow struct {
|
||||
num int
|
||||
text string
|
||||
}
|
||||
var groups []logicalRow
|
||||
for _, line := range strings.Split(csvStr, "\n") {
|
||||
if mm := rowPrefixRe.FindStringSubmatch(line); mm != nil {
|
||||
n, _ := strconv.Atoi(mm[1])
|
||||
groups = append(groups, logicalRow{num: n, text: line[len(mm[0]):]})
|
||||
} else if len(groups) > 0 {
|
||||
groups[len(groups)-1].text += "\n" + line
|
||||
}
|
||||
}
|
||||
if len(groups) == 0 {
|
||||
return out
|
||||
}
|
||||
|
||||
// Parse every logical row; widest row sets the column count. No row is
|
||||
// singled out as a header — that would assume the data is tabular, which it
|
||||
// often is not. The model reads row 1 like any other row and decides for
|
||||
// itself whether it is a header.
|
||||
parsed := make([][]string, len(groups))
|
||||
maxCols := 0
|
||||
for i, g := range groups {
|
||||
parsed[i] = parseCSVRecord(g.text)
|
||||
if len(parsed[i]) > maxCols {
|
||||
maxCols = len(parsed[i])
|
||||
}
|
||||
}
|
||||
if maxCols == 0 {
|
||||
return out
|
||||
}
|
||||
|
||||
// Column letters key each cell. Prefer the tool's col_indices (authoritative,
|
||||
// length == col_count); otherwise derive from the requested range's start col.
|
||||
letters := coerceStringSlice(m["col_indices"])
|
||||
if len(letters) < maxCols {
|
||||
start := csvStartColIndex(requestedRange)
|
||||
letters = make([]string, maxCols)
|
||||
for j := 0; j < maxCols; j++ {
|
||||
letters[j] = csvColLetter(start + j)
|
||||
}
|
||||
}
|
||||
|
||||
rows := make([]map[string]interface{}, 0, len(groups))
|
||||
for i := range groups {
|
||||
fields := parsed[i]
|
||||
values := make(map[string]interface{}, len(letters))
|
||||
for j := range letters {
|
||||
v := ""
|
||||
if j < len(fields) {
|
||||
v = fields[j]
|
||||
}
|
||||
values[letters[j]] = v
|
||||
}
|
||||
rows = append(rows, map[string]interface{}{
|
||||
"row_number": groups[i].num,
|
||||
"values": values,
|
||||
})
|
||||
}
|
||||
|
||||
result := map[string]interface{}{}
|
||||
for k, v := range m {
|
||||
result[k] = v
|
||||
}
|
||||
result["range"] = requestedRange
|
||||
result["rows"] = rows
|
||||
|
||||
// Surface the backend's "数据没读全" signal structurally instead of leaving it
|
||||
// buried in warning_message prose. The tool flags it when current_region (the
|
||||
// true data extent) reaches past actual_range (what was actually read) — the
|
||||
// single most important anti-under-read hint. Mirror that same comparison
|
||||
// (regionEndRow > actualEndRow) from the already-passthrough A1 ranges so the
|
||||
// model gets the real data range as a first-class field, never having to
|
||||
// parse it out of prose.
|
||||
if cr, _ := m["current_region"].(string); cr != "" {
|
||||
ar, _ := m["actual_range"].(string)
|
||||
regionEnd := a1EndRow(cr)
|
||||
readEnd := a1EndRow(ar)
|
||||
if regionEnd > 0 && readEnd > 0 && regionEnd > readEnd {
|
||||
result["data_not_fully_read"] = map[string]interface{}{
|
||||
"read_through_row": readEnd,
|
||||
"data_extends_through_row": regionEnd,
|
||||
"unread_rows": regionEnd - readEnd,
|
||||
"reread_range": cr,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Drop the fields whose information rows-json fully carries elsewhere:
|
||||
// - annotated_csv / row_indices / col_indices → reconstructed into
|
||||
// columns + rows (with integer row_number), losslessly.
|
||||
// - warning_message → its two halves are both handled: the static
|
||||
// "[row=N] / col_indices[j]" parse nag is moot once those fields exist,
|
||||
// and the dynamic "数据没读全" half is now the structured
|
||||
// data_not_fully_read field above. (Confirmed against the backend's
|
||||
// get-range-as-csv.ts — warning_message has no other content.)
|
||||
delete(result, "annotated_csv")
|
||||
delete(result, "row_indices")
|
||||
delete(result, "col_indices")
|
||||
delete(result, "warning_message")
|
||||
return result
|
||||
}
|
||||
|
||||
// a1EndRow extracts the ending row number from an A1 range, e.g. "A1:N51" → 51,
|
||||
// "Sheet1!B2:D9" → 9, "C5" → 5. Returns 0 when no row number is present.
|
||||
func a1EndRow(rng string) int {
|
||||
@@ -235,6 +377,89 @@ func a1EndRow(rng string) int {
|
||||
return n
|
||||
}
|
||||
|
||||
// parseCSVRecord parses a single logical CSV record (which may span multiple
|
||||
// physical lines via quoted embedded newlines) into its fields. An empty record
|
||||
// yields no fields; a malformed record falls back to a naive comma split so a
|
||||
// stray quote never drops a whole row.
|
||||
func parseCSVRecord(text string) []string {
|
||||
if strings.TrimSpace(text) == "" {
|
||||
return nil
|
||||
}
|
||||
r := csv.NewReader(strings.NewReader(text))
|
||||
r.FieldsPerRecord = -1
|
||||
fields, err := r.Read()
|
||||
if err != nil {
|
||||
return strings.Split(text, ",")
|
||||
}
|
||||
return fields
|
||||
}
|
||||
|
||||
// coerceStringSlice returns v as []string when it is a homogeneous []interface{}
|
||||
// of strings (the shape of the tool's col_indices), else nil.
|
||||
func coerceStringSlice(v interface{}) []string {
|
||||
arr, ok := v.([]interface{})
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
out := make([]string, 0, len(arr))
|
||||
for _, e := range arr {
|
||||
s, ok := e.(string)
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
out = append(out, s)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// csvStartColIndex returns the 0-based column index of a range's start column,
|
||||
// e.g. "A1:K3380" → 0, "C5:F9" → 2, "Sheet1!D2" → 3. Unparseable input → 0.
|
||||
func csvStartColIndex(rng string) int {
|
||||
rng = strings.TrimSpace(rng)
|
||||
if i := strings.LastIndex(rng, "!"); i >= 0 {
|
||||
rng = rng[i+1:]
|
||||
}
|
||||
var letters strings.Builder
|
||||
for _, c := range rng {
|
||||
if (c >= 'A' && c <= 'Z') || (c >= 'a' && c <= 'z') {
|
||||
letters.WriteRune(c)
|
||||
continue
|
||||
}
|
||||
break
|
||||
}
|
||||
if letters.Len() == 0 {
|
||||
return 0
|
||||
}
|
||||
return csvColToIndex(letters.String())
|
||||
}
|
||||
|
||||
// csvColToIndex converts a column letter to its 0-based index ("A"→0, "K"→10,
|
||||
// "AA"→26). Non-letter input → -1.
|
||||
func csvColToIndex(s string) int {
|
||||
n := 0
|
||||
for _, c := range strings.ToUpper(s) {
|
||||
if c < 'A' || c > 'Z' {
|
||||
break
|
||||
}
|
||||
n = n*26 + int(c-'A'+1)
|
||||
}
|
||||
return n - 1
|
||||
}
|
||||
|
||||
// csvColLetter converts a 0-based column index back to its letter (0→"A",
|
||||
// 25→"Z", 26→"AA"). Negative input → "".
|
||||
func csvColLetter(idx int) string {
|
||||
if idx < 0 {
|
||||
return ""
|
||||
}
|
||||
var b []byte
|
||||
for idx >= 0 {
|
||||
b = append([]byte{byte('A' + idx%26)}, b...)
|
||||
idx = idx/26 - 1
|
||||
}
|
||||
return string(b)
|
||||
}
|
||||
|
||||
// DropdownGet wraps get_cell_ranges scoped to data_validation: read the
|
||||
// dropdown configuration on a range. Aligned with its sibling +cells-get
|
||||
// — sheet selection is via --sheet-id / --sheet-name (XOR), and --range
|
||||
@@ -269,7 +494,7 @@ var DropdownGet = common.Shortcut{
|
||||
return invokeToolDryRun(token, ToolKindRead, "get_cell_ranges", dropdownGetInput(runtime, token, sheetID, sheetName))
|
||||
},
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
token, err := resolveSpreadsheetTokenExec(runtime)
|
||||
token, err := resolveSpreadsheetToken(runtime)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -63,6 +63,20 @@ func TestReadDataShortcuts_DryRun(t *testing.T) {
|
||||
"value_render_option": "formatted_value",
|
||||
},
|
||||
},
|
||||
{
|
||||
// --rows-json is post-processing on +csv-get's response; it must
|
||||
// NOT leak into the get_range_as_csv input.
|
||||
name: "+csv-get --rows-json builds the same input (flag is post-process)",
|
||||
sc: CsvGet,
|
||||
args: []string{"--url", testURL, "--sheet-id", testSheetID, "--range", "A1:C10", "--rows-json"},
|
||||
toolName: "get_range_as_csv",
|
||||
wantInput: map[string]interface{}{
|
||||
"excel_id": testToken,
|
||||
"sheet_id": testSheetID,
|
||||
"range": "A1:C10",
|
||||
"max_rows": float64(unboundedReadLimit),
|
||||
},
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
@@ -81,12 +95,15 @@ func TestReadDataShortcuts_DryRun(t *testing.T) {
|
||||
// every other get_cell_ranges wrapper uses.
|
||||
func TestDropdownGet_RequiresSheetSelector(t *testing.T) {
|
||||
t.Parallel()
|
||||
_, _, err := runShortcutCapturingErr(t, DropdownGet, []string{
|
||||
stdout, stderr, err := runShortcutCapturingErr(t, DropdownGet, []string{
|
||||
"--url", testURL, "--range", "A2:A100", "--dry-run",
|
||||
})
|
||||
ve := requireValidation(t, err, "")
|
||||
if !strings.Contains(ve.Message, "sheet-id") && !strings.Contains(ve.Message, "sheet-name") {
|
||||
t.Errorf("expected --sheet-id/--sheet-name guard; got message=%q", ve.Message)
|
||||
if err == nil {
|
||||
t.Fatalf("expected validation error; stdout=%s stderr=%s", stdout, stderr)
|
||||
}
|
||||
combined := stdout + stderr + err.Error()
|
||||
if !strings.Contains(combined, "sheet-id") && !strings.Contains(combined, "sheet-name") {
|
||||
t.Errorf("expected --sheet-id/--sheet-name guard; got=%s|%s|%v", stdout, stderr, err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -106,10 +123,15 @@ func TestReadData_RequiresRange(t *testing.T) {
|
||||
for _, c := range cases {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
_, _, err := runShortcutCapturingErr(t, c.sc, []string{
|
||||
stdout, stderr, err := runShortcutCapturingErr(t, c.sc, []string{
|
||||
"--url", testURL, "--sheet-id", testSheetID, "--range", " ", "--dry-run",
|
||||
})
|
||||
requireValidation(t, err, "--range is required")
|
||||
if err == nil {
|
||||
t.Fatalf("expected validation error; stdout=%s stderr=%s", stdout, stderr)
|
||||
}
|
||||
if !strings.Contains(stdout+stderr+err.Error(), "--range is required") {
|
||||
t.Errorf("expected --range guard; got=%s|%s|%v", stdout, stderr, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -157,3 +179,113 @@ func TestCsvGet_StripRowPrefix(t *testing.T) {
|
||||
t.Errorf("other field corrupted: %v", out["other"])
|
||||
}
|
||||
}
|
||||
|
||||
// TestAssembleRowsJSON covers the --rows-json reshaping: every logical row
|
||||
// emitted (no header singled out), integer row_number, column-letter keyed
|
||||
// values, embedded newlines inside quoted fields, and current_region passthrough.
|
||||
func TestAssembleRowsJSON(t *testing.T) {
|
||||
t.Parallel()
|
||||
in := map[string]interface{}{
|
||||
"annotated_csv": "[row=1] 姓名,备注,时间差_分钟\n[row=2] 张三,\"line1\nline2\",8.5\n[row=3] 李四,ok,3",
|
||||
"current_region": "A1:C3",
|
||||
"col_indices": []interface{}{"A", "B", "C"},
|
||||
"row_indices": []interface{}{1, 2, 3},
|
||||
"warning_message": "①定位行号…②定位列字母…",
|
||||
}
|
||||
out, ok := assembleRowsJSON(in, "A1:C3").(map[string]interface{})
|
||||
if !ok {
|
||||
t.Fatalf("assembleRowsJSON did not return a map")
|
||||
}
|
||||
|
||||
// Fields whose info rows-json carries elsewhere are dropped (annotated_csv /
|
||||
// indices → rows; warning_message → moot static nag + structured
|
||||
// data_not_fully_read). Unrelated metadata like current_region is preserved.
|
||||
if _, exists := out["annotated_csv"]; exists {
|
||||
t.Errorf("annotated_csv should be dropped")
|
||||
}
|
||||
if _, exists := out["col_indices"]; exists {
|
||||
t.Errorf("col_indices should be dropped")
|
||||
}
|
||||
if _, exists := out["warning_message"]; exists {
|
||||
t.Errorf("warning_message should be dropped in rows-json mode")
|
||||
}
|
||||
if _, exists := out["columns"]; exists {
|
||||
t.Errorf("columns field should not exist (no header assumption)")
|
||||
}
|
||||
if out["current_region"] != "A1:C3" {
|
||||
t.Errorf("current_region passthrough lost: %v", out["current_region"])
|
||||
}
|
||||
|
||||
rows, _ := out["rows"].([]map[string]interface{})
|
||||
if len(rows) != 3 {
|
||||
t.Fatalf("want all 3 rows (incl. row 1), got %d: %+v", len(rows), rows)
|
||||
}
|
||||
// Row 1 is emitted as a normal row, not consumed as a header.
|
||||
if rows[0]["row_number"].(int) != 1 {
|
||||
t.Errorf("first row_number = %v, want 1", rows[0]["row_number"])
|
||||
}
|
||||
if v := rows[0]["values"].(map[string]interface{}); v["A"] != "姓名" || v["C"] != "时间差_分钟" {
|
||||
t.Errorf("row 1 values wrong: %+v", v)
|
||||
}
|
||||
// Row 2 keeps its embedded newline inside a single cell.
|
||||
v1 := rows[1]["values"].(map[string]interface{})
|
||||
if rows[1]["row_number"].(int) != 2 || v1["A"] != "张三" || v1["B"] != "line1\nline2" || v1["C"] != "8.5" {
|
||||
t.Errorf("row 2 wrong (embedded newline?): %+v", rows[1])
|
||||
}
|
||||
}
|
||||
|
||||
// TestAssembleRowsJSON_DerivedLetters verifies cell letters are derived from the
|
||||
// range start when the tool omits col_indices (e.g. a C-anchored read).
|
||||
func TestAssembleRowsJSON_DerivedLetters(t *testing.T) {
|
||||
t.Parallel()
|
||||
in := map[string]interface{}{
|
||||
"annotated_csv": "[row=5] h1,h2\n[row=6] a,b",
|
||||
}
|
||||
out := assembleRowsJSON(in, "C5:D6").(map[string]interface{})
|
||||
rows := out["rows"].([]map[string]interface{})
|
||||
if len(rows) != 2 {
|
||||
t.Fatalf("want 2 rows, got %d", len(rows))
|
||||
}
|
||||
if rows[0]["row_number"].(int) != 5 {
|
||||
t.Errorf("first row_number = %v, want 5", rows[0]["row_number"])
|
||||
}
|
||||
if v := rows[0]["values"].(map[string]interface{}); v["C"] != "h1" || v["D"] != "h2" {
|
||||
t.Errorf("derived-letter values wrong: %+v", v)
|
||||
}
|
||||
if v := rows[1]["values"].(map[string]interface{}); v["C"] != "a" || v["D"] != "b" {
|
||||
t.Errorf("row 6 values wrong: %+v", v)
|
||||
}
|
||||
}
|
||||
|
||||
// TestAssembleRowsJSON_DataNotFullyRead verifies the structured under-read hint:
|
||||
// when current_region extends past actual_range, rows-json surfaces the true data
|
||||
// range as a first-class field (mirroring the backend's prose warning).
|
||||
func TestAssembleRowsJSON_DataNotFullyRead(t *testing.T) {
|
||||
t.Parallel()
|
||||
// Read only A1:D2, but the data region reaches D4 → 2 rows unread.
|
||||
in := map[string]interface{}{
|
||||
"annotated_csv": "[row=1] 序号,姓名\n[row=2] 101,张三",
|
||||
"actual_range": "A1:D2",
|
||||
"current_region": "A1:D4",
|
||||
}
|
||||
out := assembleRowsJSON(in, "A1:D2").(map[string]interface{})
|
||||
hint, ok := out["data_not_fully_read"].(map[string]interface{})
|
||||
if !ok {
|
||||
t.Fatalf("data_not_fully_read missing; out=%+v", out)
|
||||
}
|
||||
if hint["read_through_row"] != 2 || hint["data_extends_through_row"] != 4 ||
|
||||
hint["unread_rows"] != 2 || hint["reread_range"] != "A1:D4" {
|
||||
t.Errorf("data_not_fully_read wrong: %+v", hint)
|
||||
}
|
||||
|
||||
// Fully-read case: no hint emitted.
|
||||
in2 := map[string]interface{}{
|
||||
"annotated_csv": "[row=1] 序号,姓名\n[row=2] 101,张三",
|
||||
"actual_range": "A1:D2",
|
||||
"current_region": "A1:D2",
|
||||
}
|
||||
out2 := assembleRowsJSON(in2, "A1:D2").(map[string]interface{})
|
||||
if _, exists := out2["data_not_fully_read"]; exists {
|
||||
t.Errorf("data_not_fully_read should be absent when fully read")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -46,7 +46,7 @@ var CellsSearch = common.Shortcut{
|
||||
return invokeToolDryRun(token, ToolKindRead, "search_data", searchInput(runtime, token, sheetID, sheetName))
|
||||
},
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
token, err := resolveSpreadsheetTokenExec(runtime)
|
||||
token, err := resolveSpreadsheetToken(runtime)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -122,7 +122,7 @@ var CellsReplace = common.Shortcut{
|
||||
return invokeToolDryRun(token, ToolKindWrite, "replace_data", input)
|
||||
},
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
token, err := resolveSpreadsheetTokenExec(runtime)
|
||||
token, err := resolveSpreadsheetToken(runtime)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -89,17 +89,14 @@ func TestSearchReplaceShortcuts_DryRun(t *testing.T) {
|
||||
|
||||
func TestCellsReplace_RequireFlag(t *testing.T) {
|
||||
t.Parallel()
|
||||
// --replace not passed at all (vs empty string) should error. This trips
|
||||
// cobra's required-flag gate before our Validate hook runs, so the error
|
||||
// is cobra's plain `required flag(s) "replacement" not set` rather than a
|
||||
// typed *errs.ValidationError — keep this assertion as a substring check.
|
||||
// --replace not passed at all (vs empty string) should error.
|
||||
stdout, stderr, err := runShortcutCapturingErr(t, CellsReplace, []string{
|
||||
"--url", testURL, "--sheet-id", testSheetID, "--find", "foo", "--dry-run",
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatalf("expected error when --replacement omitted; stdout=%s stderr=%s", stdout, stderr)
|
||||
t.Fatalf("expected error when --replace omitted; stdout=%s stderr=%s", stdout, stderr)
|
||||
}
|
||||
if !strings.Contains(err.Error(), "replacement") {
|
||||
t.Errorf("expected message about --replacement; got=%s|%s|%v", stdout, stderr, err)
|
||||
if !strings.Contains(stdout+stderr+err.Error(), "replace") {
|
||||
t.Errorf("expected message about --replace; got=%s|%s|%v", stdout, stderr, err)
|
||||
}
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user