bf9395e022
CI / results (push) Blocked by required conditions
CI / license-header (push) Has been skipped
CI / e2e-dry-run (push) Has been skipped
CI / e2e-live (push) Waiting to run
CI / fast-gate (push) Failing after 0s
Test PR Label Logic / test-pr-labels (push) Failing after 1s
Skill Format Check / check-format (push) Failing after 2s
CI / security (push) Failing after 5s
CI / unit-test (push) Has been skipped
CI / lint (push) Has been skipped
CI / script-test (push) Has been skipped
CI / deterministic-gate (push) Has been skipped
CI / coverage (push) Has been skipped
CI / deadcode (push) Waiting to run
367 lines
13 KiB
Go
367 lines
13 KiB
Go
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
|
// SPDX-License-Identifier: MIT
|
|
|
|
package sheets
|
|
|
|
import (
|
|
"bytes"
|
|
"encoding/json"
|
|
"errors"
|
|
"strings"
|
|
"testing"
|
|
|
|
"github.com/spf13/cobra"
|
|
|
|
"github.com/larksuite/cli/errs"
|
|
"github.com/larksuite/cli/internal/cmdutil"
|
|
"github.com/larksuite/cli/internal/core"
|
|
"github.com/larksuite/cli/internal/httpmock"
|
|
"github.com/larksuite/cli/shortcuts/common"
|
|
)
|
|
|
|
// testConfig returns a CliConfig wired with a stable user identity. Tests
|
|
// keep the AppID test-prefixed so logs / metrics can spot them.
|
|
func testConfig(t *testing.T) *core.CliConfig {
|
|
t.Helper()
|
|
replacer := strings.NewReplacer("/", "-", " ", "-")
|
|
suffix := replacer.Replace(strings.ToLower(t.Name()))
|
|
return &core.CliConfig{
|
|
AppID: "test-sheets-" + suffix,
|
|
AppSecret: "secret-sheets-" + suffix,
|
|
Brand: core.BrandFeishu,
|
|
UserOpenId: "ou_test_user",
|
|
}
|
|
}
|
|
|
|
// newTestRig spins up a Factory wired with httpmock + the given shortcut
|
|
// mounted into a "sheets" parent command. Returns the cobra.Command ready
|
|
// to SetArgs / Execute, plus the stdout / stderr buffers and the registry.
|
|
func newTestRig(t *testing.T, sc common.Shortcut) (*cobra.Command, *bytes.Buffer, *bytes.Buffer, *httpmock.Registry) {
|
|
t.Helper()
|
|
f, stdout, stderr, reg := cmdutil.TestFactory(t, testConfig(t))
|
|
parent := &cobra.Command{Use: "sheets"}
|
|
sc.Mount(parent, f)
|
|
parent.SilenceErrors = true
|
|
parent.SilenceUsage = true
|
|
return parent, stdout, stderr, reg
|
|
}
|
|
|
|
// runShortcut executes the shortcut with the given args and returns the
|
|
// captured stdout text. Mirrors the legacy package's parent.Execute()
|
|
// flow so test cases stay close to real CLI behavior.
|
|
func runShortcut(t *testing.T, sc common.Shortcut, args []string) (string, error) {
|
|
t.Helper()
|
|
parent, stdout, _, _ := newTestRig(t, sc)
|
|
parent.SetArgs(append([]string{sc.Command}, args...))
|
|
err := parent.Execute()
|
|
return stdout.String(), err
|
|
}
|
|
|
|
// runShortcutCapturingErr is runShortcut but also returns the stderr text
|
|
// so validation tests can inspect error envelopes.
|
|
func runShortcutCapturingErr(t *testing.T, sc common.Shortcut, args []string) (stdoutStr, stderrStr string, err error) {
|
|
t.Helper()
|
|
parent, stdout, stderr, _ := newTestRig(t, sc)
|
|
parent.SetArgs(append([]string{sc.Command}, args...))
|
|
err = parent.Execute()
|
|
return stdout.String(), stderr.String(), err
|
|
}
|
|
|
|
// runShortcutWithStubs is runShortcut + a slice of httpmock stubs.
|
|
// Stubs are registered before execute so the recorded API calls are
|
|
// served from the registry instead of touching the network.
|
|
func runShortcutWithStubs(t *testing.T, sc common.Shortcut, args []string, stubs ...*httpmock.Stub) (string, error) {
|
|
t.Helper()
|
|
parent, stdout, _, reg := newTestRig(t, sc)
|
|
for _, s := range stubs {
|
|
reg.Register(s)
|
|
}
|
|
parent.SetArgs(append([]string{sc.Command}, args...))
|
|
err := parent.Execute()
|
|
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()
|
|
|
|
t.Run("missing sheet selector reports both params", func(t *testing.T) {
|
|
t.Parallel()
|
|
err := requireSheetSelector("", "")
|
|
var validationErr *errs.ValidationError
|
|
if !errors.As(err, &validationErr) {
|
|
t.Fatalf("error = %T %v, want *errs.ValidationError", err, err)
|
|
}
|
|
if len(validationErr.Params) != 2 {
|
|
t.Fatalf("params = %#v, want two structured params", validationErr.Params)
|
|
}
|
|
if validationErr.Params[0].Name != "--sheet-id" || validationErr.Params[1].Name != "--sheet-name" {
|
|
t.Fatalf("params = %#v, want --sheet-id/--sheet-name", validationErr.Params)
|
|
}
|
|
})
|
|
|
|
t.Run("spreadsheet url shape reports url param", func(t *testing.T) {
|
|
t.Parallel()
|
|
cmd := &cobra.Command{Use: "sheets"}
|
|
cmd.Flags().String("url", "not-a-sheet-url", "")
|
|
cmd.Flags().String("spreadsheet-token", "", "")
|
|
_, err := resolveSpreadsheetToken(common.TestNewRuntimeContext(cmd, testConfig(t)))
|
|
var validationErr *errs.ValidationError
|
|
if !errors.As(err, &validationErr) {
|
|
t.Fatalf("error = %T %v, want *errs.ValidationError", err, err)
|
|
}
|
|
if validationErr.Param != "--url" {
|
|
t.Fatalf("param = %q, want --url", validationErr.Param)
|
|
}
|
|
})
|
|
|
|
t.Run("sheet selector control char keeps param and cause", func(t *testing.T) {
|
|
t.Parallel()
|
|
err := requireSheetSelector("bad\x00id", "")
|
|
var validationErr *errs.ValidationError
|
|
if !errors.As(err, &validationErr) {
|
|
t.Fatalf("error = %T %v, want *errs.ValidationError", err, err)
|
|
}
|
|
if validationErr.Param != "--sheet-id" {
|
|
t.Fatalf("param = %q, want --sheet-id", validationErr.Param)
|
|
}
|
|
if validationErr.Unwrap() == nil {
|
|
t.Fatalf("expected control-char validation cause to be preserved")
|
|
}
|
|
})
|
|
|
|
t.Run("invalid json flag keeps param and cause", func(t *testing.T) {
|
|
t.Parallel()
|
|
fv := newMapFlagViewForCommand("+cells-set", map[string]interface{}{"cells": "{"})
|
|
_, err := parseJSONFlag(fv, "cells")
|
|
var validationErr *errs.ValidationError
|
|
if !errors.As(err, &validationErr) {
|
|
t.Fatalf("error = %T %v, want *errs.ValidationError", err, err)
|
|
}
|
|
if validationErr.Param != "--cells" {
|
|
t.Fatalf("param = %q, want --cells", validationErr.Param)
|
|
}
|
|
if validationErr.Unwrap() == nil {
|
|
t.Fatalf("expected JSON parse cause to be preserved")
|
|
}
|
|
})
|
|
}
|
|
|
|
// parseDryRunBody runs the shortcut in --dry-run and returns the first
|
|
// api call's body. The dry-run output format is:
|
|
//
|
|
// === Dry Run ===
|
|
// { "api": [{...}], ... }
|
|
//
|
|
// Tests use this to assert the One-OpenAPI wire body is constructed
|
|
// correctly without exercising the real endpoint.
|
|
func parseDryRunBody(t *testing.T, sc common.Shortcut, args []string) map[string]interface{} {
|
|
t.Helper()
|
|
out, err := runShortcut(t, sc, append(args, "--dry-run"))
|
|
if err != nil {
|
|
t.Fatalf("dry-run failed: %v\noutput=%s", err, out)
|
|
}
|
|
return decodeDryRunFirstCall(t, out)
|
|
}
|
|
|
|
// parseDryRunAPI returns the full list of `api` entries from a dry-run
|
|
// output — used by shortcuts that emit multiple calls (e.g.
|
|
// +workbook-export, +cells-set-image, +cells-batch-set-style).
|
|
func parseDryRunAPI(t *testing.T, sc common.Shortcut, args []string) []interface{} {
|
|
t.Helper()
|
|
out, err := runShortcut(t, sc, append(args, "--dry-run"))
|
|
if err != nil {
|
|
t.Fatalf("dry-run failed: %v\noutput=%s", err, out)
|
|
}
|
|
dryRun := decodeDryRunRaw(t, out)
|
|
calls, _ := dryRun["api"].([]interface{})
|
|
return calls
|
|
}
|
|
|
|
func decodeDryRunRaw(t *testing.T, out string) map[string]interface{} {
|
|
t.Helper()
|
|
idx := strings.Index(out, "{")
|
|
if idx < 0 {
|
|
t.Fatalf("dry-run output has no JSON body:\n%s", out)
|
|
}
|
|
var m map[string]interface{}
|
|
if err := json.Unmarshal([]byte(out[idx:]), &m); err != nil {
|
|
t.Fatalf("failed to parse dry-run JSON: %v\nraw=%s", err, out)
|
|
}
|
|
return m
|
|
}
|
|
|
|
func decodeDryRunFirstCall(t *testing.T, out string) map[string]interface{} {
|
|
t.Helper()
|
|
dryRun := decodeDryRunRaw(t, out)
|
|
calls, ok := dryRun["api"].([]interface{})
|
|
if !ok || len(calls) == 0 {
|
|
t.Fatalf("dry-run api array empty or wrong shape: %#v", dryRun)
|
|
}
|
|
call, _ := calls[0].(map[string]interface{})
|
|
body, _ := call["body"].(map[string]interface{})
|
|
if body == nil {
|
|
t.Fatalf("dry-run first call has no body: %#v", call)
|
|
}
|
|
return body
|
|
}
|
|
|
|
// decodeToolInput parses the JSON-string `input` field embedded in a
|
|
// dry-run body whose tool_name matches `expected`. Returns the decoded
|
|
// tool input map so tests can assert on specific input fields.
|
|
func decodeToolInput(t *testing.T, body map[string]interface{}, expectedToolName string) map[string]interface{} {
|
|
t.Helper()
|
|
if got, _ := body["tool_name"].(string); got != expectedToolName {
|
|
t.Fatalf("tool_name = %q, want %q", got, expectedToolName)
|
|
}
|
|
rawInput, _ := body["input"].(string)
|
|
if rawInput == "" {
|
|
t.Fatalf("body.input is empty: %#v", body)
|
|
}
|
|
var input map[string]interface{}
|
|
if err := json.Unmarshal([]byte(rawInput), &input); err != nil {
|
|
t.Fatalf("failed to parse tool input JSON: %v\nraw=%s", err, rawInput)
|
|
}
|
|
return input
|
|
}
|
|
|
|
// decodeEnvelopeData parses a successful envelope's data field — used by
|
|
// execute-path tests that go through the full callTool stack with stubs.
|
|
func decodeEnvelopeData(t *testing.T, out string) map[string]interface{} {
|
|
t.Helper()
|
|
var envelope map[string]interface{}
|
|
if err := json.Unmarshal([]byte(out), &envelope); err != nil {
|
|
t.Fatalf("failed to decode envelope: %v\nraw=%s", err, out)
|
|
}
|
|
if ok, _ := envelope["ok"].(bool); !ok {
|
|
t.Fatalf("envelope.ok=false: %#v", envelope)
|
|
}
|
|
data, _ := envelope["data"].(map[string]interface{})
|
|
return data
|
|
}
|
|
|
|
// toolOutputStub builds an httpmock stub for the One-OpenAPI invoke_read
|
|
// or invoke_write endpoint. `outputJSON` is the JSON string the tool
|
|
// returns in data.output.
|
|
func toolOutputStub(token, kind string, outputJSON string) *httpmock.Stub {
|
|
suffix := "invoke_read"
|
|
if kind == "write" {
|
|
suffix = "invoke_write"
|
|
}
|
|
return &httpmock.Stub{
|
|
Method: "POST",
|
|
URL: "/open-apis/sheet_ai/v2/spreadsheets/" + token + "/tools/" + suffix,
|
|
Body: map[string]interface{}{
|
|
"code": 0,
|
|
"msg": "success",
|
|
"data": map[string]interface{}{
|
|
"output": outputJSON,
|
|
},
|
|
},
|
|
}
|
|
}
|
|
|
|
// commonArgsURL is the typical --url and --sheet-id pair used by sheet-
|
|
// level tests.
|
|
const (
|
|
testToken = "shtcnTestTOK"
|
|
testURL = "https://example.feishu.cn/sheets/shtcnTestTOK"
|
|
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)
|
|
}
|
|
})
|
|
}
|
|
}
|