Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 25 additions & 3 deletions shortcuts/task/task_get_my_tasks.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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 {
Expand All @@ -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)
}
Expand All @@ -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)
}
112 changes: 112 additions & 0 deletions shortcuts/task/task_get_my_tasks_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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())
}
}
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

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
Expand Down
9 changes: 5 additions & 4 deletions tests/cli_e2e/task/coverage.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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.

Expand All @@ -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` | |
Expand Down
65 changes: 65 additions & 0 deletions tests/cli_e2e/task/task_get_my_tasks_dryrun_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
}
Loading