From e753b15d8431f8d7ae599f6e1ba322a4d7e280bb Mon Sep 17 00:00:00 2001 From: ILUO <2323221725@qq.com> Date: Wed, 1 Jul 2026 15:41:57 +0800 Subject: [PATCH] fix: expose completion state in my tasks output (#1641) * fix: expose completion state in my tasks output * test: cover my tasks pretty completion state --- shortcuts/task/task_get_my_tasks.go | 28 ++++- shortcuts/task/task_get_my_tasks_test.go | 112 ++++++++++++++++++ tests/cli_e2e/task/coverage.md | 9 +- .../task/task_get_my_tasks_dryrun_test.go | 65 ++++++++++ 4 files changed, 207 insertions(+), 7 deletions(-) create mode 100644 tests/cli_e2e/task/task_get_my_tasks_dryrun_test.go diff --git a/shortcuts/task/task_get_my_tasks.go b/shortcuts/task/task_get_my_tasks.go index 8badce07..334430ca 100644 --- a/shortcuts/task/task_get_my_tasks.go +++ b/shortcuts/task/task_get_my_tasks.go @@ -200,16 +200,21 @@ var GetMyTasks = common.Shortcut{ for _, item := range filteredItems { urlVal, _ := item["url"].(string) urlVal = truncateTaskURL(urlVal) + completed, completedAt := taskCompletionState(item) outputItem := map[string]interface{}{ - "guid": item["guid"], - "summary": item["summary"], - "url": urlVal, + "guid": item["guid"], + "summary": item["summary"], + "url": urlVal, + "completed": completed, } if createdAtStr, ok := item["created_at"].(string); ok { if ts, err := strconv.ParseInt(createdAtStr, 10, 64); err == nil { outputItem["created_at"] = time.UnixMilli(ts).Local().Format(time.RFC3339) } } + if !completedAt.IsZero() { + outputItem["completed_at"] = completedAt.Local().Format(time.RFC3339) + } if dueObj, ok := item["due"].(map[string]interface{}); ok { if tsStr, ok := dueObj["timestamp"].(string); ok { if ts, err := strconv.ParseInt(tsStr, 10, 64); err == nil { @@ -237,6 +242,7 @@ var GetMyTasks = common.Shortcut{ summary, _ := item["summary"].(string) urlVal, _ := item["url"].(string) urlVal = truncateTaskURL(urlVal) + completed, completedAt := taskCompletionState(item) var dueTimeStr string if dueObj, ok := item["due"].(map[string]interface{}); ok { @@ -259,6 +265,10 @@ var GetMyTasks = common.Shortcut{ if urlVal != "" { fmt.Fprintf(w, " URL: %s\n", urlVal) } + fmt.Fprintf(w, " Completed: %t\n", completed) + if !completedAt.IsZero() { + fmt.Fprintf(w, " Completed At: %s\n", completedAt.Local().Format("2006-01-02 15:04")) + } if dueTimeStr != "" { fmt.Fprintf(w, " Due: %s\n", dueTimeStr) } @@ -278,3 +288,15 @@ var GetMyTasks = common.Shortcut{ return nil }, } + +func taskCompletionState(item map[string]interface{}) (bool, time.Time) { + completedAtStr, _ := item["completed_at"].(string) + if completedAtStr == "" || completedAtStr == "0" { + return false, time.Time{} + } + ts, err := strconv.ParseInt(completedAtStr, 10, 64) + if err != nil { + return false, time.Time{} + } + return true, time.UnixMilli(ts) +} diff --git a/shortcuts/task/task_get_my_tasks_test.go b/shortcuts/task/task_get_my_tasks_test.go index 81fc8841..69d65043 100644 --- a/shortcuts/task/task_get_my_tasks_test.go +++ b/shortcuts/task/task_get_my_tasks_test.go @@ -110,6 +110,118 @@ func TestGetMyTasks_LocalTimeFormatting(t *testing.T) { } } +func TestGetMyTasks_IncludesCompletionStateInJSON(t *testing.T) { + tsMs := int64(1775174400000) + tsStr := strconv.FormatInt(tsMs, 10) + expectedCompletedAt := time.UnixMilli(tsMs).Local().Format(time.RFC3339) + + f, stdout, _, reg := taskShortcutTestFactory(t) + warmTenantToken(t, f, reg) + + reg.Register(&httpmock.Stub{ + Method: "GET", + URL: "/open-apis/task/v2/tasks", + Body: map[string]interface{}{ + "code": 0, "msg": "success", + "data": map[string]interface{}{ + "items": []interface{}{ + map[string]interface{}{ + "guid": "task-open", + "summary": "Open Task", + "completed_at": "0", + "url": "https://example.com/task-open", + }, + map[string]interface{}{ + "guid": "task-done", + "summary": "Done Task", + "completed_at": tsStr, + "url": "https://example.com/task-done", + }, + }, + "has_more": false, + "page_token": "", + }, + }, + }) + + s := GetMyTasks + s.AuthTypes = []string{"bot", "user"} + + err := runMountedTaskShortcut(t, s, []string{"+get-my-tasks", "--format", "json", "--as", "bot"}, f, stdout) + if err != nil { + t.Fatalf("expected no error, got %v", err) + } + + outNorm := strings.ReplaceAll(stdout.String(), `":"`, `": "`) + for _, expected := range []string{ + `"guid": "task-open"`, + `"completed": false`, + `"guid": "task-done"`, + `"completed": true`, + `"completed_at": "` + expectedCompletedAt + `"`, + } { + if !strings.Contains(outNorm, expected) { + t.Fatalf("output missing expected string (%s), got: %s", expected, stdout.String()) + } + } +} + +func TestGetMyTasks_IncludesCompletionStateInPretty(t *testing.T) { + tsMs := int64(1775174400000) + tsStr := strconv.FormatInt(tsMs, 10) + expectedCompletedAt := time.UnixMilli(tsMs).Local().Format("2006-01-02 15:04") + + f, stdout, _, reg := taskShortcutTestFactory(t) + warmTenantToken(t, f, reg) + + reg.Register(&httpmock.Stub{ + Method: "GET", + URL: "/open-apis/task/v2/tasks", + Body: map[string]interface{}{ + "code": 0, "msg": "success", + "data": map[string]interface{}{ + "items": []interface{}{ + map[string]interface{}{ + "guid": "task-open", + "summary": "Open Task", + "completed_at": "0", + "url": "https://example.com/task-open", + }, + map[string]interface{}{ + "guid": "task-done", + "summary": "Done Task", + "completed_at": tsStr, + "url": "https://example.com/task-done", + }, + }, + "has_more": false, + "page_token": "", + }, + }, + }) + + s := GetMyTasks + s.AuthTypes = []string{"bot", "user"} + + err := runMountedTaskShortcut(t, s, []string{"+get-my-tasks", "--format", "pretty", "--as", "bot"}, f, stdout) + if err != nil { + t.Fatalf("expected no error, got %v", err) + } + + out := stdout.String() + for _, expected := range []string{ + "[1] Open Task\n ID: task-open\n URL: https://example.com/task-open\n Completed: false\n", + "[2] Done Task\n ID: task-done\n URL: https://example.com/task-done\n Completed: true\n Completed At: " + expectedCompletedAt + "\n", + } { + if !strings.Contains(out, expected) { + t.Fatalf("output missing expected string (%s), got: %s", expected, out) + } + } + if count := strings.Count(out, "Completed At:"); count != 1 { + t.Fatalf("Completed At count = %d, want 1; output: %s", count, out) + } +} + // TestGetMyTasks_InvalidTimeFlags locks the three time-flag validation arms in // Execute (--created_at / --due-start / --due-end). The parse runs before any // API call, so a malformed value deterministically surfaces a typed diff --git a/tests/cli_e2e/task/coverage.md b/tests/cli_e2e/task/coverage.md index 8231bfe8..f781817a 100644 --- a/tests/cli_e2e/task/coverage.md +++ b/tests/cli_e2e/task/coverage.md @@ -2,8 +2,8 @@ ## Metrics - Denominator: 29 leaf commands -- Covered: 14 -- Coverage: 48.3% +- Covered: 15 +- Coverage: 51.7% ## Summary - TestTask_StatusWorkflow: creates a task via `task +create`, then proves `task +complete`, `task tasks get`, and `task +reopen` through `complete`, `get completed task`, `reopen`, and `get reopened task`; asserts `status` flips between `done` and `todo` and `completed_at` is set then cleared. @@ -13,9 +13,10 @@ - TestTask_TasklistWorkflowAsBot: runs `create tasklist with task`, then `get tasklist`, `list tasklist tasks`, and `get task`; proves `task +tasklist-create`, `task tasklists get`, `task tasklists tasks`, and `task tasks get` with seeded task payload and task-to-tasklist linkage. - TestTask_TasklistWorkflowAsUser: creates a tasklist as `--as user`, patches its name through `task tasklists patch`, then proves both `task tasklists get` and `task tasklists list` return the patched tasklist. - TestTask_TasklistAddTaskWorkflow: creates a standalone tasklist and task, runs `add task to tasklist`, then `list tasklist tasks` and `get task with tasklist link`; proves `task +tasklist-task-add`, `task tasklists tasks`, and `task tasks get`, including no failed tasks in the add response. +- TestTask_GetMyTasksDryRun: validates `task +get-my-tasks --dry-run` request shape for `type=my_tasks`, `user_id_type=open_id`, `completed`, `page_token`, and default `page_size` without calling live APIs. - Cleanup path note: workflow-created tasks and tasklists are deleted through direct `task tasks delete` / `task tasklists delete` cleanup paths in `helpers_test.go::createTask`, `helpers_test.go::createTasklist`, `tasklist_workflow_test.go::TestTask_TasklistWorkflowAsBot`, and `tasklist_workflow_test.go::TestTask_TasklistWorkflowAsUser`, but those cleanup-only executions are not counted as command coverage because no testcase asserts delete behavior as the primary proof surface. - Blocked area: assignee, follower, and tasklist member mutations still require stable real-user `open_id` fixtures; the current suite is bot-safe only. -- Blocked area: `task +get-my-tasks` and `task tasks list` did not return the workflow-created user task deterministically in UAT, so they are left uncovered instead of being counted from flaky list visibility. +- Blocked area: `task +get-my-tasks` live result assertions and `task tasks list` did not return the workflow-created user task deterministically in UAT, so live list visibility remains uncovered instead of being counted from flaky results. - Blocked area: the remaining user-oriented shortcuts still need deterministic user-owned fixtures or collaborator fixtures beyond the self-owned task created inside the testcase. - Gap pattern: direct `tasks create/delete/list/patch`, `tasklists create/delete/list/patch`, `members *`, and `subtasks *` APIs still lack deterministic direct-call workflows, so shortcut coverage does not count for those leaf commands. @@ -28,7 +29,7 @@ | ✓ | task +complete | shortcut | task_status_workflow_test.go::TestTask_StatusWorkflow/complete | `--task-id` | | | ✓ | task +create | shortcut | task_status_workflow_test.go::TestTask_StatusWorkflow; task_comment_workflow_test.go::TestTask_CommentWorkflow; task_reminder_workflow_test.go::TestTask_ReminderWorkflow; tasklist_add_task_workflow_test.go::TestTask_TasklistAddTaskWorkflow | `summary` + `description`; `due.timestamp` + `due.is_all_day` | | | ✕ | task +followers | shortcut | | none | requires real follower open_id fixtures; shortcut defaults to `--as user` | -| ✕ | task +get-my-tasks | shortcut | | none | UAT did not return the workflow-created user task deterministically in my-tasks views | +| ✓ | task +get-my-tasks | shortcut | task_get_my_tasks_dryrun_test.go::TestTask_GetMyTasksDryRun | `--complete`; `--page-token`; dry-run only | live UAT did not return the workflow-created user task deterministically in my-tasks views | | ✓ | task +reminder | shortcut | task_reminder_workflow_test.go::TestTask_ReminderWorkflow/set reminder; task_reminder_workflow_test.go::TestTask_ReminderWorkflow/remove reminder | `--task-id --set 30m`; `--task-id --remove` | | | ✓ | task +reopen | shortcut | task_status_workflow_test.go::TestTask_StatusWorkflow/reopen | `--task-id` | | | ✓ | task +tasklist-create | shortcut | tasklist_workflow_test.go::TestTask_TasklistWorkflowAsBot/create tasklist with task as bot; tasklist_workflow_test.go::TestTask_TasklistWorkflowAsUser/create tasklist as user; tasklist_add_task_workflow_test.go::TestTask_TasklistAddTaskWorkflow | `--name` only; `--name` plus task array in `--data` | | diff --git a/tests/cli_e2e/task/task_get_my_tasks_dryrun_test.go b/tests/cli_e2e/task/task_get_my_tasks_dryrun_test.go new file mode 100644 index 00000000..1d4660d6 --- /dev/null +++ b/tests/cli_e2e/task/task_get_my_tasks_dryrun_test.go @@ -0,0 +1,65 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package task + +import ( + "context" + "testing" + "time" + + clie2e "github.com/larksuite/cli/tests/cli_e2e" + "github.com/stretchr/testify/require" + "github.com/tidwall/gjson" +) + +// TestTask_GetMyTasksDryRun validates the request shape emitted by +// task +get-my-tasks under --dry-run. Fake credentials are sufficient because +// dry-run stops before any network call. +func TestTask_GetMyTasksDryRun(t *testing.T) { + t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir()) + t.Setenv("LARKSUITE_CLI_APP_ID", "task_dryrun_test") + t.Setenv("LARKSUITE_CLI_APP_SECRET", "task_dryrun_secret") + t.Setenv("LARKSUITE_CLI_BRAND", "feishu") + + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + t.Cleanup(cancel) + + result, err := clie2e.RunCmd(ctx, clie2e.Request{ + Args: []string{ + "task", "+get-my-tasks", + "--complete", + "--page-token", "pt_001", + "--dry-run", + }, + DefaultAs: "user", + }) + require.NoError(t, err) + result.AssertExitCode(t, 0) + + out := result.Stdout + if count := gjson.Get(out, "api.#").Int(); count != 1 { + t.Fatalf("expected 1 API call, got %d\nstdout:\n%s", count, out) + } + if method := gjson.Get(out, "api.0.method").String(); method != "GET" { + t.Fatalf("api[0].method = %q, want GET\nstdout:\n%s", method, out) + } + if url := gjson.Get(out, "api.0.url").String(); url != "/open-apis/task/v2/tasks" { + t.Fatalf("api[0].url = %q, want /open-apis/task/v2/tasks\nstdout:\n%s", url, out) + } + if got := gjson.Get(out, "api.0.params.type").String(); got != "my_tasks" { + t.Fatalf("api[0].params.type = %q, want my_tasks\nstdout:\n%s", got, out) + } + if got := gjson.Get(out, "api.0.params.user_id_type").String(); got != "open_id" { + t.Fatalf("api[0].params.user_id_type = %q, want open_id\nstdout:\n%s", got, out) + } + if got := gjson.Get(out, "api.0.params.completed").Bool(); !got { + t.Fatalf("api[0].params.completed = %v, want true\nstdout:\n%s", got, out) + } + if got := gjson.Get(out, "api.0.params.page_token").String(); got != "pt_001" { + t.Fatalf("api[0].params.page_token = %q, want pt_001\nstdout:\n%s", got, out) + } + if got := gjson.Get(out, "api.0.params.page_size").Int(); got != 50 { + t.Fatalf("api[0].params.page_size = %d, want 50\nstdout:\n%s", got, out) + } +}