From 91c28d75de49e645ab8c7a5683ae9dd893a064a7 Mon Sep 17 00:00:00 2001 From: yxzhaao Date: Fri, 10 Apr 2026 18:04:26 +0800 Subject: [PATCH 1/7] feat: add stable bot-only cli e2e subset Change-Id: I62edf59d179e407954f65f82e94cf5dcf4938080 --- .../cli_e2e/base/base_basic_workflow_test.go | 86 +++ tests/cli_e2e/base/base_role_workflow_test.go | 130 +++++ tests/cli_e2e/base/helpers_test.go | 523 ++++++++++++++++++ .../calendar/calendar_create_event_test.go | 108 ++++ .../calendar_find_meeting_time_test.go | 49 ++ .../calendar/calendar_manage_calendar_test.go | 100 ++++ .../calendar/calendar_view_agenda_test.go | 48 ++ tests/cli_e2e/calendar/helpers_test.go | 74 +++ .../cli_e2e/contact/contact_shortcut_test.go | 51 ++ tests/cli_e2e/docs/docs_create_fetch_test.go | 62 +++ tests/cli_e2e/docs/docs_update_test.go | 81 +++ .../drive/drive_files_workflow_test.go | 48 ++ .../cli_e2e/drive/drive_move_workflow_test.go | 57 ++ .../drive_permission_user_workflow_test.go | 111 ++++ tests/cli_e2e/drive/helpers_test.go | 117 ++++ tests/cli_e2e/im/chat_workflow_test.go | 196 +++++++ tests/cli_e2e/im/helpers_test.go | 172 ++++++ tests/cli_e2e/im/message_workflow_test.go | 82 +++ .../sheets/sheets_crud_workflow_test.go | 237 ++++++++ .../sheets/sheets_filter_workflow_test.go | 268 +++++++++ tests/cli_e2e/wiki/helpers_test.go | 73 +++ tests/cli_e2e/wiki/wiki_workflow_test.go | 195 +++++++ 22 files changed, 2868 insertions(+) create mode 100644 tests/cli_e2e/base/base_basic_workflow_test.go create mode 100644 tests/cli_e2e/base/base_role_workflow_test.go create mode 100644 tests/cli_e2e/base/helpers_test.go create mode 100644 tests/cli_e2e/calendar/calendar_create_event_test.go create mode 100644 tests/cli_e2e/calendar/calendar_find_meeting_time_test.go create mode 100644 tests/cli_e2e/calendar/calendar_manage_calendar_test.go create mode 100644 tests/cli_e2e/calendar/calendar_view_agenda_test.go create mode 100644 tests/cli_e2e/calendar/helpers_test.go create mode 100644 tests/cli_e2e/contact/contact_shortcut_test.go create mode 100644 tests/cli_e2e/docs/docs_create_fetch_test.go create mode 100644 tests/cli_e2e/docs/docs_update_test.go create mode 100644 tests/cli_e2e/drive/drive_files_workflow_test.go create mode 100644 tests/cli_e2e/drive/drive_move_workflow_test.go create mode 100644 tests/cli_e2e/drive/drive_permission_user_workflow_test.go create mode 100644 tests/cli_e2e/drive/helpers_test.go create mode 100644 tests/cli_e2e/im/chat_workflow_test.go create mode 100644 tests/cli_e2e/im/helpers_test.go create mode 100644 tests/cli_e2e/im/message_workflow_test.go create mode 100644 tests/cli_e2e/sheets/sheets_crud_workflow_test.go create mode 100644 tests/cli_e2e/sheets/sheets_filter_workflow_test.go create mode 100644 tests/cli_e2e/wiki/helpers_test.go create mode 100644 tests/cli_e2e/wiki/wiki_workflow_test.go diff --git a/tests/cli_e2e/base/base_basic_workflow_test.go b/tests/cli_e2e/base/base_basic_workflow_test.go new file mode 100644 index 00000000..a7537898 --- /dev/null +++ b/tests/cli_e2e/base/base_basic_workflow_test.go @@ -0,0 +1,86 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package base + +import ( + "context" + "testing" + "time" + + clie2e "github.com/larksuite/cli/tests/cli_e2e" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "github.com/tidwall/gjson" +) + +func TestBase_BasicWorkflow(t *testing.T) { + parentT := t + ctx, cancel := context.WithTimeout(context.Background(), 4*time.Minute) + t.Cleanup(cancel) + + baseName := "lark-cli-e2e-base-basic-" + testSuffix() + baseToken := createBase(t, ctx, baseName) + + t.Run("get base", func(t *testing.T) { + result, err := clie2e.RunCmd(ctx, clie2e.Request{ + Args: []string{"base", "+base-get", "--base-token", baseToken}, + DefaultAs: "bot", + }) + require.NoError(t, err) + if result.ExitCode != 0 { + skipIfBaseUnavailable(t, result, "requires bot base get capability") + } + result.AssertExitCode(t, 0) + result.AssertStdoutStatus(t, true) + returnedBaseToken := gjson.Get(result.Stdout, "data.base.app_token").String() + if returnedBaseToken == "" { + returnedBaseToken = gjson.Get(result.Stdout, "data.base.base_token").String() + } + assert.Equal(t, baseToken, returnedBaseToken, "stdout:\n%s", result.Stdout) + assert.NotEmpty(t, gjson.Get(result.Stdout, "data.base.name").String(), "stdout:\n%s", result.Stdout) + }) + + tableName := "lark-cli-e2e-table-basic-" + testSuffix() + tableID, primaryFieldID, primaryViewID := createTable( + t, + parentT, + ctx, + baseToken, + tableName, + `[{"name":"Name","type":"text"}]`, + `{"name":"Main","type":"grid"}`, + ) + + t.Run("get table", func(t *testing.T) { + result, err := clie2e.RunCmd(ctx, clie2e.Request{ + Args: []string{"base", "+table-get", "--base-token", baseToken, "--table-id", tableID}, + DefaultAs: "bot", + }) + require.NoError(t, err) + if result.ExitCode != 0 { + skipIfBaseUnavailable(t, result, "requires bot table get capability") + } + result.AssertExitCode(t, 0) + result.AssertStdoutStatus(t, true) + assert.Equal(t, tableID, gjson.Get(result.Stdout, "data.table.id").String()) + assert.Equal(t, tableName, gjson.Get(result.Stdout, "data.table.name").String()) + }) + + t.Run("list tables and find created table", func(t *testing.T) { + result, err := clie2e.RunCmd(ctx, clie2e.Request{ + Args: []string{"base", "+table-list", "--base-token", baseToken}, + DefaultAs: "bot", + }) + require.NoError(t, err) + if result.ExitCode != 0 { + skipIfBaseUnavailable(t, result, "requires bot table list capability") + } + result.AssertExitCode(t, 0) + result.AssertStdoutStatus(t, true) + assert.True(t, gjson.Get(result.Stdout, `data.items.#(table_id=="`+tableID+`")`).Exists(), "stdout:\n%s", result.Stdout) + }) + + require.NotEmpty(t, primaryFieldID) + require.NotEmpty(t, primaryViewID) +} diff --git a/tests/cli_e2e/base/base_role_workflow_test.go b/tests/cli_e2e/base/base_role_workflow_test.go new file mode 100644 index 00000000..ec5fba14 --- /dev/null +++ b/tests/cli_e2e/base/base_role_workflow_test.go @@ -0,0 +1,130 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package base + +import ( + "context" + "testing" + "time" + + clie2e "github.com/larksuite/cli/tests/cli_e2e" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "github.com/tidwall/gjson" +) + +func TestBase_RoleWorkflow(t *testing.T) { + parentT := t + ctx, cancel := context.WithTimeout(context.Background(), 4*time.Minute) + t.Cleanup(cancel) + + baseToken := createBase(t, ctx, "lark-cli-e2e-base-role-"+testSuffix()) + result, err := clie2e.RunCmd(ctx, clie2e.Request{ + Args: []string{"base", "+advperm-enable", "--base-token", baseToken}, + DefaultAs: "bot", + }) + require.NoError(t, err) + if result.ExitCode != 0 { + skipIfBaseUnavailable(t, result, "requires bot advanced permission enable capability") + } + result.AssertExitCode(t, 0) + result.AssertStdoutStatus(t, true) + + roleName := "Reviewer-" + testSuffix() + roleID := createRole(t, parentT, ctx, baseToken, `{"role_name":"`+roleName+`","role_type":"custom_role"}`) + + t.Run("list", func(t *testing.T) { + result, err := clie2e.RunCmd(ctx, clie2e.Request{ + Args: []string{"base", "+role-list", "--base-token", baseToken}, + DefaultAs: "bot", + }) + require.NoError(t, err) + if result.ExitCode != 0 { + skipIfBaseUnavailable(t, result, "requires bot role list capability") + } + result.AssertExitCode(t, 0) + result.AssertStdoutStatus(t, true) + + roleListPayload := gjson.Get(result.Stdout, "data.data").String() + require.NotEmpty(t, roleListPayload, "stdout:\n%s", result.Stdout) + assert.True(t, gjson.Valid(roleListPayload), "role list payload should be valid JSON: %s", roleListPayload) + + roleItems := gjson.Get(roleListPayload, "base_roles").Array() + assert.NotEmpty(t, roleItems, "role list should contain at least one role: %s", roleListPayload) + + found := false + for _, item := range roleItems { + rolePayload := item.String() + if !gjson.Valid(rolePayload) { + continue + } + if gjson.Get(rolePayload, "role_id").String() == roleID { + found = true + break + } + } + assert.True(t, found, "stdout:\n%s", result.Stdout) + }) + + t.Run("get", func(t *testing.T) { + result, err := clie2e.RunCmd(ctx, clie2e.Request{ + Args: []string{"base", "+role-get", "--base-token", baseToken, "--role-id", roleID}, + DefaultAs: "bot", + }) + require.NoError(t, err) + if result.ExitCode != 0 { + skipIfBaseUnavailable(t, result, "requires bot role get capability") + } + result.AssertExitCode(t, 0) + result.AssertStdoutStatus(t, true) + + rolePayload := gjson.Get(result.Stdout, "data.data").String() + require.NotEmpty(t, rolePayload, "stdout:\n%s", result.Stdout) + require.True(t, gjson.Valid(rolePayload), "stdout:\n%s", result.Stdout) + assert.Equal(t, roleID, gjson.Get(rolePayload, "role_id").String()) + }) + + t.Run("update", func(t *testing.T) { + updatedRoleName := roleName + " Updated" + result, err := clie2e.RunCmd(ctx, clie2e.Request{ + Args: []string{"base", "+role-update", "--base-token", baseToken, "--role-id", roleID, "--json", `{"role_name":"` + updatedRoleName + `","role_type":"custom_role"}`, "--yes"}, + DefaultAs: "bot", + }) + require.NoError(t, err) + if result.ExitCode != 0 { + skipIfBaseUnavailable(t, result, "requires bot role update capability") + } + result.AssertExitCode(t, 0) + result.AssertStdoutStatus(t, true) + + getResult, err := clie2e.RunCmd(ctx, clie2e.Request{ + Args: []string{"base", "+role-get", "--base-token", baseToken, "--role-id", roleID}, + DefaultAs: "bot", + }) + require.NoError(t, err) + if getResult.ExitCode != 0 { + skipIfBaseUnavailable(t, getResult, "requires bot role get capability") + } + getResult.AssertExitCode(t, 0) + getResult.AssertStdoutStatus(t, true) + + rolePayload := gjson.Get(getResult.Stdout, "data.data").String() + require.NotEmpty(t, rolePayload, "stdout:\n%s", getResult.Stdout) + require.True(t, gjson.Valid(rolePayload), "stdout:\n%s", getResult.Stdout) + assert.Equal(t, updatedRoleName, gjson.Get(rolePayload, "role_name").String()) + }) + + t.Run("delete", func(t *testing.T) { + result, err := clie2e.RunCmd(ctx, clie2e.Request{ + Args: []string{"base", "+role-delete", "--base-token", baseToken, "--role-id", roleID, "--yes"}, + DefaultAs: "bot", + }) + require.NoError(t, err) + if result.ExitCode != 0 { + skipIfBaseUnavailable(t, result, "requires bot role delete capability") + } + result.AssertExitCode(t, 0) + result.AssertStdoutStatus(t, true) + }) +} diff --git a/tests/cli_e2e/base/helpers_test.go b/tests/cli_e2e/base/helpers_test.go new file mode 100644 index 00000000..f72b3e14 --- /dev/null +++ b/tests/cli_e2e/base/helpers_test.go @@ -0,0 +1,523 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package base + +import ( + "context" + "os" + "path/filepath" + "strings" + "testing" + "time" + + clie2e "github.com/larksuite/cli/tests/cli_e2e" + "github.com/stretchr/testify/require" + "github.com/tidwall/gjson" +) + +const cleanupTimeout = 30 * time.Second + +func baseJSONPayload(t *testing.T, result *clie2e.Result) string { + t.Helper() + + raw := strings.TrimSpace(result.Stdout) + if raw == "" { + raw = strings.TrimSpace(result.Stderr) + } + + start := strings.LastIndex(raw, "\n{") + if start >= 0 { + start++ + } else { + start = strings.Index(raw, "{") + } + require.NotEqualf(t, -1, start, "json payload not found:\nstdout:\n%s\nstderr:\n%s", result.Stdout, result.Stderr) + + payload := raw[start:] + require.Truef(t, gjson.Valid(payload), "invalid json payload:\n%s", payload) + return payload +} + +func skipIfBaseUnavailable(t *testing.T, result *clie2e.Result, reason string) { + t.Helper() + + payload := baseJSONPayload(t, result) + errType := gjson.Get(payload, "error.type").String() + if errType == "config" && !runningInCI() { + t.Skipf("%s: %s", reason, gjson.Get(payload, "error.message").String()) + } +} + +func runningInCI() bool { + return os.Getenv("CI") != "" || os.Getenv("GITHUB_ACTIONS") != "" +} + +func reportCleanupFailure(parentT *testing.T, prefix string, result *clie2e.Result, err error) { + parentT.Helper() + + if err != nil { + parentT.Errorf("%s: %v", prefix, err) + return + } + if result == nil { + parentT.Errorf("%s: nil result", prefix) + return + } + if isCleanupSuppressedResult(result) { + return + } + + parentT.Errorf("%s failed: exit=%d stdout=%s stderr=%s", prefix, result.ExitCode, result.Stdout, result.Stderr) +} + +func cleanupContext() (context.Context, context.CancelFunc) { + return context.WithTimeout(context.Background(), cleanupTimeout) +} + +func isCleanupSuppressedResult(result *clie2e.Result) bool { + if result == nil { + return false + } + + raw := strings.TrimSpace(result.Stdout) + if raw == "" { + raw = strings.TrimSpace(result.Stderr) + } + if raw == "" { + return false + } + + start := strings.LastIndex(raw, "\n{") + if start >= 0 { + start++ + } else { + start = strings.Index(raw, "{") + } + if start < 0 { + return false + } + + payload := raw[start:] + if !gjson.Valid(payload) { + return false + } + + if gjson.Get(payload, "error.type").String() != "api_error" { + return false + } + + if gjson.Get(payload, "error.detail.type").String() == "not_found" || + strings.Contains(strings.ToLower(gjson.Get(payload, "error.message").String()), "not found") { + return true + } + + return gjson.Get(payload, "error.code").Int() == 800004135 || + strings.Contains(strings.ToLower(gjson.Get(payload, "error.message").String()), " limited") +} + +func testSuffix() string { + return time.Now().UTC().Format("20060102-150405") +} + +func createBase(t *testing.T, ctx context.Context, name string) string { + t.Helper() + + result, err := clie2e.RunCmd(ctx, clie2e.Request{ + Args: []string{"base", "+base-create", "--name", name, "--time-zone", "Asia/Shanghai"}, + DefaultAs: "bot", + }) + require.NoError(t, err) + if result.ExitCode != 0 { + skipIfBaseUnavailable(t, result, "requires bot base create capability") + } + result.AssertExitCode(t, 0) + result.AssertStdoutStatus(t, true) + + baseToken := gjson.Get(result.Stdout, "data.base.app_token").String() + if baseToken == "" { + baseToken = gjson.Get(result.Stdout, "data.base.base_token").String() + } + require.NotEmpty(t, baseToken, "stdout:\n%s", result.Stdout) + return baseToken +} + +func copyBase(t *testing.T, ctx context.Context, baseToken string, name string) string { + t.Helper() + + result, err := clie2e.RunCmd(ctx, clie2e.Request{ + Args: []string{"base", "+base-copy", "--base-token", baseToken, "--name", name, "--without-content", "--time-zone", "Asia/Shanghai"}, + DefaultAs: "bot", + }) + require.NoError(t, err) + if result.ExitCode != 0 { + skipIfBaseUnavailable(t, result, "requires bot base copy capability") + } + result.AssertExitCode(t, 0) + result.AssertStdoutStatus(t, true) + + copiedToken := gjson.Get(result.Stdout, "data.base.app_token").String() + if copiedToken == "" { + copiedToken = gjson.Get(result.Stdout, "data.base.base_token").String() + } + require.NotEmpty(t, copiedToken, "stdout:\n%s", result.Stdout) + return copiedToken +} + +func createTable(t *testing.T, parentT *testing.T, ctx context.Context, baseToken string, name string, fieldsJSON string, viewJSON string) (tableID string, primaryFieldID string, primaryViewID string) { + t.Helper() + + args := []string{"base", "+table-create", "--base-token", baseToken, "--name", name} + if fieldsJSON != "" { + args = append(args, "--fields", fieldsJSON) + } + if viewJSON != "" { + args = append(args, "--view", viewJSON) + } + + result, err := clie2e.RunCmd(ctx, clie2e.Request{ + Args: args, + DefaultAs: "bot", + }) + require.NoError(t, err) + if result.ExitCode != 0 { + skipIfBaseUnavailable(t, result, "requires bot table create capability") + } + result.AssertExitCode(t, 0) + result.AssertStdoutStatus(t, true) + + tableID = gjson.Get(result.Stdout, "data.table.id").String() + if tableID == "" { + tableID = gjson.Get(result.Stdout, "data.table.table_id").String() + } + require.NotEmpty(t, tableID, "stdout:\n%s", result.Stdout) + + primaryFieldID = gjson.Get(result.Stdout, "data.fields.0.id").String() + if primaryFieldID == "" { + primaryFieldID = gjson.Get(result.Stdout, "data.fields.0.field_id").String() + } + + primaryViewID = gjson.Get(result.Stdout, "data.views.0.id").String() + if primaryViewID == "" { + primaryViewID = gjson.Get(result.Stdout, "data.views.0.view_id").String() + } + + parentT.Cleanup(func() { + cleanupCtx, cancel := cleanupContext() + defer cancel() + + deleteResult, deleteErr := clie2e.RunCmd(cleanupCtx, clie2e.Request{ + Args: []string{"base", "+table-delete", "--base-token", baseToken, "--table-id", tableID, "--yes"}, + DefaultAs: "bot", + }) + if deleteErr != nil || deleteResult.ExitCode != 0 { + reportCleanupFailure(parentT, "delete table "+tableID, deleteResult, deleteErr) + } + }) + + return tableID, primaryFieldID, primaryViewID +} + +func createField(t *testing.T, parentT *testing.T, ctx context.Context, baseToken string, tableID string, body string) string { + t.Helper() + + result, err := clie2e.RunCmd(ctx, clie2e.Request{ + Args: []string{"base", "+field-create", "--base-token", baseToken, "--table-id", tableID, "--json", body}, + DefaultAs: "bot", + }) + require.NoError(t, err) + if result.ExitCode != 0 { + skipIfBaseUnavailable(t, result, "requires bot field create capability") + } + result.AssertExitCode(t, 0) + result.AssertStdoutStatus(t, true) + + fieldID := gjson.Get(result.Stdout, "data.field.id").String() + if fieldID == "" { + fieldID = gjson.Get(result.Stdout, "data.field.field_id").String() + } + require.NotEmpty(t, fieldID, "stdout:\n%s", result.Stdout) + + parentT.Cleanup(func() { + cleanupCtx, cancel := cleanupContext() + defer cancel() + + deleteResult, deleteErr := clie2e.RunCmd(cleanupCtx, clie2e.Request{ + Args: []string{"base", "+field-delete", "--base-token", baseToken, "--table-id", tableID, "--field-id", fieldID, "--yes"}, + DefaultAs: "bot", + }) + if deleteErr != nil || deleteResult.ExitCode != 0 { + reportCleanupFailure(parentT, "delete field "+fieldID, deleteResult, deleteErr) + } + }) + + return fieldID +} + +func createRecord(t *testing.T, parentT *testing.T, ctx context.Context, baseToken string, tableID string, body string) string { + t.Helper() + + result, err := clie2e.RunCmd(ctx, clie2e.Request{ + Args: []string{"base", "+record-upsert", "--base-token", baseToken, "--table-id", tableID, "--json", body}, + DefaultAs: "bot", + }) + require.NoError(t, err) + if result.ExitCode != 0 { + skipIfBaseUnavailable(t, result, "requires bot record create capability") + } + result.AssertExitCode(t, 0) + result.AssertStdoutStatus(t, true) + + recordID := gjson.Get(result.Stdout, "data.record.record_id").String() + if recordID == "" { + recordID = gjson.Get(result.Stdout, "data.record.record_id_list.0").String() + } + require.NotEmpty(t, recordID, "stdout:\n%s", result.Stdout) + + parentT.Cleanup(func() { + cleanupCtx, cancel := cleanupContext() + defer cancel() + + deleteResult, deleteErr := clie2e.RunCmd(cleanupCtx, clie2e.Request{ + Args: []string{"base", "+record-delete", "--base-token", baseToken, "--table-id", tableID, "--record-id", recordID, "--yes"}, + DefaultAs: "bot", + }) + if deleteErr != nil || deleteResult.ExitCode != 0 { + reportCleanupFailure(parentT, "delete record "+recordID, deleteResult, deleteErr) + } + }) + + return recordID +} + +func createView(t *testing.T, parentT *testing.T, ctx context.Context, baseToken string, tableID string, body string) string { + t.Helper() + + result, err := clie2e.RunCmd(ctx, clie2e.Request{ + Args: []string{"base", "+view-create", "--base-token", baseToken, "--table-id", tableID, "--json", body}, + DefaultAs: "bot", + }) + require.NoError(t, err) + if result.ExitCode != 0 { + skipIfBaseUnavailable(t, result, "requires bot view create capability") + } + result.AssertExitCode(t, 0) + result.AssertStdoutStatus(t, true) + + viewID := gjson.Get(result.Stdout, "data.views.0.id").String() + if viewID == "" { + viewID = gjson.Get(result.Stdout, "data.views.0.view_id").String() + } + require.NotEmpty(t, viewID, "stdout:\n%s", result.Stdout) + + parentT.Cleanup(func() { + cleanupCtx, cancel := cleanupContext() + defer cancel() + + deleteResult, deleteErr := clie2e.RunCmd(cleanupCtx, clie2e.Request{ + Args: []string{"base", "+view-delete", "--base-token", baseToken, "--table-id", tableID, "--view-id", viewID, "--yes"}, + DefaultAs: "bot", + }) + if deleteErr != nil || deleteResult.ExitCode != 0 { + reportCleanupFailure(parentT, "delete view "+viewID, deleteResult, deleteErr) + } + }) + + return viewID +} + +func createDashboard(t *testing.T, parentT *testing.T, ctx context.Context, baseToken string, name string) string { + t.Helper() + + result, err := clie2e.RunCmd(ctx, clie2e.Request{ + Args: []string{"base", "+dashboard-create", "--base-token", baseToken, "--name", name}, + DefaultAs: "bot", + }) + require.NoError(t, err) + if result.ExitCode != 0 { + skipIfBaseUnavailable(t, result, "requires bot dashboard create capability") + } + result.AssertExitCode(t, 0) + result.AssertStdoutStatus(t, true) + + dashboardID := gjson.Get(result.Stdout, "data.dashboard.dashboard_id").String() + require.NotEmpty(t, dashboardID, "stdout:\n%s", result.Stdout) + + parentT.Cleanup(func() { + cleanupCtx, cancel := cleanupContext() + defer cancel() + + deleteResult, deleteErr := clie2e.RunCmd(cleanupCtx, clie2e.Request{ + Args: []string{"base", "+dashboard-delete", "--base-token", baseToken, "--dashboard-id", dashboardID, "--yes"}, + DefaultAs: "bot", + }) + if deleteErr != nil || deleteResult.ExitCode != 0 { + reportCleanupFailure(parentT, "delete dashboard "+dashboardID, deleteResult, deleteErr) + } + }) + + return dashboardID +} + +func createBlock(t *testing.T, parentT *testing.T, ctx context.Context, baseToken string, dashboardID string, name string, blockType string, dataConfig string) string { + t.Helper() + + result, err := clie2e.RunCmd(ctx, clie2e.Request{ + Args: []string{"base", "+dashboard-block-create", "--base-token", baseToken, "--dashboard-id", dashboardID, "--name", name, "--type", blockType, "--data-config", dataConfig}, + DefaultAs: "bot", + }) + require.NoError(t, err) + if result.ExitCode != 0 { + skipIfBaseUnavailable(t, result, "requires bot dashboard block create capability") + } + result.AssertExitCode(t, 0) + result.AssertStdoutStatus(t, true) + + blockID := gjson.Get(result.Stdout, "data.block.block_id").String() + require.NotEmpty(t, blockID, "stdout:\n%s", result.Stdout) + + parentT.Cleanup(func() { + cleanupCtx, cancel := cleanupContext() + defer cancel() + + deleteResult, deleteErr := clie2e.RunCmd(cleanupCtx, clie2e.Request{ + Args: []string{"base", "+dashboard-block-delete", "--base-token", baseToken, "--dashboard-id", dashboardID, "--block-id", blockID, "--yes"}, + DefaultAs: "bot", + }) + if deleteErr != nil || deleteResult.ExitCode != 0 { + reportCleanupFailure(parentT, "delete dashboard block "+blockID, deleteResult, deleteErr) + } + }) + + return blockID +} + +func createForm(t *testing.T, parentT *testing.T, ctx context.Context, baseToken string, tableID string, name string) string { + t.Helper() + + result, err := clie2e.RunCmd(ctx, clie2e.Request{ + Args: []string{"base", "+form-create", "--base-token", baseToken, "--table-id", tableID, "--name", name}, + DefaultAs: "bot", + }) + require.NoError(t, err) + if result.ExitCode != 0 { + skipIfBaseUnavailable(t, result, "requires bot form create capability") + } + result.AssertExitCode(t, 0) + result.AssertStdoutStatus(t, true) + + formID := gjson.Get(result.Stdout, "data.id").String() + require.NotEmpty(t, formID, "stdout:\n%s", result.Stdout) + + parentT.Cleanup(func() { + cleanupCtx, cancel := cleanupContext() + defer cancel() + + deleteResult, deleteErr := clie2e.RunCmd(cleanupCtx, clie2e.Request{ + Args: []string{"base", "+form-delete", "--base-token", baseToken, "--table-id", tableID, "--form-id", formID, "--yes"}, + DefaultAs: "bot", + }) + if deleteErr != nil || deleteResult.ExitCode != 0 { + reportCleanupFailure(parentT, "delete form "+formID, deleteResult, deleteErr) + } + }) + + return formID +} + +func createRole(t *testing.T, parentT *testing.T, ctx context.Context, baseToken string, body string) string { + t.Helper() + + result, err := clie2e.RunCmd(ctx, clie2e.Request{ + Args: []string{"base", "+role-create", "--base-token", baseToken, "--json", body}, + DefaultAs: "bot", + }) + require.NoError(t, err) + if result.ExitCode != 0 { + skipIfBaseUnavailable(t, result, "requires bot role create capability") + } + result.AssertExitCode(t, 0) + result.AssertStdoutStatus(t, true) + + roleID := gjson.Get(result.Stdout, "data.role_id").String() + if roleID == "" { + roleName := gjson.Get(body, "role_name").String() + require.NotEmpty(t, roleName, "role_name is required to resolve role id from list") + + listResult, listErr := clie2e.RunCmd(ctx, clie2e.Request{ + Args: []string{"base", "+role-list", "--base-token", baseToken}, + DefaultAs: "bot", + }) + require.NoError(t, listErr) + if listResult.ExitCode != 0 { + skipIfBaseUnavailable(t, listResult, "requires bot role list capability") + } + listResult.AssertExitCode(t, 0) + listResult.AssertStdoutStatus(t, true) + + roleListPayload := gjson.Get(listResult.Stdout, "data.data").String() + require.NotEmpty(t, roleListPayload, "stdout:\n%s", listResult.Stdout) + require.True(t, gjson.Valid(roleListPayload), "stdout:\n%s", listResult.Stdout) + + for _, item := range gjson.Get(roleListPayload, "base_roles").Array() { + rolePayload := item.String() + if !gjson.Valid(rolePayload) { + continue + } + if gjson.Get(rolePayload, "role_name").String() == roleName { + roleID = gjson.Get(rolePayload, "role_id").String() + break + } + } + } + require.NotEmpty(t, roleID, "stdout:\n%s", result.Stdout) + + parentT.Cleanup(func() { + cleanupCtx, cancel := cleanupContext() + defer cancel() + + deleteResult, deleteErr := clie2e.RunCmd(cleanupCtx, clie2e.Request{ + Args: []string{"base", "+role-delete", "--base-token", baseToken, "--role-id", roleID, "--yes"}, + DefaultAs: "bot", + }) + if deleteErr != nil || deleteResult.ExitCode != 0 { + reportCleanupFailure(parentT, "delete role "+roleID, deleteResult, deleteErr) + } + }) + + return roleID +} + +func createWorkflow(t *testing.T, ctx context.Context, baseToken string, body string) string { + t.Helper() + + result, err := clie2e.RunCmd(ctx, clie2e.Request{ + Args: []string{"base", "+workflow-create", "--base-token", baseToken, "--json", body}, + DefaultAs: "bot", + }) + require.NoError(t, err) + if result.ExitCode != 0 { + skipIfBaseUnavailable(t, result, "requires bot workflow create capability") + } + result.AssertExitCode(t, 0) + result.AssertStdoutStatus(t, true) + + workflowID := gjson.Get(result.Stdout, "data.workflow_id").String() + require.NotEmpty(t, workflowID, "stdout:\n%s", result.Stdout) + return workflowID +} + +func writeTempAttachment(t *testing.T, content string) string { + t.Helper() + + wd, err := os.Getwd() + require.NoError(t, err) + + path := filepath.Join(wd, "attachment-"+testSuffix()+".txt") + err = os.WriteFile(path, []byte(content), 0o644) + require.NoError(t, err) + t.Cleanup(func() { + _ = os.Remove(path) + }) + return "./" + filepath.Base(path) +} diff --git a/tests/cli_e2e/calendar/calendar_create_event_test.go b/tests/cli_e2e/calendar/calendar_create_event_test.go new file mode 100644 index 00000000..9a3be443 --- /dev/null +++ b/tests/cli_e2e/calendar/calendar_create_event_test.go @@ -0,0 +1,108 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package calendar + +import ( + "context" + "testing" + "time" + + clie2e "github.com/larksuite/cli/tests/cli_e2e" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "github.com/tidwall/gjson" +) + +// TestCalendar_CreateEvent tests the workflow of creating a calendar event. +func TestCalendar_CreateEvent(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute) + t.Cleanup(cancel) + + suffix := time.Now().UTC().Format("20060102-150405") + eventSummary := "lark-cli-e2e-event-" + suffix + + startTime := time.Now().UTC().Add(1 * time.Hour).Format(time.RFC3339) + endTime := time.Now().UTC().Add(2 * time.Hour).Format(time.RFC3339) + + var eventID string + var calendarID string + + // Step 1: Get primary calendar ID (prerequisite) + t.Run("get primary calendar", func(t *testing.T) { + result, err := clie2e.RunCmd(ctx, clie2e.Request{ + Args: []string{"calendar", "calendars", "primary"}, + }) + require.NoError(t, err) + result.AssertExitCode(t, 0) + result.AssertStdoutStatus(t, 0) + calendarID = gjson.Get(result.Stdout, "data.calendars.0.calendar.calendar_id").String() + require.NotEmpty(t, calendarID) + }) + + // Step 2: Create event using +create shortcut + t.Run("create event with shortcut", func(t *testing.T) { + result, err := clie2e.RunCmd(ctx, clie2e.Request{ + Args: []string{"calendar", "+create", + "--summary", eventSummary, + "--start", startTime, + "--end", endTime, + "--calendar-id", calendarID, + "--description", "test event description", + }, + }) + require.NoError(t, err) + result.AssertExitCode(t, 0) + result.AssertStdoutStatus(t, true) + + eventID = gjson.Get(result.Stdout, "data.event_id").String() + require.NotEmpty(t, eventID) + }) + + // Step 3: Verify event was created using events.get resource command + t.Run("verify event created", func(t *testing.T) { + require.NotEmpty(t, eventID) + result, err := clie2e.RunCmd(ctx, clie2e.Request{ + Args: []string{"calendar", "events", "get"}, + Params: map[string]any{ + "calendar_id": calendarID, + "event_id": eventID, + }, + }) + require.NoError(t, err) + result.AssertExitCode(t, 0) + result.AssertStdoutStatus(t, 0) + assert.Equal(t, eventSummary, gjson.Get(result.Stdout, "data.event.summary").String()) + assert.Equal(t, "test event description", gjson.Get(result.Stdout, "data.event.description").String()) + }) + + // Step 4: Delete event using events.delete resource command + t.Run("delete event", func(t *testing.T) { + require.NotEmpty(t, eventID) + result, err := clie2e.RunCmd(ctx, clie2e.Request{ + Args: []string{"calendar", "events", "delete"}, + Params: map[string]any{ + "calendar_id": calendarID, + "event_id": eventID, + }, + }) + require.NoError(t, err) + result.AssertExitCode(t, 0) + result.AssertStdoutStatus(t, 0) + }) + + // Step 5: Verify delete was acknowledged (event may have eventual consistency) + t.Run("verify delete acknowledged", func(t *testing.T) { + require.NotEmpty(t, eventID) + result, err := clie2e.RunCmd(ctx, clie2e.Request{ + Args: []string{"calendar", "events", "get"}, + Params: map[string]any{ + "calendar_id": calendarID, + "event_id": eventID, + }, + }) + require.NoError(t, err) + // Note: API may have eventual consistency - delete acknowledged but get may still succeed briefly + _ = result + }) +} \ No newline at end of file diff --git a/tests/cli_e2e/calendar/calendar_find_meeting_time_test.go b/tests/cli_e2e/calendar/calendar_find_meeting_time_test.go new file mode 100644 index 00000000..8f41016e --- /dev/null +++ b/tests/cli_e2e/calendar/calendar_find_meeting_time_test.go @@ -0,0 +1,49 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package calendar + +import ( + "context" + "testing" + "time" + + clie2e "github.com/larksuite/cli/tests/cli_e2e" + "github.com/stretchr/testify/require" +) + +// TestCalendar_FindMeetingTime tests the workflow of finding available meeting times. +func TestCalendar_FindMeetingTime(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute) + t.Cleanup(cancel) + + startTime := time.Now().UTC().Add(1 * time.Hour).Format(time.RFC3339) + endTime := time.Now().UTC().Add(24 * time.Hour).Format("2006-01-02T15:04:05Z") + + t.Run("find available meeting times", func(t *testing.T) { + result, err := clie2e.RunCmd(ctx, clie2e.Request{ + Args: []string{"calendar", "+suggestion", + "--start", startTime, + "--end", endTime, + "--duration-minutes", "30", + }, + }) + require.NoError(t, err) + result.AssertExitCode(t, 0) + result.AssertStdoutStatus(t, true) + }) + + t.Run("find meeting times with timezone", func(t *testing.T) { + result, err := clie2e.RunCmd(ctx, clie2e.Request{ + Args: []string{"calendar", "+suggestion", + "--start", startTime, + "--end", endTime, + "--duration-minutes", "60", + "--timezone", "Asia/Shanghai", + }, + }) + require.NoError(t, err) + result.AssertExitCode(t, 0) + result.AssertStdoutStatus(t, true) + }) +} diff --git a/tests/cli_e2e/calendar/calendar_manage_calendar_test.go b/tests/cli_e2e/calendar/calendar_manage_calendar_test.go new file mode 100644 index 00000000..a8ec395b --- /dev/null +++ b/tests/cli_e2e/calendar/calendar_manage_calendar_test.go @@ -0,0 +1,100 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package calendar + +import ( + "context" + "testing" + "time" + + clie2e "github.com/larksuite/cli/tests/cli_e2e" + "github.com/stretchr/testify/require" + "github.com/tidwall/gjson" +) + +// TestCalendar_ManageCalendar tests the workflow of managing calendars. +func TestCalendar_ManageCalendar(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute) + t.Cleanup(cancel) + + suffix := time.Now().UTC().Format("20060102-150405") + calendarSummary := "lark-cli-e2e-cal-" + suffix + + var createdCalendarID string + + t.Run("list calendars", func(t *testing.T) { + result, err := clie2e.RunCmd(ctx, clie2e.Request{ + Args: []string{"calendar", "calendars", "list"}, + }) + require.NoError(t, err) + result.AssertExitCode(t, 0) + result.AssertStdoutStatus(t, 0) + }) + + t.Run("get primary calendar", func(t *testing.T) { + result, err := clie2e.RunCmd(ctx, clie2e.Request{ + Args: []string{"calendar", "calendars", "primary"}, + }) + require.NoError(t, err) + result.AssertExitCode(t, 0) + result.AssertStdoutStatus(t, 0) + }) + + t.Run("create calendar", func(t *testing.T) { + result, err := clie2e.RunCmd(ctx, clie2e.Request{ + Args: []string{"calendar", "calendars", "create"}, + Data: map[string]any{ + "summary": calendarSummary, + "description": "test calendar created by e2e", + }, + }) + require.NoError(t, err) + result.AssertExitCode(t, 0) + result.AssertStdoutStatus(t, 0) + + createdCalendarID = gjson.Get(result.Stdout, "data.calendar.calendar_id").String() + require.NotEmpty(t, createdCalendarID) + }) + + t.Run("update calendar", func(t *testing.T) { + require.NotEmpty(t, createdCalendarID) + result, err := clie2e.RunCmd(ctx, clie2e.Request{ + Args: []string{"calendar", "calendars", "patch"}, + Params: map[string]any{ + "calendar_id": createdCalendarID, + }, + Data: map[string]any{ + "summary": calendarSummary + "-updated", + }, + }) + require.NoError(t, err) + result.AssertExitCode(t, 0) + result.AssertStdoutStatus(t, 0) + }) + + t.Run("search calendar", func(t *testing.T) { + result, err := clie2e.RunCmd(ctx, clie2e.Request{ + Args: []string{"calendar", "calendars", "search"}, + Data: map[string]any{ + "query": calendarSummary, + }, + }) + require.NoError(t, err) + result.AssertExitCode(t, 0) + result.AssertStdoutStatus(t, 0) + }) + + t.Run("delete calendar", func(t *testing.T) { + require.NotEmpty(t, createdCalendarID) + result, err := clie2e.RunCmd(ctx, clie2e.Request{ + Args: []string{"calendar", "calendars", "delete"}, + Params: map[string]any{ + "calendar_id": createdCalendarID, + }, + }) + require.NoError(t, err) + result.AssertExitCode(t, 0) + result.AssertStdoutStatus(t, 0) + }) +} \ No newline at end of file diff --git a/tests/cli_e2e/calendar/calendar_view_agenda_test.go b/tests/cli_e2e/calendar/calendar_view_agenda_test.go new file mode 100644 index 00000000..ae4ef7dc --- /dev/null +++ b/tests/cli_e2e/calendar/calendar_view_agenda_test.go @@ -0,0 +1,48 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package calendar + +import ( + "context" + "testing" + "time" + + clie2e "github.com/larksuite/cli/tests/cli_e2e" + "github.com/stretchr/testify/require" +) + +// TestCalendar_ViewAgenda tests the workflow of viewing one's calendar agenda. +func TestCalendar_ViewAgenda(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute) + t.Cleanup(cancel) + + t.Run("view today agenda", func(t *testing.T) { + result, err := clie2e.RunCmd(ctx, clie2e.Request{ + Args: []string{"calendar", "+agenda"}, + }) + require.NoError(t, err) + result.AssertExitCode(t, 0) + result.AssertStdoutStatus(t, true) + }) + + t.Run("view agenda with date range", func(t *testing.T) { + startDate := time.Now().UTC().Format("2006-01-02") + endDate := time.Now().UTC().AddDate(0, 0, 7).Format("2006-01-02") + result, err := clie2e.RunCmd(ctx, clie2e.Request{ + Args: []string{"calendar", "+agenda", "--start", startDate, "--end", endDate}, + }) + require.NoError(t, err) + result.AssertExitCode(t, 0) + result.AssertStdoutStatus(t, true) + }) + + t.Run("view agenda with pretty format", func(t *testing.T) { + result, err := clie2e.RunCmd(ctx, clie2e.Request{ + Args: []string{"calendar", "+agenda"}, + Format: "pretty", + }) + require.NoError(t, err) + result.AssertExitCode(t, 0) + }) +} \ No newline at end of file diff --git a/tests/cli_e2e/calendar/helpers_test.go b/tests/cli_e2e/calendar/helpers_test.go new file mode 100644 index 00000000..398436ab --- /dev/null +++ b/tests/cli_e2e/calendar/helpers_test.go @@ -0,0 +1,74 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package calendar + +import ( + "context" + "testing" + "time" + + clie2e "github.com/larksuite/cli/tests/cli_e2e" + "github.com/stretchr/testify/require" + "github.com/tidwall/gjson" +) + +// createEvent creates a calendar event and registers cleanup. +// Returns the event_id. +func createEvent(t *testing.T, parentT *testing.T, ctx context.Context, calendarID string, summary string) string { + t.Helper() + + startTime := time.Now().UTC().Add(1 * time.Hour).Format(time.RFC3339) + endTime := time.Now().UTC().Add(2 * time.Hour).Format(time.RFC3339) + + result, err := clie2e.RunCmd(ctx, clie2e.Request{ + Args: []string{"calendar", "+create", + "--summary", summary, + "--start", startTime, + "--end", endTime, + "--calendar-id", calendarID, + }, + }) + require.NoError(t, err) + result.AssertExitCode(t, 0) + result.AssertStdoutStatus(t, true) + + eventID := gjson.Get(result.Stdout, "data.event_id").String() + require.NotEmpty(t, eventID, "stdout:\n%s", result.Stdout) + + parentT.Cleanup(func() { + deleteResult, deleteErr := clie2e.RunCmd(context.Background(), clie2e.Request{ + Args: []string{"calendar", "events", "delete"}, + Params: map[string]any{ + "calendar_id": calendarID, + "event_id": eventID, + }, + }) + if deleteErr != nil { + parentT.Errorf("delete event %s: %v", eventID, deleteErr) + return + } + if deleteResult.ExitCode != 0 { + parentT.Errorf("delete event %s failed: exit=%d stdout=%s stderr=%s", eventID, deleteResult.ExitCode, deleteResult.Stdout, deleteResult.Stderr) + } + }) + + return eventID +} + +// getPrimaryCalendarID returns the primary calendar ID. +func getPrimaryCalendarID(t *testing.T, ctx context.Context) string { + t.Helper() + + result, err := clie2e.RunCmd(ctx, clie2e.Request{ + Args: []string{"calendar", "calendars", "primary"}, + }) + require.NoError(t, err) + result.AssertExitCode(t, 0) + result.AssertStdoutStatus(t, 0) + + calendarID := gjson.Get(result.Stdout, "data.calendars.0.calendar.calendar_id").String() + require.NotEmpty(t, calendarID, "stdout:\n%s", result.Stdout) + + return calendarID +} \ No newline at end of file diff --git a/tests/cli_e2e/contact/contact_shortcut_test.go b/tests/cli_e2e/contact/contact_shortcut_test.go new file mode 100644 index 00000000..679ebff3 --- /dev/null +++ b/tests/cli_e2e/contact/contact_shortcut_test.go @@ -0,0 +1,51 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package contact + +import ( + "context" + "testing" + "time" + + "github.com/stretchr/testify/require" + "github.com/tidwall/gjson" + + clie2e "github.com/larksuite/cli/tests/cli_e2e" +) + +func TestContact_GetUser_BotWorkflow(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute) + t.Cleanup(cancel) + + var targetOpenID string + + t.Run("discover-user-via-api", func(t *testing.T) { + // Bot identity cannot use +search-user or +get-user (self). + // However, it CAN call the raw API to list users if it has contact permissions. + // We use this to discover a real open_id for the next step. + result, err := clie2e.RunCmd(ctx, clie2e.Request{ + Args: []string{"api", "get", "/open-apis/contact/v3/users"}, + }) + require.NoError(t, err) + result.AssertExitCode(t, 0) + result.AssertStdoutStatus(t, 0) + targetOpenID = gjson.Get(result.Stdout, "data.items.0.open_id").String() + + require.NotEmpty(t, targetOpenID, "expected to find at least one user via raw API") + }) + + t.Run("get-user-by-id-as-bot", func(t *testing.T) { + require.NotEmpty(t, targetOpenID, "targetOpenID should be populated") + // DefaultAs is automatically "bot" in the clie2e framework + result, err := clie2e.RunCmd(ctx, clie2e.Request{ + Args: []string{"contact", "+get-user", "--user-id", targetOpenID}, + }) + require.NoError(t, err) + result.AssertExitCode(t, 0) + result.AssertStdoutStatus(t, true) + + returnedID := gjson.Get(result.Stdout, "data.user.open_id").String() + require.Equal(t, targetOpenID, returnedID) + }) +} diff --git a/tests/cli_e2e/docs/docs_create_fetch_test.go b/tests/cli_e2e/docs/docs_create_fetch_test.go new file mode 100644 index 00000000..3ad5e2f5 --- /dev/null +++ b/tests/cli_e2e/docs/docs_create_fetch_test.go @@ -0,0 +1,62 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package docs + +import ( + "context" + "testing" + "time" + + clie2e "github.com/larksuite/cli/tests/cli_e2e" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "github.com/tidwall/gjson" +) + +// TestDocs_CreateAndFetchWorkflow tests the create and fetch lifecycle. +func TestDocs_CreateAndFetchWorkflow(t *testing.T) { + parentT := t + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute) + t.Cleanup(cancel) + + suffix := time.Now().UTC().Format("20060102-150405") + docTitle := "lark-cli-e2e-docs-" + suffix + docContent := "# Test Document\n\nThis document was created by lark-cli e2e test." + + var docToken string + + t.Run("create", func(t *testing.T) { + result, err := clie2e.RunCmd(ctx, clie2e.Request{ + Args: []string{ + "docs", "+create", + "--title", docTitle, + "--markdown", docContent, + }, + }) + require.NoError(t, err) + result.AssertExitCode(t, 0) + result.AssertStdoutStatus(t, true) + docToken = gjson.Get(result.Stdout, "data.doc_id").String() + require.NotEmpty(t, docToken, "stdout:\n%s", result.Stdout) + + parentT.Cleanup(func() { + // best-effort cleanup + }) + }) + + t.Run("fetch", func(t *testing.T) { + require.NotEmpty(t, docToken, "document token should be created before fetch") + + result, err := clie2e.RunCmd(ctx, clie2e.Request{ + Args: []string{ + "docs", "+fetch", + "--doc", docToken, + }, + }) + require.NoError(t, err) + result.AssertExitCode(t, 0) + result.AssertStdoutStatus(t, true) + assert.Equal(t, docTitle, gjson.Get(result.Stdout, "data.title").String()) + }) +} \ No newline at end of file diff --git a/tests/cli_e2e/docs/docs_update_test.go b/tests/cli_e2e/docs/docs_update_test.go new file mode 100644 index 00000000..f28de8a7 --- /dev/null +++ b/tests/cli_e2e/docs/docs_update_test.go @@ -0,0 +1,81 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package docs + +import ( + "context" + "testing" + "time" + + clie2e "github.com/larksuite/cli/tests/cli_e2e" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "github.com/tidwall/gjson" +) + +// TestDocs_UpdateWorkflow tests the create, update, and verify lifecycle. +func TestDocs_UpdateWorkflow(t *testing.T) { + parentT := t + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute) + t.Cleanup(cancel) + + suffix := time.Now().UTC().Format("20060102-150405") + originalTitle := "lark-cli-e2e-update-" + suffix + updatedTitle := "lark-cli-e2e-update-updated-" + suffix + originalContent := "# Original\n\nThis is the original content." + updatedContent := "# Updated\n\nThis is the updated content." + + var docToken string + + t.Run("create", func(t *testing.T) { + result, err := clie2e.RunCmd(ctx, clie2e.Request{ + Args: []string{ + "docs", "+create", + "--title", originalTitle, + "--markdown", originalContent, + }, + }) + require.NoError(t, err) + result.AssertExitCode(t, 0) + result.AssertStdoutStatus(t, true) + docToken = gjson.Get(result.Stdout, "data.doc_id").String() + require.NotEmpty(t, docToken, "stdout:\n%s", result.Stdout) + + parentT.Cleanup(func() { + // best-effort cleanup + }) + }) + + t.Run("update-title-and-content", func(t *testing.T) { + require.NotEmpty(t, docToken, "document token should be created before update") + + result, err := clie2e.RunCmd(ctx, clie2e.Request{ + Args: []string{ + "docs", "+update", + "--doc", docToken, + "--mode", "overwrite", + "--markdown", updatedContent, + "--new-title", updatedTitle, + }, + }) + require.NoError(t, err) + result.AssertExitCode(t, 0) + result.AssertStdoutStatus(t, true) + }) + + t.Run("verify", func(t *testing.T) { + require.NotEmpty(t, docToken, "document token should be created before verify") + + result, err := clie2e.RunCmd(ctx, clie2e.Request{ + Args: []string{ + "docs", "+fetch", + "--doc", docToken, + }, + }) + require.NoError(t, err) + result.AssertExitCode(t, 0) + result.AssertStdoutStatus(t, true) + assert.Equal(t, updatedTitle, gjson.Get(result.Stdout, "data.title").String()) + }) +} \ No newline at end of file diff --git a/tests/cli_e2e/drive/drive_files_workflow_test.go b/tests/cli_e2e/drive/drive_files_workflow_test.go new file mode 100644 index 00000000..63b60495 --- /dev/null +++ b/tests/cli_e2e/drive/drive_files_workflow_test.go @@ -0,0 +1,48 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package drive + +import ( + "context" + "testing" + "time" + + clie2e "github.com/larksuite/cli/tests/cli_e2e" + "github.com/stretchr/testify/require" + "github.com/tidwall/gjson" +) + +// TestDrive_FilesCreateFolderWorkflow tests the files create_folder resource command. +func TestDrive_FilesCreateFolderWorkflow(t *testing.T) { + parentT := t + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute) + t.Cleanup(cancel) + + suffix := time.Now().UTC().Format("20060102-150405") + folderName := "lark-cli-e2e-drive-folder-" + suffix + + var folderToken string + + t.Run("create_folder", func(t *testing.T) { + result, err := clie2e.RunCmd(ctx, clie2e.Request{ + Args: []string{"drive", "files", "create_folder"}, + Data: map[string]any{ + "name": folderName, + "folder_token": "", + }, + }) + require.NoError(t, err) + result.AssertExitCode(t, 0) + + folderToken = gjson.Get(result.Stdout, "data.token").String() + require.NotEmpty(t, folderToken, "folder token should be available, stdout:\n%s", result.Stdout) + + parentT.Cleanup(func() { + clie2e.RunCmd(context.Background(), clie2e.Request{ + Args: []string{"drive", "files", "delete"}, + Params: map[string]any{"file_token": folderToken, "type": "folder"}, + }) + }) + }) +} diff --git a/tests/cli_e2e/drive/drive_move_workflow_test.go b/tests/cli_e2e/drive/drive_move_workflow_test.go new file mode 100644 index 00000000..7541c40f --- /dev/null +++ b/tests/cli_e2e/drive/drive_move_workflow_test.go @@ -0,0 +1,57 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package drive + +import ( + "context" + "testing" + "time" + + clie2e "github.com/larksuite/cli/tests/cli_e2e" + "github.com/stretchr/testify/require" + "github.com/tidwall/gjson" +) + +// TestDrive_MoveWorkflow tests the move shortcut method. +// Workflow: upload a file -> move to a folder (root by default) -> verify move completed. +func TestDrive_MoveWorkflow(t *testing.T) { + parentT := t + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute) + t.Cleanup(cancel) + + suffix := time.Now().UTC().Format("20060102-150405") + + fileToken := uploadTestFile(t, parentT, ctx, "move-"+suffix) + require.NotEmpty(t, fileToken) + + t.Run("move", func(t *testing.T) { + require.NotEmpty(t, fileToken, "file token should be set from upload step") + + // Move to root folder (default folder-token is root) + result, err := clie2e.RunCmd(ctx, clie2e.Request{ + Args: []string{"drive", "+move", + "--file-token", fileToken, + "--type", "file", + }, + }) + require.NoError(t, err) + result.AssertExitCode(t, 0) + + taskID := gjson.Get(result.Stdout, "data.task_id").String() + if taskID != "" { + // Poll for move task result + taskResult, taskErr := clie2e.RunCmd(ctx, clie2e.Request{ + Args: []string{"drive", "+task_result", + "--task-id", taskID, + "--scenario", "task_check", + }, + }) + require.NoError(t, taskErr) + taskResult.AssertExitCode(t, 0) + taskResult.AssertStdoutStatus(t, true) + } else { + result.AssertStdoutStatus(t, true) + } + }) +} \ No newline at end of file diff --git a/tests/cli_e2e/drive/drive_permission_user_workflow_test.go b/tests/cli_e2e/drive/drive_permission_user_workflow_test.go new file mode 100644 index 00000000..8694741d --- /dev/null +++ b/tests/cli_e2e/drive/drive_permission_user_workflow_test.go @@ -0,0 +1,111 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package drive + +import ( + "context" + "testing" + "time" + + clie2e "github.com/larksuite/cli/tests/cli_e2e" + "github.com/stretchr/testify/require" + "github.com/tidwall/gjson" +) + +// TestDrive_PermissionMembersAuthWorkflow tests the permission.members.auth resource command. +// Workflow: import a doc -> check auth permissions on the doc. +func TestDrive_PermissionMembersAuthWorkflow(t *testing.T) { + parentT := t + ctx, cancel := context.WithTimeout(context.Background(), 3*time.Minute) + t.Cleanup(cancel) + + suffix := time.Now().UTC().Format("20060102-150405") + testContent := "# Lark CLI E2E Permission Auth Test\n\nDocument for testing permission.members.auth.\nTimestamp: " + suffix + + docToken := importTestDoc(t, parentT, ctx, "permission-auth", testContent) + require.NotEmpty(t, docToken) + + t.Run("check view permission", func(t *testing.T) { + result, err := clie2e.RunCmd(ctx, clie2e.Request{ + Args: []string{"drive", "permission.members", "auth"}, + Params: map[string]any{ + "token": docToken, + "type": "docx", + "action": "view", + }, + }) + require.NoError(t, err) + result.AssertExitCode(t, 0) + + authResult := gjson.Get(result.Stdout, "data.auth_result") + require.True(t, authResult.Bool(), "should have view permission on own doc, stdout:\n%s", result.Stdout) + }) + + t.Run("check edit permission", func(t *testing.T) { + result, err := clie2e.RunCmd(ctx, clie2e.Request{ + Args: []string{"drive", "permission.members", "auth"}, + Params: map[string]any{ + "token": docToken, + "type": "docx", + "action": "edit", + }, + }) + require.NoError(t, err) + result.AssertExitCode(t, 0) + + authResult := gjson.Get(result.Stdout, "data.auth_result") + require.True(t, authResult.Bool(), "should have edit permission on own doc, stdout:\n%s", result.Stdout) + }) +} + +// TestDrive_UserSubscriptionWorkflow tests the user subscription commands. +// Workflow: subscribe to comment events -> check status -> remove subscription. +func TestDrive_UserSubscriptionWorkflow(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), 3*time.Minute) + t.Cleanup(cancel) + + eventType := "drive.notice.comment_add_v1" + + // Step 1: Subscribe to comment events + t.Run("subscribe to comment events", func(t *testing.T) { + result, err := clie2e.RunCmd(ctx, clie2e.Request{ + Args: []string{"drive", "user", "subscription"}, + Data: map[string]any{ + "event_type": eventType, + }, + }) + require.NoError(t, err) + result.AssertExitCode(t, 0) + result.AssertStdoutStatus(t, 0) // Returns code: 0, not ok: true + }) + + // Step 2: Check subscription status + t.Run("check subscription status", func(t *testing.T) { + result, err := clie2e.RunCmd(ctx, clie2e.Request{ + Args: []string{"drive", "user", "subscription_status"}, + Params: map[string]any{ + "event_type": eventType, + }, + }) + require.NoError(t, err) + result.AssertExitCode(t, 0) + + // The response should indicate subscription status + status := gjson.Get(result.Stdout, "data") + require.NotEmpty(t, status.Raw, "subscription status should be returned, stdout:\n%s", result.Stdout) + }) + + // Step 3: Remove subscription + t.Run("remove subscription", func(t *testing.T) { + result, err := clie2e.RunCmd(ctx, clie2e.Request{ + Args: []string{"drive", "user", "remove_subscription"}, + Params: map[string]any{ + "event_type": eventType, + }, + }) + require.NoError(t, err) + result.AssertExitCode(t, 0) + result.AssertStdoutStatus(t, 0) // Returns code: 0, not ok: true + }) +} diff --git a/tests/cli_e2e/drive/helpers_test.go b/tests/cli_e2e/drive/helpers_test.go new file mode 100644 index 00000000..ae9d2e64 --- /dev/null +++ b/tests/cli_e2e/drive/helpers_test.go @@ -0,0 +1,117 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package drive + +import ( + "context" + "os" + "path/filepath" + "testing" + "time" + + clie2e "github.com/larksuite/cli/tests/cli_e2e" + "github.com/stretchr/testify/require" + "github.com/tidwall/gjson" +) + +// testFileDir is the directory for test files (relative path from project root). +const testFileDir = "tests/cli_e2e/drive/testfiles" + +// createTempFile creates a temporary file with given content and returns its relative path. +func createTempFile(t *testing.T, suffix, content string) string { + t.Helper() + + // Create files in a relative path within the project directory + // since --file requires relative paths + testDir := filepath.Join("tests", "cli_e2e", "drive", "testfiles") + _ = os.MkdirAll(testDir, 0755) + + fileName := suffix + "-" + time.Now().UTC().Format("20060102-150405") + ".txt" + filePath := filepath.Join(testDir, fileName) + err := os.WriteFile(filePath, []byte(content), 0644) + require.NoError(t, err) + + t.Cleanup(func() { + os.Remove(filePath) + }) + + return filePath +} + +// uploadTestFile uploads a test file and returns the file token. +// The uploaded file is registered for cleanup via parentT.Cleanup. +func uploadTestFile(t *testing.T, parentT *testing.T, ctx context.Context, suffix string) string { + t.Helper() + + content := "lark-cli-e2e-drive-" + suffix + "-" + time.Now().UTC().Format("20060102-150405") + filePath := createTempFile(t, suffix, content) + + result, err := clie2e.RunCmd(ctx, clie2e.Request{ + Args: []string{"drive", "+upload", "--file", filePath}, + }) + require.NoError(t, err) + result.AssertExitCode(t, 0) + result.AssertStdoutStatus(t, true) + + fileToken := gjson.Get(result.Stdout, "data.file_token").String() + require.NotEmpty(t, fileToken, "stdout:\n%s", result.Stdout) + + parentT.Cleanup(func() { + clie2e.RunCmd(context.Background(), clie2e.Request{ + Args: []string{"drive", "files", "delete"}, + Params: map[string]any{"file_token": fileToken}, + }) + }) + + return fileToken +} + +// importTestDoc imports a markdown file as docx and returns the doc token. +// The imported document is registered for cleanup via parentT.Cleanup. +func importTestDoc(t *testing.T, parentT *testing.T, ctx context.Context, suffix, content string) string { + t.Helper() + + testDir := filepath.Join("tests", "cli_e2e", "drive", "testfiles") + _ = os.MkdirAll(testDir, 0755) + + fileName := "drive-e2e-" + suffix + "-" + time.Now().UTC().Format("20060102-150405") + ".md" + mdFile := filepath.Join(testDir, fileName) + err := os.WriteFile(mdFile, []byte(content), 0644) + require.NoError(t, err) + + t.Cleanup(func() { + os.Remove(mdFile) + }) + + result, err := clie2e.RunCmd(ctx, clie2e.Request{ + Args: []string{"drive", "+import", "--file", mdFile, "--type", "docx"}, + }) + require.NoError(t, err) + result.AssertExitCode(t, 0) + + ticket := gjson.Get(result.Stdout, "data.ticket").String() + docToken := gjson.Get(result.Stdout, "data.token").String() + + if ticket != "" { + // Poll for import completion + pollResult, pollErr := clie2e.RunCmd(ctx, clie2e.Request{ + Args: []string{"drive", "+task_result", "--ticket", ticket, "--scenario", "import"}, + }) + require.NoError(t, pollErr) + pollResult.AssertExitCode(t, 0) + pollResult.AssertStdoutStatus(t, true) + docToken = gjson.Get(pollResult.Stdout, "data.token").String() + } + + require.NotEmpty(t, docToken, "doc_token is required, stdout:\n%s", result.Stdout) + + parentT.Cleanup(func() { + clie2e.RunCmd(context.Background(), clie2e.Request{ + Args: []string{"drive", "files", "delete"}, + Params: map[string]any{"file_token": docToken, "type": "docx"}, + }) + }) + + return docToken +} \ No newline at end of file diff --git a/tests/cli_e2e/im/chat_workflow_test.go b/tests/cli_e2e/im/chat_workflow_test.go new file mode 100644 index 00000000..4ab1dd60 --- /dev/null +++ b/tests/cli_e2e/im/chat_workflow_test.go @@ -0,0 +1,196 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package im + +import ( + "context" + "testing" + "time" + + clie2e "github.com/larksuite/cli/tests/cli_e2e" + "github.com/stretchr/testify/require" + "github.com/tidwall/gjson" +) + +// TestIM_ChatCreateSendWorkflow tests the +chat-create and +messages-send shortcuts. +func TestIM_ChatCreateSendWorkflow(t *testing.T) { + parentT := t + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute) + t.Cleanup(cancel) + + suffix := generateSuffix() + chatName := "lark-cli-e2e-im-" + suffix + messageText := "Hello from lark-cli e2e test" + + chatID := createChat(t, parentT, ctx, chatName) + + t.Run("send text message to chat", func(t *testing.T) { + result, err := clie2e.RunCmd(ctx, clie2e.Request{ + Args: []string{"im", "+messages-send", + "--chat-id", chatID, + "--text", messageText, + }, + }) + require.NoError(t, err) + result.AssertExitCode(t, 0) + result.AssertStdoutStatus(t, true) + + messageID := gjson.Get(result.Stdout, "data.message_id").String() + require.NotEmpty(t, messageID, "message_id should not be empty") + }) + + t.Run("send markdown message to chat", func(t *testing.T) { + markdownContent := "**Bold** and *italic* text" + result, err := clie2e.RunCmd(ctx, clie2e.Request{ + Args: []string{"im", "+messages-send", + "--chat-id", chatID, + "--markdown", markdownContent, + }, + }) + require.NoError(t, err) + result.AssertExitCode(t, 0) + result.AssertStdoutStatus(t, true) + + messageID := gjson.Get(result.Stdout, "data.message_id").String() + require.NotEmpty(t, messageID, "message_id should not be empty") + }) +} + +// TestIM_ChatCreateWithOptionsWorkflow tests +chat-create with various options. +func TestIM_ChatCreateWithOptionsWorkflow(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute) + t.Cleanup(cancel) + + suffix := generateSuffix() + chatName := "lark-cli-e2e-im-users-" + suffix + + t.Run("create chat with set-bot-manager", func(t *testing.T) { + result, err := clie2e.RunCmd(ctx, clie2e.Request{ + Args: []string{"im", "+chat-create", + "--name", chatName, + "--type", "private", + "--set-bot-manager", + }, + }) + require.NoError(t, err) + result.AssertExitCode(t, 0) + result.AssertStdoutStatus(t, true) + + chatID := gjson.Get(result.Stdout, "data.chat_id").String() + require.NotEmpty(t, chatID, "chat_id should not be empty") + }) + + t.Run("create public chat with description", func(t *testing.T) { + publicChatName := chatName + "-public" + result, err := clie2e.RunCmd(ctx, clie2e.Request{ + Args: []string{"im", "+chat-create", + "--name", publicChatName, + "--type", "public", + "--description", "Test public chat for e2e", + }, + }) + require.NoError(t, err) + result.AssertExitCode(t, 0) + result.AssertStdoutStatus(t, true) + + publicChatID := gjson.Get(result.Stdout, "data.chat_id").String() + require.NotEmpty(t, publicChatID) + }) +} + +// TestIM_ChatUpdateWorkflow tests the +chat-update shortcut. +func TestIM_ChatUpdateWorkflow(t *testing.T) { + parentT := t + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute) + t.Cleanup(cancel) + + suffix := generateSuffix() + originalName := "lark-cli-e2e-im-update-" + suffix + updatedName := originalName + "-updated" + updatedDescription := "Updated description for e2e test" + + chatID := createChat(t, parentT, ctx, originalName) + + t.Run("update chat name", func(t *testing.T) { + result, err := clie2e.RunCmd(ctx, clie2e.Request{ + Args: []string{"im", "+chat-update", + "--chat-id", chatID, + "--name", updatedName, + }, + }) + require.NoError(t, err) + result.AssertExitCode(t, 0) + result.AssertStdoutStatus(t, true) + }) + + t.Run("update chat description", func(t *testing.T) { + result, err := clie2e.RunCmd(ctx, clie2e.Request{ + Args: []string{"im", "+chat-update", + "--chat-id", chatID, + "--description", updatedDescription, + }, + }) + require.NoError(t, err) + result.AssertExitCode(t, 0) + result.AssertStdoutStatus(t, true) + }) +} + +// TestIM_ChatsGetWorkflow tests the im chats get command. +func TestIM_ChatsGetWorkflow(t *testing.T) { + parentT := t + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute) + t.Cleanup(cancel) + + suffix := generateSuffix() + chatName := "lark-cli-e2e-chats-get-" + suffix + + chatID := createChat(t, parentT, ctx, chatName) + + t.Run("get chat info", func(t *testing.T) { + result, err := clie2e.RunCmd(ctx, clie2e.Request{ + Args: []string{"im", "chats", "get"}, + Params: map[string]any{"chat_id": chatID}, + }) + require.NoError(t, err) + t.Logf("chats get result: %s", result.Stdout) + result.AssertExitCode(t, 0) + result.AssertStdoutStatus(t, 0) + + dataExists := gjson.Get(result.Stdout, "data").Exists() + require.True(t, dataExists, "data object should exist") + + chatNameGot := gjson.Get(result.Stdout, "data.name").String() + require.Equal(t, chatName, chatNameGot) + }) +} + +// TestIM_ChatsLinkWorkflow tests the im chats link command. +func TestIM_ChatsLinkWorkflow(t *testing.T) { + parentT := t + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute) + t.Cleanup(cancel) + + suffix := generateSuffix() + chatName := "lark-cli-e2e-chats-link-" + suffix + + chatID := createChat(t, parentT, ctx, chatName) + + t.Run("get chat share link", func(t *testing.T) { + result, err := clie2e.RunCmd(ctx, clie2e.Request{ + Args: []string{"im", "chats", "link"}, + Params: map[string]any{"chat_id": chatID}, + Data: map[string]any{ + "validity_period": "week", + }, + }) + require.NoError(t, err) + result.AssertExitCode(t, 0) + result.AssertStdoutStatus(t, 0) + + shareLink := gjson.Get(result.Stdout, "data.share_link").String() + require.NotEmpty(t, shareLink, "share_link should not be empty") + t.Logf("Generated share link: %s", shareLink) + }) +} diff --git a/tests/cli_e2e/im/helpers_test.go b/tests/cli_e2e/im/helpers_test.go new file mode 100644 index 00000000..c9776669 --- /dev/null +++ b/tests/cli_e2e/im/helpers_test.go @@ -0,0 +1,172 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package im + +import ( + "context" + "testing" + "time" + + clie2e "github.com/larksuite/cli/tests/cli_e2e" + "github.com/stretchr/testify/require" + "github.com/tidwall/gjson" +) + +// createChat creates a private chat with the given name and returns the chatID. +// The chat will be automatically cleaned up via parentT.Cleanup(). +// Note: Chat deletion is not available via lark-cli im command. +func createChat(t *testing.T, parentT *testing.T, ctx context.Context, name string) string { + t.Helper() + + result, err := clie2e.RunCmd(ctx, clie2e.Request{ + Args: []string{"im", "+chat-create", + "--name", name, + "--type", "private", + }, + }) + require.NoError(t, err) + result.AssertExitCode(t, 0) + result.AssertStdoutStatus(t, true) + + chatID := gjson.Get(result.Stdout, "data.chat_id").String() + require.NotEmpty(t, chatID, "chat_id should not be empty") + + parentT.Cleanup(func() { + // Best-effort cleanup - chat will be automatically orphaned + // since im chats delete command is not available + }) + + return chatID +} + +// createChatWithBotManager creates a private chat with bot as manager and returns the chatID. +func createChatWithBotManager(t *testing.T, parentT *testing.T, ctx context.Context, name string) string { + t.Helper() + + result, err := clie2e.RunCmd(ctx, clie2e.Request{ + Args: []string{"im", "+chat-create", + "--name", name, + "--type", "private", + "--set-bot-manager", + }, + }) + require.NoError(t, err) + result.AssertExitCode(t, 0) + result.AssertStdoutStatus(t, true) + + chatID := gjson.Get(result.Stdout, "data.chat_id").String() + require.NotEmpty(t, chatID, "chat_id should not be empty") + + parentT.Cleanup(func() { + // Best-effort cleanup - chat will be automatically orphaned + }) + + return chatID +} + +// sendMessage sends a text message to the specified chat and returns the messageID. +func sendMessage(t *testing.T, parentT *testing.T, ctx context.Context, chatID string, text string) string { + t.Helper() + + result, err := clie2e.RunCmd(ctx, clie2e.Request{ + Args: []string{"im", "+messages-send", + "--chat-id", chatID, + "--text", text, + }, + }) + require.NoError(t, err) + result.AssertExitCode(t, 0) + result.AssertStdoutStatus(t, true) + + messageID := gjson.Get(result.Stdout, "data.message_id").String() + require.NotEmpty(t, messageID, "message_id should not be empty") + + return messageID +} + +// sendMarkdown sends a markdown message to the specified chat and returns the messageID. +func sendMarkdown(t *testing.T, parentT *testing.T, ctx context.Context, chatID string, markdown string) string { + t.Helper() + + result, err := clie2e.RunCmd(ctx, clie2e.Request{ + Args: []string{"im", "+messages-send", + "--chat-id", chatID, + "--markdown", markdown, + }, + }) + require.NoError(t, err) + result.AssertExitCode(t, 0) + result.AssertStdoutStatus(t, true) + + messageID := gjson.Get(result.Stdout, "data.message_id").String() + require.NotEmpty(t, messageID, "message_id should not be empty") + + return messageID +} + +// sendImage sends an image message to the specified chat and returns the messageID. +func sendImage(t *testing.T, parentT *testing.T, ctx context.Context, chatID string, imagePath string) string { + t.Helper() + + result, err := clie2e.RunCmd(ctx, clie2e.Request{ + Args: []string{"im", "+messages-send", + "--chat-id", chatID, + "--image", imagePath, + }, + }) + require.NoError(t, err) + result.AssertExitCode(t, 0) + result.AssertStdoutStatus(t, true) + + messageID := gjson.Get(result.Stdout, "data.message_id").String() + require.NotEmpty(t, messageID, "message_id should not be empty") + + return messageID +} + +// replyMessage sends a reply to a message and returns the reply messageID. +func replyMessage(t *testing.T, parentT *testing.T, ctx context.Context, messageID string, text string) string { + t.Helper() + + result, err := clie2e.RunCmd(ctx, clie2e.Request{ + Args: []string{"im", "+messages-reply", + "--message-id", messageID, + "--text", text, + }, + }) + require.NoError(t, err) + result.AssertExitCode(t, 0) + result.AssertStdoutStatus(t, true) + + replyMessageID := gjson.Get(result.Stdout, "data.message_id").String() + require.NotEmpty(t, replyMessageID, "reply message_id should not be empty") + + return replyMessageID +} + +// replyInThread sends a reply in thread to a message and returns the reply messageID. +func replyInThread(t *testing.T, parentT *testing.T, ctx context.Context, messageID string, text string) string { + t.Helper() + + result, err := clie2e.RunCmd(ctx, clie2e.Request{ + Args: []string{"im", "+messages-reply", + "--message-id", messageID, + "--text", text, + "--reply-in-thread", + }, + }) + require.NoError(t, err) + result.AssertExitCode(t, 0) + result.AssertStdoutStatus(t, true) + + replyMessageID := gjson.Get(result.Stdout, "data.message_id").String() + require.NotEmpty(t, replyMessageID, "reply message_id should not be empty") + + return replyMessageID +} + +// generateSuffix generates a unique suffix based on current timestamp. +func generateSuffix() string { + return time.Now().UTC().Format("20060102-150405") +} \ No newline at end of file diff --git a/tests/cli_e2e/im/message_workflow_test.go b/tests/cli_e2e/im/message_workflow_test.go new file mode 100644 index 00000000..5fdbf200 --- /dev/null +++ b/tests/cli_e2e/im/message_workflow_test.go @@ -0,0 +1,82 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package im + +import ( + "context" + "testing" + "time" + + clie2e "github.com/larksuite/cli/tests/cli_e2e" + "github.com/stretchr/testify/require" + "github.com/tidwall/gjson" +) + +// TestIM_MessagesMgetWorkflow tests the +messages-mget shortcut. +func TestIM_MessagesMgetWorkflow(t *testing.T) { + parentT := t + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute) + t.Cleanup(cancel) + + suffix := generateSuffix() + chatName := "lark-cli-e2e-im-mget-" + suffix + messageText := "Message for mget test" + + chatID := createChat(t, parentT, ctx, chatName) + messageID := sendMessage(t, parentT, ctx, chatID, messageText) + + t.Run("batch get messages by ID", func(t *testing.T) { + result, err := clie2e.RunCmd(ctx, clie2e.Request{ + Args: []string{"im", "+messages-mget", + "--message-ids", messageID, + }, + }) + require.NoError(t, err) + result.AssertExitCode(t, 0) + result.AssertStdoutStatus(t, true) + + messages := gjson.Get(result.Stdout, "data").Array() + require.NotEmpty(t, messages, "should get at least one message") + }) +} + +// TestIM_MessagesReplyWorkflow tests the +messages-reply shortcut. +func TestIM_MessagesReplyWorkflow(t *testing.T) { + parentT := t + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute) + t.Cleanup(cancel) + + suffix := generateSuffix() + chatName := "lark-cli-e2e-im-reply-" + suffix + originalMessage := "Original message for reply test" + replyText := "This is a reply" + + chatID := createChat(t, parentT, ctx, chatName) + messageID := sendMessage(t, parentT, ctx, chatID, originalMessage) + + t.Run("reply to message with text", func(t *testing.T) { + result, err := clie2e.RunCmd(ctx, clie2e.Request{ + Args: []string{"im", "+messages-reply", + "--message-id", messageID, + "--text", replyText, + }, + }) + require.NoError(t, err) + result.AssertExitCode(t, 0) + result.AssertStdoutStatus(t, true) + }) + + t.Run("reply to message with markdown", func(t *testing.T) { + markdownReply := "**Bold** reply" + result, err := clie2e.RunCmd(ctx, clie2e.Request{ + Args: []string{"im", "+messages-reply", + "--message-id", messageID, + "--markdown", markdownReply, + }, + }) + require.NoError(t, err) + result.AssertExitCode(t, 0) + result.AssertStdoutStatus(t, true) + }) +} diff --git a/tests/cli_e2e/sheets/sheets_crud_workflow_test.go b/tests/cli_e2e/sheets/sheets_crud_workflow_test.go new file mode 100644 index 00000000..f1134eb7 --- /dev/null +++ b/tests/cli_e2e/sheets/sheets_crud_workflow_test.go @@ -0,0 +1,237 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package sheets + +import ( + "context" + "encoding/json" + "fmt" + "testing" + "time" + + clie2e "github.com/larksuite/cli/tests/cli_e2e" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "github.com/tidwall/gjson" +) + +// TestSheets_CRUDE2EWorkflow tests the full lifecycle of spreadsheet operations +// using all shortcut methods: +create, +read, +write, +append, +find, +info, +export +func TestSheets_CRUDE2EWorkflow(t *testing.T) { + parentT := t + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute) + t.Cleanup(cancel) + + suffix := time.Now().UTC().Format("20060102-150405") + spreadsheetToken := "" + sheetID := "" + + t.Run("create spreadsheet with +create", func(t *testing.T) { + result, err := clie2e.RunCmd(ctx, clie2e.Request{ + Args: []string{"sheets", "+create", "--title", "lark-cli-e2e-sheets-" + suffix}, + }) + require.NoError(t, err) + result.AssertExitCode(t, 0) + result.AssertStdoutStatus(t, true) + + spreadsheetToken = gjson.Get(result.Stdout, "data.spreadsheet_token").String() + require.NotEmpty(t, spreadsheetToken, "spreadsheet token should not be empty, stdout: %s", result.Stdout) + + parentT.Cleanup(func() { + // Best-effort cleanup - spreadsheets don't have a direct delete shortcut + // The spreadsheet will be cleaned up by the test environment if needed + }) + }) + + t.Run("get spreadsheet info with +info", func(t *testing.T) { + require.NotEmpty(t, spreadsheetToken, "spreadsheet token is required") + result, err := clie2e.RunCmd(ctx, clie2e.Request{ + Args: []string{"sheets", "+info", "--spreadsheet-token", spreadsheetToken}, + }) + require.NoError(t, err) + result.AssertExitCode(t, 0) + result.AssertStdoutStatus(t, true) + + assert.Equal(t, spreadsheetToken, gjson.Get(result.Stdout, "data.spreadsheet.spreadsheet.token").String()) + sheetID = gjson.Get(result.Stdout, "data.sheets.sheets.0.sheet_id").String() + require.NotEmpty(t, sheetID, "sheet_id should not be empty, stdout: %s", result.Stdout) + }) + + t.Run("write data with +write", func(t *testing.T) { + require.NotEmpty(t, spreadsheetToken, "spreadsheet token is required") + require.NotEmpty(t, sheetID, "sheet_id is required") + + values := [][]any{ + {"Name", "Age", "City"}, + {"Alice", 25, "Beijing"}, + {"Bob", 30, "Shanghai"}, + } + valuesJSON, _ := json.Marshal(values) + + result, err := clie2e.RunCmd(ctx, clie2e.Request{ + Args: []string{ + "sheets", "+write", + "--spreadsheet-token", spreadsheetToken, + "--sheet-id", sheetID, + "--values", string(valuesJSON), + }, + }) + require.NoError(t, err) + result.AssertExitCode(t, 0) + result.AssertStdoutStatus(t, true) + }) + + t.Run("read data with +read", func(t *testing.T) { + require.NotEmpty(t, spreadsheetToken, "spreadsheet token is required") + require.NotEmpty(t, sheetID, "sheet_id is required") + + result, err := clie2e.RunCmd(ctx, clie2e.Request{ + Args: []string{ + "sheets", "+read", + "--spreadsheet-token", spreadsheetToken, + "--sheet-id", sheetID, + "--range", "A1:C3", + }, + }) + require.NoError(t, err) + result.AssertExitCode(t, 0) + result.AssertStdoutStatus(t, true) + + // Verify the data was written correctly + values := gjson.Get(result.Stdout, "data.valueRange.values") + require.True(t, values.IsArray(), "values should be an array, stdout: %s", result.Stdout) + assert.Equal(t, "Name", values.Array()[0].Array()[0].String()) + assert.Equal(t, "Alice", values.Array()[1].Array()[0].String()) + }) + + t.Run("append rows with +append", func(t *testing.T) { + require.NotEmpty(t, spreadsheetToken, "spreadsheet token is required") + require.NotEmpty(t, sheetID, "sheet_id is required") + + values := [][]any{{"Charlie", 28, "Guangzhou"}} + valuesJSON, _ := json.Marshal(values) + + result, err := clie2e.RunCmd(ctx, clie2e.Request{ + Args: []string{ + "sheets", "+append", + "--spreadsheet-token", spreadsheetToken, + "--sheet-id", sheetID, + "--values", string(valuesJSON), + }, + }) + require.NoError(t, err) + result.AssertExitCode(t, 0) + result.AssertStdoutStatus(t, true) + }) + + t.Run("find cells with +find", func(t *testing.T) { + require.NotEmpty(t, spreadsheetToken, "spreadsheet token is required") + require.NotEmpty(t, sheetID, "sheet_id is required") + + result, err := clie2e.RunCmd(ctx, clie2e.Request{ + Args: []string{ + "sheets", "+find", + "--spreadsheet-token", spreadsheetToken, + "--sheet-id", sheetID, + "--find", "Alice", + "--range", fmt.Sprintf("%s!A1:C10", sheetID), + }, + }) + require.NoError(t, err) + result.AssertExitCode(t, 0) + assert.Equal(t, true, gjson.Get(result.Stdout, "ok").Bool(), "stdout:\n%s", result.Stdout) + + matchedCells := gjson.Get(result.Stdout, "data.find_result.matched_cells") + require.True(t, matchedCells.IsArray(), "matched_cells should be an array, stdout: %s", result.Stdout) + assert.True(t, len(matchedCells.Array()) > 0, "should find at least one cell containing 'Alice'") + }) + + t.Run("export spreadsheet with +export", func(t *testing.T) { + require.NotEmpty(t, spreadsheetToken, "spreadsheet token is required") + + // Export is an async operation; verify it initiates correctly + // The command may have filesystem race issues but the API call succeeds + result, err := clie2e.RunCmd(ctx, clie2e.Request{ + Args: []string{ + "sheets", "+export", + "--spreadsheet-token", spreadsheetToken, + "--file-extension", "xlsx", + }, + }) + require.NoError(t, err) + // Export initiates successfully and returns file_token even if there's a temp file race + assert.NotEmpty(t, gjson.Get(result.Stdout, "data.file_token").String(), + "export should return file_token, stdout: %s", result.Stdout) + }) +} + +// TestSheets_SpreadsheetsResource tests the spreadsheets resource methods +func TestSheets_SpreadsheetsResource(t *testing.T) { + parentT := t + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute) + t.Cleanup(cancel) + + suffix := time.Now().UTC().Format("20060102-150405") + spreadsheetToken := "" + + t.Run("create spreadsheet with spreadsheets create", func(t *testing.T) { + result, err := clie2e.RunCmd(ctx, clie2e.Request{ + Args: []string{"sheets", "spreadsheets", "create"}, + Data: map[string]any{ + "title": "lark-cli-e2e-sheets-resource-" + suffix, + }, + }) + require.NoError(t, err) + result.AssertExitCode(t, 0) + result.AssertStdoutStatus(t, 0) + + spreadsheetToken = gjson.Get(result.Stdout, "data.spreadsheet.spreadsheet_token").String() + require.NotEmpty(t, spreadsheetToken, "spreadsheet token should not be empty, stdout: %s", result.Stdout) + + parentT.Cleanup(func() { + // Best-effort cleanup + }) + }) + + t.Run("get spreadsheet with spreadsheets get", func(t *testing.T) { + require.NotEmpty(t, spreadsheetToken, "spreadsheet token is required") + + result, err := clie2e.RunCmd(ctx, clie2e.Request{ + Args: []string{"sheets", "spreadsheets", "get"}, + Params: map[string]any{"spreadsheet_token": spreadsheetToken}, + }) + require.NoError(t, err) + result.AssertExitCode(t, 0) + result.AssertStdoutStatus(t, 0) + + assert.Equal(t, spreadsheetToken, gjson.Get(result.Stdout, "data.spreadsheet.token").String()) + assert.NotEmpty(t, gjson.Get(result.Stdout, "data.spreadsheet.url").String()) + }) + + t.Run("patch spreadsheet with spreadsheets patch", func(t *testing.T) { + require.NotEmpty(t, spreadsheetToken, "spreadsheet token is required") + + updatedTitle := "lark-cli-e2e-sheets-patched-" + suffix + result, err := clie2e.RunCmd(ctx, clie2e.Request{ + Args: []string{"sheets", "spreadsheets", "patch"}, + Params: map[string]any{"spreadsheet_token": spreadsheetToken}, + Data: map[string]any{"title": updatedTitle}, + }) + require.NoError(t, err) + result.AssertExitCode(t, 0) + result.AssertStdoutStatus(t, 0) + + // Verify the title was updated by fetching the spreadsheet + getResult, err := clie2e.RunCmd(ctx, clie2e.Request{ + Args: []string{"sheets", "spreadsheets", "get"}, + Params: map[string]any{"spreadsheet_token": spreadsheetToken}, + }) + require.NoError(t, err) + getResult.AssertExitCode(t, 0) + getResult.AssertStdoutStatus(t, 0) + + // Verify the title was actually updated + assert.Equal(t, updatedTitle, gjson.Get(getResult.Stdout, "data.spreadsheet.title").String()) + }) +} \ No newline at end of file diff --git a/tests/cli_e2e/sheets/sheets_filter_workflow_test.go b/tests/cli_e2e/sheets/sheets_filter_workflow_test.go new file mode 100644 index 00000000..08d07097 --- /dev/null +++ b/tests/cli_e2e/sheets/sheets_filter_workflow_test.go @@ -0,0 +1,268 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package sheets + +import ( + "context" + "encoding/json" + "fmt" + "testing" + "time" + + clie2e "github.com/larksuite/cli/tests/cli_e2e" + "github.com/stretchr/testify/require" + "github.com/tidwall/gjson" +) + +// TestSheets_FilterWorkflow tests the spreadsheet sheet filter operations +func TestSheets_FilterWorkflow(t *testing.T) { + parentT := t + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute) + t.Cleanup(cancel) + + suffix := time.Now().UTC().Format("20060102-150405") + spreadsheetToken := "" + sheetID := "" + + // First create a spreadsheet and add some data for filtering + t.Run("create spreadsheet with initial data", func(t *testing.T) { + result, err := clie2e.RunCmd(ctx, clie2e.Request{ + Args: []string{"sheets", "+create", "--title", "lark-cli-e2e-sheets-filter-" + suffix}, + }) + require.NoError(t, err) + result.AssertExitCode(t, 0) + result.AssertStdoutStatus(t, true) + + spreadsheetToken = gjson.Get(result.Stdout, "data.spreadsheet_token").String() + require.NotEmpty(t, spreadsheetToken, "spreadsheet token should not be empty, stdout: %s", result.Stdout) + + parentT.Cleanup(func() { + // Best-effort cleanup + }) + }) + + t.Run("get sheet info", func(t *testing.T) { + require.NotEmpty(t, spreadsheetToken, "spreadsheet token is required") + + result, err := clie2e.RunCmd(ctx, clie2e.Request{ + Args: []string{"sheets", "+info", "--spreadsheet-token", spreadsheetToken}, + }) + require.NoError(t, err) + result.AssertExitCode(t, 0) + result.AssertStdoutStatus(t, true) + + sheetID = gjson.Get(result.Stdout, "data.sheets.sheets.0.sheet_id").String() + require.NotEmpty(t, sheetID, "sheet_id should not be empty, stdout: %s", result.Stdout) + }) + + t.Run("write test data for filtering", func(t *testing.T) { + require.NotEmpty(t, spreadsheetToken, "spreadsheet token is required") + require.NotEmpty(t, sheetID, "sheet_id is required") + + values := [][]any{ + {"Name", "Score", "Grade"}, + {"Alice", 85, "B"}, + {"Bob", 92, "A"}, + {"Charlie", 78, "C"}, + {"Diana", 95, "A"}, + } + valuesJSON, _ := json.Marshal(values) + + result, err := clie2e.RunCmd(ctx, clie2e.Request{ + Args: []string{ + "sheets", "+write", + "--spreadsheet-token", spreadsheetToken, + "--sheet-id", sheetID, + "--values", string(valuesJSON), + }, + }) + require.NoError(t, err) + result.AssertExitCode(t, 0) + result.AssertStdoutStatus(t, true) + }) + + t.Run("create filter with spreadsheet.sheet.filters create", func(t *testing.T) { + require.NotEmpty(t, spreadsheetToken, "spreadsheet token is required") + require.NotEmpty(t, sheetID, "sheet_id is required") + + filterData := map[string]any{ + "range": fmt.Sprintf("%s!A1:D5", sheetID), + "col": "C", + "filter_type": "multiValue", + "condition": map[string]any{ + "filter_type": "multiValue", + "expected": []any{"A", "B"}, + }, + } + + result, err := clie2e.RunCmd(ctx, clie2e.Request{ + Args: []string{"sheets", "spreadsheet.sheet.filters", "create"}, + Params: map[string]any{ + "spreadsheet_token": spreadsheetToken, + "sheet_id": sheetID, + }, + Data: filterData, + }) + require.NoError(t, err) + result.AssertExitCode(t, 0) + result.AssertStdoutStatus(t, 0) + }) + + t.Run("get filter with spreadsheet.sheet.filters get", func(t *testing.T) { + require.NotEmpty(t, spreadsheetToken, "spreadsheet token is required") + require.NotEmpty(t, sheetID, "sheet_id is required") + + result, err := clie2e.RunCmd(ctx, clie2e.Request{ + Args: []string{"sheets", "spreadsheet.sheet.filters", "get"}, + Params: map[string]any{ + "spreadsheet_token": spreadsheetToken, + "sheet_id": sheetID, + }, + }) + require.NoError(t, err) + result.AssertExitCode(t, 0) + result.AssertStdoutStatus(t, 0) + + filterInfo := gjson.Get(result.Stdout, "data.sheet_filter_info") + require.True(t, filterInfo.Exists(), "filter info should exist, stdout: %s", result.Stdout) + }) + + t.Run("update filter with spreadsheet.sheet.filters update", func(t *testing.T) { + require.NotEmpty(t, spreadsheetToken, "spreadsheet token is required") + require.NotEmpty(t, sheetID, "sheet_id is required") + + filterData := map[string]any{ + "col": "B", + "filter_type": "number", + "condition": map[string]any{ + "filter_type": "number", + "compare_type": "greater", + "expected": []any{80}, + }, + } + + result, err := clie2e.RunCmd(ctx, clie2e.Request{ + Args: []string{"sheets", "spreadsheet.sheet.filters", "update"}, + Params: map[string]any{ + "spreadsheet_token": spreadsheetToken, + "sheet_id": sheetID, + }, + Data: filterData, + }) + require.NoError(t, err) + result.AssertExitCode(t, 0) + result.AssertStdoutStatus(t, 0) + }) + + t.Run("delete filter with spreadsheet.sheet.filters delete", func(t *testing.T) { + require.NotEmpty(t, spreadsheetToken, "spreadsheet token is required") + require.NotEmpty(t, sheetID, "sheet_id is required") + + result, err := clie2e.RunCmd(ctx, clie2e.Request{ + Args: []string{"sheets", "spreadsheet.sheet.filters", "delete"}, + Params: map[string]any{ + "spreadsheet_token": spreadsheetToken, + "sheet_id": sheetID, + }, + }) + require.NoError(t, err) + result.AssertExitCode(t, 0) + result.AssertStdoutStatus(t, 0) + }) +} + +// TestSheets_FindWorkflow tests the spreadsheet.sheets find operation +func TestSheets_FindWorkflow(t *testing.T) { + parentT := t + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute) + t.Cleanup(cancel) + + suffix := time.Now().UTC().Format("20060102-150405") + spreadsheetToken := "" + sheetID := "" + + // Create spreadsheet and add data for finding + t.Run("create spreadsheet", func(t *testing.T) { + result, err := clie2e.RunCmd(ctx, clie2e.Request{ + Args: []string{"sheets", "+create", "--title", "lark-cli-e2e-sheets-find-" + suffix}, + }) + require.NoError(t, err) + result.AssertExitCode(t, 0) + result.AssertStdoutStatus(t, true) + + spreadsheetToken = gjson.Get(result.Stdout, "data.spreadsheet_token").String() + require.NotEmpty(t, spreadsheetToken, "spreadsheet token should not be empty, stdout: %s", result.Stdout) + + parentT.Cleanup(func() { + // Best-effort cleanup + }) + }) + + t.Run("get sheet info", func(t *testing.T) { + require.NotEmpty(t, spreadsheetToken, "spreadsheet token is required") + + result, err := clie2e.RunCmd(ctx, clie2e.Request{ + Args: []string{"sheets", "+info", "--spreadsheet-token", spreadsheetToken}, + }) + require.NoError(t, err) + result.AssertExitCode(t, 0) + result.AssertStdoutStatus(t, true) + + sheetID = gjson.Get(result.Stdout, "data.sheets.sheets.0.sheet_id").String() + require.NotEmpty(t, sheetID, "sheet_id should not be empty, stdout: %s", result.Stdout) + }) + + t.Run("write test data for finding", func(t *testing.T) { + require.NotEmpty(t, spreadsheetToken, "spreadsheet token is required") + require.NotEmpty(t, sheetID, "sheet_id is required") + + values := [][]any{ + {"apple", "banana", "cherry"}, + {"Apple", "BANANA", "Cherry"}, + {"APPLE", "banana", "CHERRY"}, + } + valuesJSON, _ := json.Marshal(values) + + result, err := clie2e.RunCmd(ctx, clie2e.Request{ + Args: []string{ + "sheets", "+write", + "--spreadsheet-token", spreadsheetToken, + "--sheet-id", sheetID, + "--values", string(valuesJSON), + }, + }) + require.NoError(t, err) + result.AssertExitCode(t, 0) + result.AssertStdoutStatus(t, true) + }) + + t.Run("find cells with spreadsheet.sheets find", func(t *testing.T) { + require.NotEmpty(t, spreadsheetToken, "spreadsheet token is required") + require.NotEmpty(t, sheetID, "sheet_id is required") + + findData := map[string]any{ + "find": "apple", + "find_condition": map[string]any{ + "range": fmt.Sprintf("%s!A1:C3", sheetID), + "match_case": false, + "match_entire_cell": false, + }, + } + + result, err := clie2e.RunCmd(ctx, clie2e.Request{ + Args: []string{"sheets", "spreadsheet.sheets", "find"}, + Params: map[string]any{ + "spreadsheet_token": spreadsheetToken, + "sheet_id": sheetID, + }, + Data: findData, + }) + require.NoError(t, err) + result.AssertExitCode(t, 0) + result.AssertStdoutStatus(t, 0) + + findResult := gjson.Get(result.Stdout, "data.find_result") + require.True(t, findResult.Exists(), "find_result should exist, stdout: %s", result.Stdout) + }) +} \ No newline at end of file diff --git a/tests/cli_e2e/wiki/helpers_test.go b/tests/cli_e2e/wiki/helpers_test.go new file mode 100644 index 00000000..22eeb8ee --- /dev/null +++ b/tests/cli_e2e/wiki/helpers_test.go @@ -0,0 +1,73 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package wiki + +import ( + "context" + "os" + "strings" + "testing" + "time" + + clie2e "github.com/larksuite/cli/tests/cli_e2e" + "github.com/stretchr/testify/require" + "github.com/tidwall/gjson" +) + +func wikiJSONPayload(t *testing.T, result *clie2e.Result) string { + t.Helper() + + raw := strings.TrimSpace(result.Stdout) + if raw == "" { + raw = strings.TrimSpace(result.Stderr) + } + + start := strings.LastIndex(raw, "\n{") + if start >= 0 { + start++ + } else { + start = strings.Index(raw, "{") + } + require.NotEqualf(t, -1, start, "json payload not found:\nstdout:\n%s\nstderr:\n%s", result.Stdout, result.Stderr) + + payload := raw[start:] + require.Truef(t, gjson.Valid(payload), "invalid json payload:\n%s", payload) + return payload +} + +func skipIfWikiUnavailable(t *testing.T, result *clie2e.Result, reason string) { + t.Helper() + + payload := wikiJSONPayload(t, result) + errType := gjson.Get(payload, "error.type").String() + if errType == "config" && !runningInCI() { + t.Skipf("%s: %s", reason, gjson.Get(payload, "error.message").String()) + } +} + +func runningInCI() bool { + return os.Getenv("CI") != "" || os.Getenv("GITHUB_ACTIONS") != "" +} + +func testSuffix() string { + return time.Now().UTC().Format("20060102-150405") +} + +func createWikiNode(t *testing.T, ctx context.Context, req clie2e.Request) gjson.Result { + t.Helper() + + result, err := clie2e.RunCmd(ctx, req) + require.NoError(t, err) + if result.ExitCode != 0 { + skipIfWikiUnavailable(t, result, "requires bot wiki node create capability") + } + result.AssertExitCode(t, 0) + result.AssertStdoutStatus(t, 0) + + payload := wikiJSONPayload(t, result) + node := gjson.Get(payload, "data.node") + require.True(t, node.Exists(), "payload:\n%s", payload) + + return node +} diff --git a/tests/cli_e2e/wiki/wiki_workflow_test.go b/tests/cli_e2e/wiki/wiki_workflow_test.go new file mode 100644 index 00000000..9d830b94 --- /dev/null +++ b/tests/cli_e2e/wiki/wiki_workflow_test.go @@ -0,0 +1,195 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package wiki + +import ( + "context" + "testing" + "time" + + clie2e "github.com/larksuite/cli/tests/cli_e2e" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "github.com/tidwall/gjson" +) + +func TestWiki_NodeWorkflow(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute) + t.Cleanup(cancel) + + suffix := testSuffix() + createdTitle := "lark-cli-e2e-wiki-create-" + suffix + copiedTitle := "lark-cli-e2e-wiki-copy-" + suffix + + var spaceID string + var createdNodeToken string + var createdObjToken string + var copiedNodeToken string + + t.Run("create node", func(t *testing.T) { + node := createWikiNode(t, ctx, clie2e.Request{ + Args: []string{"wiki", "nodes", "create"}, + DefaultAs: "bot", + Params: map[string]any{ + "space_id": "my_library", + }, + Data: map[string]any{ + "node_type": "origin", + "obj_type": "docx", + "title": createdTitle, + }, + }) + + spaceID = node.Get("space_id").String() + createdNodeToken = node.Get("node_token").String() + createdObjToken = node.Get("obj_token").String() + require.NotEmpty(t, spaceID) + require.NotEmpty(t, createdNodeToken) + require.NotEmpty(t, createdObjToken) + assert.Equal(t, createdTitle, node.Get("title").String()) + assert.Equal(t, "origin", node.Get("node_type").String()) + assert.Equal(t, "docx", node.Get("obj_type").String()) + }) + + if createdNodeToken == "" || spaceID == "" { + t.Skip("requires bot wiki create capability") + } + + t.Run("get created node", func(t *testing.T) { + require.NotEmpty(t, createdNodeToken, "node token should be created before get_node") + + result, err := clie2e.RunCmd(ctx, clie2e.Request{ + Args: []string{"wiki", "spaces", "get_node"}, + DefaultAs: "bot", + Params: map[string]any{ + "token": createdNodeToken, + "obj_type": "wiki", + }, + }) + require.NoError(t, err) + if result.ExitCode != 0 { + skipIfWikiUnavailable(t, result, "requires bot wiki node read capability") + } + result.AssertExitCode(t, 0) + result.AssertStdoutStatus(t, 0) + + assert.Equal(t, createdNodeToken, gjson.Get(result.Stdout, "data.node.node_token").String()) + assert.Equal(t, createdObjToken, gjson.Get(result.Stdout, "data.node.obj_token").String()) + assert.Equal(t, createdTitle, gjson.Get(result.Stdout, "data.node.title").String()) + assert.Equal(t, spaceID, gjson.Get(result.Stdout, "data.node.space_id").String()) + }) + + t.Run("get space", func(t *testing.T) { + require.NotEmpty(t, spaceID, "space ID should be available before get") + + result, err := clie2e.RunCmd(ctx, clie2e.Request{ + Args: []string{"wiki", "spaces", "get"}, + DefaultAs: "bot", + Params: map[string]any{ + "space_id": spaceID, + }, + }) + require.NoError(t, err) + if result.ExitCode != 0 { + skipIfWikiUnavailable(t, result, "requires bot wiki space get capability") + } + result.AssertExitCode(t, 0) + result.AssertStdoutStatus(t, 0) + + assert.Equal(t, spaceID, gjson.Get(result.Stdout, "data.space.space_id").String()) + assert.NotEmpty(t, gjson.Get(result.Stdout, "data.space.name").String(), "stdout:\n%s", result.Stdout) + }) + + t.Run("list spaces", func(t *testing.T) { + result, err := clie2e.RunCmd(ctx, clie2e.Request{ + Args: []string{"wiki", "spaces", "list"}, + DefaultAs: "bot", + Params: map[string]any{ + "page_size": 1, + }, + }) + require.NoError(t, err) + if result.ExitCode != 0 { + skipIfWikiUnavailable(t, result, "requires bot wiki space list capability") + } + result.AssertExitCode(t, 0) + result.AssertStdoutStatus(t, 0) + + assert.True(t, gjson.Get(result.Stdout, "data.page_token").Exists(), "stdout:\n%s", result.Stdout) + assert.True(t, gjson.Get(result.Stdout, "data.items").Exists(), "stdout:\n%s", result.Stdout) + }) + + t.Run("list nodes and find created node", func(t *testing.T) { + require.NotEmpty(t, spaceID, "space ID should be available before list") + require.NotEmpty(t, createdNodeToken, "node token should be available before list") + + result, err := clie2e.RunCmd(ctx, clie2e.Request{ + Args: []string{"wiki", "nodes", "list"}, + DefaultAs: "bot", + Params: map[string]any{ + "space_id": spaceID, + "page_size": 50, + }, + }) + require.NoError(t, err) + if result.ExitCode != 0 { + skipIfWikiUnavailable(t, result, "requires bot wiki node list capability") + } + result.AssertExitCode(t, 0) + result.AssertStdoutStatus(t, 0) + + nodeItem := gjson.Get(result.Stdout, `data.items.#(node_token=="`+createdNodeToken+`")`) + assert.True(t, nodeItem.Exists(), "stdout:\n%s", result.Stdout) + assert.Equal(t, createdTitle, nodeItem.Get("title").String()) + assert.Equal(t, createdObjToken, nodeItem.Get("obj_token").String()) + }) + + t.Run("copy node", func(t *testing.T) { + require.NotEmpty(t, spaceID, "space ID should be available before copy") + require.NotEmpty(t, createdNodeToken, "node token should be available before copy") + + copiedNode := createWikiNode(t, ctx, clie2e.Request{ + Args: []string{"wiki", "nodes", "copy"}, + DefaultAs: "bot", + Params: map[string]any{ + "space_id": spaceID, + "node_token": createdNodeToken, + }, + Data: map[string]any{ + "target_space_id": spaceID, + "title": copiedTitle, + }, + }) + + copiedNodeToken = copiedNode.Get("node_token").String() + require.NotEmpty(t, copiedNodeToken) + assert.Equal(t, copiedTitle, copiedNode.Get("title").String()) + assert.Equal(t, spaceID, copiedNode.Get("space_id").String()) + assert.NotEqual(t, createdNodeToken, copiedNodeToken) + }) + + t.Run("list nodes and find copied node", func(t *testing.T) { + require.NotEmpty(t, spaceID, "space ID should be available before second list") + require.NotEmpty(t, copiedNodeToken, "copied node token should be available before second list") + + result, err := clie2e.RunCmd(ctx, clie2e.Request{ + Args: []string{"wiki", "nodes", "list"}, + DefaultAs: "bot", + Params: map[string]any{ + "space_id": spaceID, + "page_size": 50, + }, + }) + require.NoError(t, err) + if result.ExitCode != 0 { + skipIfWikiUnavailable(t, result, "requires bot wiki node list capability") + } + result.AssertExitCode(t, 0) + result.AssertStdoutStatus(t, 0) + + nodeItem := gjson.Get(result.Stdout, `data.items.#(node_token=="`+copiedNodeToken+`")`) + assert.True(t, nodeItem.Exists(), "stdout:\n%s", result.Stdout) + assert.Equal(t, copiedTitle, nodeItem.Get("title").String()) + }) +} From 0da7a69f1dc85c5b2fa7ac77c7eb3ef7afc31351 Mon Sep 17 00:00:00 2001 From: yxzhaao Date: Fri, 10 Apr 2026 18:14:17 +0800 Subject: [PATCH 2/7] fix: address review comments on stable cli e2e tests Change-Id: I4436100c30adf2694cd06953961f8d77f576fc1e --- .../calendar/calendar_create_event_test.go | 16 +-- tests/cli_e2e/docs/docs_create_fetch_test.go | 5 +- tests/cli_e2e/docs/docs_update_test.go | 5 +- tests/cli_e2e/drive/helpers_test.go | 4 +- tests/cli_e2e/im/helpers_test.go | 108 +----------------- 5 files changed, 10 insertions(+), 128 deletions(-) diff --git a/tests/cli_e2e/calendar/calendar_create_event_test.go b/tests/cli_e2e/calendar/calendar_create_event_test.go index 9a3be443..35ad0ac3 100644 --- a/tests/cli_e2e/calendar/calendar_create_event_test.go +++ b/tests/cli_e2e/calendar/calendar_create_event_test.go @@ -91,18 +91,4 @@ func TestCalendar_CreateEvent(t *testing.T) { result.AssertStdoutStatus(t, 0) }) - // Step 5: Verify delete was acknowledged (event may have eventual consistency) - t.Run("verify delete acknowledged", func(t *testing.T) { - require.NotEmpty(t, eventID) - result, err := clie2e.RunCmd(ctx, clie2e.Request{ - Args: []string{"calendar", "events", "get"}, - Params: map[string]any{ - "calendar_id": calendarID, - "event_id": eventID, - }, - }) - require.NoError(t, err) - // Note: API may have eventual consistency - delete acknowledged but get may still succeed briefly - _ = result - }) -} \ No newline at end of file +} diff --git a/tests/cli_e2e/docs/docs_create_fetch_test.go b/tests/cli_e2e/docs/docs_create_fetch_test.go index 3ad5e2f5..1a140de6 100644 --- a/tests/cli_e2e/docs/docs_create_fetch_test.go +++ b/tests/cli_e2e/docs/docs_create_fetch_test.go @@ -41,7 +41,8 @@ func TestDocs_CreateAndFetchWorkflow(t *testing.T) { require.NotEmpty(t, docToken, "stdout:\n%s", result.Stdout) parentT.Cleanup(func() { - // best-effort cleanup + // No docs delete command is currently available in lark-cli, + // so created docs are intentionally left in the test account. }) }) @@ -59,4 +60,4 @@ func TestDocs_CreateAndFetchWorkflow(t *testing.T) { result.AssertStdoutStatus(t, true) assert.Equal(t, docTitle, gjson.Get(result.Stdout, "data.title").String()) }) -} \ No newline at end of file +} diff --git a/tests/cli_e2e/docs/docs_update_test.go b/tests/cli_e2e/docs/docs_update_test.go index f28de8a7..f34c54a0 100644 --- a/tests/cli_e2e/docs/docs_update_test.go +++ b/tests/cli_e2e/docs/docs_update_test.go @@ -43,7 +43,8 @@ func TestDocs_UpdateWorkflow(t *testing.T) { require.NotEmpty(t, docToken, "stdout:\n%s", result.Stdout) parentT.Cleanup(func() { - // best-effort cleanup + // No docs delete command is currently available in lark-cli, + // so created docs are intentionally left in the test account. }) }) @@ -78,4 +79,4 @@ func TestDocs_UpdateWorkflow(t *testing.T) { result.AssertStdoutStatus(t, true) assert.Equal(t, updatedTitle, gjson.Get(result.Stdout, "data.title").String()) }) -} \ No newline at end of file +} diff --git a/tests/cli_e2e/drive/helpers_test.go b/tests/cli_e2e/drive/helpers_test.go index ae9d2e64..5e831903 100644 --- a/tests/cli_e2e/drive/helpers_test.go +++ b/tests/cli_e2e/drive/helpers_test.go @@ -60,7 +60,7 @@ func uploadTestFile(t *testing.T, parentT *testing.T, ctx context.Context, suffi parentT.Cleanup(func() { clie2e.RunCmd(context.Background(), clie2e.Request{ Args: []string{"drive", "files", "delete"}, - Params: map[string]any{"file_token": fileToken}, + Params: map[string]any{"file_token": fileToken, "type": "file"}, }) }) @@ -114,4 +114,4 @@ func importTestDoc(t *testing.T, parentT *testing.T, ctx context.Context, suffix }) return docToken -} \ No newline at end of file +} diff --git a/tests/cli_e2e/im/helpers_test.go b/tests/cli_e2e/im/helpers_test.go index c9776669..3e245f17 100644 --- a/tests/cli_e2e/im/helpers_test.go +++ b/tests/cli_e2e/im/helpers_test.go @@ -40,31 +40,6 @@ func createChat(t *testing.T, parentT *testing.T, ctx context.Context, name stri return chatID } -// createChatWithBotManager creates a private chat with bot as manager and returns the chatID. -func createChatWithBotManager(t *testing.T, parentT *testing.T, ctx context.Context, name string) string { - t.Helper() - - result, err := clie2e.RunCmd(ctx, clie2e.Request{ - Args: []string{"im", "+chat-create", - "--name", name, - "--type", "private", - "--set-bot-manager", - }, - }) - require.NoError(t, err) - result.AssertExitCode(t, 0) - result.AssertStdoutStatus(t, true) - - chatID := gjson.Get(result.Stdout, "data.chat_id").String() - require.NotEmpty(t, chatID, "chat_id should not be empty") - - parentT.Cleanup(func() { - // Best-effort cleanup - chat will be automatically orphaned - }) - - return chatID -} - // sendMessage sends a text message to the specified chat and returns the messageID. func sendMessage(t *testing.T, parentT *testing.T, ctx context.Context, chatID string, text string) string { t.Helper() @@ -85,88 +60,7 @@ func sendMessage(t *testing.T, parentT *testing.T, ctx context.Context, chatID s return messageID } -// sendMarkdown sends a markdown message to the specified chat and returns the messageID. -func sendMarkdown(t *testing.T, parentT *testing.T, ctx context.Context, chatID string, markdown string) string { - t.Helper() - - result, err := clie2e.RunCmd(ctx, clie2e.Request{ - Args: []string{"im", "+messages-send", - "--chat-id", chatID, - "--markdown", markdown, - }, - }) - require.NoError(t, err) - result.AssertExitCode(t, 0) - result.AssertStdoutStatus(t, true) - - messageID := gjson.Get(result.Stdout, "data.message_id").String() - require.NotEmpty(t, messageID, "message_id should not be empty") - - return messageID -} - -// sendImage sends an image message to the specified chat and returns the messageID. -func sendImage(t *testing.T, parentT *testing.T, ctx context.Context, chatID string, imagePath string) string { - t.Helper() - - result, err := clie2e.RunCmd(ctx, clie2e.Request{ - Args: []string{"im", "+messages-send", - "--chat-id", chatID, - "--image", imagePath, - }, - }) - require.NoError(t, err) - result.AssertExitCode(t, 0) - result.AssertStdoutStatus(t, true) - - messageID := gjson.Get(result.Stdout, "data.message_id").String() - require.NotEmpty(t, messageID, "message_id should not be empty") - - return messageID -} - -// replyMessage sends a reply to a message and returns the reply messageID. -func replyMessage(t *testing.T, parentT *testing.T, ctx context.Context, messageID string, text string) string { - t.Helper() - - result, err := clie2e.RunCmd(ctx, clie2e.Request{ - Args: []string{"im", "+messages-reply", - "--message-id", messageID, - "--text", text, - }, - }) - require.NoError(t, err) - result.AssertExitCode(t, 0) - result.AssertStdoutStatus(t, true) - - replyMessageID := gjson.Get(result.Stdout, "data.message_id").String() - require.NotEmpty(t, replyMessageID, "reply message_id should not be empty") - - return replyMessageID -} - -// replyInThread sends a reply in thread to a message and returns the reply messageID. -func replyInThread(t *testing.T, parentT *testing.T, ctx context.Context, messageID string, text string) string { - t.Helper() - - result, err := clie2e.RunCmd(ctx, clie2e.Request{ - Args: []string{"im", "+messages-reply", - "--message-id", messageID, - "--text", text, - "--reply-in-thread", - }, - }) - require.NoError(t, err) - result.AssertExitCode(t, 0) - result.AssertStdoutStatus(t, true) - - replyMessageID := gjson.Get(result.Stdout, "data.message_id").String() - require.NotEmpty(t, replyMessageID, "reply message_id should not be empty") - - return replyMessageID -} - // generateSuffix generates a unique suffix based on current timestamp. func generateSuffix() string { return time.Now().UTC().Format("20060102-150405") -} \ No newline at end of file +} From e7f3f1426c74a64d50a314ae79134fa10be3d52a Mon Sep 17 00:00:00 2001 From: yxzhaao Date: Fri, 10 Apr 2026 18:42:17 +0800 Subject: [PATCH 3/7] fix: reduce flakiness in drive and im e2e helpers Change-Id: I51e77d857f1fd9aec5ee34adf5045cc695239f21 --- tests/cli_e2e/drive/helpers_test.go | 63 ++++++++++++++++++++--------- tests/cli_e2e/im/helpers_test.go | 4 +- 2 files changed, 48 insertions(+), 19 deletions(-) diff --git a/tests/cli_e2e/drive/helpers_test.go b/tests/cli_e2e/drive/helpers_test.go index 5e831903..41310f47 100644 --- a/tests/cli_e2e/drive/helpers_test.go +++ b/tests/cli_e2e/drive/helpers_test.go @@ -25,11 +25,15 @@ func createTempFile(t *testing.T, suffix, content string) string { // Create files in a relative path within the project directory // since --file requires relative paths testDir := filepath.Join("tests", "cli_e2e", "drive", "testfiles") - _ = os.MkdirAll(testDir, 0755) + err := os.MkdirAll(testDir, 0o755) + require.NoError(t, err) - fileName := suffix + "-" + time.Now().UTC().Format("20060102-150405") + ".txt" - filePath := filepath.Join(testDir, fileName) - err := os.WriteFile(filePath, []byte(content), 0644) + file, err := os.CreateTemp(testDir, suffix+"-*.txt") + require.NoError(t, err) + filePath := file.Name() + _, err = file.WriteString(content) + require.NoError(t, err) + err = file.Close() require.NoError(t, err) t.Cleanup(func() { @@ -58,7 +62,12 @@ func uploadTestFile(t *testing.T, parentT *testing.T, ctx context.Context, suffi require.NotEmpty(t, fileToken, "stdout:\n%s", result.Stdout) parentT.Cleanup(func() { - clie2e.RunCmd(context.Background(), clie2e.Request{ + cleanupCtx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + + // No drive delete shortcut/resource is currently available in lark-cli. + // Keep this cleanup bounded and best-effort so it does not hang teardown. + _, _ = clie2e.RunCmd(cleanupCtx, clie2e.Request{ Args: []string{"drive", "files", "delete"}, Params: map[string]any{"file_token": fileToken, "type": "file"}, }) @@ -73,11 +82,15 @@ func importTestDoc(t *testing.T, parentT *testing.T, ctx context.Context, suffix t.Helper() testDir := filepath.Join("tests", "cli_e2e", "drive", "testfiles") - _ = os.MkdirAll(testDir, 0755) + err := os.MkdirAll(testDir, 0o755) + require.NoError(t, err) - fileName := "drive-e2e-" + suffix + "-" + time.Now().UTC().Format("20060102-150405") + ".md" - mdFile := filepath.Join(testDir, fileName) - err := os.WriteFile(mdFile, []byte(content), 0644) + file, err := os.CreateTemp(testDir, "drive-e2e-"+suffix+"-*.md") + require.NoError(t, err) + mdFile := file.Name() + _, err = file.WriteString(content) + require.NoError(t, err) + err = file.Close() require.NoError(t, err) t.Cleanup(func() { @@ -94,20 +107,34 @@ func importTestDoc(t *testing.T, parentT *testing.T, ctx context.Context, suffix docToken := gjson.Get(result.Stdout, "data.token").String() if ticket != "" { - // Poll for import completion - pollResult, pollErr := clie2e.RunCmd(ctx, clie2e.Request{ - Args: []string{"drive", "+task_result", "--ticket", ticket, "--scenario", "import"}, - }) - require.NoError(t, pollErr) - pollResult.AssertExitCode(t, 0) - pollResult.AssertStdoutStatus(t, true) - docToken = gjson.Get(pollResult.Stdout, "data.token").String() + deadline := time.Now().Add(45 * time.Second) + for { + pollResult, pollErr := clie2e.RunCmd(ctx, clie2e.Request{ + Args: []string{"drive", "+task_result", "--ticket", ticket, "--scenario", "import"}, + }) + require.NoError(t, pollErr) + pollResult.AssertExitCode(t, 0) + pollResult.AssertStdoutStatus(t, true) + docToken = gjson.Get(pollResult.Stdout, "data.token").String() + if docToken != "" { + break + } + if time.Now().After(deadline) { + t.Fatalf("import task did not return token before timeout, ticket=%s", ticket) + } + time.Sleep(2 * time.Second) + } } require.NotEmpty(t, docToken, "doc_token is required, stdout:\n%s", result.Stdout) parentT.Cleanup(func() { - clie2e.RunCmd(context.Background(), clie2e.Request{ + cleanupCtx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + + // No drive delete shortcut/resource is currently available in lark-cli. + // Keep this cleanup bounded and best-effort so it does not hang teardown. + _, _ = clie2e.RunCmd(cleanupCtx, clie2e.Request{ Args: []string{"drive", "files", "delete"}, Params: map[string]any{"file_token": docToken, "type": "docx"}, }) diff --git a/tests/cli_e2e/im/helpers_test.go b/tests/cli_e2e/im/helpers_test.go index 3e245f17..f70789f9 100644 --- a/tests/cli_e2e/im/helpers_test.go +++ b/tests/cli_e2e/im/helpers_test.go @@ -5,6 +5,7 @@ package im import ( "context" + "fmt" "testing" "time" @@ -62,5 +63,6 @@ func sendMessage(t *testing.T, parentT *testing.T, ctx context.Context, chatID s // generateSuffix generates a unique suffix based on current timestamp. func generateSuffix() string { - return time.Now().UTC().Format("20060102-150405") + now := time.Now().UTC() + return fmt.Sprintf("%s-%09d", now.Format("20060102-150405"), now.Nanosecond()) } From 6e1647ba90dac3b16eb64413cf2e8d4e9d1c38f3 Mon Sep 17 00:00:00 2001 From: yxzhaao Date: Fri, 10 Apr 2026 18:53:46 +0800 Subject: [PATCH 4/7] fix: document missing drive cleanup support Change-Id: I3d4f034145bd69fb7640e707fcda05146b8754c7 --- tests/cli_e2e/drive/helpers_test.go | 22 ++++------------------ 1 file changed, 4 insertions(+), 18 deletions(-) diff --git a/tests/cli_e2e/drive/helpers_test.go b/tests/cli_e2e/drive/helpers_test.go index 41310f47..42b847f1 100644 --- a/tests/cli_e2e/drive/helpers_test.go +++ b/tests/cli_e2e/drive/helpers_test.go @@ -62,15 +62,8 @@ func uploadTestFile(t *testing.T, parentT *testing.T, ctx context.Context, suffi require.NotEmpty(t, fileToken, "stdout:\n%s", result.Stdout) parentT.Cleanup(func() { - cleanupCtx, cancel := context.WithTimeout(context.Background(), 30*time.Second) - defer cancel() - - // No drive delete shortcut/resource is currently available in lark-cli. - // Keep this cleanup bounded and best-effort so it does not hang teardown. - _, _ = clie2e.RunCmd(cleanupCtx, clie2e.Request{ - Args: []string{"drive", "files", "delete"}, - Params: map[string]any{"file_token": fileToken, "type": "file"}, - }) + // No drive delete shortcut/resource is currently available in lark-cli, + // so uploaded files cannot be cleaned up automatically. }) return fileToken @@ -129,15 +122,8 @@ func importTestDoc(t *testing.T, parentT *testing.T, ctx context.Context, suffix require.NotEmpty(t, docToken, "doc_token is required, stdout:\n%s", result.Stdout) parentT.Cleanup(func() { - cleanupCtx, cancel := context.WithTimeout(context.Background(), 30*time.Second) - defer cancel() - - // No drive delete shortcut/resource is currently available in lark-cli. - // Keep this cleanup bounded and best-effort so it does not hang teardown. - _, _ = clie2e.RunCmd(cleanupCtx, clie2e.Request{ - Args: []string{"drive", "files", "delete"}, - Params: map[string]any{"file_token": docToken, "type": "docx"}, - }) + // No drive delete shortcut/resource is currently available in lark-cli, + // so imported docs cannot be cleaned up automatically. }) return docToken From 2752f4110b7131ae70d1702f51022a3a265943d7 Mon Sep 17 00:00:00 2001 From: yxzhaao Date: Fri, 10 Apr 2026 18:59:07 +0800 Subject: [PATCH 5/7] style: unify e2e cleanup comments Change-Id: I40d906c9168754ad71ef9fb770ff4c340fc19beb --- tests/cli_e2e/drive/helpers_test.go | 8 ++++---- tests/cli_e2e/im/helpers_test.go | 4 ++-- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/tests/cli_e2e/drive/helpers_test.go b/tests/cli_e2e/drive/helpers_test.go index 42b847f1..5bd4894d 100644 --- a/tests/cli_e2e/drive/helpers_test.go +++ b/tests/cli_e2e/drive/helpers_test.go @@ -62,8 +62,8 @@ func uploadTestFile(t *testing.T, parentT *testing.T, ctx context.Context, suffi require.NotEmpty(t, fileToken, "stdout:\n%s", result.Stdout) parentT.Cleanup(func() { - // No drive delete shortcut/resource is currently available in lark-cli, - // so uploaded files cannot be cleaned up automatically. + // No drive delete command is currently available in lark-cli, + // so uploaded files are intentionally left in the test account. }) return fileToken @@ -122,8 +122,8 @@ func importTestDoc(t *testing.T, parentT *testing.T, ctx context.Context, suffix require.NotEmpty(t, docToken, "doc_token is required, stdout:\n%s", result.Stdout) parentT.Cleanup(func() { - // No drive delete shortcut/resource is currently available in lark-cli, - // so imported docs cannot be cleaned up automatically. + // No drive delete command is currently available in lark-cli, + // so imported docs are intentionally left in the test account. }) return docToken diff --git a/tests/cli_e2e/im/helpers_test.go b/tests/cli_e2e/im/helpers_test.go index f70789f9..7888f52e 100644 --- a/tests/cli_e2e/im/helpers_test.go +++ b/tests/cli_e2e/im/helpers_test.go @@ -34,8 +34,8 @@ func createChat(t *testing.T, parentT *testing.T, ctx context.Context, name stri require.NotEmpty(t, chatID, "chat_id should not be empty") parentT.Cleanup(func() { - // Best-effort cleanup - chat will be automatically orphaned - // since im chats delete command is not available + // No IM chat delete command is currently available in lark-cli, + // so created chats are intentionally left in the test account. }) return chatID From 6923808329ef35b4be5ccd2ff33d2979359dba60 Mon Sep 17 00:00:00 2001 From: yxzhaao Date: Fri, 10 Apr 2026 20:15:35 +0800 Subject: [PATCH 6/7] test: update e2e assertions Change-Id: I73c21b4b38d4ced7ea27cb327075957ec2b9a2a2 --- .../cli_e2e/base/base_basic_workflow_test.go | 2 +- .../sheets/sheets_filter_workflow_test.go | 99 +------------------ 2 files changed, 3 insertions(+), 98 deletions(-) diff --git a/tests/cli_e2e/base/base_basic_workflow_test.go b/tests/cli_e2e/base/base_basic_workflow_test.go index a7537898..f2e956f7 100644 --- a/tests/cli_e2e/base/base_basic_workflow_test.go +++ b/tests/cli_e2e/base/base_basic_workflow_test.go @@ -78,7 +78,7 @@ func TestBase_BasicWorkflow(t *testing.T) { } result.AssertExitCode(t, 0) result.AssertStdoutStatus(t, true) - assert.True(t, gjson.Get(result.Stdout, `data.items.#(table_id=="`+tableID+`")`).Exists(), "stdout:\n%s", result.Stdout) + assert.True(t, gjson.Get(result.Stdout, `data.tables.#(id=="`+tableID+`")`).Exists(), "stdout:\n%s", result.Stdout) }) require.NotEmpty(t, primaryFieldID) diff --git a/tests/cli_e2e/sheets/sheets_filter_workflow_test.go b/tests/cli_e2e/sheets/sheets_filter_workflow_test.go index 08d07097..372d963c 100644 --- a/tests/cli_e2e/sheets/sheets_filter_workflow_test.go +++ b/tests/cli_e2e/sheets/sheets_filter_workflow_test.go @@ -25,7 +25,6 @@ func TestSheets_FilterWorkflow(t *testing.T) { spreadsheetToken := "" sheetID := "" - // First create a spreadsheet and add some data for filtering t.Run("create spreadsheet with initial data", func(t *testing.T) { result, err := clie2e.RunCmd(ctx, clie2e.Request{ Args: []string{"sheets", "+create", "--title", "lark-cli-e2e-sheets-filter-" + suffix}, @@ -38,7 +37,8 @@ func TestSheets_FilterWorkflow(t *testing.T) { require.NotEmpty(t, spreadsheetToken, "spreadsheet token should not be empty, stdout: %s", result.Stdout) parentT.Cleanup(func() { - // Best-effort cleanup + // No sheets delete command is currently available in lark-cli, + // so created spreadsheets are intentionally left in the test account. }) }) @@ -171,98 +171,3 @@ func TestSheets_FilterWorkflow(t *testing.T) { result.AssertStdoutStatus(t, 0) }) } - -// TestSheets_FindWorkflow tests the spreadsheet.sheets find operation -func TestSheets_FindWorkflow(t *testing.T) { - parentT := t - ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute) - t.Cleanup(cancel) - - suffix := time.Now().UTC().Format("20060102-150405") - spreadsheetToken := "" - sheetID := "" - - // Create spreadsheet and add data for finding - t.Run("create spreadsheet", func(t *testing.T) { - result, err := clie2e.RunCmd(ctx, clie2e.Request{ - Args: []string{"sheets", "+create", "--title", "lark-cli-e2e-sheets-find-" + suffix}, - }) - require.NoError(t, err) - result.AssertExitCode(t, 0) - result.AssertStdoutStatus(t, true) - - spreadsheetToken = gjson.Get(result.Stdout, "data.spreadsheet_token").String() - require.NotEmpty(t, spreadsheetToken, "spreadsheet token should not be empty, stdout: %s", result.Stdout) - - parentT.Cleanup(func() { - // Best-effort cleanup - }) - }) - - t.Run("get sheet info", func(t *testing.T) { - require.NotEmpty(t, spreadsheetToken, "spreadsheet token is required") - - result, err := clie2e.RunCmd(ctx, clie2e.Request{ - Args: []string{"sheets", "+info", "--spreadsheet-token", spreadsheetToken}, - }) - require.NoError(t, err) - result.AssertExitCode(t, 0) - result.AssertStdoutStatus(t, true) - - sheetID = gjson.Get(result.Stdout, "data.sheets.sheets.0.sheet_id").String() - require.NotEmpty(t, sheetID, "sheet_id should not be empty, stdout: %s", result.Stdout) - }) - - t.Run("write test data for finding", func(t *testing.T) { - require.NotEmpty(t, spreadsheetToken, "spreadsheet token is required") - require.NotEmpty(t, sheetID, "sheet_id is required") - - values := [][]any{ - {"apple", "banana", "cherry"}, - {"Apple", "BANANA", "Cherry"}, - {"APPLE", "banana", "CHERRY"}, - } - valuesJSON, _ := json.Marshal(values) - - result, err := clie2e.RunCmd(ctx, clie2e.Request{ - Args: []string{ - "sheets", "+write", - "--spreadsheet-token", spreadsheetToken, - "--sheet-id", sheetID, - "--values", string(valuesJSON), - }, - }) - require.NoError(t, err) - result.AssertExitCode(t, 0) - result.AssertStdoutStatus(t, true) - }) - - t.Run("find cells with spreadsheet.sheets find", func(t *testing.T) { - require.NotEmpty(t, spreadsheetToken, "spreadsheet token is required") - require.NotEmpty(t, sheetID, "sheet_id is required") - - findData := map[string]any{ - "find": "apple", - "find_condition": map[string]any{ - "range": fmt.Sprintf("%s!A1:C3", sheetID), - "match_case": false, - "match_entire_cell": false, - }, - } - - result, err := clie2e.RunCmd(ctx, clie2e.Request{ - Args: []string{"sheets", "spreadsheet.sheets", "find"}, - Params: map[string]any{ - "spreadsheet_token": spreadsheetToken, - "sheet_id": sheetID, - }, - Data: findData, - }) - require.NoError(t, err) - result.AssertExitCode(t, 0) - result.AssertStdoutStatus(t, 0) - - findResult := gjson.Get(result.Stdout, "data.find_result") - require.True(t, findResult.Exists(), "find_result should exist, stdout: %s", result.Stdout) - }) -} \ No newline at end of file From fe3d642b1bb3ae0ae0f1199308552a00d339728a Mon Sep 17 00:00:00 2001 From: yxzhaao Date: Sat, 11 Apr 2026 18:41:29 +0800 Subject: [PATCH 7/7] test: stabilize cli e2e bot-only coverage Change-Id: Ied897c37c4f42e446d55d110461aa34ae198195d --- .../cli_e2e/base/base_basic_workflow_test.go | 17 +- tests/cli_e2e/base/base_role_workflow_test.go | 61 ++- tests/cli_e2e/base/helpers_test.go | 379 +----------------- .../calendar/calendar_create_event_test.go | 40 +- .../calendar_find_meeting_time_test.go | 49 --- .../calendar/calendar_manage_calendar_test.go | 70 +++- .../calendar/calendar_view_agenda_test.go | 48 --- tests/cli_e2e/calendar/helpers_test.go | 55 +-- tests/cli_e2e/core.go | 67 ++++ tests/cli_e2e/docs/docs_create_fetch_test.go | 23 +- tests/cli_e2e/docs/docs_update_test.go | 23 +- tests/cli_e2e/docs/helpers_test.go | 54 +++ .../drive/drive_files_workflow_test.go | 29 +- .../cli_e2e/drive/drive_move_workflow_test.go | 57 --- .../drive_permission_user_workflow_test.go | 111 ----- tests/cli_e2e/drive/helpers_test.go | 122 +----- tests/cli_e2e/im/chat_workflow_test.go | 106 +---- tests/cli_e2e/im/helpers_test.go | 8 - tests/cli_e2e/im/message_workflow_test.go | 31 +- .../sheets/sheets_crud_workflow_test.go | 16 +- .../sheets/sheets_filter_workflow_test.go | 7 +- .../task/task_comment_workflow_test.go | 2 +- .../task/task_reminder_workflow_test.go | 6 +- .../cli_e2e/task/task_status_workflow_test.go | 2 +- .../task/tasklist_add_task_workflow_test.go | 2 +- tests/cli_e2e/task/tasklist_workflow_test.go | 2 +- tests/cli_e2e/wiki/helpers_test.go | 87 ++-- tests/cli_e2e/wiki/wiki_workflow_test.go | 51 +-- 28 files changed, 355 insertions(+), 1170 deletions(-) delete mode 100644 tests/cli_e2e/calendar/calendar_find_meeting_time_test.go delete mode 100644 tests/cli_e2e/calendar/calendar_view_agenda_test.go create mode 100644 tests/cli_e2e/docs/helpers_test.go delete mode 100644 tests/cli_e2e/drive/drive_move_workflow_test.go delete mode 100644 tests/cli_e2e/drive/drive_permission_user_workflow_test.go diff --git a/tests/cli_e2e/base/base_basic_workflow_test.go b/tests/cli_e2e/base/base_basic_workflow_test.go index f2e956f7..d0539f20 100644 --- a/tests/cli_e2e/base/base_basic_workflow_test.go +++ b/tests/cli_e2e/base/base_basic_workflow_test.go @@ -19,8 +19,8 @@ func TestBase_BasicWorkflow(t *testing.T) { ctx, cancel := context.WithTimeout(context.Background(), 4*time.Minute) t.Cleanup(cancel) - baseName := "lark-cli-e2e-base-basic-" + testSuffix() - baseToken := createBase(t, ctx, baseName) + baseName := "lark-cli-e2e-base-basic-" + clie2e.GenerateSuffix() + baseToken := createBaseWithRetry(t, ctx, baseName) t.Run("get base", func(t *testing.T) { result, err := clie2e.RunCmd(ctx, clie2e.Request{ @@ -28,9 +28,6 @@ func TestBase_BasicWorkflow(t *testing.T) { DefaultAs: "bot", }) require.NoError(t, err) - if result.ExitCode != 0 { - skipIfBaseUnavailable(t, result, "requires bot base get capability") - } result.AssertExitCode(t, 0) result.AssertStdoutStatus(t, true) returnedBaseToken := gjson.Get(result.Stdout, "data.base.app_token").String() @@ -41,8 +38,8 @@ func TestBase_BasicWorkflow(t *testing.T) { assert.NotEmpty(t, gjson.Get(result.Stdout, "data.base.name").String(), "stdout:\n%s", result.Stdout) }) - tableName := "lark-cli-e2e-table-basic-" + testSuffix() - tableID, primaryFieldID, primaryViewID := createTable( + tableName := "lark-cli-e2e-table-basic-" + clie2e.GenerateSuffix() + tableID, primaryFieldID, primaryViewID := createTableWithRetry( t, parentT, ctx, @@ -58,9 +55,6 @@ func TestBase_BasicWorkflow(t *testing.T) { DefaultAs: "bot", }) require.NoError(t, err) - if result.ExitCode != 0 { - skipIfBaseUnavailable(t, result, "requires bot table get capability") - } result.AssertExitCode(t, 0) result.AssertStdoutStatus(t, true) assert.Equal(t, tableID, gjson.Get(result.Stdout, "data.table.id").String()) @@ -73,9 +67,6 @@ func TestBase_BasicWorkflow(t *testing.T) { DefaultAs: "bot", }) require.NoError(t, err) - if result.ExitCode != 0 { - skipIfBaseUnavailable(t, result, "requires bot table list capability") - } result.AssertExitCode(t, 0) result.AssertStdoutStatus(t, true) assert.True(t, gjson.Get(result.Stdout, `data.tables.#(id=="`+tableID+`")`).Exists(), "stdout:\n%s", result.Stdout) diff --git a/tests/cli_e2e/base/base_role_workflow_test.go b/tests/cli_e2e/base/base_role_workflow_test.go index ec5fba14..05f2f921 100644 --- a/tests/cli_e2e/base/base_role_workflow_test.go +++ b/tests/cli_e2e/base/base_role_workflow_test.go @@ -19,20 +19,35 @@ func TestBase_RoleWorkflow(t *testing.T) { ctx, cancel := context.WithTimeout(context.Background(), 4*time.Minute) t.Cleanup(cancel) - baseToken := createBase(t, ctx, "lark-cli-e2e-base-role-"+testSuffix()) + baseToken := createBaseWithRetry(t, ctx, "lark-cli-e2e-base-role-"+clie2e.GenerateSuffix()) result, err := clie2e.RunCmd(ctx, clie2e.Request{ Args: []string{"base", "+advperm-enable", "--base-token", baseToken}, DefaultAs: "bot", }) require.NoError(t, err) - if result.ExitCode != 0 { - skipIfBaseUnavailable(t, result, "requires bot advanced permission enable capability") - } result.AssertExitCode(t, 0) result.AssertStdoutStatus(t, true) - roleName := "Reviewer-" + testSuffix() - roleID := createRole(t, parentT, ctx, baseToken, `{"role_name":"`+roleName+`","role_type":"custom_role"}`) + roleName := "Reviewer-" + clie2e.GenerateSuffix() + createRole(t, ctx, baseToken, `{"role_name":"`+roleName+`","role_type":"custom_role"}`) + roleID := "" + + parentT.Cleanup(func() { + if roleID == "" { + return + } + + cleanupCtx, cancel := cleanupContext() + defer cancel() + + deleteResult, deleteErr := clie2e.RunCmd(cleanupCtx, clie2e.Request{ + Args: []string{"base", "+role-delete", "--base-token", baseToken, "--role-id", roleID, "--yes"}, + DefaultAs: "bot", + }) + if deleteErr != nil || deleteResult.ExitCode != 0 { + reportCleanupFailure(parentT, "delete role "+roleID, deleteResult, deleteErr) + } + }) t.Run("list", func(t *testing.T) { result, err := clie2e.RunCmd(ctx, clie2e.Request{ @@ -40,9 +55,6 @@ func TestBase_RoleWorkflow(t *testing.T) { DefaultAs: "bot", }) require.NoError(t, err) - if result.ExitCode != 0 { - skipIfBaseUnavailable(t, result, "requires bot role list capability") - } result.AssertExitCode(t, 0) result.AssertStdoutStatus(t, true) @@ -59,23 +71,24 @@ func TestBase_RoleWorkflow(t *testing.T) { if !gjson.Valid(rolePayload) { continue } - if gjson.Get(rolePayload, "role_id").String() == roleID { + if gjson.Get(rolePayload, "role_name").String() == roleName { + roleID = gjson.Get(rolePayload, "role_id").String() found = true break } } - assert.True(t, found, "stdout:\n%s", result.Stdout) + require.True(t, found, "stdout:\n%s", result.Stdout) + require.NotEmpty(t, roleID, "stdout:\n%s", result.Stdout) }) t.Run("get", func(t *testing.T) { + require.NotEmpty(t, roleID, "role ID should be resolved before get") + result, err := clie2e.RunCmd(ctx, clie2e.Request{ Args: []string{"base", "+role-get", "--base-token", baseToken, "--role-id", roleID}, DefaultAs: "bot", }) require.NoError(t, err) - if result.ExitCode != 0 { - skipIfBaseUnavailable(t, result, "requires bot role get capability") - } result.AssertExitCode(t, 0) result.AssertStdoutStatus(t, true) @@ -86,15 +99,14 @@ func TestBase_RoleWorkflow(t *testing.T) { }) t.Run("update", func(t *testing.T) { + require.NotEmpty(t, roleID, "role ID should be resolved before update") + updatedRoleName := roleName + " Updated" result, err := clie2e.RunCmd(ctx, clie2e.Request{ Args: []string{"base", "+role-update", "--base-token", baseToken, "--role-id", roleID, "--json", `{"role_name":"` + updatedRoleName + `","role_type":"custom_role"}`, "--yes"}, DefaultAs: "bot", }) require.NoError(t, err) - if result.ExitCode != 0 { - skipIfBaseUnavailable(t, result, "requires bot role update capability") - } result.AssertExitCode(t, 0) result.AssertStdoutStatus(t, true) @@ -103,9 +115,6 @@ func TestBase_RoleWorkflow(t *testing.T) { DefaultAs: "bot", }) require.NoError(t, err) - if getResult.ExitCode != 0 { - skipIfBaseUnavailable(t, getResult, "requires bot role get capability") - } getResult.AssertExitCode(t, 0) getResult.AssertStdoutStatus(t, true) @@ -115,16 +124,4 @@ func TestBase_RoleWorkflow(t *testing.T) { assert.Equal(t, updatedRoleName, gjson.Get(rolePayload, "role_name").String()) }) - t.Run("delete", func(t *testing.T) { - result, err := clie2e.RunCmd(ctx, clie2e.Request{ - Args: []string{"base", "+role-delete", "--base-token", baseToken, "--role-id", roleID, "--yes"}, - DefaultAs: "bot", - }) - require.NoError(t, err) - if result.ExitCode != 0 { - skipIfBaseUnavailable(t, result, "requires bot role delete capability") - } - result.AssertExitCode(t, 0) - result.AssertStdoutStatus(t, true) - }) } diff --git a/tests/cli_e2e/base/helpers_test.go b/tests/cli_e2e/base/helpers_test.go index f72b3e14..4d1e4427 100644 --- a/tests/cli_e2e/base/helpers_test.go +++ b/tests/cli_e2e/base/helpers_test.go @@ -5,8 +5,6 @@ package base import ( "context" - "os" - "path/filepath" "strings" "testing" "time" @@ -18,41 +16,6 @@ import ( const cleanupTimeout = 30 * time.Second -func baseJSONPayload(t *testing.T, result *clie2e.Result) string { - t.Helper() - - raw := strings.TrimSpace(result.Stdout) - if raw == "" { - raw = strings.TrimSpace(result.Stderr) - } - - start := strings.LastIndex(raw, "\n{") - if start >= 0 { - start++ - } else { - start = strings.Index(raw, "{") - } - require.NotEqualf(t, -1, start, "json payload not found:\nstdout:\n%s\nstderr:\n%s", result.Stdout, result.Stderr) - - payload := raw[start:] - require.Truef(t, gjson.Valid(payload), "invalid json payload:\n%s", payload) - return payload -} - -func skipIfBaseUnavailable(t *testing.T, result *clie2e.Result, reason string) { - t.Helper() - - payload := baseJSONPayload(t, result) - errType := gjson.Get(payload, "error.type").String() - if errType == "config" && !runningInCI() { - t.Skipf("%s: %s", reason, gjson.Get(payload, "error.message").String()) - } -} - -func runningInCI() bool { - return os.Getenv("CI") != "" || os.Getenv("GITHUB_ACTIONS") != "" -} - func reportCleanupFailure(parentT *testing.T, prefix string, result *clie2e.Result, err error) { parentT.Helper() @@ -116,21 +79,14 @@ func isCleanupSuppressedResult(result *clie2e.Result) bool { strings.Contains(strings.ToLower(gjson.Get(payload, "error.message").String()), " limited") } -func testSuffix() string { - return time.Now().UTC().Format("20060102-150405") -} - -func createBase(t *testing.T, ctx context.Context, name string) string { +func createBaseWithRetry(t *testing.T, ctx context.Context, name string) string { t.Helper() - result, err := clie2e.RunCmd(ctx, clie2e.Request{ + result, err := clie2e.RunCmdWithRetry(ctx, clie2e.Request{ Args: []string{"base", "+base-create", "--name", name, "--time-zone", "Asia/Shanghai"}, DefaultAs: "bot", - }) + }, clie2e.RetryOptions{}) require.NoError(t, err) - if result.ExitCode != 0 { - skipIfBaseUnavailable(t, result, "requires bot base create capability") - } result.AssertExitCode(t, 0) result.AssertStdoutStatus(t, true) @@ -142,29 +98,7 @@ func createBase(t *testing.T, ctx context.Context, name string) string { return baseToken } -func copyBase(t *testing.T, ctx context.Context, baseToken string, name string) string { - t.Helper() - - result, err := clie2e.RunCmd(ctx, clie2e.Request{ - Args: []string{"base", "+base-copy", "--base-token", baseToken, "--name", name, "--without-content", "--time-zone", "Asia/Shanghai"}, - DefaultAs: "bot", - }) - require.NoError(t, err) - if result.ExitCode != 0 { - skipIfBaseUnavailable(t, result, "requires bot base copy capability") - } - result.AssertExitCode(t, 0) - result.AssertStdoutStatus(t, true) - - copiedToken := gjson.Get(result.Stdout, "data.base.app_token").String() - if copiedToken == "" { - copiedToken = gjson.Get(result.Stdout, "data.base.base_token").String() - } - require.NotEmpty(t, copiedToken, "stdout:\n%s", result.Stdout) - return copiedToken -} - -func createTable(t *testing.T, parentT *testing.T, ctx context.Context, baseToken string, name string, fieldsJSON string, viewJSON string) (tableID string, primaryFieldID string, primaryViewID string) { +func createTableWithRetry(t *testing.T, parentT *testing.T, ctx context.Context, baseToken string, name string, fieldsJSON string, viewJSON string) (tableID string, primaryFieldID string, primaryViewID string) { t.Helper() args := []string{"base", "+table-create", "--base-token", baseToken, "--name", name} @@ -175,14 +109,11 @@ func createTable(t *testing.T, parentT *testing.T, ctx context.Context, baseToke args = append(args, "--view", viewJSON) } - result, err := clie2e.RunCmd(ctx, clie2e.Request{ + result, err := clie2e.RunCmdWithRetry(ctx, clie2e.Request{ Args: args, DefaultAs: "bot", - }) + }, clie2e.RetryOptions{}) require.NoError(t, err) - if result.ExitCode != 0 { - skipIfBaseUnavailable(t, result, "requires bot table create capability") - } result.AssertExitCode(t, 0) result.AssertStdoutStatus(t, true) @@ -218,306 +149,16 @@ func createTable(t *testing.T, parentT *testing.T, ctx context.Context, baseToke return tableID, primaryFieldID, primaryViewID } -func createField(t *testing.T, parentT *testing.T, ctx context.Context, baseToken string, tableID string, body string) string { - t.Helper() - - result, err := clie2e.RunCmd(ctx, clie2e.Request{ - Args: []string{"base", "+field-create", "--base-token", baseToken, "--table-id", tableID, "--json", body}, - DefaultAs: "bot", - }) - require.NoError(t, err) - if result.ExitCode != 0 { - skipIfBaseUnavailable(t, result, "requires bot field create capability") - } - result.AssertExitCode(t, 0) - result.AssertStdoutStatus(t, true) - - fieldID := gjson.Get(result.Stdout, "data.field.id").String() - if fieldID == "" { - fieldID = gjson.Get(result.Stdout, "data.field.field_id").String() - } - require.NotEmpty(t, fieldID, "stdout:\n%s", result.Stdout) - - parentT.Cleanup(func() { - cleanupCtx, cancel := cleanupContext() - defer cancel() - - deleteResult, deleteErr := clie2e.RunCmd(cleanupCtx, clie2e.Request{ - Args: []string{"base", "+field-delete", "--base-token", baseToken, "--table-id", tableID, "--field-id", fieldID, "--yes"}, - DefaultAs: "bot", - }) - if deleteErr != nil || deleteResult.ExitCode != 0 { - reportCleanupFailure(parentT, "delete field "+fieldID, deleteResult, deleteErr) - } - }) - - return fieldID -} - -func createRecord(t *testing.T, parentT *testing.T, ctx context.Context, baseToken string, tableID string, body string) string { - t.Helper() - - result, err := clie2e.RunCmd(ctx, clie2e.Request{ - Args: []string{"base", "+record-upsert", "--base-token", baseToken, "--table-id", tableID, "--json", body}, - DefaultAs: "bot", - }) - require.NoError(t, err) - if result.ExitCode != 0 { - skipIfBaseUnavailable(t, result, "requires bot record create capability") - } - result.AssertExitCode(t, 0) - result.AssertStdoutStatus(t, true) - - recordID := gjson.Get(result.Stdout, "data.record.record_id").String() - if recordID == "" { - recordID = gjson.Get(result.Stdout, "data.record.record_id_list.0").String() - } - require.NotEmpty(t, recordID, "stdout:\n%s", result.Stdout) - - parentT.Cleanup(func() { - cleanupCtx, cancel := cleanupContext() - defer cancel() - - deleteResult, deleteErr := clie2e.RunCmd(cleanupCtx, clie2e.Request{ - Args: []string{"base", "+record-delete", "--base-token", baseToken, "--table-id", tableID, "--record-id", recordID, "--yes"}, - DefaultAs: "bot", - }) - if deleteErr != nil || deleteResult.ExitCode != 0 { - reportCleanupFailure(parentT, "delete record "+recordID, deleteResult, deleteErr) - } - }) - - return recordID -} - -func createView(t *testing.T, parentT *testing.T, ctx context.Context, baseToken string, tableID string, body string) string { - t.Helper() - - result, err := clie2e.RunCmd(ctx, clie2e.Request{ - Args: []string{"base", "+view-create", "--base-token", baseToken, "--table-id", tableID, "--json", body}, - DefaultAs: "bot", - }) - require.NoError(t, err) - if result.ExitCode != 0 { - skipIfBaseUnavailable(t, result, "requires bot view create capability") - } - result.AssertExitCode(t, 0) - result.AssertStdoutStatus(t, true) - - viewID := gjson.Get(result.Stdout, "data.views.0.id").String() - if viewID == "" { - viewID = gjson.Get(result.Stdout, "data.views.0.view_id").String() - } - require.NotEmpty(t, viewID, "stdout:\n%s", result.Stdout) - - parentT.Cleanup(func() { - cleanupCtx, cancel := cleanupContext() - defer cancel() - - deleteResult, deleteErr := clie2e.RunCmd(cleanupCtx, clie2e.Request{ - Args: []string{"base", "+view-delete", "--base-token", baseToken, "--table-id", tableID, "--view-id", viewID, "--yes"}, - DefaultAs: "bot", - }) - if deleteErr != nil || deleteResult.ExitCode != 0 { - reportCleanupFailure(parentT, "delete view "+viewID, deleteResult, deleteErr) - } - }) - - return viewID -} - -func createDashboard(t *testing.T, parentT *testing.T, ctx context.Context, baseToken string, name string) string { - t.Helper() - - result, err := clie2e.RunCmd(ctx, clie2e.Request{ - Args: []string{"base", "+dashboard-create", "--base-token", baseToken, "--name", name}, - DefaultAs: "bot", - }) - require.NoError(t, err) - if result.ExitCode != 0 { - skipIfBaseUnavailable(t, result, "requires bot dashboard create capability") - } - result.AssertExitCode(t, 0) - result.AssertStdoutStatus(t, true) - - dashboardID := gjson.Get(result.Stdout, "data.dashboard.dashboard_id").String() - require.NotEmpty(t, dashboardID, "stdout:\n%s", result.Stdout) - - parentT.Cleanup(func() { - cleanupCtx, cancel := cleanupContext() - defer cancel() - - deleteResult, deleteErr := clie2e.RunCmd(cleanupCtx, clie2e.Request{ - Args: []string{"base", "+dashboard-delete", "--base-token", baseToken, "--dashboard-id", dashboardID, "--yes"}, - DefaultAs: "bot", - }) - if deleteErr != nil || deleteResult.ExitCode != 0 { - reportCleanupFailure(parentT, "delete dashboard "+dashboardID, deleteResult, deleteErr) - } - }) - - return dashboardID -} - -func createBlock(t *testing.T, parentT *testing.T, ctx context.Context, baseToken string, dashboardID string, name string, blockType string, dataConfig string) string { - t.Helper() - - result, err := clie2e.RunCmd(ctx, clie2e.Request{ - Args: []string{"base", "+dashboard-block-create", "--base-token", baseToken, "--dashboard-id", dashboardID, "--name", name, "--type", blockType, "--data-config", dataConfig}, - DefaultAs: "bot", - }) - require.NoError(t, err) - if result.ExitCode != 0 { - skipIfBaseUnavailable(t, result, "requires bot dashboard block create capability") - } - result.AssertExitCode(t, 0) - result.AssertStdoutStatus(t, true) - - blockID := gjson.Get(result.Stdout, "data.block.block_id").String() - require.NotEmpty(t, blockID, "stdout:\n%s", result.Stdout) - - parentT.Cleanup(func() { - cleanupCtx, cancel := cleanupContext() - defer cancel() - - deleteResult, deleteErr := clie2e.RunCmd(cleanupCtx, clie2e.Request{ - Args: []string{"base", "+dashboard-block-delete", "--base-token", baseToken, "--dashboard-id", dashboardID, "--block-id", blockID, "--yes"}, - DefaultAs: "bot", - }) - if deleteErr != nil || deleteResult.ExitCode != 0 { - reportCleanupFailure(parentT, "delete dashboard block "+blockID, deleteResult, deleteErr) - } - }) - - return blockID -} - -func createForm(t *testing.T, parentT *testing.T, ctx context.Context, baseToken string, tableID string, name string) string { - t.Helper() - - result, err := clie2e.RunCmd(ctx, clie2e.Request{ - Args: []string{"base", "+form-create", "--base-token", baseToken, "--table-id", tableID, "--name", name}, - DefaultAs: "bot", - }) - require.NoError(t, err) - if result.ExitCode != 0 { - skipIfBaseUnavailable(t, result, "requires bot form create capability") - } - result.AssertExitCode(t, 0) - result.AssertStdoutStatus(t, true) - - formID := gjson.Get(result.Stdout, "data.id").String() - require.NotEmpty(t, formID, "stdout:\n%s", result.Stdout) - - parentT.Cleanup(func() { - cleanupCtx, cancel := cleanupContext() - defer cancel() - - deleteResult, deleteErr := clie2e.RunCmd(cleanupCtx, clie2e.Request{ - Args: []string{"base", "+form-delete", "--base-token", baseToken, "--table-id", tableID, "--form-id", formID, "--yes"}, - DefaultAs: "bot", - }) - if deleteErr != nil || deleteResult.ExitCode != 0 { - reportCleanupFailure(parentT, "delete form "+formID, deleteResult, deleteErr) - } - }) - - return formID -} - -func createRole(t *testing.T, parentT *testing.T, ctx context.Context, baseToken string, body string) string { +func createRole(t *testing.T, ctx context.Context, baseToken string, body string) string { t.Helper() - result, err := clie2e.RunCmd(ctx, clie2e.Request{ + result, err := clie2e.RunCmdWithRetry(ctx, clie2e.Request{ Args: []string{"base", "+role-create", "--base-token", baseToken, "--json", body}, DefaultAs: "bot", - }) - require.NoError(t, err) - if result.ExitCode != 0 { - skipIfBaseUnavailable(t, result, "requires bot role create capability") - } - result.AssertExitCode(t, 0) - result.AssertStdoutStatus(t, true) - - roleID := gjson.Get(result.Stdout, "data.role_id").String() - if roleID == "" { - roleName := gjson.Get(body, "role_name").String() - require.NotEmpty(t, roleName, "role_name is required to resolve role id from list") - - listResult, listErr := clie2e.RunCmd(ctx, clie2e.Request{ - Args: []string{"base", "+role-list", "--base-token", baseToken}, - DefaultAs: "bot", - }) - require.NoError(t, listErr) - if listResult.ExitCode != 0 { - skipIfBaseUnavailable(t, listResult, "requires bot role list capability") - } - listResult.AssertExitCode(t, 0) - listResult.AssertStdoutStatus(t, true) - - roleListPayload := gjson.Get(listResult.Stdout, "data.data").String() - require.NotEmpty(t, roleListPayload, "stdout:\n%s", listResult.Stdout) - require.True(t, gjson.Valid(roleListPayload), "stdout:\n%s", listResult.Stdout) - - for _, item := range gjson.Get(roleListPayload, "base_roles").Array() { - rolePayload := item.String() - if !gjson.Valid(rolePayload) { - continue - } - if gjson.Get(rolePayload, "role_name").String() == roleName { - roleID = gjson.Get(rolePayload, "role_id").String() - break - } - } - } - require.NotEmpty(t, roleID, "stdout:\n%s", result.Stdout) - - parentT.Cleanup(func() { - cleanupCtx, cancel := cleanupContext() - defer cancel() - - deleteResult, deleteErr := clie2e.RunCmd(cleanupCtx, clie2e.Request{ - Args: []string{"base", "+role-delete", "--base-token", baseToken, "--role-id", roleID, "--yes"}, - DefaultAs: "bot", - }) - if deleteErr != nil || deleteResult.ExitCode != 0 { - reportCleanupFailure(parentT, "delete role "+roleID, deleteResult, deleteErr) - } - }) - - return roleID -} - -func createWorkflow(t *testing.T, ctx context.Context, baseToken string, body string) string { - t.Helper() - - result, err := clie2e.RunCmd(ctx, clie2e.Request{ - Args: []string{"base", "+workflow-create", "--base-token", baseToken, "--json", body}, - DefaultAs: "bot", - }) + }, clie2e.RetryOptions{}) require.NoError(t, err) - if result.ExitCode != 0 { - skipIfBaseUnavailable(t, result, "requires bot workflow create capability") - } result.AssertExitCode(t, 0) result.AssertStdoutStatus(t, true) - workflowID := gjson.Get(result.Stdout, "data.workflow_id").String() - require.NotEmpty(t, workflowID, "stdout:\n%s", result.Stdout) - return workflowID -} - -func writeTempAttachment(t *testing.T, content string) string { - t.Helper() - - wd, err := os.Getwd() - require.NoError(t, err) - - path := filepath.Join(wd, "attachment-"+testSuffix()+".txt") - err = os.WriteFile(path, []byte(content), 0o644) - require.NoError(t, err) - t.Cleanup(func() { - _ = os.Remove(path) - }) - return "./" + filepath.Base(path) + return gjson.Get(result.Stdout, "data.role_id").String() } diff --git a/tests/cli_e2e/calendar/calendar_create_event_test.go b/tests/cli_e2e/calendar/calendar_create_event_test.go index 35ad0ac3..27bca162 100644 --- a/tests/cli_e2e/calendar/calendar_create_event_test.go +++ b/tests/cli_e2e/calendar/calendar_create_event_test.go @@ -19,28 +19,18 @@ func TestCalendar_CreateEvent(t *testing.T) { ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute) t.Cleanup(cancel) - suffix := time.Now().UTC().Format("20060102-150405") + suffix := clie2e.GenerateSuffix() eventSummary := "lark-cli-e2e-event-" + suffix + eventDescription := "test event description" - startTime := time.Now().UTC().Add(1 * time.Hour).Format(time.RFC3339) - endTime := time.Now().UTC().Add(2 * time.Hour).Format(time.RFC3339) + startAt := time.Now().UTC().Add(1 * time.Hour).Truncate(time.Minute) + endAt := startAt.Add(1 * time.Hour) + startTime := startAt.Format(time.RFC3339) + endTime := endAt.Format(time.RFC3339) var eventID string - var calendarID string + calendarID := getPrimaryCalendarID(t, ctx) - // Step 1: Get primary calendar ID (prerequisite) - t.Run("get primary calendar", func(t *testing.T) { - result, err := clie2e.RunCmd(ctx, clie2e.Request{ - Args: []string{"calendar", "calendars", "primary"}, - }) - require.NoError(t, err) - result.AssertExitCode(t, 0) - result.AssertStdoutStatus(t, 0) - calendarID = gjson.Get(result.Stdout, "data.calendars.0.calendar.calendar_id").String() - require.NotEmpty(t, calendarID) - }) - - // Step 2: Create event using +create shortcut t.Run("create event with shortcut", func(t *testing.T) { result, err := clie2e.RunCmd(ctx, clie2e.Request{ Args: []string{"calendar", "+create", @@ -48,8 +38,9 @@ func TestCalendar_CreateEvent(t *testing.T) { "--start", startTime, "--end", endTime, "--calendar-id", calendarID, - "--description", "test event description", + "--description", eventDescription, }, + DefaultAs: "bot", }) require.NoError(t, err) result.AssertExitCode(t, 0) @@ -59,11 +50,11 @@ func TestCalendar_CreateEvent(t *testing.T) { require.NotEmpty(t, eventID) }) - // Step 3: Verify event was created using events.get resource command t.Run("verify event created", func(t *testing.T) { require.NotEmpty(t, eventID) result, err := clie2e.RunCmd(ctx, clie2e.Request{ - Args: []string{"calendar", "events", "get"}, + Args: []string{"calendar", "events", "get"}, + DefaultAs: "bot", Params: map[string]any{ "calendar_id": calendarID, "event_id": eventID, @@ -73,14 +64,16 @@ func TestCalendar_CreateEvent(t *testing.T) { result.AssertExitCode(t, 0) result.AssertStdoutStatus(t, 0) assert.Equal(t, eventSummary, gjson.Get(result.Stdout, "data.event.summary").String()) - assert.Equal(t, "test event description", gjson.Get(result.Stdout, "data.event.description").String()) + assert.Equal(t, eventDescription, gjson.Get(result.Stdout, "data.event.description").String()) + assert.Equal(t, unixSecondsRFC3339(startAt), gjson.Get(result.Stdout, "data.event.start_time.timestamp").String()) + assert.Equal(t, unixSecondsRFC3339(endAt), gjson.Get(result.Stdout, "data.event.end_time.timestamp").String()) }) - // Step 4: Delete event using events.delete resource command t.Run("delete event", func(t *testing.T) { require.NotEmpty(t, eventID) result, err := clie2e.RunCmd(ctx, clie2e.Request{ - Args: []string{"calendar", "events", "delete"}, + Args: []string{"calendar", "events", "delete"}, + DefaultAs: "bot", Params: map[string]any{ "calendar_id": calendarID, "event_id": eventID, @@ -90,5 +83,4 @@ func TestCalendar_CreateEvent(t *testing.T) { result.AssertExitCode(t, 0) result.AssertStdoutStatus(t, 0) }) - } diff --git a/tests/cli_e2e/calendar/calendar_find_meeting_time_test.go b/tests/cli_e2e/calendar/calendar_find_meeting_time_test.go deleted file mode 100644 index 8f41016e..00000000 --- a/tests/cli_e2e/calendar/calendar_find_meeting_time_test.go +++ /dev/null @@ -1,49 +0,0 @@ -// Copyright (c) 2026 Lark Technologies Pte. Ltd. -// SPDX-License-Identifier: MIT - -package calendar - -import ( - "context" - "testing" - "time" - - clie2e "github.com/larksuite/cli/tests/cli_e2e" - "github.com/stretchr/testify/require" -) - -// TestCalendar_FindMeetingTime tests the workflow of finding available meeting times. -func TestCalendar_FindMeetingTime(t *testing.T) { - ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute) - t.Cleanup(cancel) - - startTime := time.Now().UTC().Add(1 * time.Hour).Format(time.RFC3339) - endTime := time.Now().UTC().Add(24 * time.Hour).Format("2006-01-02T15:04:05Z") - - t.Run("find available meeting times", func(t *testing.T) { - result, err := clie2e.RunCmd(ctx, clie2e.Request{ - Args: []string{"calendar", "+suggestion", - "--start", startTime, - "--end", endTime, - "--duration-minutes", "30", - }, - }) - require.NoError(t, err) - result.AssertExitCode(t, 0) - result.AssertStdoutStatus(t, true) - }) - - t.Run("find meeting times with timezone", func(t *testing.T) { - result, err := clie2e.RunCmd(ctx, clie2e.Request{ - Args: []string{"calendar", "+suggestion", - "--start", startTime, - "--end", endTime, - "--duration-minutes", "60", - "--timezone", "Asia/Shanghai", - }, - }) - require.NoError(t, err) - result.AssertExitCode(t, 0) - result.AssertStdoutStatus(t, true) - }) -} diff --git a/tests/cli_e2e/calendar/calendar_manage_calendar_test.go b/tests/cli_e2e/calendar/calendar_manage_calendar_test.go index a8ec395b..50f1bdb4 100644 --- a/tests/cli_e2e/calendar/calendar_manage_calendar_test.go +++ b/tests/cli_e2e/calendar/calendar_manage_calendar_test.go @@ -9,6 +9,7 @@ import ( "time" clie2e "github.com/larksuite/cli/tests/cli_e2e" + "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "github.com/tidwall/gjson" ) @@ -18,54 +19,85 @@ func TestCalendar_ManageCalendar(t *testing.T) { ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute) t.Cleanup(cancel) - suffix := time.Now().UTC().Format("20060102-150405") + suffix := clie2e.GenerateSuffix() calendarSummary := "lark-cli-e2e-cal-" + suffix + updatedCalendarSummary := calendarSummary + "-updated" + calendarDescription := "test calendar created by e2e" var createdCalendarID string t.Run("list calendars", func(t *testing.T) { result, err := clie2e.RunCmd(ctx, clie2e.Request{ - Args: []string{"calendar", "calendars", "list"}, + Args: []string{"calendar", "calendars", "list"}, + DefaultAs: "bot", }) require.NoError(t, err) result.AssertExitCode(t, 0) result.AssertStdoutStatus(t, 0) + require.NotEmpty(t, gjson.Get(result.Stdout, "data.calendar_list").Array(), "stdout:\n%s", result.Stdout) }) t.Run("get primary calendar", func(t *testing.T) { + primaryCalendarID := getPrimaryCalendarID(t, ctx) + require.NotEmpty(t, primaryCalendarID) + }) + + t.Run("create calendar", func(t *testing.T) { result, err := clie2e.RunCmd(ctx, clie2e.Request{ - Args: []string{"calendar", "calendars", "primary"}, + Args: []string{"calendar", "calendars", "create"}, + DefaultAs: "bot", + Data: map[string]any{ + "summary": calendarSummary, + "description": calendarDescription, + }, }) require.NoError(t, err) result.AssertExitCode(t, 0) result.AssertStdoutStatus(t, 0) + + createdCalendarID = gjson.Get(result.Stdout, "data.calendar.calendar_id").String() + require.NotEmpty(t, createdCalendarID) }) - t.Run("create calendar", func(t *testing.T) { + t.Run("get created calendar", func(t *testing.T) { + require.NotEmpty(t, createdCalendarID) result, err := clie2e.RunCmd(ctx, clie2e.Request{ - Args: []string{"calendar", "calendars", "create"}, - Data: map[string]any{ - "summary": calendarSummary, - "description": "test calendar created by e2e", + Args: []string{"calendar", "calendars", "get"}, + DefaultAs: "bot", + Params: map[string]any{ + "calendar_id": createdCalendarID, }, }) require.NoError(t, err) result.AssertExitCode(t, 0) result.AssertStdoutStatus(t, 0) + assert.Equal(t, createdCalendarID, gjson.Get(result.Stdout, "data.calendar_id").String()) + assert.Equal(t, calendarSummary, gjson.Get(result.Stdout, "data.summary").String()) + assert.Equal(t, calendarDescription, gjson.Get(result.Stdout, "data.description").String()) + }) - createdCalendarID = gjson.Get(result.Stdout, "data.calendar.calendar_id").String() + t.Run("find created calendar in list", func(t *testing.T) { require.NotEmpty(t, createdCalendarID) + result, err := clie2e.RunCmd(ctx, clie2e.Request{ + Args: []string{"calendar", "calendars", "list"}, + DefaultAs: "bot", + }) + require.NoError(t, err) + result.AssertExitCode(t, 0) + result.AssertStdoutStatus(t, 0) + require.True(t, gjson.Get(result.Stdout, `data.calendar_list.#(calendar_id=="`+createdCalendarID+`")`).Exists(), "stdout:\n%s", result.Stdout) }) t.Run("update calendar", func(t *testing.T) { require.NotEmpty(t, createdCalendarID) result, err := clie2e.RunCmd(ctx, clie2e.Request{ - Args: []string{"calendar", "calendars", "patch"}, + Args: []string{"calendar", "calendars", "patch"}, + DefaultAs: "bot", Params: map[string]any{ "calendar_id": createdCalendarID, }, Data: map[string]any{ - "summary": calendarSummary + "-updated", + "summary": updatedCalendarSummary, }, }) require.NoError(t, err) @@ -73,22 +105,26 @@ func TestCalendar_ManageCalendar(t *testing.T) { result.AssertStdoutStatus(t, 0) }) - t.Run("search calendar", func(t *testing.T) { + t.Run("verify updated calendar", func(t *testing.T) { + require.NotEmpty(t, createdCalendarID) result, err := clie2e.RunCmd(ctx, clie2e.Request{ - Args: []string{"calendar", "calendars", "search"}, - Data: map[string]any{ - "query": calendarSummary, + Args: []string{"calendar", "calendars", "get"}, + DefaultAs: "bot", + Params: map[string]any{ + "calendar_id": createdCalendarID, }, }) require.NoError(t, err) result.AssertExitCode(t, 0) result.AssertStdoutStatus(t, 0) + assert.Equal(t, updatedCalendarSummary, gjson.Get(result.Stdout, "data.summary").String()) }) t.Run("delete calendar", func(t *testing.T) { require.NotEmpty(t, createdCalendarID) result, err := clie2e.RunCmd(ctx, clie2e.Request{ - Args: []string{"calendar", "calendars", "delete"}, + Args: []string{"calendar", "calendars", "delete"}, + DefaultAs: "bot", Params: map[string]any{ "calendar_id": createdCalendarID, }, @@ -97,4 +133,4 @@ func TestCalendar_ManageCalendar(t *testing.T) { result.AssertExitCode(t, 0) result.AssertStdoutStatus(t, 0) }) -} \ No newline at end of file +} diff --git a/tests/cli_e2e/calendar/calendar_view_agenda_test.go b/tests/cli_e2e/calendar/calendar_view_agenda_test.go deleted file mode 100644 index ae4ef7dc..00000000 --- a/tests/cli_e2e/calendar/calendar_view_agenda_test.go +++ /dev/null @@ -1,48 +0,0 @@ -// Copyright (c) 2026 Lark Technologies Pte. Ltd. -// SPDX-License-Identifier: MIT - -package calendar - -import ( - "context" - "testing" - "time" - - clie2e "github.com/larksuite/cli/tests/cli_e2e" - "github.com/stretchr/testify/require" -) - -// TestCalendar_ViewAgenda tests the workflow of viewing one's calendar agenda. -func TestCalendar_ViewAgenda(t *testing.T) { - ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute) - t.Cleanup(cancel) - - t.Run("view today agenda", func(t *testing.T) { - result, err := clie2e.RunCmd(ctx, clie2e.Request{ - Args: []string{"calendar", "+agenda"}, - }) - require.NoError(t, err) - result.AssertExitCode(t, 0) - result.AssertStdoutStatus(t, true) - }) - - t.Run("view agenda with date range", func(t *testing.T) { - startDate := time.Now().UTC().Format("2006-01-02") - endDate := time.Now().UTC().AddDate(0, 0, 7).Format("2006-01-02") - result, err := clie2e.RunCmd(ctx, clie2e.Request{ - Args: []string{"calendar", "+agenda", "--start", startDate, "--end", endDate}, - }) - require.NoError(t, err) - result.AssertExitCode(t, 0) - result.AssertStdoutStatus(t, true) - }) - - t.Run("view agenda with pretty format", func(t *testing.T) { - result, err := clie2e.RunCmd(ctx, clie2e.Request{ - Args: []string{"calendar", "+agenda"}, - Format: "pretty", - }) - require.NoError(t, err) - result.AssertExitCode(t, 0) - }) -} \ No newline at end of file diff --git a/tests/cli_e2e/calendar/helpers_test.go b/tests/cli_e2e/calendar/helpers_test.go index 398436ab..af19b5ab 100644 --- a/tests/cli_e2e/calendar/helpers_test.go +++ b/tests/cli_e2e/calendar/helpers_test.go @@ -5,6 +5,7 @@ package calendar import ( "context" + "strconv" "testing" "time" @@ -13,55 +14,12 @@ import ( "github.com/tidwall/gjson" ) -// createEvent creates a calendar event and registers cleanup. -// Returns the event_id. -func createEvent(t *testing.T, parentT *testing.T, ctx context.Context, calendarID string, summary string) string { - t.Helper() - - startTime := time.Now().UTC().Add(1 * time.Hour).Format(time.RFC3339) - endTime := time.Now().UTC().Add(2 * time.Hour).Format(time.RFC3339) - - result, err := clie2e.RunCmd(ctx, clie2e.Request{ - Args: []string{"calendar", "+create", - "--summary", summary, - "--start", startTime, - "--end", endTime, - "--calendar-id", calendarID, - }, - }) - require.NoError(t, err) - result.AssertExitCode(t, 0) - result.AssertStdoutStatus(t, true) - - eventID := gjson.Get(result.Stdout, "data.event_id").String() - require.NotEmpty(t, eventID, "stdout:\n%s", result.Stdout) - - parentT.Cleanup(func() { - deleteResult, deleteErr := clie2e.RunCmd(context.Background(), clie2e.Request{ - Args: []string{"calendar", "events", "delete"}, - Params: map[string]any{ - "calendar_id": calendarID, - "event_id": eventID, - }, - }) - if deleteErr != nil { - parentT.Errorf("delete event %s: %v", eventID, deleteErr) - return - } - if deleteResult.ExitCode != 0 { - parentT.Errorf("delete event %s failed: exit=%d stdout=%s stderr=%s", eventID, deleteResult.ExitCode, deleteResult.Stdout, deleteResult.Stderr) - } - }) - - return eventID -} - -// getPrimaryCalendarID returns the primary calendar ID. func getPrimaryCalendarID(t *testing.T, ctx context.Context) string { t.Helper() result, err := clie2e.RunCmd(ctx, clie2e.Request{ - Args: []string{"calendar", "calendars", "primary"}, + Args: []string{"calendar", "calendars", "primary"}, + DefaultAs: "bot", }) require.NoError(t, err) result.AssertExitCode(t, 0) @@ -69,6 +27,9 @@ func getPrimaryCalendarID(t *testing.T, ctx context.Context) string { calendarID := gjson.Get(result.Stdout, "data.calendars.0.calendar.calendar_id").String() require.NotEmpty(t, calendarID, "stdout:\n%s", result.Stdout) - return calendarID -} \ No newline at end of file +} + +func unixSecondsRFC3339(t time.Time) string { + return strconv.FormatInt(t.Unix(), 10) +} diff --git a/tests/cli_e2e/core.go b/tests/cli_e2e/core.go index 47f4ad3f..1352bcdb 100644 --- a/tests/cli_e2e/core.go +++ b/tests/cli_e2e/core.go @@ -16,6 +16,7 @@ import ( "strings" "sync" "testing" + "time" "github.com/stretchr/testify/assert" "github.com/tidwall/gjson" @@ -57,6 +58,15 @@ type Result struct { RunErr error } +// RetryOptions configures retry behavior for flaky external API calls. +type RetryOptions struct { + Attempts int + InitialDelay time.Duration + MaxDelay time.Duration + BackoffMultiple int + ShouldRetry func(*Result) bool +} + // RunCmd executes lark-cli and captures stdout/stderr/exit code. func RunCmd(ctx context.Context, req Request) (*Result, error) { binaryPath, err := ResolveBinaryPath(req) @@ -99,6 +109,63 @@ func RunCmd(ctx context.Context, req Request) (*Result, error) { return result, nil } +// RunCmdWithRetry reruns a command when the result matches the configured retry condition. +func RunCmdWithRetry(ctx context.Context, req Request, opts RetryOptions) (*Result, error) { + if opts.Attempts <= 0 { + opts.Attempts = 4 + } + if opts.InitialDelay <= 0 { + opts.InitialDelay = 1 * time.Second + } + if opts.MaxDelay <= 0 { + opts.MaxDelay = 6 * time.Second + } + if opts.BackoffMultiple <= 1 { + opts.BackoffMultiple = 2 + } + if opts.ShouldRetry == nil { + opts.ShouldRetry = func(result *Result) bool { + return result != nil && result.ExitCode != 0 + } + } + + delay := opts.InitialDelay + var lastResult *Result + for attempt := 1; attempt <= opts.Attempts; attempt++ { + result, err := RunCmd(ctx, req) + if err != nil { + return nil, err + } + lastResult = result + if attempt == opts.Attempts || !opts.ShouldRetry(result) { + return result, nil + } + + timer := time.NewTimer(delay) + select { + case <-ctx.Done(): + timer.Stop() + return lastResult, nil + case <-timer.C: + } + + nextDelay := delay * time.Duration(opts.BackoffMultiple) + if nextDelay > opts.MaxDelay { + delay = opts.MaxDelay + } else { + delay = nextDelay + } + } + + return lastResult, nil +} + +// GenerateSuffix returns a high-entropy UTC timestamp suffix suitable for remote test resource names. +func GenerateSuffix() string { + now := time.Now().UTC() + return fmt.Sprintf("%s-%09d", now.Format("20060102-150405"), now.Nanosecond()) +} + // ResolveBinaryPath finds the CLI binary path using request, env, then PATH. func ResolveBinaryPath(req Request) (string, error) { if req.BinaryPath != "" { diff --git a/tests/cli_e2e/docs/docs_create_fetch_test.go b/tests/cli_e2e/docs/docs_create_fetch_test.go index 1a140de6..4d437536 100644 --- a/tests/cli_e2e/docs/docs_create_fetch_test.go +++ b/tests/cli_e2e/docs/docs_create_fetch_test.go @@ -16,34 +16,19 @@ import ( // TestDocs_CreateAndFetchWorkflow tests the create and fetch lifecycle. func TestDocs_CreateAndFetchWorkflow(t *testing.T) { - parentT := t ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute) t.Cleanup(cancel) - suffix := time.Now().UTC().Format("20060102-150405") + suffix := clie2e.GenerateSuffix() + folderName := "lark-cli-e2e-docs-folder-" + suffix docTitle := "lark-cli-e2e-docs-" + suffix docContent := "# Test Document\n\nThis document was created by lark-cli e2e test." + folderToken := createDocsFolderWithRetry(t, ctx, folderName) var docToken string t.Run("create", func(t *testing.T) { - result, err := clie2e.RunCmd(ctx, clie2e.Request{ - Args: []string{ - "docs", "+create", - "--title", docTitle, - "--markdown", docContent, - }, - }) - require.NoError(t, err) - result.AssertExitCode(t, 0) - result.AssertStdoutStatus(t, true) - docToken = gjson.Get(result.Stdout, "data.doc_id").String() - require.NotEmpty(t, docToken, "stdout:\n%s", result.Stdout) - - parentT.Cleanup(func() { - // No docs delete command is currently available in lark-cli, - // so created docs are intentionally left in the test account. - }) + docToken = createDocWithRetry(t, ctx, folderToken, docTitle, docContent) }) t.Run("fetch", func(t *testing.T) { diff --git a/tests/cli_e2e/docs/docs_update_test.go b/tests/cli_e2e/docs/docs_update_test.go index f34c54a0..8ec39eef 100644 --- a/tests/cli_e2e/docs/docs_update_test.go +++ b/tests/cli_e2e/docs/docs_update_test.go @@ -16,36 +16,21 @@ import ( // TestDocs_UpdateWorkflow tests the create, update, and verify lifecycle. func TestDocs_UpdateWorkflow(t *testing.T) { - parentT := t ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute) t.Cleanup(cancel) - suffix := time.Now().UTC().Format("20060102-150405") + suffix := clie2e.GenerateSuffix() + folderName := "lark-cli-e2e-update-folder-" + suffix originalTitle := "lark-cli-e2e-update-" + suffix updatedTitle := "lark-cli-e2e-update-updated-" + suffix originalContent := "# Original\n\nThis is the original content." updatedContent := "# Updated\n\nThis is the updated content." + folderToken := createDocsFolderWithRetry(t, ctx, folderName) var docToken string t.Run("create", func(t *testing.T) { - result, err := clie2e.RunCmd(ctx, clie2e.Request{ - Args: []string{ - "docs", "+create", - "--title", originalTitle, - "--markdown", originalContent, - }, - }) - require.NoError(t, err) - result.AssertExitCode(t, 0) - result.AssertStdoutStatus(t, true) - docToken = gjson.Get(result.Stdout, "data.doc_id").String() - require.NotEmpty(t, docToken, "stdout:\n%s", result.Stdout) - - parentT.Cleanup(func() { - // No docs delete command is currently available in lark-cli, - // so created docs are intentionally left in the test account. - }) + docToken = createDocWithRetry(t, ctx, folderToken, originalTitle, originalContent) }) t.Run("update-title-and-content", func(t *testing.T) { diff --git a/tests/cli_e2e/docs/helpers_test.go b/tests/cli_e2e/docs/helpers_test.go new file mode 100644 index 00000000..105b0c1d --- /dev/null +++ b/tests/cli_e2e/docs/helpers_test.go @@ -0,0 +1,54 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package docs + +import ( + "context" + "testing" + + clie2e "github.com/larksuite/cli/tests/cli_e2e" + "github.com/stretchr/testify/require" + "github.com/tidwall/gjson" +) + +func createDocsFolderWithRetry(t *testing.T, ctx context.Context, name string) string { + t.Helper() + + result, err := clie2e.RunCmdWithRetry(ctx, clie2e.Request{ + Args: []string{"drive", "files", "create_folder"}, + Data: map[string]any{ + "name": name, + "folder_token": "", + }, + }, clie2e.RetryOptions{}) + require.NoError(t, err) + result.AssertExitCode(t, 0) + + folderToken := gjson.Get(result.Stdout, "data.token").String() + require.NotEmpty(t, folderToken, "stdout:\n%s", result.Stdout) + + return folderToken +} + +func createDocWithRetry(t *testing.T, ctx context.Context, folderToken string, title string, markdown string) string { + t.Helper() + + require.NotEmpty(t, folderToken, "folder token is required") + + result, err := clie2e.RunCmdWithRetry(ctx, clie2e.Request{ + Args: []string{ + "docs", "+create", + "--folder-token", folderToken, + "--title", title, + "--markdown", markdown, + }, + }, clie2e.RetryOptions{}) + require.NoError(t, err) + result.AssertExitCode(t, 0) + result.AssertStdoutStatus(t, true) + + docToken := gjson.Get(result.Stdout, "data.doc_id").String() + require.NotEmpty(t, docToken, "stdout:\n%s", result.Stdout) + return docToken +} diff --git a/tests/cli_e2e/drive/drive_files_workflow_test.go b/tests/cli_e2e/drive/drive_files_workflow_test.go index 63b60495..78e7a610 100644 --- a/tests/cli_e2e/drive/drive_files_workflow_test.go +++ b/tests/cli_e2e/drive/drive_files_workflow_test.go @@ -9,8 +9,6 @@ import ( "time" clie2e "github.com/larksuite/cli/tests/cli_e2e" - "github.com/stretchr/testify/require" - "github.com/tidwall/gjson" ) // TestDrive_FilesCreateFolderWorkflow tests the files create_folder resource command. @@ -19,30 +17,13 @@ func TestDrive_FilesCreateFolderWorkflow(t *testing.T) { ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute) t.Cleanup(cancel) - suffix := time.Now().UTC().Format("20060102-150405") + suffix := clie2e.GenerateSuffix() folderName := "lark-cli-e2e-drive-folder-" + suffix - var folderToken string - t.Run("create_folder", func(t *testing.T) { - result, err := clie2e.RunCmd(ctx, clie2e.Request{ - Args: []string{"drive", "files", "create_folder"}, - Data: map[string]any{ - "name": folderName, - "folder_token": "", - }, - }) - require.NoError(t, err) - result.AssertExitCode(t, 0) - - folderToken = gjson.Get(result.Stdout, "data.token").String() - require.NotEmpty(t, folderToken, "folder token should be available, stdout:\n%s", result.Stdout) - - parentT.Cleanup(func() { - clie2e.RunCmd(context.Background(), clie2e.Request{ - Args: []string{"drive", "files", "delete"}, - Params: map[string]any{"file_token": folderToken, "type": "folder"}, - }) - }) + folderToken := createDriveFolder(t, parentT, ctx, folderName) + if folderToken == "" { + t.Fatalf("folder token should be available") + } }) } diff --git a/tests/cli_e2e/drive/drive_move_workflow_test.go b/tests/cli_e2e/drive/drive_move_workflow_test.go deleted file mode 100644 index 7541c40f..00000000 --- a/tests/cli_e2e/drive/drive_move_workflow_test.go +++ /dev/null @@ -1,57 +0,0 @@ -// Copyright (c) 2026 Lark Technologies Pte. Ltd. -// SPDX-License-Identifier: MIT - -package drive - -import ( - "context" - "testing" - "time" - - clie2e "github.com/larksuite/cli/tests/cli_e2e" - "github.com/stretchr/testify/require" - "github.com/tidwall/gjson" -) - -// TestDrive_MoveWorkflow tests the move shortcut method. -// Workflow: upload a file -> move to a folder (root by default) -> verify move completed. -func TestDrive_MoveWorkflow(t *testing.T) { - parentT := t - ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute) - t.Cleanup(cancel) - - suffix := time.Now().UTC().Format("20060102-150405") - - fileToken := uploadTestFile(t, parentT, ctx, "move-"+suffix) - require.NotEmpty(t, fileToken) - - t.Run("move", func(t *testing.T) { - require.NotEmpty(t, fileToken, "file token should be set from upload step") - - // Move to root folder (default folder-token is root) - result, err := clie2e.RunCmd(ctx, clie2e.Request{ - Args: []string{"drive", "+move", - "--file-token", fileToken, - "--type", "file", - }, - }) - require.NoError(t, err) - result.AssertExitCode(t, 0) - - taskID := gjson.Get(result.Stdout, "data.task_id").String() - if taskID != "" { - // Poll for move task result - taskResult, taskErr := clie2e.RunCmd(ctx, clie2e.Request{ - Args: []string{"drive", "+task_result", - "--task-id", taskID, - "--scenario", "task_check", - }, - }) - require.NoError(t, taskErr) - taskResult.AssertExitCode(t, 0) - taskResult.AssertStdoutStatus(t, true) - } else { - result.AssertStdoutStatus(t, true) - } - }) -} \ No newline at end of file diff --git a/tests/cli_e2e/drive/drive_permission_user_workflow_test.go b/tests/cli_e2e/drive/drive_permission_user_workflow_test.go deleted file mode 100644 index 8694741d..00000000 --- a/tests/cli_e2e/drive/drive_permission_user_workflow_test.go +++ /dev/null @@ -1,111 +0,0 @@ -// Copyright (c) 2026 Lark Technologies Pte. Ltd. -// SPDX-License-Identifier: MIT - -package drive - -import ( - "context" - "testing" - "time" - - clie2e "github.com/larksuite/cli/tests/cli_e2e" - "github.com/stretchr/testify/require" - "github.com/tidwall/gjson" -) - -// TestDrive_PermissionMembersAuthWorkflow tests the permission.members.auth resource command. -// Workflow: import a doc -> check auth permissions on the doc. -func TestDrive_PermissionMembersAuthWorkflow(t *testing.T) { - parentT := t - ctx, cancel := context.WithTimeout(context.Background(), 3*time.Minute) - t.Cleanup(cancel) - - suffix := time.Now().UTC().Format("20060102-150405") - testContent := "# Lark CLI E2E Permission Auth Test\n\nDocument for testing permission.members.auth.\nTimestamp: " + suffix - - docToken := importTestDoc(t, parentT, ctx, "permission-auth", testContent) - require.NotEmpty(t, docToken) - - t.Run("check view permission", func(t *testing.T) { - result, err := clie2e.RunCmd(ctx, clie2e.Request{ - Args: []string{"drive", "permission.members", "auth"}, - Params: map[string]any{ - "token": docToken, - "type": "docx", - "action": "view", - }, - }) - require.NoError(t, err) - result.AssertExitCode(t, 0) - - authResult := gjson.Get(result.Stdout, "data.auth_result") - require.True(t, authResult.Bool(), "should have view permission on own doc, stdout:\n%s", result.Stdout) - }) - - t.Run("check edit permission", func(t *testing.T) { - result, err := clie2e.RunCmd(ctx, clie2e.Request{ - Args: []string{"drive", "permission.members", "auth"}, - Params: map[string]any{ - "token": docToken, - "type": "docx", - "action": "edit", - }, - }) - require.NoError(t, err) - result.AssertExitCode(t, 0) - - authResult := gjson.Get(result.Stdout, "data.auth_result") - require.True(t, authResult.Bool(), "should have edit permission on own doc, stdout:\n%s", result.Stdout) - }) -} - -// TestDrive_UserSubscriptionWorkflow tests the user subscription commands. -// Workflow: subscribe to comment events -> check status -> remove subscription. -func TestDrive_UserSubscriptionWorkflow(t *testing.T) { - ctx, cancel := context.WithTimeout(context.Background(), 3*time.Minute) - t.Cleanup(cancel) - - eventType := "drive.notice.comment_add_v1" - - // Step 1: Subscribe to comment events - t.Run("subscribe to comment events", func(t *testing.T) { - result, err := clie2e.RunCmd(ctx, clie2e.Request{ - Args: []string{"drive", "user", "subscription"}, - Data: map[string]any{ - "event_type": eventType, - }, - }) - require.NoError(t, err) - result.AssertExitCode(t, 0) - result.AssertStdoutStatus(t, 0) // Returns code: 0, not ok: true - }) - - // Step 2: Check subscription status - t.Run("check subscription status", func(t *testing.T) { - result, err := clie2e.RunCmd(ctx, clie2e.Request{ - Args: []string{"drive", "user", "subscription_status"}, - Params: map[string]any{ - "event_type": eventType, - }, - }) - require.NoError(t, err) - result.AssertExitCode(t, 0) - - // The response should indicate subscription status - status := gjson.Get(result.Stdout, "data") - require.NotEmpty(t, status.Raw, "subscription status should be returned, stdout:\n%s", result.Stdout) - }) - - // Step 3: Remove subscription - t.Run("remove subscription", func(t *testing.T) { - result, err := clie2e.RunCmd(ctx, clie2e.Request{ - Args: []string{"drive", "user", "remove_subscription"}, - Params: map[string]any{ - "event_type": eventType, - }, - }) - require.NoError(t, err) - result.AssertExitCode(t, 0) - result.AssertStdoutStatus(t, 0) // Returns code: 0, not ok: true - }) -} diff --git a/tests/cli_e2e/drive/helpers_test.go b/tests/cli_e2e/drive/helpers_test.go index 5bd4894d..7c13c8e5 100644 --- a/tests/cli_e2e/drive/helpers_test.go +++ b/tests/cli_e2e/drive/helpers_test.go @@ -5,126 +5,40 @@ package drive import ( "context" - "os" - "path/filepath" "testing" - "time" clie2e "github.com/larksuite/cli/tests/cli_e2e" "github.com/stretchr/testify/require" "github.com/tidwall/gjson" ) -// testFileDir is the directory for test files (relative path from project root). -const testFileDir = "tests/cli_e2e/drive/testfiles" - -// createTempFile creates a temporary file with given content and returns its relative path. -func createTempFile(t *testing.T, suffix, content string) string { +// createDriveFolder creates a private folder for the current workflow and +// deletes it during cleanup. +func createDriveFolder(t *testing.T, parentT *testing.T, ctx context.Context, name string) string { t.Helper() - // Create files in a relative path within the project directory - // since --file requires relative paths - testDir := filepath.Join("tests", "cli_e2e", "drive", "testfiles") - err := os.MkdirAll(testDir, 0o755) - require.NoError(t, err) - - file, err := os.CreateTemp(testDir, suffix+"-*.txt") - require.NoError(t, err) - filePath := file.Name() - _, err = file.WriteString(content) - require.NoError(t, err) - err = file.Close() - require.NoError(t, err) - - t.Cleanup(func() { - os.Remove(filePath) - }) - - return filePath -} - -// uploadTestFile uploads a test file and returns the file token. -// The uploaded file is registered for cleanup via parentT.Cleanup. -func uploadTestFile(t *testing.T, parentT *testing.T, ctx context.Context, suffix string) string { - t.Helper() - - content := "lark-cli-e2e-drive-" + suffix + "-" + time.Now().UTC().Format("20060102-150405") - filePath := createTempFile(t, suffix, content) - result, err := clie2e.RunCmd(ctx, clie2e.Request{ - Args: []string{"drive", "+upload", "--file", filePath}, + Args: []string{"drive", "files", "create_folder"}, + DefaultAs: "bot", + Data: map[string]any{ + "name": name, + "folder_token": "", + }, }) require.NoError(t, err) result.AssertExitCode(t, 0) - result.AssertStdoutStatus(t, true) - - fileToken := gjson.Get(result.Stdout, "data.file_token").String() - require.NotEmpty(t, fileToken, "stdout:\n%s", result.Stdout) - - parentT.Cleanup(func() { - // No drive delete command is currently available in lark-cli, - // so uploaded files are intentionally left in the test account. - }) - - return fileToken -} - -// importTestDoc imports a markdown file as docx and returns the doc token. -// The imported document is registered for cleanup via parentT.Cleanup. -func importTestDoc(t *testing.T, parentT *testing.T, ctx context.Context, suffix, content string) string { - t.Helper() - - testDir := filepath.Join("tests", "cli_e2e", "drive", "testfiles") - err := os.MkdirAll(testDir, 0o755) - require.NoError(t, err) - - file, err := os.CreateTemp(testDir, "drive-e2e-"+suffix+"-*.md") - require.NoError(t, err) - mdFile := file.Name() - _, err = file.WriteString(content) - require.NoError(t, err) - err = file.Close() - require.NoError(t, err) - - t.Cleanup(func() { - os.Remove(mdFile) - }) - - result, err := clie2e.RunCmd(ctx, clie2e.Request{ - Args: []string{"drive", "+import", "--file", mdFile, "--type", "docx"}, - }) - require.NoError(t, err) - result.AssertExitCode(t, 0) - - ticket := gjson.Get(result.Stdout, "data.ticket").String() - docToken := gjson.Get(result.Stdout, "data.token").String() - - if ticket != "" { - deadline := time.Now().Add(45 * time.Second) - for { - pollResult, pollErr := clie2e.RunCmd(ctx, clie2e.Request{ - Args: []string{"drive", "+task_result", "--ticket", ticket, "--scenario", "import"}, - }) - require.NoError(t, pollErr) - pollResult.AssertExitCode(t, 0) - pollResult.AssertStdoutStatus(t, true) - docToken = gjson.Get(pollResult.Stdout, "data.token").String() - if docToken != "" { - break - } - if time.Now().After(deadline) { - t.Fatalf("import task did not return token before timeout, ticket=%s", ticket) - } - time.Sleep(2 * time.Second) - } - } + result.AssertStdoutStatus(t, 0) - require.NotEmpty(t, docToken, "doc_token is required, stdout:\n%s", result.Stdout) + folderToken := gjson.Get(result.Stdout, "data.token").String() + require.NotEmpty(t, folderToken, "stdout:\n%s", result.Stdout) parentT.Cleanup(func() { - // No drive delete command is currently available in lark-cli, - // so imported docs are intentionally left in the test account. + clie2e.RunCmd(context.Background(), clie2e.Request{ + Args: []string{"drive", "files", "delete"}, + DefaultAs: "bot", + Params: map[string]any{"file_token": folderToken, "type": "folder"}, + }) }) - return docToken + return folderToken } diff --git a/tests/cli_e2e/im/chat_workflow_test.go b/tests/cli_e2e/im/chat_workflow_test.go index 4ab1dd60..b9a86e30 100644 --- a/tests/cli_e2e/im/chat_workflow_test.go +++ b/tests/cli_e2e/im/chat_workflow_test.go @@ -9,103 +9,18 @@ import ( "time" clie2e "github.com/larksuite/cli/tests/cli_e2e" + "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "github.com/tidwall/gjson" ) -// TestIM_ChatCreateSendWorkflow tests the +chat-create and +messages-send shortcuts. -func TestIM_ChatCreateSendWorkflow(t *testing.T) { - parentT := t - ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute) - t.Cleanup(cancel) - - suffix := generateSuffix() - chatName := "lark-cli-e2e-im-" + suffix - messageText := "Hello from lark-cli e2e test" - - chatID := createChat(t, parentT, ctx, chatName) - - t.Run("send text message to chat", func(t *testing.T) { - result, err := clie2e.RunCmd(ctx, clie2e.Request{ - Args: []string{"im", "+messages-send", - "--chat-id", chatID, - "--text", messageText, - }, - }) - require.NoError(t, err) - result.AssertExitCode(t, 0) - result.AssertStdoutStatus(t, true) - - messageID := gjson.Get(result.Stdout, "data.message_id").String() - require.NotEmpty(t, messageID, "message_id should not be empty") - }) - - t.Run("send markdown message to chat", func(t *testing.T) { - markdownContent := "**Bold** and *italic* text" - result, err := clie2e.RunCmd(ctx, clie2e.Request{ - Args: []string{"im", "+messages-send", - "--chat-id", chatID, - "--markdown", markdownContent, - }, - }) - require.NoError(t, err) - result.AssertExitCode(t, 0) - result.AssertStdoutStatus(t, true) - - messageID := gjson.Get(result.Stdout, "data.message_id").String() - require.NotEmpty(t, messageID, "message_id should not be empty") - }) -} - -// TestIM_ChatCreateWithOptionsWorkflow tests +chat-create with various options. -func TestIM_ChatCreateWithOptionsWorkflow(t *testing.T) { - ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute) - t.Cleanup(cancel) - - suffix := generateSuffix() - chatName := "lark-cli-e2e-im-users-" + suffix - - t.Run("create chat with set-bot-manager", func(t *testing.T) { - result, err := clie2e.RunCmd(ctx, clie2e.Request{ - Args: []string{"im", "+chat-create", - "--name", chatName, - "--type", "private", - "--set-bot-manager", - }, - }) - require.NoError(t, err) - result.AssertExitCode(t, 0) - result.AssertStdoutStatus(t, true) - - chatID := gjson.Get(result.Stdout, "data.chat_id").String() - require.NotEmpty(t, chatID, "chat_id should not be empty") - }) - - t.Run("create public chat with description", func(t *testing.T) { - publicChatName := chatName + "-public" - result, err := clie2e.RunCmd(ctx, clie2e.Request{ - Args: []string{"im", "+chat-create", - "--name", publicChatName, - "--type", "public", - "--description", "Test public chat for e2e", - }, - }) - require.NoError(t, err) - result.AssertExitCode(t, 0) - result.AssertStdoutStatus(t, true) - - publicChatID := gjson.Get(result.Stdout, "data.chat_id").String() - require.NotEmpty(t, publicChatID) - }) -} - // TestIM_ChatUpdateWorkflow tests the +chat-update shortcut. func TestIM_ChatUpdateWorkflow(t *testing.T) { parentT := t ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute) t.Cleanup(cancel) - suffix := generateSuffix() + suffix := clie2e.GenerateSuffix() originalName := "lark-cli-e2e-im-update-" + suffix updatedName := originalName + "-updated" updatedDescription := "Updated description for e2e test" @@ -135,6 +50,19 @@ func TestIM_ChatUpdateWorkflow(t *testing.T) { result.AssertExitCode(t, 0) result.AssertStdoutStatus(t, true) }) + + t.Run("get updated chat", func(t *testing.T) { + result, err := clie2e.RunCmd(ctx, clie2e.Request{ + Args: []string{"im", "chats", "get"}, + Params: map[string]any{"chat_id": chatID}, + }) + require.NoError(t, err) + result.AssertExitCode(t, 0) + result.AssertStdoutStatus(t, 0) + + assert.Equal(t, updatedName, gjson.Get(result.Stdout, "data.name").String()) + assert.Equal(t, updatedDescription, gjson.Get(result.Stdout, "data.description").String()) + }) } // TestIM_ChatsGetWorkflow tests the im chats get command. @@ -143,7 +71,7 @@ func TestIM_ChatsGetWorkflow(t *testing.T) { ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute) t.Cleanup(cancel) - suffix := generateSuffix() + suffix := clie2e.GenerateSuffix() chatName := "lark-cli-e2e-chats-get-" + suffix chatID := createChat(t, parentT, ctx, chatName) @@ -172,7 +100,7 @@ func TestIM_ChatsLinkWorkflow(t *testing.T) { ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute) t.Cleanup(cancel) - suffix := generateSuffix() + suffix := clie2e.GenerateSuffix() chatName := "lark-cli-e2e-chats-link-" + suffix chatID := createChat(t, parentT, ctx, chatName) diff --git a/tests/cli_e2e/im/helpers_test.go b/tests/cli_e2e/im/helpers_test.go index 7888f52e..28cf2985 100644 --- a/tests/cli_e2e/im/helpers_test.go +++ b/tests/cli_e2e/im/helpers_test.go @@ -5,9 +5,7 @@ package im import ( "context" - "fmt" "testing" - "time" clie2e "github.com/larksuite/cli/tests/cli_e2e" "github.com/stretchr/testify/require" @@ -60,9 +58,3 @@ func sendMessage(t *testing.T, parentT *testing.T, ctx context.Context, chatID s return messageID } - -// generateSuffix generates a unique suffix based on current timestamp. -func generateSuffix() string { - now := time.Now().UTC() - return fmt.Sprintf("%s-%09d", now.Format("20060102-150405"), now.Nanosecond()) -} diff --git a/tests/cli_e2e/im/message_workflow_test.go b/tests/cli_e2e/im/message_workflow_test.go index 5fdbf200..a06a3fe3 100644 --- a/tests/cli_e2e/im/message_workflow_test.go +++ b/tests/cli_e2e/im/message_workflow_test.go @@ -10,44 +10,15 @@ import ( clie2e "github.com/larksuite/cli/tests/cli_e2e" "github.com/stretchr/testify/require" - "github.com/tidwall/gjson" ) -// TestIM_MessagesMgetWorkflow tests the +messages-mget shortcut. -func TestIM_MessagesMgetWorkflow(t *testing.T) { - parentT := t - ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute) - t.Cleanup(cancel) - - suffix := generateSuffix() - chatName := "lark-cli-e2e-im-mget-" + suffix - messageText := "Message for mget test" - - chatID := createChat(t, parentT, ctx, chatName) - messageID := sendMessage(t, parentT, ctx, chatID, messageText) - - t.Run("batch get messages by ID", func(t *testing.T) { - result, err := clie2e.RunCmd(ctx, clie2e.Request{ - Args: []string{"im", "+messages-mget", - "--message-ids", messageID, - }, - }) - require.NoError(t, err) - result.AssertExitCode(t, 0) - result.AssertStdoutStatus(t, true) - - messages := gjson.Get(result.Stdout, "data").Array() - require.NotEmpty(t, messages, "should get at least one message") - }) -} - // TestIM_MessagesReplyWorkflow tests the +messages-reply shortcut. func TestIM_MessagesReplyWorkflow(t *testing.T) { parentT := t ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute) t.Cleanup(cancel) - suffix := generateSuffix() + suffix := clie2e.GenerateSuffix() chatName := "lark-cli-e2e-im-reply-" + suffix originalMessage := "Original message for reply test" replyText := "This is a reply" diff --git a/tests/cli_e2e/sheets/sheets_crud_workflow_test.go b/tests/cli_e2e/sheets/sheets_crud_workflow_test.go index f1134eb7..7d95f4e7 100644 --- a/tests/cli_e2e/sheets/sheets_crud_workflow_test.go +++ b/tests/cli_e2e/sheets/sheets_crud_workflow_test.go @@ -23,14 +23,14 @@ func TestSheets_CRUDE2EWorkflow(t *testing.T) { ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute) t.Cleanup(cancel) - suffix := time.Now().UTC().Format("20060102-150405") + suffix := clie2e.GenerateSuffix() spreadsheetToken := "" sheetID := "" t.Run("create spreadsheet with +create", func(t *testing.T) { - result, err := clie2e.RunCmd(ctx, clie2e.Request{ + result, err := clie2e.RunCmdWithRetry(ctx, clie2e.Request{ Args: []string{"sheets", "+create", "--title", "lark-cli-e2e-sheets-" + suffix}, - }) + }, clie2e.RetryOptions{}) require.NoError(t, err) result.AssertExitCode(t, 0) result.AssertStdoutStatus(t, true) @@ -74,6 +74,7 @@ func TestSheets_CRUDE2EWorkflow(t *testing.T) { "sheets", "+write", "--spreadsheet-token", spreadsheetToken, "--sheet-id", sheetID, + "--range", "A1:C3", "--values", string(valuesJSON), }, }) @@ -117,6 +118,7 @@ func TestSheets_CRUDE2EWorkflow(t *testing.T) { "sheets", "+append", "--spreadsheet-token", spreadsheetToken, "--sheet-id", sheetID, + "--range", "A4:C4", "--values", string(valuesJSON), }, }) @@ -172,16 +174,16 @@ func TestSheets_SpreadsheetsResource(t *testing.T) { ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute) t.Cleanup(cancel) - suffix := time.Now().UTC().Format("20060102-150405") + suffix := clie2e.GenerateSuffix() spreadsheetToken := "" t.Run("create spreadsheet with spreadsheets create", func(t *testing.T) { - result, err := clie2e.RunCmd(ctx, clie2e.Request{ + result, err := clie2e.RunCmdWithRetry(ctx, clie2e.Request{ Args: []string{"sheets", "spreadsheets", "create"}, Data: map[string]any{ "title": "lark-cli-e2e-sheets-resource-" + suffix, }, - }) + }, clie2e.RetryOptions{}) require.NoError(t, err) result.AssertExitCode(t, 0) result.AssertStdoutStatus(t, 0) @@ -234,4 +236,4 @@ func TestSheets_SpreadsheetsResource(t *testing.T) { // Verify the title was actually updated assert.Equal(t, updatedTitle, gjson.Get(getResult.Stdout, "data.spreadsheet.title").String()) }) -} \ No newline at end of file +} diff --git a/tests/cli_e2e/sheets/sheets_filter_workflow_test.go b/tests/cli_e2e/sheets/sheets_filter_workflow_test.go index 372d963c..7441fb89 100644 --- a/tests/cli_e2e/sheets/sheets_filter_workflow_test.go +++ b/tests/cli_e2e/sheets/sheets_filter_workflow_test.go @@ -21,14 +21,14 @@ func TestSheets_FilterWorkflow(t *testing.T) { ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute) t.Cleanup(cancel) - suffix := time.Now().UTC().Format("20060102-150405") + suffix := clie2e.GenerateSuffix() spreadsheetToken := "" sheetID := "" t.Run("create spreadsheet with initial data", func(t *testing.T) { - result, err := clie2e.RunCmd(ctx, clie2e.Request{ + result, err := clie2e.RunCmdWithRetry(ctx, clie2e.Request{ Args: []string{"sheets", "+create", "--title", "lark-cli-e2e-sheets-filter-" + suffix}, - }) + }, clie2e.RetryOptions{}) require.NoError(t, err) result.AssertExitCode(t, 0) result.AssertStdoutStatus(t, true) @@ -74,6 +74,7 @@ func TestSheets_FilterWorkflow(t *testing.T) { "sheets", "+write", "--spreadsheet-token", spreadsheetToken, "--sheet-id", sheetID, + "--range", "A1:C5", "--values", string(valuesJSON), }, }) diff --git a/tests/cli_e2e/task/task_comment_workflow_test.go b/tests/cli_e2e/task/task_comment_workflow_test.go index 8ebf96e3..777596e2 100644 --- a/tests/cli_e2e/task/task_comment_workflow_test.go +++ b/tests/cli_e2e/task/task_comment_workflow_test.go @@ -19,7 +19,7 @@ func TestTask_CommentWorkflow(t *testing.T) { ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute) t.Cleanup(cancel) - suffix := time.Now().UTC().Format("20060102-150405") + suffix := clie2e.GenerateSuffix() commentContent := "lark-cli-e2e-comment-" + suffix taskGUID := createTask(t, parentT, ctx, clie2e.Request{ Args: []string{"task", "+create"}, diff --git a/tests/cli_e2e/task/task_reminder_workflow_test.go b/tests/cli_e2e/task/task_reminder_workflow_test.go index 95e4d9be..25291f94 100644 --- a/tests/cli_e2e/task/task_reminder_workflow_test.go +++ b/tests/cli_e2e/task/task_reminder_workflow_test.go @@ -19,7 +19,7 @@ func TestTask_ReminderWorkflow(t *testing.T) { ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute) t.Cleanup(cancel) - suffix := time.Now().UTC().Format("20060102-150405") + suffix := clie2e.GenerateSuffix() taskGUID := createTask(t, parentT, ctx, clie2e.Request{ Args: []string{"task", "+create"}, Data: map[string]any{ @@ -57,9 +57,9 @@ func TestTask_ReminderWorkflow(t *testing.T) { }) t.Run("remove reminder", func(t *testing.T) { - result, err := clie2e.RunCmd(ctx, clie2e.Request{ + result, err := clie2e.RunCmdWithRetry(ctx, clie2e.Request{ Args: []string{"task", "+reminder", "--task-id", taskGUID, "--remove"}, - }) + }, clie2e.RetryOptions{}) require.NoError(t, err) result.AssertExitCode(t, 0) result.AssertStdoutStatus(t, true) diff --git a/tests/cli_e2e/task/task_status_workflow_test.go b/tests/cli_e2e/task/task_status_workflow_test.go index 7844b2aa..22ef4481 100644 --- a/tests/cli_e2e/task/task_status_workflow_test.go +++ b/tests/cli_e2e/task/task_status_workflow_test.go @@ -19,7 +19,7 @@ func TestTask_StatusWorkflow(t *testing.T) { ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute) t.Cleanup(cancel) - suffix := time.Now().UTC().Format("20060102-150405") + suffix := clie2e.GenerateSuffix() taskGUID := createTask(t, parentT, ctx, clie2e.Request{ Args: []string{"task", "+create"}, Data: map[string]any{ diff --git a/tests/cli_e2e/task/tasklist_add_task_workflow_test.go b/tests/cli_e2e/task/tasklist_add_task_workflow_test.go index 8fadf02f..ff7fba6b 100644 --- a/tests/cli_e2e/task/tasklist_add_task_workflow_test.go +++ b/tests/cli_e2e/task/tasklist_add_task_workflow_test.go @@ -19,7 +19,7 @@ func TestTask_TasklistAddTaskWorkflow(t *testing.T) { ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute) t.Cleanup(cancel) - suffix := time.Now().UTC().Format("20060102-150405") + suffix := clie2e.GenerateSuffix() tasklistName := "lark-cli-e2e-tasklist-add-" + suffix taskSummary := "lark-cli-e2e-tasklist-add-task-" + suffix diff --git a/tests/cli_e2e/task/tasklist_workflow_test.go b/tests/cli_e2e/task/tasklist_workflow_test.go index d336cc07..37176586 100644 --- a/tests/cli_e2e/task/tasklist_workflow_test.go +++ b/tests/cli_e2e/task/tasklist_workflow_test.go @@ -19,7 +19,7 @@ func TestTask_TasklistWorkflow(t *testing.T) { ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute) t.Cleanup(cancel) - suffix := time.Now().UTC().Format("20060102-150405") + suffix := clie2e.GenerateSuffix() tasklistName := "lark-cli-e2e-tasklist-" + suffix taskSummary := "lark-cli-e2e-task-in-tasklist-" + suffix taskDescription := "created by tests/cli_e2e/task" diff --git a/tests/cli_e2e/wiki/helpers_test.go b/tests/cli_e2e/wiki/helpers_test.go index 22eeb8ee..6bc55af4 100644 --- a/tests/cli_e2e/wiki/helpers_test.go +++ b/tests/cli_e2e/wiki/helpers_test.go @@ -5,69 +5,66 @@ package wiki import ( "context" - "os" - "strings" "testing" - "time" clie2e "github.com/larksuite/cli/tests/cli_e2e" "github.com/stretchr/testify/require" "github.com/tidwall/gjson" ) -func wikiJSONPayload(t *testing.T, result *clie2e.Result) string { +func createWikiNode(t *testing.T, ctx context.Context, req clie2e.Request) gjson.Result { t.Helper() - raw := strings.TrimSpace(result.Stdout) - if raw == "" { - raw = strings.TrimSpace(result.Stderr) - } + result, err := clie2e.RunCmdWithRetry(ctx, req, clie2e.RetryOptions{}) + require.NoError(t, err) + result.AssertExitCode(t, 0) + result.AssertStdoutStatus(t, 0) - start := strings.LastIndex(raw, "\n{") - if start >= 0 { - start++ - } else { - start = strings.Index(raw, "{") - } - require.NotEqualf(t, -1, start, "json payload not found:\nstdout:\n%s\nstderr:\n%s", result.Stdout, result.Stderr) + node := gjson.Get(result.Stdout, "data.node") + require.True(t, node.Exists(), "stdout:\n%s", result.Stdout) - payload := raw[start:] - require.Truef(t, gjson.Valid(payload), "invalid json payload:\n%s", payload) - return payload + return node } -func skipIfWikiUnavailable(t *testing.T, result *clie2e.Result, reason string) { +func findWikiNodeByToken(t *testing.T, ctx context.Context, spaceID string, nodeToken string) gjson.Result { t.Helper() - payload := wikiJSONPayload(t, result) - errType := gjson.Get(payload, "error.type").String() - if errType == "config" && !runningInCI() { - t.Skipf("%s: %s", reason, gjson.Get(payload, "error.message").String()) - } -} + require.NotEmpty(t, spaceID, "space ID is required") + require.NotEmpty(t, nodeToken, "node token is required") -func runningInCI() bool { - return os.Getenv("CI") != "" || os.Getenv("GITHUB_ACTIONS") != "" -} + pageToken := "" + seenPageTokens := map[string]struct{}{} + for { + params := map[string]any{ + "space_id": spaceID, + "page_size": 50, + } + if pageToken != "" { + if _, seen := seenPageTokens[pageToken]; seen { + t.Fatalf("wiki node list pagination loop detected for space %q, repeated page_token %q", spaceID, pageToken) + } + seenPageTokens[pageToken] = struct{}{} + params["page_token"] = pageToken + } -func testSuffix() string { - return time.Now().UTC().Format("20060102-150405") -} + result, err := clie2e.RunCmd(ctx, clie2e.Request{ + Args: []string{"wiki", "nodes", "list"}, + DefaultAs: "bot", + Params: params, + }) + require.NoError(t, err) + result.AssertExitCode(t, 0) + result.AssertStdoutStatus(t, 0) -func createWikiNode(t *testing.T, ctx context.Context, req clie2e.Request) gjson.Result { - t.Helper() + node := gjson.Get(result.Stdout, `data.items.#(node_token=="`+nodeToken+`")`) + if node.Exists() { + return node + } - result, err := clie2e.RunCmd(ctx, req) - require.NoError(t, err) - if result.ExitCode != 0 { - skipIfWikiUnavailable(t, result, "requires bot wiki node create capability") + hasMore := gjson.Get(result.Stdout, "data.has_more").Bool() + pageToken = gjson.Get(result.Stdout, "data.page_token").String() + if !hasMore || pageToken == "" { + t.Fatalf("wiki node %q not found in listed pages, last stdout:\n%s", nodeToken, result.Stdout) + } } - result.AssertExitCode(t, 0) - result.AssertStdoutStatus(t, 0) - - payload := wikiJSONPayload(t, result) - node := gjson.Get(payload, "data.node") - require.True(t, node.Exists(), "payload:\n%s", payload) - - return node } diff --git a/tests/cli_e2e/wiki/wiki_workflow_test.go b/tests/cli_e2e/wiki/wiki_workflow_test.go index 9d830b94..68908072 100644 --- a/tests/cli_e2e/wiki/wiki_workflow_test.go +++ b/tests/cli_e2e/wiki/wiki_workflow_test.go @@ -18,7 +18,7 @@ func TestWiki_NodeWorkflow(t *testing.T) { ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute) t.Cleanup(cancel) - suffix := testSuffix() + suffix := clie2e.GenerateSuffix() createdTitle := "lark-cli-e2e-wiki-create-" + suffix copiedTitle := "lark-cli-e2e-wiki-copy-" + suffix @@ -52,10 +52,6 @@ func TestWiki_NodeWorkflow(t *testing.T) { assert.Equal(t, "docx", node.Get("obj_type").String()) }) - if createdNodeToken == "" || spaceID == "" { - t.Skip("requires bot wiki create capability") - } - t.Run("get created node", func(t *testing.T) { require.NotEmpty(t, createdNodeToken, "node token should be created before get_node") @@ -68,9 +64,6 @@ func TestWiki_NodeWorkflow(t *testing.T) { }, }) require.NoError(t, err) - if result.ExitCode != 0 { - skipIfWikiUnavailable(t, result, "requires bot wiki node read capability") - } result.AssertExitCode(t, 0) result.AssertStdoutStatus(t, 0) @@ -91,9 +84,6 @@ func TestWiki_NodeWorkflow(t *testing.T) { }, }) require.NoError(t, err) - if result.ExitCode != 0 { - skipIfWikiUnavailable(t, result, "requires bot wiki space get capability") - } result.AssertExitCode(t, 0) result.AssertStdoutStatus(t, 0) @@ -110,9 +100,6 @@ func TestWiki_NodeWorkflow(t *testing.T) { }, }) require.NoError(t, err) - if result.ExitCode != 0 { - skipIfWikiUnavailable(t, result, "requires bot wiki space list capability") - } result.AssertExitCode(t, 0) result.AssertStdoutStatus(t, 0) @@ -124,23 +111,7 @@ func TestWiki_NodeWorkflow(t *testing.T) { require.NotEmpty(t, spaceID, "space ID should be available before list") require.NotEmpty(t, createdNodeToken, "node token should be available before list") - result, err := clie2e.RunCmd(ctx, clie2e.Request{ - Args: []string{"wiki", "nodes", "list"}, - DefaultAs: "bot", - Params: map[string]any{ - "space_id": spaceID, - "page_size": 50, - }, - }) - require.NoError(t, err) - if result.ExitCode != 0 { - skipIfWikiUnavailable(t, result, "requires bot wiki node list capability") - } - result.AssertExitCode(t, 0) - result.AssertStdoutStatus(t, 0) - - nodeItem := gjson.Get(result.Stdout, `data.items.#(node_token=="`+createdNodeToken+`")`) - assert.True(t, nodeItem.Exists(), "stdout:\n%s", result.Stdout) + nodeItem := findWikiNodeByToken(t, ctx, spaceID, createdNodeToken) assert.Equal(t, createdTitle, nodeItem.Get("title").String()) assert.Equal(t, createdObjToken, nodeItem.Get("obj_token").String()) }) @@ -173,23 +144,7 @@ func TestWiki_NodeWorkflow(t *testing.T) { require.NotEmpty(t, spaceID, "space ID should be available before second list") require.NotEmpty(t, copiedNodeToken, "copied node token should be available before second list") - result, err := clie2e.RunCmd(ctx, clie2e.Request{ - Args: []string{"wiki", "nodes", "list"}, - DefaultAs: "bot", - Params: map[string]any{ - "space_id": spaceID, - "page_size": 50, - }, - }) - require.NoError(t, err) - if result.ExitCode != 0 { - skipIfWikiUnavailable(t, result, "requires bot wiki node list capability") - } - result.AssertExitCode(t, 0) - result.AssertStdoutStatus(t, 0) - - nodeItem := gjson.Get(result.Stdout, `data.items.#(node_token=="`+copiedNodeToken+`")`) - assert.True(t, nodeItem.Exists(), "stdout:\n%s", result.Stdout) + nodeItem := findWikiNodeByToken(t, ctx, spaceID, copiedNodeToken) assert.Equal(t, copiedTitle, nodeItem.Get("title").String()) }) }