mirror of
https://github.com/larksuite/cli.git
synced 2026-07-04 14:38:53 +08:00
Compare commits
9 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d4f97e4ea4 | ||
|
|
78d7f54770 | ||
|
|
5788a6c384 | ||
|
|
bd07859c90 | ||
|
|
8c3cba17b2 | ||
|
|
6367aaa0f5 | ||
|
|
37b17f3d37 | ||
|
|
be5527ca4e | ||
|
|
a75420f72c |
@@ -73,20 +73,20 @@ linters:
|
||||
- forbidigo
|
||||
# errs-typed-only enforced on paths already migrated to errs.NewXxxError.
|
||||
# Add a path when its migration is complete.
|
||||
- path-except: (internal/auth/|internal/errcompat/|internal/errclass/|internal/client/|internal/cmdutil/factory\.go|cmd/auth/|cmd/config/|cmd/service/|shortcuts/common/mcp_client\.go|shortcuts/calendar/|shortcuts/drive/|shortcuts/mail/|shortcuts/base/)
|
||||
- path-except: (internal/auth/|internal/errcompat/|internal/errclass/|internal/client/|internal/cmdutil/factory\.go|cmd/auth/|cmd/config/|cmd/service/|shortcuts/common/mcp_client\.go|shortcuts/base/|shortcuts/calendar/|shortcuts/drive/|shortcuts/mail/|shortcuts/okr/|shortcuts/task/|shortcuts/whiteboard/|shortcuts/im/)
|
||||
text: errs-typed-only
|
||||
linters:
|
||||
- forbidigo
|
||||
# errs-no-bare-wrap enforced on paths fully migrated to typed final
|
||||
# errors. Scoped separately from errs-typed-only because cmd/auth/,
|
||||
# cmd/config/ still have residual fmt.Errorf and must not be caught.
|
||||
- path-except: (shortcuts/drive/|shortcuts/mail/|shortcuts/base/|shortcuts/calendar/|shortcuts/common/mcp_client\.go)
|
||||
- path-except: (shortcuts/base/|shortcuts/calendar/|shortcuts/drive/|shortcuts/mail/|shortcuts/okr/|shortcuts/task/|shortcuts/whiteboard/|shortcuts/im/|shortcuts/common/mcp_client\.go)
|
||||
text: errs-no-bare-wrap
|
||||
linters:
|
||||
- forbidigo
|
||||
# errs-no-legacy-helper enforced on domains whose shared validation/save
|
||||
# helpers have migrated to typed final errors.
|
||||
- path-except: (shortcuts/drive/|shortcuts/mail/|shortcuts/base/|shortcuts/calendar/)
|
||||
- path-except: (shortcuts/base/|shortcuts/calendar/|shortcuts/drive/|shortcuts/mail/|shortcuts/okr/|shortcuts/task/|shortcuts/whiteboard/|shortcuts/im/)
|
||||
text: errs-no-legacy-helper
|
||||
linters:
|
||||
- forbidigo
|
||||
|
||||
@@ -377,9 +377,9 @@ func TestIntegration_Shortcut_BusinessError_OutputsEnvelope(t *testing.T) {
|
||||
OK: false,
|
||||
Identity: "bot",
|
||||
Error: &output.ErrDetail{
|
||||
Type: "api_error",
|
||||
Type: "api",
|
||||
Code: 230002,
|
||||
Message: "HTTP 400: Bot/User can NOT be out of the chat.",
|
||||
Message: "Bot/User can NOT be out of the chat.",
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
"github.com/larksuite/cli/events/im"
|
||||
"github.com/larksuite/cli/events/minutes"
|
||||
"github.com/larksuite/cli/events/vc"
|
||||
"github.com/larksuite/cli/events/whiteboard"
|
||||
"github.com/larksuite/cli/internal/event"
|
||||
)
|
||||
|
||||
@@ -17,6 +18,7 @@ func init() {
|
||||
im.Keys(),
|
||||
minutes.Keys(),
|
||||
vc.Keys(),
|
||||
whiteboard.Keys(),
|
||||
}
|
||||
for _, keys := range all {
|
||||
for _, k := range keys {
|
||||
|
||||
23
events/whiteboard/native.go
Normal file
23
events/whiteboard/native.go
Normal file
@@ -0,0 +1,23 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package whiteboard
|
||||
|
||||
// BoardWhiteboardUpdatedV1Data is the flattened whiteboard updated source payload.
|
||||
type BoardWhiteboardUpdatedV1Data struct {
|
||||
// WhiteboardID is the id of the whiteboard whose content was updated.
|
||||
WhiteboardID string `json:"whiteboard_id"`
|
||||
// OperatorIDs lists the operators that produced this update batch.
|
||||
OperatorIDs []OperatorID `json:"operator_ids"`
|
||||
}
|
||||
|
||||
// OperatorID identifies an operator that produced the whiteboard update,
|
||||
// expressed in the three Lark identity formats.
|
||||
type OperatorID struct {
|
||||
// OpenID is the operator's open_id within the current app.
|
||||
OpenID string `json:"open_id"`
|
||||
// UnionID is the operator's union_id across apps under the same ISV.
|
||||
UnionID string `json:"union_id"`
|
||||
// UserID is the operator's user_id within the tenant.
|
||||
UserID string `json:"user_id"`
|
||||
}
|
||||
48
events/whiteboard/preconsume.go
Normal file
48
events/whiteboard/preconsume.go
Normal file
@@ -0,0 +1,48 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package whiteboard
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/larksuite/cli/internal/event"
|
||||
"github.com/larksuite/cli/internal/validate"
|
||||
)
|
||||
|
||||
// cleanupTimeout bounds how long the unsubscribe call has to finish during
|
||||
// PreConsume cleanup so a stuck OAPI cannot block process shutdown.
|
||||
const cleanupTimeout = 5 * time.Second
|
||||
|
||||
// whiteboardSubscriptionPreConsume calls the whiteboard event subscribe OAPI
|
||||
// and returns a cleanup that invokes the matching unsubscribe.
|
||||
//
|
||||
// board.whiteboard.updated_v1 is subscribed per-whiteboard (by whiteboard_id),
|
||||
// so the path contains a :whiteboard_id placeholder that must be supplied via params.
|
||||
func whiteboardSubscriptionPreConsume(eventType string) func(context.Context, event.APIClient, map[string]string) (func(), error) {
|
||||
return func(ctx context.Context, rt event.APIClient, params map[string]string) (func(), error) {
|
||||
if rt == nil {
|
||||
return nil, fmt.Errorf("runtime API client is required for pre-consume subscription")
|
||||
}
|
||||
whiteboardID := params["whiteboard_id"]
|
||||
if whiteboardID == "" {
|
||||
return nil, fmt.Errorf("param whiteboard_id is required for %s", eventType)
|
||||
}
|
||||
encoded := validate.EncodePathSegment(whiteboardID)
|
||||
subscribePath := fmt.Sprintf("/open-apis/board/v1/whiteboards/%s/subscribe", encoded)
|
||||
unsubscribePath := fmt.Sprintf("/open-apis/board/v1/whiteboards/%s/unsubscribe", encoded)
|
||||
|
||||
body := map[string]string{"event_type": eventType}
|
||||
if _, err := rt.CallAPI(ctx, "POST", subscribePath, body); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return func() {
|
||||
cleanupCtx, cancel := context.WithTimeout(context.Background(), cleanupTimeout)
|
||||
defer cancel()
|
||||
_, _ = rt.CallAPI(cleanupCtx, "POST", unsubscribePath, body)
|
||||
}, nil
|
||||
}
|
||||
}
|
||||
198
events/whiteboard/preconsume_test.go
Normal file
198
events/whiteboard/preconsume_test.go
Normal file
@@ -0,0 +1,198 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package whiteboard
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/internal/event"
|
||||
)
|
||||
|
||||
// recordedCall captures a single APIClient invocation for assertion.
|
||||
type recordedCall struct {
|
||||
method string
|
||||
path string
|
||||
body interface{}
|
||||
}
|
||||
|
||||
// fakeAPIClient is a minimal event.APIClient stub that records calls and
|
||||
// can be configured to fail when the request path matches errOnPath.
|
||||
type fakeAPIClient struct {
|
||||
mu sync.Mutex
|
||||
calls []recordedCall
|
||||
errOnPath string
|
||||
}
|
||||
|
||||
// CallAPI records the invocation and optionally returns a simulated error
|
||||
// when the path contains the configured errOnPath substring.
|
||||
func (f *fakeAPIClient) CallAPI(_ context.Context, method, path string, body interface{}) (json.RawMessage, error) {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
f.calls = append(f.calls, recordedCall{method: method, path: path, body: body})
|
||||
if f.errOnPath != "" && strings.Contains(path, f.errOnPath) {
|
||||
return nil, errors.New("simulated subscribe failure")
|
||||
}
|
||||
return json.RawMessage(`{}`), nil
|
||||
}
|
||||
|
||||
// TestWhiteboardSubscriptionPreConsume_MissingWhiteboardID verifies that the
|
||||
// PreConsume hook fails fast with an actionable error when whiteboard_id
|
||||
// is absent from the params map.
|
||||
func TestWhiteboardSubscriptionPreConsume_MissingWhiteboardID(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
|
||||
pc := whiteboardSubscriptionPreConsume(eventTypeWhiteboardUpdated)
|
||||
cleanup, err := pc(context.Background(), &fakeAPIClient{}, map[string]string{})
|
||||
if err == nil {
|
||||
t.Fatalf("expected error when whiteboard_id missing")
|
||||
}
|
||||
if cleanup != nil {
|
||||
t.Fatalf("expected nil cleanup on error")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "whiteboard_id") {
|
||||
t.Fatalf("error should mention whiteboard_id, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestWhiteboardSubscriptionPreConsume_NilRuntime verifies that PreConsume
|
||||
// returns an error when the runtime APIClient dependency is missing.
|
||||
func TestWhiteboardSubscriptionPreConsume_NilRuntime(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
|
||||
pc := whiteboardSubscriptionPreConsume(eventTypeWhiteboardUpdated)
|
||||
_, err := pc(context.Background(), nil, map[string]string{"whiteboard_id": "wb1"})
|
||||
if err == nil {
|
||||
t.Fatalf("expected error when runtime client is nil")
|
||||
}
|
||||
}
|
||||
|
||||
// TestWhiteboardSubscriptionPreConsume_SubscribeError verifies that a
|
||||
// failed subscribe call surfaces the error and skips registering a cleanup,
|
||||
// so no spurious unsubscribe is invoked.
|
||||
func TestWhiteboardSubscriptionPreConsume_SubscribeError(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
|
||||
pc := whiteboardSubscriptionPreConsume(eventTypeWhiteboardUpdated)
|
||||
rt := &fakeAPIClient{errOnPath: "/subscribe"}
|
||||
cleanup, err := pc(context.Background(), rt, map[string]string{"whiteboard_id": "wb1"})
|
||||
if err == nil {
|
||||
t.Fatalf("expected error from subscribe call")
|
||||
}
|
||||
if cleanup != nil {
|
||||
t.Fatalf("expected nil cleanup when subscribe fails")
|
||||
}
|
||||
// only the failed subscribe call should have been made; no unsubscribe.
|
||||
if len(rt.calls) != 1 {
|
||||
t.Fatalf("expected exactly 1 call (subscribe), got %d", len(rt.calls))
|
||||
}
|
||||
}
|
||||
|
||||
// TestWhiteboardSubscriptionPreConsume_SubscribeAndCleanup verifies the full
|
||||
// happy-path: subscribe is called once with the correct method/path/body,
|
||||
// and the returned cleanup invokes the matching unsubscribe.
|
||||
func TestWhiteboardSubscriptionPreConsume_SubscribeAndCleanup(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
|
||||
pc := whiteboardSubscriptionPreConsume(eventTypeWhiteboardUpdated)
|
||||
rt := &fakeAPIClient{}
|
||||
cleanup, err := pc(context.Background(), rt, map[string]string{"whiteboard_id": "wb1"})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if cleanup == nil {
|
||||
t.Fatalf("expected non-nil cleanup")
|
||||
}
|
||||
|
||||
if len(rt.calls) != 1 {
|
||||
t.Fatalf("expected 1 call after subscribe, got %d", len(rt.calls))
|
||||
}
|
||||
got := rt.calls[0]
|
||||
if got.method != "POST" {
|
||||
t.Errorf("subscribe method: got %q, want POST", got.method)
|
||||
}
|
||||
wantSubPath := "/open-apis/board/v1/whiteboards/wb1/subscribe"
|
||||
if got.path != wantSubPath {
|
||||
t.Errorf("subscribe path: got %q, want %q", got.path, wantSubPath)
|
||||
}
|
||||
body, _ := got.body.(map[string]string)
|
||||
if body["event_type"] != eventTypeWhiteboardUpdated {
|
||||
t.Errorf("subscribe body event_type: got %q, want %q", body["event_type"], eventTypeWhiteboardUpdated)
|
||||
}
|
||||
|
||||
cleanup()
|
||||
if len(rt.calls) != 2 {
|
||||
t.Fatalf("expected 2 calls after cleanup, got %d", len(rt.calls))
|
||||
}
|
||||
got2 := rt.calls[1]
|
||||
if got2.method != "POST" {
|
||||
t.Errorf("unsubscribe method: got %q, want POST", got2.method)
|
||||
}
|
||||
wantUnsubPath := "/open-apis/board/v1/whiteboards/wb1/unsubscribe"
|
||||
if got2.path != wantUnsubPath {
|
||||
t.Errorf("unsubscribe path: got %q, want %q", got2.path, wantUnsubPath)
|
||||
}
|
||||
body2, _ := got2.body.(map[string]string)
|
||||
if body2["event_type"] != eventTypeWhiteboardUpdated {
|
||||
t.Errorf("unsubscribe body event_type: got %q, want %q", body2["event_type"], eventTypeWhiteboardUpdated)
|
||||
}
|
||||
}
|
||||
|
||||
// TestWhiteboardSubscriptionPreConsume_PathSegmentEncoded verifies that
|
||||
// whiteboard_id values containing reserved URL characters are properly
|
||||
// path-segment encoded so they cannot escape into adjacent path segments.
|
||||
func TestWhiteboardSubscriptionPreConsume_PathSegmentEncoded(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
|
||||
pc := whiteboardSubscriptionPreConsume(eventTypeWhiteboardUpdated)
|
||||
rt := &fakeAPIClient{}
|
||||
// 含特殊字符的 whiteboard_id 应被 path-segment 编码,避免越界到其他 path 段。
|
||||
_, err := pc(context.Background(), rt, map[string]string{"whiteboard_id": "wb/1?evil"})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if len(rt.calls) != 1 {
|
||||
t.Fatalf("expected 1 call, got %d", len(rt.calls))
|
||||
}
|
||||
if strings.Contains(rt.calls[0].path, "wb/1?evil") {
|
||||
t.Errorf("whiteboard_id was not encoded; path: %s", rt.calls[0].path)
|
||||
}
|
||||
}
|
||||
|
||||
// TestWhiteboardUpdatedV1HasPreConsume ensures the registered EventKey for
|
||||
// board.whiteboard.updated_v1 wires the PreConsume hook and declares the
|
||||
// required whiteboard_id parameter.
|
||||
func TestWhiteboardUpdatedV1HasPreConsume(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
|
||||
keys := Keys()
|
||||
for _, k := range keys {
|
||||
if k.Key == eventTypeWhiteboardUpdated {
|
||||
if k.PreConsume == nil {
|
||||
t.Fatalf("EventKey %s should have PreConsume hook", eventTypeWhiteboardUpdated)
|
||||
}
|
||||
if len(k.Params) == 0 {
|
||||
t.Fatalf("EventKey %s should declare whiteboard_id param", eventTypeWhiteboardUpdated)
|
||||
}
|
||||
var found bool
|
||||
for _, p := range k.Params {
|
||||
if p.Name == "whiteboard_id" && p.Required {
|
||||
found = true
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Fatalf("EventKey %s must declare required whiteboard_id param", eventTypeWhiteboardUpdated)
|
||||
}
|
||||
return
|
||||
}
|
||||
}
|
||||
t.Fatalf("EventKey %s not registered", eventTypeWhiteboardUpdated)
|
||||
}
|
||||
|
||||
// 确保 event.APIClient 接口与本测试 mock 一致。
|
||||
var _ event.APIClient = (*fakeAPIClient)(nil)
|
||||
48
events/whiteboard/register.go
Normal file
48
events/whiteboard/register.go
Normal file
@@ -0,0 +1,48 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
// Package whiteboard registers Board-domain EventKeys.
|
||||
package whiteboard
|
||||
|
||||
import (
|
||||
"reflect"
|
||||
|
||||
"github.com/larksuite/cli/internal/event"
|
||||
"github.com/larksuite/cli/internal/event/schemas"
|
||||
)
|
||||
|
||||
// eventTypeWhiteboardUpdated is the OAPI event type for whiteboard content updates.
|
||||
const eventTypeWhiteboardUpdated = "board.whiteboard.updated_v1"
|
||||
|
||||
// Keys returns all Board-domain EventKey definitions.
|
||||
func Keys() []event.KeyDefinition {
|
||||
return []event.KeyDefinition{
|
||||
{
|
||||
Key: eventTypeWhiteboardUpdated,
|
||||
DisplayName: "Whiteboard updated",
|
||||
Description: "Pushed when the whiteboard content is updated.",
|
||||
EventType: eventTypeWhiteboardUpdated,
|
||||
Params: []event.ParamDef{
|
||||
{
|
||||
Name: "whiteboard_id",
|
||||
Type: event.ParamString,
|
||||
Required: true,
|
||||
Description: "Whiteboard id to subscribe; subscription is per-whiteboard.",
|
||||
},
|
||||
},
|
||||
Schema: event.SchemaDef{
|
||||
Native: &event.SchemaSpec{Type: reflect.TypeOf(BoardWhiteboardUpdatedV1Data{})},
|
||||
FieldOverrides: map[string]schemas.FieldMeta{
|
||||
"/event/whiteboard_id": {Kind: "whiteboard_id", Description: "whiteboard id to subscribe"},
|
||||
"/event/operator_ids/*/open_id": {Kind: "open_id"},
|
||||
"/event/operator_ids/*/union_id": {Kind: "union_id"},
|
||||
"/event/operator_ids/*/user_id": {Kind: "user_id"},
|
||||
},
|
||||
},
|
||||
PreConsume: whiteboardSubscriptionPreConsume(eventTypeWhiteboardUpdated),
|
||||
Scopes: []string{"board:whiteboard:node:read"},
|
||||
AuthTypes: []string{"user", "bot"},
|
||||
RequiredConsoleEvents: []string{eventTypeWhiteboardUpdated},
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -16,9 +16,12 @@ import (
|
||||
// common replacements or construct an errs.* typed error directly.
|
||||
var migratedCommonHelperPaths = []string{
|
||||
"shortcuts/base/",
|
||||
"shortcuts/calendar/",
|
||||
"shortcuts/drive/",
|
||||
"shortcuts/mail/",
|
||||
"shortcuts/calendar/",
|
||||
"shortcuts/okr/",
|
||||
"shortcuts/task/",
|
||||
"shortcuts/whiteboard/",
|
||||
}
|
||||
|
||||
const commonImportPath = "github.com/larksuite/cli/shortcuts/common"
|
||||
|
||||
@@ -17,9 +17,13 @@ import (
|
||||
// appending their path prefix here.
|
||||
var migratedEnvelopePaths = []string{
|
||||
"shortcuts/base/",
|
||||
"shortcuts/calendar/",
|
||||
"shortcuts/drive/",
|
||||
"shortcuts/mail/",
|
||||
"shortcuts/calendar/",
|
||||
"shortcuts/okr/",
|
||||
"shortcuts/task/",
|
||||
"shortcuts/whiteboard/",
|
||||
"shortcuts/im/",
|
||||
}
|
||||
|
||||
// legacyOutputImportPath is the import path of the package that declares the
|
||||
|
||||
@@ -18,7 +18,7 @@ import (
|
||||
// forbidigo's errs-typed-only ban does not see them because they are method
|
||||
// calls, not output.Err* identifiers — this AST rule covers that gap.
|
||||
//
|
||||
// Migrated code must call a typed API wrapper (e.g. drive's driveCallAPI) or use
|
||||
// Migrated code must call the domain's typed API wrapper or use
|
||||
// runtime.DoAPI + errclass.BuildAPIError directly, so failures classify into
|
||||
// typed errs.* errors.
|
||||
//
|
||||
@@ -53,7 +53,7 @@ func CheckNoLegacyRuntimeAPICall(path, src string) []Violation {
|
||||
File: path,
|
||||
Line: fset.Position(call.Pos()).Line,
|
||||
Message: "runtime." + name + " emits a legacy output.ExitError api_error envelope and downgrades typed network/auth boundary errors; it is forbidden on migrated paths",
|
||||
Suggestion: "call the domain's typed API wrapper (e.g. driveCallAPI) or runtime.DoAPI + errclass.BuildAPIError " +
|
||||
Suggestion: "call the domain's typed API wrapper (for example driveCallAPI or callTaskAPITyped) or runtime.DoAPI + errclass.BuildAPIError " +
|
||||
"so failures classify into typed errs.* errors",
|
||||
})
|
||||
}
|
||||
|
||||
@@ -618,6 +618,35 @@ func boom() error {
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheckNoLegacyEnvelopeLiteral_RejectsExitErrorLiteralOnMigratedShortcutPaths(t *testing.T) {
|
||||
for _, path := range []string{
|
||||
"shortcuts/okr/okr_image_upload.go",
|
||||
"shortcuts/task/task_update.go",
|
||||
"shortcuts/whiteboard/whiteboard_update.go",
|
||||
} {
|
||||
t.Run(path, func(t *testing.T) {
|
||||
src := `package migrated
|
||||
|
||||
import "github.com/larksuite/cli/internal/output"
|
||||
|
||||
func boom() error {
|
||||
return &output.ExitError{Code: 1}
|
||||
}
|
||||
`
|
||||
v := CheckNoLegacyEnvelopeLiteral(path, src)
|
||||
if len(v) != 1 {
|
||||
t.Fatalf("expected 1 violation, got %d: %+v", len(v), v)
|
||||
}
|
||||
if v[0].Action != ActionReject {
|
||||
t.Errorf("action = %q, want REJECT", v[0].Action)
|
||||
}
|
||||
if !strings.Contains(v[0].Message, "ExitError") {
|
||||
t.Errorf("message should name the legacy type: %s", v[0].Message)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheckNoLegacyEnvelopeLiteral_RejectsErrDetailLiteralOnDrivePath(t *testing.T) {
|
||||
src := `package drive
|
||||
|
||||
@@ -662,7 +691,7 @@ func boom() error {
|
||||
return &output.ExitError{Code: 1}
|
||||
}
|
||||
`
|
||||
v := CheckNoLegacyEnvelopeLiteral("shortcuts/im/foo.go", src)
|
||||
v := CheckNoLegacyEnvelopeLiteral("shortcuts/contact/foo.go", src)
|
||||
if len(v) != 0 {
|
||||
t.Errorf("non-migrated path should pass, got: %+v", v)
|
||||
}
|
||||
@@ -801,6 +830,26 @@ func boom(runtime *common.RuntimeContext) error {
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheckNoLegacyRuntimeAPICall_RejectsCallAPIOnTaskPath(t *testing.T) {
|
||||
src := `package task
|
||||
|
||||
func boom(runtime *common.RuntimeContext) error {
|
||||
_, err := runtime.CallAPI("POST", "/x", nil, nil)
|
||||
return err
|
||||
}
|
||||
`
|
||||
v := CheckNoLegacyRuntimeAPICall("shortcuts/task/task_update.go", src)
|
||||
if len(v) != 1 {
|
||||
t.Fatalf("expected 1 violation, got %d: %+v", len(v), v)
|
||||
}
|
||||
if v[0].Action != ActionReject {
|
||||
t.Errorf("action = %q, want REJECT", v[0].Action)
|
||||
}
|
||||
if !strings.Contains(v[0].Message, "CallAPI") {
|
||||
t.Errorf("message should name the legacy method: %s", v[0].Message)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheckNoLegacyRuntimeAPICall_RejectsDoAPIJSONWithLogIDOnDrivePath(t *testing.T) {
|
||||
src := `package drive
|
||||
|
||||
@@ -851,14 +900,14 @@ func boom(runtime *common.RuntimeContext) error {
|
||||
}
|
||||
|
||||
func TestCheckNoLegacyRuntimeAPICall_IgnoresNonMigratedPath(t *testing.T) {
|
||||
src := `package im
|
||||
src := `package contact
|
||||
|
||||
func boom(runtime *common.RuntimeContext) error {
|
||||
_, err := runtime.CallAPI("POST", "/x", nil, nil)
|
||||
return err
|
||||
}
|
||||
`
|
||||
v := CheckNoLegacyRuntimeAPICall("shortcuts/im/im_send.go", src)
|
||||
v := CheckNoLegacyRuntimeAPICall("shortcuts/contact/contact_get.go", src)
|
||||
if len(v) != 0 {
|
||||
t.Errorf("non-migrated path must not fire, got: %+v", v)
|
||||
}
|
||||
@@ -897,6 +946,9 @@ func TestCheckNoLegacyCommonHelperCall_RejectsLegacyHelpersOnMigratedPath(t *tes
|
||||
paths := []string{
|
||||
"shortcuts/drive/drive_search.go",
|
||||
"shortcuts/mail/mail_send.go",
|
||||
"shortcuts/okr/okr_progress_create.go",
|
||||
"shortcuts/task/task_update.go",
|
||||
"shortcuts/whiteboard/whiteboard_query.go",
|
||||
}
|
||||
for _, path := range paths {
|
||||
for _, helper := range helpers {
|
||||
@@ -946,7 +998,7 @@ func boom() {
|
||||
}
|
||||
|
||||
func TestCheckNoLegacyCommonHelperCall_AllowsNonMigratedPath(t *testing.T) {
|
||||
src := `package im
|
||||
src := `package contact
|
||||
|
||||
import "github.com/larksuite/cli/shortcuts/common"
|
||||
|
||||
@@ -954,7 +1006,7 @@ func boom() {
|
||||
common.FlagErrorf("legacy allowed until domain migrates")
|
||||
}
|
||||
`
|
||||
v := CheckNoLegacyCommonHelperCall("shortcuts/im/im_send.go", src)
|
||||
v := CheckNoLegacyCommonHelperCall("shortcuts/contact/contact_get.go", src)
|
||||
if len(v) != 0 {
|
||||
t.Errorf("non-migrated path must pass, got: %+v", v)
|
||||
}
|
||||
|
||||
@@ -12,6 +12,7 @@ import (
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/client"
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/internal/httpmock"
|
||||
@@ -198,3 +199,58 @@ func TestCallAPITyped_NonObjectJSON(t *testing.T) {
|
||||
t.Errorf("subtype = %q, want %q", intErr.Subtype, errs.SubtypeInvalidResponse)
|
||||
}
|
||||
}
|
||||
|
||||
// TestDoAPIJSONTyped_Success returns the data object on code 0, confirming the
|
||||
// typed DoAPIJSON replacement preserves the success contract of DoAPIJSON.
|
||||
func TestDoAPIJSONTyped_Success(t *testing.T) {
|
||||
rt, reg := newCallAPITypedRuntime(t)
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/x/z",
|
||||
Body: map[string]interface{}{"code": float64(0), "data": map[string]interface{}{"id": "z1"}},
|
||||
})
|
||||
|
||||
data, err := rt.DoAPIJSONTyped("GET", "/open-apis/x/z", nil, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if data["id"] != "z1" {
|
||||
t.Errorf("data[id] = %v, want z1", data["id"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestDoAPIJSONTyped_RawClientErrorBecomesTypedInternal(t *testing.T) {
|
||||
rt := TestNewRuntimeContextForAPI(context.Background(), &cobra.Command{Use: "+x"}, &core.CliConfig{}, nil, core.AsUser)
|
||||
rt.apiClientFunc = func() (*client.APIClient, error) {
|
||||
return nil, errors.New("raw client construction error")
|
||||
}
|
||||
|
||||
_, err := rt.DoAPIJSONTyped("GET", "/open-apis/x/z", nil, nil)
|
||||
var internalErr *errs.InternalError
|
||||
if !errors.As(err, &internalErr) {
|
||||
t.Fatalf("expected raw client errors to be lifted to typed internal errors, got %T: %v", err, err)
|
||||
}
|
||||
if internalErr.Subtype != errs.SubtypeUnknown {
|
||||
t.Errorf("subtype = %q, want %q", internalErr.Subtype, errs.SubtypeUnknown)
|
||||
}
|
||||
}
|
||||
|
||||
// TestDoAPIJSONTyped_NonZeroCode classifies a non-zero API code into a typed
|
||||
// errs.* error (carrying log_id), never a legacy output.ExitError envelope.
|
||||
func TestDoAPIJSONTyped_NonZeroCode(t *testing.T) {
|
||||
rt, reg := newCallAPITypedRuntime(t)
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/x/z",
|
||||
Body: map[string]interface{}{"code": float64(1061044), "msg": "boom", "log_id": "lz"},
|
||||
})
|
||||
|
||||
_, err := rt.DoAPIJSONTyped("POST", "/open-apis/x/z", nil, map[string]any{})
|
||||
p, ok := errs.ProblemOf(err)
|
||||
if !ok {
|
||||
t.Fatalf("expected a typed errs.* error, got %T: %v", err, err)
|
||||
}
|
||||
if p.LogID != "lz" {
|
||||
t.Errorf("LogID = %q, want lz", p.LogID)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -492,6 +492,28 @@ func (ctx *RuntimeContext) DoAPIJSONWithLogID(method, apiPath string, query lark
|
||||
return ctx.doAPIJSON(method, apiPath, query, body, true)
|
||||
}
|
||||
|
||||
// DoAPIJSONTyped is the typed-only replacement for DoAPIJSON: it issues the same
|
||||
// larkcore.ApiReq request (identical method / path / query / body model) but
|
||||
// classifies failures into typed errs.* errors via ClassifyAPIResponse instead
|
||||
// of emitting a legacy output.ExitError "api_error" envelope. A transport / auth
|
||||
// error from the client boundary is already typed and passes through unchanged;
|
||||
// a non-zero API code is classified with subtype / code / log_id.
|
||||
func (ctx *RuntimeContext) DoAPIJSONTyped(method, apiPath string, query larkcore.QueryParams, body any) (map[string]any, error) {
|
||||
req := &larkcore.ApiReq{
|
||||
HttpMethod: method,
|
||||
ApiPath: apiPath,
|
||||
QueryParams: query,
|
||||
}
|
||||
if body != nil {
|
||||
req.Body = body
|
||||
}
|
||||
resp, err := ctx.DoAPI(req)
|
||||
if err != nil {
|
||||
return nil, typedOrInternal(err)
|
||||
}
|
||||
return ctx.ClassifyAPIResponse(resp)
|
||||
}
|
||||
|
||||
func (ctx *RuntimeContext) doAPIJSON(method, apiPath string, query larkcore.QueryParams, body any, includeLogID bool) (map[string]any, error) {
|
||||
req := &larkcore.ApiReq{
|
||||
HttpMethod: method,
|
||||
@@ -603,27 +625,6 @@ func (ctx *RuntimeContext) ResolveSavePath(path string) (string, error) {
|
||||
return resolved, nil
|
||||
}
|
||||
|
||||
// WrapSaveError matches a FileIO.Save error against known categories and wraps
|
||||
// it with the caller-provided message prefix, preserving backward-compatible
|
||||
// error text per shortcut.
|
||||
func WrapSaveError(err error, pathMsg, mkdirMsg, writeMsg string) error {
|
||||
if err == nil {
|
||||
return nil
|
||||
}
|
||||
var me *fileio.MkdirError
|
||||
var we *fileio.WriteError
|
||||
switch {
|
||||
case errors.Is(err, fileio.ErrPathValidation):
|
||||
return fmt.Errorf("%s: %w", pathMsg, err)
|
||||
case errors.As(err, &me):
|
||||
return fmt.Errorf("%s: %w", mkdirMsg, err)
|
||||
case errors.As(err, &we):
|
||||
return fmt.Errorf("%s: %w", writeMsg, err)
|
||||
default:
|
||||
return fmt.Errorf("%s: %w", writeMsg, err)
|
||||
}
|
||||
}
|
||||
|
||||
// WrapOpenError matches a FileIO.Open/Stat error and wraps it with the
|
||||
// caller-provided message prefix.
|
||||
func WrapOpenError(err error, pathMsg, readMsg string) error {
|
||||
@@ -703,6 +704,9 @@ func WrapSaveErrorTyped(err error) error {
|
||||
if err == nil {
|
||||
return nil
|
||||
}
|
||||
if _, ok := errs.ProblemOf(err); ok {
|
||||
return err
|
||||
}
|
||||
var me *fileio.MkdirError
|
||||
switch {
|
||||
case errors.Is(err, fileio.ErrPathValidation):
|
||||
|
||||
@@ -9,18 +9,6 @@ import (
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
)
|
||||
|
||||
// ValidateChatID checks if a chat ID has valid format (oc_ prefix).
|
||||
// Also extracts token from URL if provided.
|
||||
//
|
||||
// Deprecated: use ValidateChatIDTyped for typed error envelopes.
|
||||
func ValidateChatID(input string) (string, error) {
|
||||
chatID, msg := normalizeChatID(input)
|
||||
if msg != "" {
|
||||
return "", output.ErrValidation("%s", msg)
|
||||
}
|
||||
return chatID, nil
|
||||
}
|
||||
|
||||
// ValidateChatIDTyped checks if a chat ID has valid format (oc_ prefix).
|
||||
// Also extracts token from URL if provided. param names the flag being
|
||||
// validated (e.g. "--chat-ids") and is recorded on the typed error.
|
||||
|
||||
@@ -194,6 +194,21 @@ func TestWrapSaveErrorTyped_ClassifiesPathAndFileIO(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestWrapSaveErrorTyped_PreservesTypedWriteCause(t *testing.T) {
|
||||
typed := errs.NewNetworkError(errs.SubtypeNetworkServer, "HTTP 500: chunk failed").
|
||||
WithCode(500)
|
||||
err := WrapSaveErrorTyped(&fileio.WriteError{Err: typed})
|
||||
|
||||
p, ok := errs.ProblemOf(err)
|
||||
if !ok {
|
||||
t.Fatalf("expected typed problem, got %T: %v", err, err)
|
||||
}
|
||||
if p.Category != errs.CategoryNetwork || p.Subtype != errs.SubtypeNetworkServer || p.Code != 500 {
|
||||
t.Fatalf("problem = category %q subtype %q code %d, want network/%s/500",
|
||||
p.Category, p.Subtype, p.Code, errs.SubtypeNetworkServer)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAtLeastOne(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
|
||||
@@ -162,7 +162,7 @@ func batchResolveByBasicContact(runtime *common.RuntimeContext, missingIDs []str
|
||||
}
|
||||
batch := missingIDs[i:end]
|
||||
|
||||
data, err := runtime.DoAPIJSON(http.MethodPost,
|
||||
data, err := runtime.DoAPIJSONTyped(http.MethodPost,
|
||||
"/open-apis/contact/v3/users/basic_batch",
|
||||
larkcore.QueryParams{"user_id_type": []string{"open_id"}},
|
||||
map[string]interface{}{"user_ids": batch},
|
||||
@@ -198,7 +198,7 @@ func batchResolveUsers(runtime *common.RuntimeContext, missingIDs []string, name
|
||||
}
|
||||
apiURL := "/open-apis/contact/v3/users/batch?" + strings.Join(parts, "&")
|
||||
|
||||
data, err := runtime.DoAPIJSON(http.MethodGet, apiURL, nil, nil)
|
||||
data, err := runtime.DoAPIJSONTyped(http.MethodGet, apiURL, nil, nil)
|
||||
if err != nil {
|
||||
break
|
||||
}
|
||||
|
||||
@@ -200,20 +200,20 @@ func batchResolveMergeForwardSenders(runtime *common.RuntimeContext, prefetch ma
|
||||
// container via a single API call. Returns a flat list of raw message items
|
||||
// with upper_message_id for tree reconstruction.
|
||||
//
|
||||
// Uses DoAPIJSON so the response envelope's code/msg are checked and surfaced
|
||||
// Uses DoAPIJSONTyped so the response envelope's code/msg are checked and surfaced
|
||||
// — earlier this used the low-level DoAPI and reported every non-zero code
|
||||
// as a generic "empty data" error, hiding the real failure (e.g. a server
|
||||
// "code: 2200 Internal Error" with its log_id would show up as just "empty
|
||||
// data" in the output).
|
||||
func fetchMergeForwardSubMessages(messageID string, runtime *common.RuntimeContext) ([]map[string]interface{}, error) {
|
||||
data, err := runtime.DoAPIJSON(http.MethodGet, mergeForwardMessagesPath(messageID), larkcore.QueryParams{
|
||||
data, err := runtime.DoAPIJSONTyped(http.MethodGet, mergeForwardMessagesPath(messageID), larkcore.QueryParams{
|
||||
"user_id_type": []string{"open_id"},
|
||||
"card_msg_content_type": []string{"raw_card_content"},
|
||||
}, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// DoAPIJSON returns the envelope's `data` field; when the server's JSON
|
||||
// DoAPIJSONTyped returns the envelope's `data` field; when the server's JSON
|
||||
// has `code: 0` but omits `data` entirely, that field comes back as nil.
|
||||
// Reading from a nil map in Go is safe (returns the zero value, never
|
||||
// panics), but guarding explicitly makes the "successful empty
|
||||
|
||||
@@ -156,7 +156,7 @@ func fetchReactionsBatch(runtime *common.RuntimeContext, batchIDs []string, idIn
|
||||
queries = append(queries, map[string]interface{}{"message_id": id})
|
||||
}
|
||||
|
||||
data, err := runtime.DoAPIJSON(http.MethodPost,
|
||||
data, err := runtime.DoAPIJSONTyped(http.MethodPost,
|
||||
"/open-apis/im/v1/messages/reactions/batch_query",
|
||||
nil,
|
||||
map[string]interface{}{"queries": queries},
|
||||
|
||||
@@ -243,7 +243,7 @@ func ExpandThreadReplies(runtime *common.RuntimeContext, messages []map[string]i
|
||||
// Returns the raw message items, whether more replies exist beyond the limit,
|
||||
// and a non-nil error when the API call fails.
|
||||
func fetchThreadReplies(runtime *common.RuntimeContext, threadID string, limit int) ([]map[string]interface{}, bool, error) {
|
||||
data, err := runtime.DoAPIJSON(http.MethodGet, "/open-apis/im/v1/messages", larkcore.QueryParams{
|
||||
data, err := runtime.DoAPIJSONTyped(http.MethodGet, "/open-apis/im/v1/messages", larkcore.QueryParams{
|
||||
"container_id_type": []string{"thread"},
|
||||
"container_id": []string{threadID},
|
||||
"sort_type": []string{"ByCreateTimeAsc"},
|
||||
@@ -251,7 +251,7 @@ func fetchThreadReplies(runtime *common.RuntimeContext, threadID string, limit i
|
||||
"card_msg_content_type": []string{"raw_card_content"},
|
||||
}, nil)
|
||||
if err != nil {
|
||||
return nil, false, fmt.Errorf("fetch thread replies for %s: %w", threadID, err)
|
||||
return nil, false, fmt.Errorf("fetch thread replies for %s: %w", threadID, err) //nolint:forbidigo // best-effort internal thread fetch; never surfaced as a final shortcut error (ExpandThreadReplies is void)
|
||||
}
|
||||
hasMore, _ := data["has_more"].(bool)
|
||||
rawItems, _ := data["items"].([]interface{})
|
||||
|
||||
@@ -19,10 +19,10 @@ import (
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/extension/fileio"
|
||||
"github.com/larksuite/cli/internal/auth"
|
||||
"github.com/larksuite/cli/internal/credential"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"github.com/larksuite/cli/internal/validate"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
larkcore "github.com/larksuite/oapi-sdk-go/v3/core"
|
||||
@@ -37,11 +37,11 @@ var messageIDRe = regexp.MustCompile(`^om_`)
|
||||
func flagMessageID(rt *common.RuntimeContext) (string, error) {
|
||||
id := strings.TrimSpace(rt.Str("message-id"))
|
||||
if id == "" {
|
||||
return "", output.ErrValidation("--message-id is required")
|
||||
return "", errs.NewValidationError(errs.SubtypeInvalidArgument, "--message-id is required").WithParam("--message-id")
|
||||
}
|
||||
if strings.HasPrefix(id, "omt_") {
|
||||
return "", output.ErrValidation(
|
||||
"invalid message ID %q: omt_ prefix is a thread ID, not a message ID; flag operations require om_ message IDs", id)
|
||||
return "", errs.NewValidationError(errs.SubtypeInvalidArgument,
|
||||
"invalid message ID %q: omt_ prefix is a thread ID, not a message ID; flag operations require om_ message IDs", id).WithParam("--message-id")
|
||||
}
|
||||
return validateMessageID(id)
|
||||
}
|
||||
@@ -65,10 +65,10 @@ func buildMGetURL(ids []string) string {
|
||||
func validateMessageID(input string) (string, error) {
|
||||
input = strings.TrimSpace(input)
|
||||
if input == "" {
|
||||
return "", output.ErrValidation("message ID cannot be empty")
|
||||
return "", errs.NewValidationError(errs.SubtypeInvalidArgument, "message ID cannot be empty").WithParam("--message-id")
|
||||
}
|
||||
if !strings.HasPrefix(input, "om_") {
|
||||
return "", output.ErrValidation("invalid message ID %q: must start with om_", input)
|
||||
return "", errs.NewValidationError(errs.SubtypeInvalidArgument, "invalid message ID %q: must start with om_", input).WithParam("--message-id")
|
||||
}
|
||||
return input, nil
|
||||
}
|
||||
@@ -173,14 +173,16 @@ func sanitizeURLForDisplay(rawURL string) string {
|
||||
// startURLDownload performs URL validation, creates an HTTP client, and sends a
|
||||
// GET request. It returns the response (with Body still open) and the file
|
||||
// extension inferred from the URL. The caller must close resp.Body.
|
||||
func startURLDownload(ctx context.Context, runtime *common.RuntimeContext, rawURL string) (*http.Response, string, error) {
|
||||
func startURLDownload(ctx context.Context, runtime *common.RuntimeContext, rawURL, param string) (*http.Response, string, error) {
|
||||
if err := validate.ValidateDownloadSourceURL(ctx, rawURL); err != nil {
|
||||
return nil, "", fmt.Errorf("blocked URL: %w", err)
|
||||
return nil, "", errs.NewValidationError(errs.SubtypeInvalidArgument, "blocked URL: %v", err).
|
||||
WithParam(param).
|
||||
WithCause(err)
|
||||
}
|
||||
|
||||
httpClient, err := runtime.Factory.HttpClient()
|
||||
if err != nil {
|
||||
return nil, "", fmt.Errorf("http client: %w", err)
|
||||
return nil, "", errs.NewInternalError(errs.SubtypeSDKError, "http client: %v", err).WithCause(err)
|
||||
}
|
||||
httpClient = validate.NewDownloadHTTPClient(httpClient, validate.DownloadHTTPClientOptions{
|
||||
AllowHTTP: true,
|
||||
@@ -188,17 +190,19 @@ func startURLDownload(ctx context.Context, runtime *common.RuntimeContext, rawUR
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, rawURL, nil)
|
||||
if err != nil {
|
||||
return nil, "", fmt.Errorf("invalid URL: %w", err)
|
||||
return nil, "", errs.NewValidationError(errs.SubtypeInvalidArgument, "invalid URL: %v", err).
|
||||
WithParam(param).
|
||||
WithCause(err)
|
||||
}
|
||||
|
||||
resp, err := httpClient.Do(req)
|
||||
if err != nil {
|
||||
return nil, "", fmt.Errorf("download failed: %w", err)
|
||||
return nil, "", wrapIMNetworkErr(err, "download failed")
|
||||
}
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
resp.Body.Close()
|
||||
return nil, "", fmt.Errorf("download failed: HTTP %d", resp.StatusCode)
|
||||
return nil, "", errs.NewNetworkError(errs.SubtypeNetworkTransport, "download failed: HTTP %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
ext := filepath.Ext(fileNameFromURL(rawURL))
|
||||
@@ -208,8 +212,8 @@ func startURLDownload(ctx context.Context, runtime *common.RuntimeContext, rawUR
|
||||
// downloadURLToReader returns a size-limited io.ReadCloser for the URL content
|
||||
// and the file extension inferred from the URL. The caller must close the
|
||||
// returned ReadCloser. No temp file is created and the content is not buffered.
|
||||
func downloadURLToReader(ctx context.Context, runtime *common.RuntimeContext, rawURL string, maxSize int64) (io.ReadCloser, string, error) {
|
||||
resp, ext, err := startURLDownload(ctx, runtime, rawURL) //nolint:bodyclose // resp.Body is closed by the returned limitedReadCloser
|
||||
func downloadURLToReader(ctx context.Context, runtime *common.RuntimeContext, rawURL string, maxSize int64, param string) (io.ReadCloser, string, error) {
|
||||
resp, ext, err := startURLDownload(ctx, runtime, rawURL, param) //nolint:bodyclose // resp.Body is closed by the returned limitedReadCloser
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
@@ -233,7 +237,7 @@ func (l *limitedReadCloser) Read(p []byte) (int, error) {
|
||||
n, err := l.r.Read(p)
|
||||
l.n += int64(n)
|
||||
if l.n > l.max {
|
||||
return n, fmt.Errorf("download exceeds size limit (max %s)", common.FormatSize(l.max))
|
||||
return n, fmt.Errorf("download exceeds size limit (max %s)", common.FormatSize(l.max)) //nolint:forbidigo // io.Reader.Read contract returns a plain error; classified by the download caller
|
||||
}
|
||||
return n, err
|
||||
}
|
||||
@@ -314,7 +318,7 @@ func resolveURLMedia(ctx context.Context, runtime *common.RuntimeContext, s medi
|
||||
fmt.Fprintf(runtime.IO().ErrOut, "downloading %s: %s\n", s.flagName, sanitizeURLForDisplay(s.value))
|
||||
|
||||
if s.kind == mediaKindImage {
|
||||
rc, _, err := downloadURLToReader(ctx, runtime, s.value, s.maxSize)
|
||||
rc, _, err := downloadURLToReader(ctx, runtime, s.value, s.maxSize, s.flagName)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
@@ -324,7 +328,7 @@ func resolveURLMedia(ctx context.Context, runtime *common.RuntimeContext, s medi
|
||||
}
|
||||
|
||||
// File-kind: buffer in memory for possible duration parsing.
|
||||
mb, err := newMediaBuffer(ctx, runtime, s.value, s.maxSize)
|
||||
mb, err := newMediaBuffer(ctx, runtime, s.value, s.maxSize, s.flagName)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
@@ -341,7 +345,7 @@ func resolveLocalMedia(ctx context.Context, runtime *common.RuntimeContext, s me
|
||||
fmt.Fprintf(runtime.IO().ErrOut, "uploading %s: %s\n", s.mediaType, filepath.Base(s.value))
|
||||
|
||||
if s.kind == mediaKindImage {
|
||||
return uploadImageToIM(ctx, runtime, s.value, "message")
|
||||
return uploadImageToIM(ctx, runtime, s.value, "message", s.flagName)
|
||||
}
|
||||
|
||||
ft := detectIMFileType(s.value)
|
||||
@@ -349,7 +353,7 @@ func resolveLocalMedia(ctx context.Context, runtime *common.RuntimeContext, s me
|
||||
if s.withDuration {
|
||||
dur = parseMediaDuration(runtime, s.value, ft)
|
||||
}
|
||||
return uploadFileToIM(ctx, runtime, s.value, ft, dur)
|
||||
return uploadFileToIM(ctx, runtime, s.value, ft, dur, s.flagName)
|
||||
}
|
||||
|
||||
// resolveVideoContent handles the video case which needs both a file_key and
|
||||
@@ -370,7 +374,7 @@ func resolveVideoContent(ctx context.Context, runtime *common.RuntimeContext, vi
|
||||
}
|
||||
coverKey, err := resolveOneMedia(ctx, runtime, coverSpec)
|
||||
if err != nil {
|
||||
return "", "", fmt.Errorf("cover image upload failed: %w", err)
|
||||
return "", "", wrapIMNetworkErr(err, "cover image upload failed")
|
||||
}
|
||||
|
||||
jsonBytes, _ := json.Marshal(map[string]string{"file_key": fKey, "image_key": coverKey})
|
||||
@@ -386,13 +390,13 @@ func mediaFallbackOrError(originalValue, mediaType string, uploadErr error) (str
|
||||
jsonBytes, _ := json.Marshal(map[string]string{"text": fallbackText})
|
||||
return "text", string(jsonBytes), nil
|
||||
}
|
||||
return "", "", fmt.Errorf("%s upload failed: %w", mediaType, uploadErr)
|
||||
return "", "", wrapIMNetworkErr(uploadErr, "%s upload failed", mediaType)
|
||||
}
|
||||
|
||||
// resolveP2PChatID resolves user open_id to P2P chat_id.
|
||||
func resolveP2PChatID(runtime *common.RuntimeContext, openID string) (string, error) {
|
||||
if runtime.IsBot() {
|
||||
return "", output.Errorf(output.ExitValidation, "validation", "--user-id requires user identity (--as user); use --chat-id when calling with bot identity")
|
||||
return "", errs.NewValidationError(errs.SubtypeInvalidArgument, "--user-id requires user identity (--as user); use --chat-id when calling with bot identity").WithParam("--user-id")
|
||||
}
|
||||
apiResp, err := runtime.DoAPI(&larkcore.ApiReq{
|
||||
HttpMethod: http.MethodPost,
|
||||
@@ -405,11 +409,10 @@ func resolveP2PChatID(runtime *common.RuntimeContext, openID string) (string, er
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
var result map[string]interface{}
|
||||
if err := json.Unmarshal(apiResp.RawBody, &result); err != nil {
|
||||
return "", fmt.Errorf("failed to parse chat_p2p response: %w", err)
|
||||
data, err := runtime.ClassifyAPIResponse(apiResp)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
data, _ := result["data"].(map[string]interface{})
|
||||
|
||||
chats, _ := data["p2p_chats"].([]interface{})
|
||||
for _, item := range chats {
|
||||
@@ -420,7 +423,7 @@ func resolveP2PChatID(runtime *common.RuntimeContext, openID string) (string, er
|
||||
}
|
||||
}
|
||||
|
||||
return "", output.Errorf(output.ExitAPI, "not_found", "P2P chat not found for this user")
|
||||
return "", errs.NewAPIError(errs.SubtypeNotFound, "P2P chat not found for this user")
|
||||
}
|
||||
|
||||
// resolveThreadID normalizes a message ID to its thread ID when possible.
|
||||
@@ -429,7 +432,7 @@ func resolveThreadID(runtime *common.RuntimeContext, id string) (string, error)
|
||||
return id, nil
|
||||
}
|
||||
if !messageIDRe.MatchString(id) {
|
||||
return "", output.Errorf(output.ExitValidation, "validation", "invalid thread ID format: must start with om_ or omt_")
|
||||
return "", errs.NewValidationError(errs.SubtypeInvalidArgument, "invalid thread ID format: must start with om_ or omt_").WithParam("--thread")
|
||||
}
|
||||
|
||||
apiResp, err := runtime.DoAPI(&larkcore.ApiReq{
|
||||
@@ -439,11 +442,10 @@ func resolveThreadID(runtime *common.RuntimeContext, id string) (string, error)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
var result map[string]interface{}
|
||||
if err := json.Unmarshal(apiResp.RawBody, &result); err != nil {
|
||||
return "", fmt.Errorf("failed to parse message response: %w", err)
|
||||
data, err := runtime.ClassifyAPIResponse(apiResp)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
data, _ := result["data"].(map[string]interface{})
|
||||
|
||||
items, _ := data["items"].([]interface{})
|
||||
for _, item := range items {
|
||||
@@ -454,7 +456,7 @@ func resolveThreadID(runtime *common.RuntimeContext, id string) (string, error)
|
||||
}
|
||||
}
|
||||
|
||||
return "", output.Errorf(output.ExitAPI, "not_found", "thread ID not found for this message")
|
||||
return "", errs.NewAPIError(errs.SubtypeNotFound, "thread ID not found for this message")
|
||||
}
|
||||
|
||||
// parseOggOpusDuration parses the duration in milliseconds from an OGG/Opus
|
||||
@@ -612,8 +614,8 @@ type mediaBuffer struct {
|
||||
}
|
||||
|
||||
// newMediaBuffer downloads URL content into memory via downloadURLToReader.
|
||||
func newMediaBuffer(ctx context.Context, runtime *common.RuntimeContext, rawURL string, maxSize int64) (*mediaBuffer, error) {
|
||||
rc, ext, err := downloadURLToReader(ctx, runtime, rawURL, maxSize)
|
||||
func newMediaBuffer(ctx context.Context, runtime *common.RuntimeContext, rawURL string, maxSize int64, param string) (*mediaBuffer, error) {
|
||||
rc, ext, err := downloadURLToReader(ctx, runtime, rawURL, maxSize, param)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -621,7 +623,7 @@ func newMediaBuffer(ctx context.Context, runtime *common.RuntimeContext, rawURL
|
||||
|
||||
data, err := io.ReadAll(rc)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("download failed: %w", err)
|
||||
return nil, wrapIMNetworkErr(err, "download failed")
|
||||
}
|
||||
return newMediaBufferFromBytes(data, ext, rawURL), nil
|
||||
}
|
||||
@@ -927,7 +929,7 @@ func resolveMarkdownImageURLs(ctx context.Context, runtime *common.RuntimeContex
|
||||
}
|
||||
imgURL := sub[1]
|
||||
|
||||
rc, _, err := downloadURLToReader(ctx, runtime, imgURL, maxImageUploadSize)
|
||||
rc, _, err := downloadURLToReader(ctx, runtime, imgURL, maxImageUploadSize, "--markdown")
|
||||
if err != nil {
|
||||
fmt.Fprintf(runtime.IO().ErrOut, "warning: failed to download image %s: %v\n", sanitizeURLForDisplay(imgURL), err)
|
||||
return ""
|
||||
@@ -1049,14 +1051,14 @@ func detectIMFileType(filePath string) string {
|
||||
const maxImageUploadSize = 5 * 1024 * 1024 // 5MB — Lark API limit for images
|
||||
const maxFileUploadSize = 100 * 1024 * 1024 // 100MB — Lark API limit for files
|
||||
|
||||
func uploadImageToIM(ctx context.Context, runtime *common.RuntimeContext, filePath, imageType string) (string, error) {
|
||||
func uploadImageToIM(ctx context.Context, runtime *common.RuntimeContext, filePath, imageType, param string) (string, error) {
|
||||
if info, err := runtime.FileIO().Stat(filePath); err == nil && info.Size() > maxImageUploadSize {
|
||||
return "", fmt.Errorf("image size %s exceeds limit (max 5MB)", common.FormatSize(info.Size()))
|
||||
return "", errs.NewValidationError(errs.SubtypeInvalidArgument, "image size %s exceeds limit (max 5MB)", common.FormatSize(info.Size())).WithParam(param)
|
||||
}
|
||||
|
||||
f, err := runtime.FileIO().Open(filePath)
|
||||
if err != nil {
|
||||
return "", err
|
||||
return "", withIMValidationParam(common.WrapInputStatErrorTyped(err), param)
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
@@ -1073,27 +1075,25 @@ func uploadImageToIM(ctx context.Context, runtime *common.RuntimeContext, filePa
|
||||
return "", err
|
||||
}
|
||||
|
||||
var result map[string]interface{}
|
||||
if err := json.Unmarshal(apiResp.RawBody, &result); err != nil {
|
||||
return "", fmt.Errorf("parse error: %w", err)
|
||||
data, err := runtime.ClassifyAPIResponse(apiResp)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
data, _ := result["data"].(map[string]interface{})
|
||||
imageKey, _ := data["image_key"].(string)
|
||||
if imageKey == "" {
|
||||
return "", fmt.Errorf("image_key not found in response (code: %v, msg: %v)", result["code"], result["msg"])
|
||||
return "", errs.NewInternalError(errs.SubtypeInvalidResponse, "image_key missing from a successful upload response")
|
||||
}
|
||||
return imageKey, nil
|
||||
}
|
||||
|
||||
func uploadFileToIM(ctx context.Context, runtime *common.RuntimeContext, filePath, fileType, duration string) (string, error) {
|
||||
func uploadFileToIM(ctx context.Context, runtime *common.RuntimeContext, filePath, fileType, duration, param string) (string, error) {
|
||||
if info, err := runtime.FileIO().Stat(filePath); err == nil && info.Size() > maxFileUploadSize {
|
||||
return "", fmt.Errorf("file size %s exceeds limit (max 100MB)", common.FormatSize(info.Size()))
|
||||
return "", errs.NewValidationError(errs.SubtypeInvalidArgument, "file size %s exceeds limit (max 100MB)", common.FormatSize(info.Size())).WithParam(param)
|
||||
}
|
||||
|
||||
f, err := runtime.FileIO().Open(filePath)
|
||||
if err != nil {
|
||||
return "", err
|
||||
return "", withIMValidationParam(common.WrapInputStatErrorTyped(err), param)
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
@@ -1114,15 +1114,13 @@ func uploadFileToIM(ctx context.Context, runtime *common.RuntimeContext, filePat
|
||||
return "", err
|
||||
}
|
||||
|
||||
var result map[string]interface{}
|
||||
if err := json.Unmarshal(apiResp.RawBody, &result); err != nil {
|
||||
return "", fmt.Errorf("parse error: %w", err)
|
||||
data, err := runtime.ClassifyAPIResponse(apiResp)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
data, _ := result["data"].(map[string]interface{})
|
||||
fileKey, _ := data["file_key"].(string)
|
||||
if fileKey == "" {
|
||||
return "", fmt.Errorf("file_key not found in response (code: %v, msg: %v)", result["code"], result["msg"])
|
||||
return "", errs.NewInternalError(errs.SubtypeInvalidResponse, "file_key missing from a successful upload response")
|
||||
}
|
||||
return fileKey, nil
|
||||
}
|
||||
@@ -1142,15 +1140,13 @@ func uploadImageFromReader(ctx context.Context, runtime *common.RuntimeContext,
|
||||
return "", err
|
||||
}
|
||||
|
||||
var result map[string]interface{}
|
||||
if err := json.Unmarshal(apiResp.RawBody, &result); err != nil {
|
||||
return "", fmt.Errorf("parse error: %w", err)
|
||||
data, err := runtime.ClassifyAPIResponse(apiResp)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
data, _ := result["data"].(map[string]interface{})
|
||||
imageKey, _ := data["image_key"].(string)
|
||||
if imageKey == "" {
|
||||
return "", fmt.Errorf("image_key not found in response (code: %v, msg: %v)", result["code"], result["msg"])
|
||||
return "", errs.NewInternalError(errs.SubtypeInvalidResponse, "image_key missing from a successful upload response")
|
||||
}
|
||||
return imageKey, nil
|
||||
}
|
||||
@@ -1174,15 +1170,13 @@ func uploadFileFromReader(ctx context.Context, runtime *common.RuntimeContext, r
|
||||
return "", err
|
||||
}
|
||||
|
||||
var result map[string]interface{}
|
||||
if err := json.Unmarshal(apiResp.RawBody, &result); err != nil {
|
||||
return "", fmt.Errorf("parse error: %w", err)
|
||||
data, err := runtime.ClassifyAPIResponse(apiResp)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
data, _ := result["data"].(map[string]interface{})
|
||||
fileKey, _ := data["file_key"].(string)
|
||||
if fileKey == "" {
|
||||
return "", fmt.Errorf("file_key not found in response (code: %v, msg: %v)", result["code"], result["msg"])
|
||||
return "", errs.NewInternalError(errs.SubtypeInvalidResponse, "file_key missing from a successful upload response")
|
||||
}
|
||||
return fileKey, nil
|
||||
}
|
||||
@@ -1237,9 +1231,9 @@ func checkFlagRequiredScopes(ctx context.Context, rt *common.RuntimeContext, req
|
||||
}
|
||||
result, err := rt.Factory.Credential.ResolveToken(ctx, credential.NewTokenSpec(rt.As(), rt.Config.AppID))
|
||||
if err != nil {
|
||||
return output.ErrWithHint(output.ExitAuth, "auth",
|
||||
fmt.Sprintf("cannot verify required scope(s): %v", err),
|
||||
flagScopeLoginHint(required))
|
||||
return errs.NewAuthenticationError(errs.SubtypeTokenMissing, "cannot verify required scope(s): %v", err).
|
||||
WithHint("%s", flagScopeLoginHint(required)).
|
||||
WithCause(err)
|
||||
}
|
||||
if result == nil || result.Scopes == "" {
|
||||
fmt.Fprintf(rt.IO().ErrOut,
|
||||
@@ -1248,9 +1242,9 @@ func checkFlagRequiredScopes(ctx context.Context, rt *common.RuntimeContext, req
|
||||
return nil
|
||||
}
|
||||
if missing := auth.MissingScopes(result.Scopes, required); len(missing) > 0 {
|
||||
return output.ErrWithHint(output.ExitAuth, "missing_scope",
|
||||
fmt.Sprintf("missing required scope(s): %s", strings.Join(missing, ", ")),
|
||||
flagScopeLoginHint(missing))
|
||||
return errs.NewPermissionError(errs.SubtypeMissingScope, "missing required scope(s): %s", strings.Join(missing, ", ")).
|
||||
WithMissingScopes(missing...).
|
||||
WithHint("%s", flagScopeLoginHint(missing))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -1276,11 +1270,11 @@ func parseItemID(id string) (ItemType, FlagType, error) {
|
||||
case strings.HasPrefix(id, "om_"):
|
||||
return ItemTypeDefault, FlagTypeMessage, nil
|
||||
case id == "":
|
||||
return 0, 0, output.ErrValidation("--message-id cannot be empty")
|
||||
return 0, 0, errs.NewValidationError(errs.SubtypeInvalidArgument, "--message-id cannot be empty").WithParam("--message-id")
|
||||
default:
|
||||
return 0, 0, output.ErrValidation(
|
||||
return 0, 0, errs.NewValidationError(errs.SubtypeInvalidArgument,
|
||||
"cannot infer item type from id %q: expected om_ (message) prefix; "+
|
||||
"pass --item-type and --flag-type explicitly if you are using a different id format", id)
|
||||
"pass --item-type and --flag-type explicitly if you are using a different id format", id).WithParam("--message-id")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1294,7 +1288,7 @@ func parseItemType(s string) (ItemType, error) {
|
||||
case "msg_thread":
|
||||
return ItemTypeMsgThread, nil
|
||||
}
|
||||
return 0, output.ErrValidation("invalid --item-type %q: expected one of default|thread|msg_thread", s)
|
||||
return 0, errs.NewValidationError(errs.SubtypeInvalidArgument, "invalid --item-type %q: expected one of default|thread|msg_thread", s).WithParam("--item-type")
|
||||
}
|
||||
|
||||
// parseFlagType converts a user-facing string to the server enum.
|
||||
@@ -1305,7 +1299,7 @@ func parseFlagType(s string) (FlagType, error) {
|
||||
case "feed":
|
||||
return FlagTypeFeed, nil
|
||||
}
|
||||
return 0, output.ErrValidation("invalid --flag-type %q: expected one of message|feed", s)
|
||||
return 0, errs.NewValidationError(errs.SubtypeInvalidArgument, "invalid --flag-type %q: expected one of message|feed", s).WithParam("--flag-type")
|
||||
}
|
||||
|
||||
// isValidCombo checks if the (ItemType, FlagType) pair is accepted by the server.
|
||||
@@ -1363,24 +1357,24 @@ func newFlagItem(itemID string, it ItemType, ft FlagType) flagItem {
|
||||
// getMessageChatID queries the message API to get the chat_id.
|
||||
// Used by flag-create to determine the chat type for feed-layer flags.
|
||||
func getMessageChatID(rt *common.RuntimeContext, messageID string) (string, error) {
|
||||
data, err := rt.DoAPIJSON("GET", "/open-apis/im/v1/messages/"+validate.EncodePathSegment(messageID), nil, nil)
|
||||
data, err := rt.DoAPIJSONTyped("GET", "/open-apis/im/v1/messages/"+validate.EncodePathSegment(messageID), nil, nil)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
items, ok := data["items"].([]any)
|
||||
if !ok || len(items) == 0 {
|
||||
return "", output.ErrValidation("message not found or unexpected API response format")
|
||||
return "", errs.NewAPIError(errs.SubtypeNotFound, "message not found")
|
||||
}
|
||||
|
||||
msg, ok := items[0].(map[string]any)
|
||||
if !ok {
|
||||
return "", output.ErrValidation("unexpected message format in API response")
|
||||
return "", errs.NewInternalError(errs.SubtypeInvalidResponse, "unexpected message format in API response")
|
||||
}
|
||||
|
||||
chatID, ok := msg["chat_id"].(string)
|
||||
if !ok {
|
||||
return "", output.ErrValidation("message response missing chat_id field")
|
||||
return "", errs.NewInternalError(errs.SubtypeInvalidResponse, "message response missing chat_id field")
|
||||
}
|
||||
return chatID, nil
|
||||
}
|
||||
@@ -1393,15 +1387,324 @@ func getMessageChatID(rt *common.RuntimeContext, messageID string) (string, erro
|
||||
// Returns an error if the chat query fails, since guessing the wrong item_type
|
||||
// can cause silent failures in flag operations.
|
||||
func resolveThreadFeedItemType(rt *common.RuntimeContext, chatID string) (ItemType, error) {
|
||||
data, err := rt.DoAPIJSON("GET", "/open-apis/im/v1/chats/"+validate.EncodePathSegment(chatID), nil, nil)
|
||||
data, err := rt.DoAPIJSONTyped("GET", "/open-apis/im/v1/chats/"+validate.EncodePathSegment(chatID), nil, nil)
|
||||
if err != nil {
|
||||
return ItemTypeDefault, fmt.Errorf("failed to query chat_mode for chat %s: %w", chatID, err)
|
||||
return ItemTypeDefault, wrapIMNetworkErr(err, "failed to query chat_mode for chat %s", chatID)
|
||||
}
|
||||
|
||||
// DoAPIJSON returns envelope.Data, so chat_mode is at the top level
|
||||
// DoAPIJSONTyped returns envelope.Data, so chat_mode is at the top level
|
||||
chatMode, _ := data["chat_mode"].(string)
|
||||
if chatMode == "topic" {
|
||||
return ItemTypeThread, nil
|
||||
}
|
||||
return ItemTypeMsgThread, nil
|
||||
}
|
||||
|
||||
// ShortcutType enumerates the OpenAPI feed-shortcut types.
|
||||
// Currently the server only opens CHAT (1) externally; other internal values
|
||||
// (DOC, OPENAPP, etc.) are not yet whitelisted on the OAPI gateway.
|
||||
type ShortcutType int
|
||||
|
||||
const (
|
||||
ShortcutTypeUnknown ShortcutType = 0
|
||||
ShortcutTypeChat ShortcutType = 1
|
||||
)
|
||||
|
||||
const (
|
||||
feedShortcutBatchLimit = 10
|
||||
feedShortcutWriteScope = "im:feed.shortcut:write"
|
||||
feedShortcutReadScope = "im:feed.shortcut:read"
|
||||
)
|
||||
|
||||
// shortcutItem is one entry in the feed_shortcuts API body.
|
||||
type shortcutItem struct {
|
||||
FeedCardID string `json:"feed_card_id"`
|
||||
Type int `json:"type"`
|
||||
}
|
||||
|
||||
// collectChatIDs reads --chat-id values (repeatable + comma-split) and
|
||||
// returns deduped, validated oc_ IDs. The server batch limit is 10.
|
||||
func collectChatIDs(rt *common.RuntimeContext) ([]string, error) {
|
||||
raw := rt.StrSlice("chat-id")
|
||||
if len(raw) == 0 {
|
||||
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "--chat-id is required (oc_xxx); repeat the flag or pass comma-separated values").WithParam("--chat-id")
|
||||
}
|
||||
|
||||
seen := make(map[string]struct{}, len(raw))
|
||||
out := make([]string, 0, len(raw))
|
||||
for _, v := range raw {
|
||||
v = strings.TrimSpace(v)
|
||||
if v == "" {
|
||||
continue
|
||||
}
|
||||
if !strings.HasPrefix(v, "oc_") {
|
||||
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument,
|
||||
"invalid --chat-id %q: must be an open_chat_id starting with oc_", v).WithParam("--chat-id")
|
||||
}
|
||||
if _, ok := seen[v]; ok {
|
||||
continue
|
||||
}
|
||||
seen[v] = struct{}{}
|
||||
out = append(out, v)
|
||||
}
|
||||
if len(out) == 0 {
|
||||
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "--chat-id is required (oc_xxx)").WithParam("--chat-id")
|
||||
}
|
||||
if len(out) > feedShortcutBatchLimit {
|
||||
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument,
|
||||
"too many --chat-id values (%d); the server accepts up to %d per request",
|
||||
len(out), feedShortcutBatchLimit).WithParam("--chat-id")
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// buildShortcutItems converts chat IDs to API payload entries (type=CHAT).
|
||||
func buildShortcutItems(ids []string) []shortcutItem {
|
||||
items := make([]shortcutItem, 0, len(ids))
|
||||
for _, id := range ids {
|
||||
items = append(items, shortcutItem{FeedCardID: id, Type: int(ShortcutTypeChat)})
|
||||
}
|
||||
return items
|
||||
}
|
||||
|
||||
// shortcutFailedReasonString converts the numeric failed-reason enum returned
|
||||
// by the server into a human-readable label. Used to enrich the response
|
||||
// when the API reports per-item failures.
|
||||
func shortcutFailedReasonString(reason int) string {
|
||||
switch reason {
|
||||
case 0:
|
||||
return "unknown"
|
||||
case 1:
|
||||
return "no_permission"
|
||||
case 2:
|
||||
return "invalid_item"
|
||||
case 3:
|
||||
return "has_pending_delete"
|
||||
case 4:
|
||||
return "type_not_support"
|
||||
case 5:
|
||||
return "internal_error"
|
||||
}
|
||||
return "unknown"
|
||||
}
|
||||
|
||||
// chatBatchQueryScope is the scope required by im.chats.batch_query, which
|
||||
// the CHAT detail resolver depends on. Surfaced as a conditional scope on
|
||||
// +feed-shortcut-list so the framework's scope diagnostics know about it.
|
||||
const chatBatchQueryScope = "im:chat:read"
|
||||
|
||||
// chatBatchQuerySize matches the server-side limit on /im/v1/chats/batch_query.
|
||||
const chatBatchQuerySize = 50
|
||||
|
||||
// shortcutTypeFromValue parses the type field as returned by the v2
|
||||
// feed_shortcuts API. JSON numbers come back as float64 after generic
|
||||
// unmarshal; we also tolerate the int form for forward-compat.
|
||||
func shortcutTypeFromValue(v any) ShortcutType {
|
||||
switch n := v.(type) {
|
||||
case float64:
|
||||
return ShortcutType(int(n))
|
||||
case int:
|
||||
return ShortcutType(n)
|
||||
case json.Number:
|
||||
i, err := n.Int64()
|
||||
if err == nil {
|
||||
return ShortcutType(i)
|
||||
}
|
||||
}
|
||||
return ShortcutTypeUnknown
|
||||
}
|
||||
|
||||
// queryChatBatch fetches one im.chats.batch_query page (at most
|
||||
// chatBatchQuerySize ids) and merges the full chat objects into dst keyed by
|
||||
// chat_id. Shared by feed-shortcut detail enrichment and message-search chat
|
||||
// context lookup, which apply their own per-chunk error policies.
|
||||
func queryChatBatch(rt *common.RuntimeContext, batch []string, dst map[string]map[string]any) error {
|
||||
res, err := rt.DoAPIJSONTyped(http.MethodPost, "/open-apis/im/v1/chats/batch_query",
|
||||
larkcore.QueryParams{"user_id_type": []string{"open_id"}},
|
||||
map[string]any{"chat_ids": batch})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
items, _ := res["items"].([]any)
|
||||
for _, ci := range items {
|
||||
cm, _ := ci.(map[string]any)
|
||||
if cm == nil {
|
||||
continue
|
||||
}
|
||||
if id := asString(cm["chat_id"]); id != "" {
|
||||
dst[id] = cm
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// resolveChatDetail batch-fetches the full chat object via
|
||||
// im.chats.batch_query (50 ids per request — server limit) and returns the
|
||||
// objects keyed by chat_id, verbatim, so the caller can decide which fields
|
||||
// to surface. The server's `name` field is empty for p2p chats (client UI
|
||||
// shows the partner's display name there), but the full object still carries
|
||||
// `chat_mode`, `p2p_target_id`, `description`, etc., so callers can render
|
||||
// p2p entries however they want.
|
||||
func resolveChatDetail(rt *common.RuntimeContext, ids []string) (map[string]map[string]any, error) {
|
||||
out := map[string]map[string]any{}
|
||||
if len(ids) == 0 {
|
||||
return out, nil
|
||||
}
|
||||
if err := checkFlagRequiredScopes(rt.Ctx(), rt, []string{chatBatchQueryScope}); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, batch := range chunkStrings(ids, chatBatchQuerySize) {
|
||||
if err := queryChatBatch(rt, batch, out); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// enrichFeedShortcutDetail walks the list response and attaches the full chat
|
||||
// object under `detail` for CHAT-type entries — the only type the OpenAPI
|
||||
// gateway exposes today. Mutates data in place.
|
||||
//
|
||||
// Failures are returned to the caller so it can decide whether to hard-fail
|
||||
// the command or downgrade to a warning. Listing the shortcuts succeeds even
|
||||
// if enrichment is unavailable (missing scope, network error, etc.).
|
||||
func enrichFeedShortcutDetail(rt *common.RuntimeContext, data map[string]any) error {
|
||||
items, _ := data["shortcuts"].([]any)
|
||||
if len(items) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
seen := map[string]struct{}{}
|
||||
ids := make([]string, 0, len(items))
|
||||
for _, it := range items {
|
||||
m, _ := it.(map[string]any)
|
||||
if m == nil || shortcutTypeFromValue(m["type"]) != ShortcutTypeChat {
|
||||
continue
|
||||
}
|
||||
id := asString(m["feed_card_id"])
|
||||
if id == "" {
|
||||
continue
|
||||
}
|
||||
if _, ok := seen[id]; ok {
|
||||
continue
|
||||
}
|
||||
seen[id] = struct{}{}
|
||||
ids = append(ids, id)
|
||||
}
|
||||
if len(ids) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
details, err := resolveChatDetail(rt, ids)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Missing items (server didn't return one for an id we asked about) are
|
||||
// left untouched, so the presence of `detail` signals a successful lookup.
|
||||
for _, it := range items {
|
||||
m, _ := it.(map[string]any)
|
||||
if m == nil || shortcutTypeFromValue(m["type"]) != ShortcutTypeChat {
|
||||
continue
|
||||
}
|
||||
if info, ok := details[asString(m["feed_card_id"])]; ok {
|
||||
m["detail"] = info
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// annotateFailedShortcuts walks the API response and attaches a
|
||||
// reason_label string next to each numeric reason. Mutates data in place.
|
||||
func annotateFailedShortcuts(data map[string]any) {
|
||||
items, ok := data["failed_shortcuts"].([]any)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
for _, it := range items {
|
||||
m, _ := it.(map[string]any)
|
||||
if m == nil {
|
||||
continue
|
||||
}
|
||||
// reason is serialized as a JSON number → float64 after generic unmarshal.
|
||||
switch r := m["reason"].(type) {
|
||||
case float64:
|
||||
m["reason_label"] = shortcutFailedReasonString(int(r))
|
||||
case int:
|
||||
m["reason_label"] = shortcutFailedReasonString(r)
|
||||
case json.Number:
|
||||
i, err := r.Int64()
|
||||
if err == nil {
|
||||
m["reason_label"] = shortcutFailedReasonString(int(i))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// emitFeedShortcutWriteResult preserves the server payload while adding a
|
||||
// batch ledger. A feed-shortcut write can return HTTP/API success with
|
||||
// failed_shortcuts populated; callers still need a complete account of which
|
||||
// requested entries succeeded and which failed.
|
||||
func emitFeedShortcutWriteResult(rt *common.RuntimeContext, requested []shortcutItem, data map[string]any) error {
|
||||
// A fully-successful write can come back as code:0 with data:null, in
|
||||
// which case DoAPIJSON hands us a nil map; the caller is still owed a
|
||||
// ledger, so start from an empty object instead of panicking on write.
|
||||
if data == nil {
|
||||
data = map[string]any{}
|
||||
}
|
||||
annotateFailedShortcuts(data)
|
||||
addFeedShortcutWriteLedger(data, requested)
|
||||
if hasFailedShortcuts(data) {
|
||||
return rt.OutPartialFailure(data, nil)
|
||||
}
|
||||
rt.Out(data, nil)
|
||||
return nil
|
||||
}
|
||||
|
||||
func addFeedShortcutWriteLedger(data map[string]any, requested []shortcutItem) {
|
||||
failed := failedShortcutItems(data)
|
||||
// Failed entries are matched back to requested items by feed_card_id
|
||||
// alone: every requested item is CHAT-type, so the id is the identity,
|
||||
// and a failed echo with a missing or zero type still excludes its item
|
||||
// from the success list.
|
||||
failedIDs := map[string]struct{}{}
|
||||
for _, it := range failed {
|
||||
m, _ := it.(map[string]any)
|
||||
if m == nil {
|
||||
continue
|
||||
}
|
||||
shortcut, _ := m["shortcut"].(map[string]any)
|
||||
if shortcut == nil {
|
||||
continue
|
||||
}
|
||||
if id := asString(shortcut["feed_card_id"]); id != "" {
|
||||
failedIDs[id] = struct{}{}
|
||||
}
|
||||
}
|
||||
|
||||
succeeded := make([]shortcutItem, 0, len(requested))
|
||||
for _, it := range requested {
|
||||
if _, isFailed := failedIDs[it.FeedCardID]; isFailed {
|
||||
continue
|
||||
}
|
||||
succeeded = append(succeeded, it)
|
||||
}
|
||||
|
||||
// Counts are derived from the requested-item accounting alone so the
|
||||
// success+failure==total invariant holds even if the server echoes a
|
||||
// failed entry twice or reports one we never asked about;
|
||||
// failed_shortcuts still carries the raw server report.
|
||||
data["total"] = len(requested)
|
||||
data["success_count"] = len(succeeded)
|
||||
data["failure_count"] = len(requested) - len(succeeded)
|
||||
data["succeeded_shortcuts"] = succeeded
|
||||
}
|
||||
|
||||
func hasFailedShortcuts(data map[string]any) bool {
|
||||
return len(failedShortcutItems(data)) > 0
|
||||
}
|
||||
|
||||
func failedShortcutItems(data map[string]any) []any {
|
||||
items, _ := data["failed_shortcuts"].([]any)
|
||||
return items
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
"context"
|
||||
"crypto/md5"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
@@ -22,6 +23,7 @@ import (
|
||||
lark "github.com/larksuite/oapi-sdk-go/v3"
|
||||
larkcore "github.com/larksuite/oapi-sdk-go/v3/core"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/extension/fileio"
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
@@ -445,8 +447,15 @@ func TestDownloadIMResourceToPathRetryContextCanceled(t *testing.T) {
|
||||
cmdutil.TestChdir(t, t.TempDir())
|
||||
target := "out.bin"
|
||||
_, _, err := downloadIMResourceToPath(ctx, runtime, "om_cancel", "file_cancel", "file", target, true)
|
||||
if err != context.Canceled {
|
||||
t.Fatalf("downloadIMResourceToPath() error = %v, want context.Canceled", err)
|
||||
if !errors.Is(err, context.Canceled) {
|
||||
t.Fatalf("downloadIMResourceToPath() error = %v, want errors.Is(context.Canceled)", err)
|
||||
}
|
||||
var ne *errs.NetworkError
|
||||
if !errors.As(err, &ne) {
|
||||
t.Fatalf("downloadIMResourceToPath() error = %T, want *errs.NetworkError", err)
|
||||
}
|
||||
if ne.Subtype != errs.SubtypeNetworkTransport {
|
||||
t.Fatalf("network subtype = %q, want %q", ne.Subtype, errs.SubtypeNetworkTransport)
|
||||
}
|
||||
// First attempt is made, then retry checks ctx.Err() and returns
|
||||
if attempts != 1 {
|
||||
@@ -600,6 +609,14 @@ func TestDownloadIMResourceToPathRangeChunkFailureCleansOutput(t *testing.T) {
|
||||
if err == nil || !strings.Contains(err.Error(), "HTTP 500: chunk failed") {
|
||||
t.Fatalf("downloadIMResourceToPath() error = %v", err)
|
||||
}
|
||||
p, ok := errs.ProblemOf(err)
|
||||
if !ok {
|
||||
t.Fatalf("downloadIMResourceToPath() error = %T, want typed problem", err)
|
||||
}
|
||||
if p.Category != errs.CategoryNetwork || p.Subtype != errs.SubtypeNetworkServer || p.Code != http.StatusInternalServerError {
|
||||
t.Fatalf("network problem = subtype %q code %d, want subtype %q code %d",
|
||||
p.Subtype, p.Code, errs.SubtypeNetworkServer, http.StatusInternalServerError)
|
||||
}
|
||||
if _, statErr := os.Stat(target); !os.IsNotExist(statErr) {
|
||||
t.Fatalf("output file exists after failed download, stat error = %v", statErr)
|
||||
}
|
||||
@@ -716,7 +733,7 @@ func TestUploadImageToIMSuccess(t *testing.T) {
|
||||
t.Fatalf("WriteFile() error = %v", err)
|
||||
}
|
||||
|
||||
got, err := uploadImageToIM(context.Background(), runtime, path, "message")
|
||||
got, err := uploadImageToIM(context.Background(), runtime, path, "message", "--image")
|
||||
if err != nil {
|
||||
t.Fatalf("uploadImageToIM() error = %v", err)
|
||||
}
|
||||
@@ -754,7 +771,7 @@ func TestUploadFileToIMSuccess(t *testing.T) {
|
||||
t.Fatalf("WriteFile() error = %v", err)
|
||||
}
|
||||
|
||||
got, err := uploadFileToIM(context.Background(), runtime, path, "stream", "1200")
|
||||
got, err := uploadFileToIM(context.Background(), runtime, path, "stream", "1200", "--file")
|
||||
if err != nil {
|
||||
t.Fatalf("uploadFileToIM() error = %v", err)
|
||||
}
|
||||
@@ -784,10 +801,14 @@ func TestUploadImageToIMSizeLimit(t *testing.T) {
|
||||
rt := newBotShortcutRuntime(t, shortcutRoundTripFunc(func(req *http.Request) (*http.Response, error) {
|
||||
return nil, fmt.Errorf("unexpected")
|
||||
}))
|
||||
_, err = uploadImageToIM(context.Background(), rt, path, "message")
|
||||
_, err = uploadImageToIM(context.Background(), rt, path, "message", "--image")
|
||||
if err == nil || !strings.Contains(err.Error(), "exceeds limit") {
|
||||
t.Fatalf("uploadImageToIM() error = %v", err)
|
||||
}
|
||||
var ve *errs.ValidationError
|
||||
if !errors.As(err, &ve) || ve.Param != "--image" {
|
||||
t.Fatalf("uploadImageToIM() size error must carry Param=--image, got %T %+v", err, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUploadFileToIMSizeLimit(t *testing.T) {
|
||||
@@ -805,13 +826,21 @@ func TestUploadFileToIMSizeLimit(t *testing.T) {
|
||||
rt := newBotShortcutRuntime(t, shortcutRoundTripFunc(func(req *http.Request) (*http.Response, error) {
|
||||
return nil, fmt.Errorf("unexpected")
|
||||
}))
|
||||
_, err = uploadFileToIM(context.Background(), rt, path, "stream", "")
|
||||
_, err = uploadFileToIM(context.Background(), rt, path, "stream", "", "--file")
|
||||
if err == nil || !strings.Contains(err.Error(), "exceeds limit") {
|
||||
t.Fatalf("uploadFileToIM() error = %v", err)
|
||||
}
|
||||
var ve *errs.ValidationError
|
||||
if !errors.As(err, &ve) || ve.Param != "--file" {
|
||||
t.Fatalf("uploadFileToIM() size error must carry Param=--file, got %T %+v", err, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveMediaContentWrapsUploadError(t *testing.T) {
|
||||
// TestResolveMediaContentMissingLocalFileIsValidation pins that a missing local
|
||||
// media path is a typed validation error (bad --image input), not a network or
|
||||
// internal error: the file never opened, so there is no transport failure to
|
||||
// classify as network.
|
||||
func TestResolveMediaContentMissingLocalFileIsValidation(t *testing.T) {
|
||||
runtime := &common.RuntimeContext{
|
||||
Factory: &cmdutil.Factory{
|
||||
FileIOProvider: fileio.GetProvider(),
|
||||
@@ -826,8 +855,49 @@ func TestResolveMediaContentWrapsUploadError(t *testing.T) {
|
||||
|
||||
missing := "missing.png"
|
||||
_, _, err := resolveMediaContent(context.Background(), runtime, "", missing, "", "", "", "")
|
||||
if err == nil || !strings.Contains(err.Error(), "image upload failed") {
|
||||
t.Fatalf("resolveMediaContent() error = %v", err)
|
||||
var ve *errs.ValidationError
|
||||
if !errors.As(err, &ve) {
|
||||
t.Fatalf("missing local media file must be a validation error, got %T: %v", err, err)
|
||||
}
|
||||
if ve.Param != "--image" {
|
||||
t.Fatalf("missing local media file Param = %q, want --image", ve.Param)
|
||||
}
|
||||
if !strings.Contains(err.Error(), "cannot read file") {
|
||||
t.Fatalf("error should explain the unreadable file, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUploadFileToIMMissingLocalFileCarriesParam(t *testing.T) {
|
||||
runtime := &common.RuntimeContext{
|
||||
Factory: &cmdutil.Factory{
|
||||
FileIOProvider: fileio.GetProvider(),
|
||||
IOStreams: &cmdutil.IOStreams{
|
||||
Out: &bytes.Buffer{},
|
||||
ErrOut: &bytes.Buffer{},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
cmdutil.TestChdir(t, t.TempDir())
|
||||
|
||||
_, err := uploadFileToIM(context.Background(), runtime, "missing.bin", "stream", "", "--file")
|
||||
var ve *errs.ValidationError
|
||||
if !errors.As(err, &ve) {
|
||||
t.Fatalf("missing local file must be a validation error, got %T: %v", err, err)
|
||||
}
|
||||
if ve.Param != "--file" {
|
||||
t.Fatalf("missing local file Param = %q, want --file", ve.Param)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStartURLDownloadBlockedURLCarriesParam(t *testing.T) {
|
||||
_, _, err := startURLDownload(context.Background(), nil, "http://127.0.0.1/image.png", "--image")
|
||||
var ve *errs.ValidationError
|
||||
if !errors.As(err, &ve) {
|
||||
t.Fatalf("blocked URL must be a validation error, got %T: %v", err, err)
|
||||
}
|
||||
if ve.Param != "--image" {
|
||||
t.Fatalf("blocked URL Param = %q, want --image", ve.Param)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -920,7 +990,7 @@ func TestUploadFileToIMPreservesLocalFileName(t *testing.T) {
|
||||
t.Fatalf("WriteFile() error = %v", err)
|
||||
}
|
||||
|
||||
if _, err := uploadFileToIM(context.Background(), runtime, "./"+localName, "pdf", ""); err != nil {
|
||||
if _, err := uploadFileToIM(context.Background(), runtime, "./"+localName, "pdf", "", "--file"); err != nil {
|
||||
t.Fatalf("uploadFileToIM() error = %v", err)
|
||||
}
|
||||
if !strings.Contains(gotBody, `name="file_name"`) || !strings.Contains(gotBody, localName) {
|
||||
|
||||
@@ -606,6 +606,9 @@ func TestShortcuts(t *testing.T) {
|
||||
"+flag-create",
|
||||
"+flag-cancel",
|
||||
"+flag-list",
|
||||
"+feed-shortcut-create",
|
||||
"+feed-shortcut-remove",
|
||||
"+feed-shortcut-list",
|
||||
}
|
||||
if !reflect.DeepEqual(commands, want) {
|
||||
t.Fatalf("Shortcuts() commands = %#v, want %#v", commands, want)
|
||||
|
||||
@@ -10,6 +10,7 @@ import (
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"github.com/larksuite/cli/internal/validate"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
@@ -52,7 +53,7 @@ var ImChatCreate = common.Shortcut{
|
||||
},
|
||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
if runtime.Bool("set-bot-manager") && !runtime.IsBot() {
|
||||
return output.ErrValidation("--set-bot-manager is only supported with bot identity (--as bot)")
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--set-bot-manager is only supported with bot identity (--as bot)").WithParam("--set-bot-manager")
|
||||
}
|
||||
|
||||
name := runtime.Str("name")
|
||||
@@ -60,25 +61,25 @@ var ImChatCreate = common.Shortcut{
|
||||
|
||||
// Public groups must have a name with at least 2 characters.
|
||||
if chatType == "public" && len([]rune(name)) < 2 {
|
||||
return output.ErrValidation("--name is required for public groups and must be at least 2 characters")
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--name is required for public groups and must be at least 2 characters").WithParam("--name")
|
||||
}
|
||||
// Group name length must not exceed 60 characters.
|
||||
if len([]rune(name)) > 60 {
|
||||
return output.ErrValidation("--name exceeds the maximum of 60 characters (got %d)", len([]rune(name)))
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--name exceeds the maximum of 60 characters (got %d)", len([]rune(name))).WithParam("--name")
|
||||
}
|
||||
// Description length must not exceed 100 characters.
|
||||
if desc := runtime.Str("description"); len([]rune(desc)) > 100 {
|
||||
return output.ErrValidation("--description exceeds the maximum of 100 characters (got %d)", len([]rune(desc)))
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--description exceeds the maximum of 100 characters (got %d)", len([]rune(desc))).WithParam("--description")
|
||||
}
|
||||
|
||||
// Validate users.
|
||||
if users := runtime.Str("users"); users != "" {
|
||||
ids := common.SplitCSV(users)
|
||||
if len(ids) > 50 {
|
||||
return output.ErrValidation("--users exceeds the maximum of 50 (got %d)", len(ids))
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--users exceeds the maximum of 50 (got %d)", len(ids)).WithParam("--users")
|
||||
}
|
||||
for _, id := range ids {
|
||||
if _, err := common.ValidateUserID(id); err != nil {
|
||||
if _, err := common.ValidateUserIDTyped("--users", id); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
@@ -88,18 +89,18 @@ var ImChatCreate = common.Shortcut{
|
||||
if bots := runtime.Str("bots"); bots != "" {
|
||||
ids := common.SplitCSV(bots)
|
||||
if len(ids) > 5 {
|
||||
return output.ErrValidation("--bots exceeds the maximum of 5 (got %d)", len(ids))
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--bots exceeds the maximum of 5 (got %d)", len(ids)).WithParam("--bots")
|
||||
}
|
||||
for _, id := range ids {
|
||||
if !strings.HasPrefix(id, "cli_") {
|
||||
return output.ErrValidation("invalid bot id %q: expected app ID (cli_xxx)", id)
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "invalid bot id %q: expected app ID (cli_xxx)", id).WithParam("--bots")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Validate owner.
|
||||
if owner := runtime.Str("owner"); owner != "" {
|
||||
if _, err := common.ValidateUserID(owner); err != nil {
|
||||
if _, err := common.ValidateUserIDTyped("--owner", owner); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
@@ -112,7 +113,7 @@ var ImChatCreate = common.Shortcut{
|
||||
if runtime.Bool("set-bot-manager") {
|
||||
qp["set_bot_manager"] = []string{"true"}
|
||||
}
|
||||
resData, err := runtime.DoAPIJSON(http.MethodPost, "/open-apis/im/v1/chats", qp, body)
|
||||
resData, err := runtime.DoAPIJSONTyped(http.MethodPost, "/open-apis/im/v1/chats", qp, body)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -127,7 +128,7 @@ var ImChatCreate = common.Shortcut{
|
||||
|
||||
// Try to fetch the group share link without blocking on failure.
|
||||
if chatID, ok := resData["chat_id"].(string); ok && chatID != "" {
|
||||
linkData, err := runtime.DoAPIJSON(http.MethodPost,
|
||||
linkData, err := runtime.DoAPIJSONTyped(http.MethodPost,
|
||||
fmt.Sprintf("/open-apis/im/v1/chats/%s/link", validate.EncodePathSegment(chatID)),
|
||||
nil, nil)
|
||||
if err == nil {
|
||||
|
||||
@@ -9,6 +9,7 @@ import (
|
||||
"io"
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
@@ -71,15 +72,15 @@ var ImChatList = common.Shortcut{
|
||||
// enum, and the bot + single-p2p rejection (mixed types degrade in Execute).
|
||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
if n := runtime.Int("page-size"); n < 1 || n > 100 {
|
||||
return output.ErrValidation("--page-size must be an integer between 1 and 100")
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--page-size must be an integer between 1 and 100").WithParam("--page-size")
|
||||
}
|
||||
parts, err := normalizeTypes(runtime.StrSlice("types"))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if len(parts) == 1 && parts[0] == "p2p" && runtime.IsBot() {
|
||||
return output.ErrValidation(
|
||||
`--types=p2p (single chats) is only supported with user identity (--as user). To protect user privacy, bot identity cannot list p2p chats. Use --as user, or include "group" in --types.`)
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument,
|
||||
`--types=p2p (single chats) is only supported with user identity (--as user). To protect user privacy, bot identity cannot list p2p chats. Use --as user, or include "group" in --types.`).WithParam("--types")
|
||||
}
|
||||
return nil
|
||||
},
|
||||
@@ -95,7 +96,7 @@ var ImChatList = common.Shortcut{
|
||||
writeBotStripP2pWarning(runtime.IO().ErrOut)
|
||||
}
|
||||
params := buildChatListParams(runtime, effective)
|
||||
resData, err := runtime.CallAPI("GET", imChatListPath, params, nil)
|
||||
resData, err := runtime.CallAPITyped("GET", imChatListPath, params, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -211,10 +212,10 @@ func normalizeTypes(raw []string) ([]string, error) {
|
||||
for _, p := range raw {
|
||||
p = strings.TrimSpace(strings.ToLower(p))
|
||||
if p == "" {
|
||||
return nil, output.ErrValidation("--types must contain at least one of p2p, group")
|
||||
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "--types must contain at least one of p2p, group").WithParam("--types")
|
||||
}
|
||||
if p != "p2p" && p != "group" {
|
||||
return nil, output.ErrValidation("--types contains invalid value %q: expected one of p2p, group", p)
|
||||
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "--types contains invalid value %q: expected one of p2p, group", p).WithParam("--types")
|
||||
}
|
||||
if _, dup := seen[p]; dup {
|
||||
continue
|
||||
|
||||
@@ -10,6 +10,7 @@ import (
|
||||
"net/http"
|
||||
"strconv"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
convertlib "github.com/larksuite/cli/shortcuts/im/convert_lib"
|
||||
@@ -66,15 +67,15 @@ var ImChatMessageList = common.Shortcut{
|
||||
// Under bot identity, --user-id is not supported; require --chat-id only.
|
||||
if runtime.IsBot() {
|
||||
if runtime.Str("user-id") != "" {
|
||||
return common.FlagErrorf("--user-id requires user identity (--as user); use --chat-id when calling with bot identity")
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--user-id requires user identity (--as user); use --chat-id when calling with bot identity").WithParam("--user-id")
|
||||
}
|
||||
if runtime.Str("chat-id") == "" {
|
||||
return common.FlagErrorf("specify --chat-id (bot identity does not support --user-id)")
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "specify --chat-id (bot identity does not support --user-id)").WithParam("--chat-id")
|
||||
}
|
||||
} else {
|
||||
if err := common.ExactlyOne(runtime, "chat-id", "user-id"); err != nil {
|
||||
if err := common.ExactlyOneTyped(runtime, "chat-id", "user-id"); err != nil {
|
||||
if runtime.Str("chat-id") == "" && runtime.Str("user-id") == "" {
|
||||
return common.FlagErrorf("specify at least one of --chat-id or --user-id")
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "specify at least one of --chat-id or --user-id")
|
||||
}
|
||||
return err
|
||||
}
|
||||
@@ -82,12 +83,12 @@ var ImChatMessageList = common.Shortcut{
|
||||
|
||||
// Validate ID formats
|
||||
if chatFlag := runtime.Str("chat-id"); chatFlag != "" {
|
||||
if _, err := common.ValidateChatID(chatFlag); err != nil {
|
||||
if _, err := common.ValidateChatIDTyped("--chat-id", chatFlag); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if userFlag := runtime.Str("user-id"); userFlag != "" {
|
||||
if _, err := common.ValidateUserID(userFlag); err != nil {
|
||||
if _, err := common.ValidateUserIDTyped("--user-id", userFlag); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
@@ -109,7 +110,7 @@ var ImChatMessageList = common.Shortcut{
|
||||
return err
|
||||
}
|
||||
|
||||
data, err := runtime.DoAPIJSON(http.MethodGet, "/open-apis/im/v1/messages", params, nil)
|
||||
data, err := runtime.DoAPIJSONTyped(http.MethodGet, "/open-apis/im/v1/messages", params, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -205,14 +206,14 @@ func buildChatMessageListRequest(runtime *common.RuntimeContext, chatId string)
|
||||
if startFlag := runtime.Str("start"); startFlag != "" {
|
||||
startTime, err := common.ParseTime(startFlag)
|
||||
if err != nil {
|
||||
return nil, output.ErrValidation("--start: %v", err)
|
||||
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "--start: %v", err).WithParam("--start")
|
||||
}
|
||||
params["start_time"] = []string{startTime}
|
||||
}
|
||||
if endFlag := runtime.Str("end"); endFlag != "" {
|
||||
endTime, err := common.ParseTime(endFlag, "end")
|
||||
if err != nil {
|
||||
return nil, output.ErrValidation("--end: %v", err)
|
||||
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "--end: %v", err).WithParam("--end")
|
||||
}
|
||||
params["end_time"] = []string{endTime}
|
||||
}
|
||||
@@ -236,7 +237,7 @@ func resolveChatIDForMessagesList(runtime *common.RuntimeContext, dryRun bool) (
|
||||
return "", err
|
||||
}
|
||||
if chatId == "" {
|
||||
return "", output.Errorf(output.ExitAPI, "not_found", "P2P chat not found for this user")
|
||||
return "", errs.NewAPIError(errs.SubtypeNotFound, "P2P chat not found for this user")
|
||||
}
|
||||
return chatId, nil
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@ import (
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"github.com/larksuite/cli/internal/util"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
@@ -53,10 +54,10 @@ var ImChatSearch = common.Shortcut{
|
||||
query := runtime.Str("query")
|
||||
memberIDs := runtime.Str("member-ids")
|
||||
if query == "" && memberIDs == "" {
|
||||
return output.ErrValidation("--query and --member-ids cannot both be empty; provide at least one (e.g. --query \"team-name\" or --member-ids \"ou_xxx\")")
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--query and --member-ids cannot both be empty; provide at least one (e.g. --query \"team-name\" or --member-ids \"ou_xxx\")")
|
||||
}
|
||||
if query != "" && len([]rune(query)) > 64 {
|
||||
return output.ErrValidation("--query exceeds the maximum of 64 characters (got %d)", len([]rune(query)))
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--query exceeds the maximum of 64 characters (got %d)", len([]rune(query))).WithParam("--query")
|
||||
}
|
||||
if st := runtime.Str("search-types"); st != "" {
|
||||
allowed := map[string]struct{}{
|
||||
@@ -67,23 +68,23 @@ var ImChatSearch = common.Shortcut{
|
||||
}
|
||||
for _, item := range common.SplitCSV(st) {
|
||||
if _, ok := allowed[item]; !ok {
|
||||
return output.ErrValidation("invalid --search-types value %q: expected one of private, external, public_joined, public_not_joined", item)
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "invalid --search-types value %q: expected one of private, external, public_joined, public_not_joined", item).WithParam("--search-types")
|
||||
}
|
||||
}
|
||||
}
|
||||
if mi := runtime.Str("member-ids"); mi != "" {
|
||||
ids := common.SplitCSV(mi)
|
||||
if len(ids) > 50 {
|
||||
return output.ErrValidation("--member-ids exceeds the maximum of 50 (got %d)", len(ids))
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--member-ids exceeds the maximum of 50 (got %d)", len(ids)).WithParam("--member-ids")
|
||||
}
|
||||
for _, id := range ids {
|
||||
if _, err := common.ValidateUserID(id); err != nil {
|
||||
if _, err := common.ValidateUserIDTyped("--member-ids", id); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
if n := runtime.Int("page-size"); n < 1 || n > 100 {
|
||||
return output.ErrValidation("--page-size must be an integer between 1 and 100")
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--page-size must be an integer between 1 and 100").WithParam("--page-size")
|
||||
}
|
||||
return nil
|
||||
},
|
||||
@@ -94,7 +95,7 @@ var ImChatSearch = common.Shortcut{
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
body := buildSearchChatBody(runtime)
|
||||
params := buildSearchChatParams(runtime)
|
||||
resData, err := runtime.CallAPI("POST", "/open-apis/im/v2/chats/search", params, body)
|
||||
resData, err := runtime.CallAPITyped("POST", "/open-apis/im/v2/chats/search", params, body)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -9,7 +9,7 @@ import (
|
||||
"io"
|
||||
"net/http"
|
||||
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/validate"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
larkcore "github.com/larksuite/oapi-sdk-go/v3/core"
|
||||
@@ -38,25 +38,25 @@ var ImChatUpdate = common.Shortcut{
|
||||
},
|
||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
chat := runtime.Str("chat-id")
|
||||
if _, err := common.ValidateChatID(chat); err != nil {
|
||||
if _, err := common.ValidateChatIDTyped("--chat-id", chat); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Validate --name length.
|
||||
name := runtime.Str("name")
|
||||
if name != "" && len([]rune(name)) > 60 {
|
||||
return output.ErrValidation("--name exceeds the maximum of 60 characters (got %d)", len([]rune(name)))
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--name exceeds the maximum of 60 characters (got %d)", len([]rune(name))).WithParam("--name")
|
||||
}
|
||||
|
||||
// Validate --description length.
|
||||
if desc := runtime.Str("description"); desc != "" && len([]rune(desc)) > 100 {
|
||||
return output.ErrValidation("--description exceeds the maximum of 100 characters (got %d)", len([]rune(desc)))
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--description exceeds the maximum of 100 characters (got %d)", len([]rune(desc))).WithParam("--description")
|
||||
}
|
||||
|
||||
// At least one field must be provided for update.
|
||||
body := buildUpdateChatBody(runtime)
|
||||
if len(body) == 0 {
|
||||
return output.ErrValidation("at least one field must be specified to update")
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "at least one field must be specified to update")
|
||||
}
|
||||
|
||||
return nil
|
||||
@@ -65,7 +65,7 @@ var ImChatUpdate = common.Shortcut{
|
||||
chatID := runtime.Str("chat-id")
|
||||
body := buildUpdateChatBody(runtime)
|
||||
|
||||
_, err := runtime.DoAPIJSON(http.MethodPut,
|
||||
_, err := runtime.DoAPIJSONTyped(http.MethodPut,
|
||||
fmt.Sprintf("/open-apis/im/v1/chats/%s", validate.EncodePathSegment(chatID)),
|
||||
larkcore.QueryParams{"user_id_type": []string{"open_id"}},
|
||||
body,
|
||||
|
||||
63
shortcuts/im/im_errors.go
Normal file
63
shortcuts/im/im_errors.go
Normal file
@@ -0,0 +1,63 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package im
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
)
|
||||
|
||||
// wrapIMNetworkErr returns err unchanged when it is already a typed errs.*
|
||||
// error (preserving its subtype / code / log_id from the runtime boundary),
|
||||
// and only wraps a raw, unclassified error as a transport-level network error.
|
||||
func wrapIMNetworkErr(err error, format string, args ...any) error {
|
||||
if _, ok := errs.ProblemOf(err); ok {
|
||||
return err
|
||||
}
|
||||
return errs.NewNetworkError(errs.SubtypeNetworkTransport, format, args...).WithCause(err)
|
||||
}
|
||||
|
||||
func imContextError(err error) error {
|
||||
if err == nil {
|
||||
return nil
|
||||
}
|
||||
subtype := errs.SubtypeNetworkTransport
|
||||
if errors.Is(err, context.DeadlineExceeded) {
|
||||
subtype = errs.SubtypeNetworkTimeout
|
||||
}
|
||||
return errs.NewNetworkError(subtype, "%s", err.Error()).WithCause(err)
|
||||
}
|
||||
|
||||
func withIMValidationParam(err error, param string) error {
|
||||
if err == nil || param == "" {
|
||||
return err
|
||||
}
|
||||
var ve *errs.ValidationError
|
||||
if errors.As(err, &ve) && ve.Param == "" {
|
||||
ve.WithParam(param)
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
// appendIMRecoveryHint attaches a recovery hint to err. A typed error keeps its
|
||||
// classification (category/subtype/code/log_id); only the hint is appended to
|
||||
// p.Hint (newline-joined when a hint already exists), and err is returned
|
||||
// unchanged. An unclassified error falls back to a typed internal error.
|
||||
func appendIMRecoveryHint(err error, hint string) error {
|
||||
if err == nil {
|
||||
return nil
|
||||
}
|
||||
if p, ok := errs.ProblemOf(err); ok {
|
||||
if strings.TrimSpace(p.Hint) != "" {
|
||||
p.Hint = p.Hint + "\n" + hint
|
||||
} else {
|
||||
p.Hint = hint
|
||||
}
|
||||
return err
|
||||
}
|
||||
return errs.NewInternalError(errs.SubtypeSDKError, "%s", err.Error()).WithHint(hint).WithCause(err)
|
||||
}
|
||||
82
shortcuts/im/im_errors_test.go
Normal file
82
shortcuts/im/im_errors_test.go
Normal file
@@ -0,0 +1,82 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package im
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
)
|
||||
|
||||
func TestWrapIMNetworkErr_PassthroughTyped(t *testing.T) {
|
||||
typed := errs.NewValidationError(errs.SubtypeInvalidArgument, "bad input")
|
||||
got := wrapIMNetworkErr(typed, "download failed")
|
||||
if got != error(typed) {
|
||||
t.Fatalf("typed error must be passed through unchanged, got %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWrapIMNetworkErr_WrapsRaw(t *testing.T) {
|
||||
raw := errors.New("dial tcp: i/o timeout")
|
||||
got := wrapIMNetworkErr(raw, "download failed: %s", "x")
|
||||
var ne *errs.NetworkError
|
||||
if !errors.As(got, &ne) {
|
||||
t.Fatalf("raw error must become *errs.NetworkError, got %T", got)
|
||||
}
|
||||
if ne.Subtype != errs.SubtypeNetworkTransport {
|
||||
t.Errorf("subtype = %q, want %q", ne.Subtype, errs.SubtypeNetworkTransport)
|
||||
}
|
||||
if !errors.Is(got, raw) {
|
||||
t.Errorf("cause must be chained for errors.Is")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAppendIMRecoveryHint_TypedPreservedHintAppended(t *testing.T) {
|
||||
typed := errs.NewAPIError(errs.SubtypeNotFound, "message not found")
|
||||
got := appendIMRecoveryHint(typed, "specify --item-type explicitly")
|
||||
if got != error(typed) {
|
||||
t.Fatalf("typed error must be returned unchanged, got %T", got)
|
||||
}
|
||||
var ae *errs.APIError
|
||||
if !errors.As(got, &ae) {
|
||||
t.Fatalf("typed classification must be preserved, got %T", got)
|
||||
}
|
||||
if ae.Subtype != errs.SubtypeNotFound {
|
||||
t.Errorf("subtype = %q, want %q", ae.Subtype, errs.SubtypeNotFound)
|
||||
}
|
||||
p, ok := errs.ProblemOf(got)
|
||||
if !ok || p.Hint != "specify --item-type explicitly" {
|
||||
t.Errorf("hint = %q (ok=%v), want %q", p.Hint, ok, "specify --item-type explicitly")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAppendIMRecoveryHint_RawBecomesInternal(t *testing.T) {
|
||||
got := appendIMRecoveryHint(errors.New("boom"), "specify --item-type explicitly")
|
||||
var ie *errs.InternalError
|
||||
if !errors.As(got, &ie) {
|
||||
t.Fatalf("raw error must become *errs.InternalError, got %T", got)
|
||||
}
|
||||
if ie.Hint != "specify --item-type explicitly" {
|
||||
t.Errorf("hint = %q, want %q", ie.Hint, "specify --item-type explicitly")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAppendIMRecoveryHint_Nil(t *testing.T) {
|
||||
if appendIMRecoveryHint(nil, "hint") != nil {
|
||||
t.Errorf("nil in -> nil out")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAppendIMRecoveryHint_AppendsExistingHint(t *testing.T) {
|
||||
typed := errs.NewAPIError(errs.SubtypeNotFound, "message not found").WithHint("first")
|
||||
got := appendIMRecoveryHint(typed, "second")
|
||||
p, ok := errs.ProblemOf(got)
|
||||
if !ok {
|
||||
t.Fatalf("expected typed problem, got %T", got)
|
||||
}
|
||||
if p.Hint != "first\nsecond" {
|
||||
t.Errorf("hint = %q, want %q", p.Hint, "first\nsecond")
|
||||
}
|
||||
}
|
||||
97
shortcuts/im/im_feed_shortcut_create.go
Normal file
97
shortcuts/im/im_feed_shortcut_create.go
Normal file
@@ -0,0 +1,97 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package im
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
// ImFeedShortcutCreate provides the +feed-shortcut-create shortcut for adding
|
||||
// chats to the user's feed shortcuts. Currently only CHAT-type shortcuts are
|
||||
// exposed by the OpenAPI gateway; feed_card_id must be an open_chat_id
|
||||
// (oc_xxx).
|
||||
var ImFeedShortcutCreate = common.Shortcut{
|
||||
Service: "im",
|
||||
Command: "+feed-shortcut-create",
|
||||
Description: "Add chats to the user's feed shortcuts; user-only; batch up to 10 chat IDs per call; --head/--tail controls insertion order",
|
||||
Risk: "write",
|
||||
UserScopes: []string{feedShortcutWriteScope},
|
||||
AuthTypes: []string{"user"},
|
||||
HasFormat: true,
|
||||
Flags: []common.Flag{
|
||||
// chat-id is mandatory but intentionally not cobra-Required: the
|
||||
// requiredness check lives in collectChatIDs so a missing flag is
|
||||
// reported through the structured validation envelope (exit 2)
|
||||
// instead of cobra's plain-text error.
|
||||
{Name: "chat-id", Type: "string_slice",
|
||||
Desc: "open_chat_id to add as a feed shortcut (oc_xxx); required; repeat the flag or pass comma-separated; max 10 per call"},
|
||||
{Name: "head", Type: "bool",
|
||||
Desc: "insert at the top of the shortcut list (default); mutually exclusive with --tail"},
|
||||
{Name: "tail", Type: "bool",
|
||||
Desc: "append at the bottom of the shortcut list; mutually exclusive with --head"},
|
||||
},
|
||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
if _, err := collectChatIDs(runtime); err != nil {
|
||||
return err
|
||||
}
|
||||
_, err := resolveIsHeader(runtime)
|
||||
return err
|
||||
},
|
||||
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
ids, err := collectChatIDs(runtime)
|
||||
if err != nil {
|
||||
return common.NewDryRunAPI().Set("error", err.Error())
|
||||
}
|
||||
isHeader, err := resolveIsHeader(runtime)
|
||||
if err != nil {
|
||||
return common.NewDryRunAPI().Set("error", err.Error())
|
||||
}
|
||||
return common.NewDryRunAPI().
|
||||
POST("/open-apis/im/v2/feed_shortcuts").
|
||||
Body(map[string]any{
|
||||
"shortcuts": buildShortcutItems(ids),
|
||||
"is_header": isHeader,
|
||||
})
|
||||
},
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
ids, err := collectChatIDs(runtime)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
isHeader, err := resolveIsHeader(runtime)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
items := buildShortcutItems(ids)
|
||||
data, err := runtime.DoAPIJSONTyped("POST", "/open-apis/im/v2/feed_shortcuts", nil,
|
||||
map[string]any{
|
||||
"shortcuts": items,
|
||||
"is_header": isHeader,
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return emitFeedShortcutWriteResult(runtime, items, data)
|
||||
},
|
||||
}
|
||||
|
||||
// resolveIsHeader determines the insertion position.
|
||||
// - default (neither flag set) → true (head)
|
||||
// - --head → true
|
||||
// - --tail → false
|
||||
// - both set → error
|
||||
func resolveIsHeader(rt *common.RuntimeContext) (bool, error) {
|
||||
head := rt.Bool("head")
|
||||
tail := rt.Bool("tail")
|
||||
if head && tail {
|
||||
return false, errs.NewValidationError(errs.SubtypeInvalidArgument, "--head and --tail are mutually exclusive")
|
||||
}
|
||||
if tail {
|
||||
return false, nil
|
||||
}
|
||||
return true, nil
|
||||
}
|
||||
79
shortcuts/im/im_feed_shortcut_list.go
Normal file
79
shortcuts/im/im_feed_shortcut_list.go
Normal file
@@ -0,0 +1,79 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package im
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
larkcore "github.com/larksuite/oapi-sdk-go/v3/core"
|
||||
)
|
||||
|
||||
// ImFeedShortcutList provides the +feed-shortcut-list shortcut for listing
|
||||
// the user's feed shortcuts. The server-controlled page size covers the full
|
||||
// list in practice, but pagination is version-locked: when the list changes
|
||||
// between calls the server rejects the stale token and the caller has to
|
||||
// restart by omitting --page-token.
|
||||
//
|
||||
// The shortcut is a thin one-page wrapper — there is no automatic walking.
|
||||
// Callers are expected to drive their own loop when they actually need to
|
||||
// paginate, because the version-lock means each page is a real checkpoint
|
||||
// that the caller must consciously decide what to do with on failure.
|
||||
var ImFeedShortcutList = common.Shortcut{
|
||||
Service: "im",
|
||||
Command: "+feed-shortcut-list",
|
||||
Description: "List one page of the user's feed shortcuts; user-only; first call omits --page-token, subsequent calls pass the previous response's page_token; each entry is auto-enriched with the full per-type info object attached as `detail` (pass --no-detail to skip)",
|
||||
Risk: "read",
|
||||
UserScopes: []string{feedShortcutReadScope},
|
||||
ConditionalUserScopes: []string{chatBatchQueryScope},
|
||||
AuthTypes: []string{"user"},
|
||||
HasFormat: true,
|
||||
Flags: []common.Flag{
|
||||
{Name: "page-token",
|
||||
Desc: "opaque pagination token from the previous response; omit for the first page. If a token is rejected because the list changed, restart by omitting it."},
|
||||
{Name: "no-detail", Type: "bool",
|
||||
Desc: "skip fetching the full info object for each shortcut (default: enrichment enabled — CHAT-type entries call im.chats.batch_query, require im:chat:read, and attach the object under the detail field)"},
|
||||
},
|
||||
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
d := common.NewDryRunAPI().
|
||||
GET("/open-apis/im/v2/feed_shortcuts")
|
||||
if token := runtime.Str("page-token"); token != "" {
|
||||
d.Params(map[string]any{"page_token": token})
|
||||
}
|
||||
if !runtime.Bool("no-detail") {
|
||||
d.Desc("conditional enrichment: if CHAT-type entries exist, execution also calls POST /open-apis/im/v1/chats/batch_query and requires scope im:chat:read; pass --no-detail to skip this extra call and extra scope")
|
||||
}
|
||||
return d
|
||||
},
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
data, err := runtime.DoAPIJSONTyped("GET", "/open-apis/im/v2/feed_shortcuts",
|
||||
feedShortcutListQuery(runtime.Str("page-token")), nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !runtime.Bool("no-detail") {
|
||||
if err := enrichFeedShortcutDetail(runtime, data); err != nil {
|
||||
fmt.Fprintf(runtime.IO().ErrOut, "warning: detail enrichment failed: %v\n", err)
|
||||
// Mirror the warning into the data payload so stdout-only
|
||||
// consumers can tell "enrichment skipped" from "nothing to
|
||||
// enrich" (same convention as mail's data-level _notice).
|
||||
if data != nil {
|
||||
data["_notice"] = fmt.Sprintf("detail enrichment skipped: %v", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
runtime.Out(data, nil)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
// feedShortcutListQuery omits the page_token key entirely when the token is
|
||||
// empty, so the server treats the call as a first-page request.
|
||||
func feedShortcutListQuery(token string) larkcore.QueryParams {
|
||||
if token == "" {
|
||||
return larkcore.QueryParams{}
|
||||
}
|
||||
return larkcore.QueryParams{"page_token": []string{token}}
|
||||
}
|
||||
57
shortcuts/im/im_feed_shortcut_remove.go
Normal file
57
shortcuts/im/im_feed_shortcut_remove.go
Normal file
@@ -0,0 +1,57 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package im
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
// ImFeedShortcutRemove provides the +feed-shortcut-remove shortcut for
|
||||
// removing chats from the user's feed shortcuts. Per-item failures are kept
|
||||
// in stdout and returned as a partial-failure exit.
|
||||
var ImFeedShortcutRemove = common.Shortcut{
|
||||
Service: "im",
|
||||
Command: "+feed-shortcut-remove",
|
||||
Description: "Remove chats from the user's feed shortcuts; user-only; batch up to 10 chat IDs per call; per-item failures return ok:false with failed_shortcuts",
|
||||
Risk: "write",
|
||||
UserScopes: []string{feedShortcutWriteScope},
|
||||
AuthTypes: []string{"user"},
|
||||
HasFormat: true,
|
||||
Flags: []common.Flag{
|
||||
// chat-id is mandatory but intentionally not cobra-Required: the
|
||||
// requiredness check lives in collectChatIDs so a missing flag is
|
||||
// reported through the structured validation envelope (exit 2)
|
||||
// instead of cobra's plain-text error.
|
||||
{Name: "chat-id", Type: "string_slice",
|
||||
Desc: "open_chat_id to remove from feed shortcuts (oc_xxx); required; repeat the flag or pass comma-separated; max 10 per call"},
|
||||
},
|
||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
_, err := collectChatIDs(runtime)
|
||||
return err
|
||||
},
|
||||
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
ids, err := collectChatIDs(runtime)
|
||||
if err != nil {
|
||||
return common.NewDryRunAPI().Set("error", err.Error())
|
||||
}
|
||||
return common.NewDryRunAPI().
|
||||
POST("/open-apis/im/v2/feed_shortcuts/remove").
|
||||
Body(map[string]any{"shortcuts": buildShortcutItems(ids)})
|
||||
},
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
ids, err := collectChatIDs(runtime)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
items := buildShortcutItems(ids)
|
||||
data, err := runtime.DoAPIJSONTyped("POST", "/open-apis/im/v2/feed_shortcuts/remove", nil,
|
||||
map[string]any{"shortcuts": items})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return emitFeedShortcutWriteResult(runtime, items, data)
|
||||
},
|
||||
}
|
||||
1092
shortcuts/im/im_feed_shortcut_test.go
Normal file
1092
shortcuts/im/im_feed_shortcut_test.go
Normal file
File diff suppressed because it is too large
Load Diff
@@ -8,7 +8,7 @@ import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
@@ -63,11 +63,9 @@ var ImFlagCancel = common.Shortcut{
|
||||
"item_type": itemType,
|
||||
"flag_type": flagType,
|
||||
}
|
||||
data, err := runtime.DoAPIJSON("POST", "/open-apis/im/v1/flags/cancel", nil,
|
||||
data, err := runtime.DoAPIJSONTyped("POST", "/open-apis/im/v1/flags/cancel", nil,
|
||||
map[string]any{"flag_items": []flagItem{item}})
|
||||
if err != nil {
|
||||
fmt.Fprintf(runtime.IO().ErrOut, "warning: cancel failed for %s/%s: %v\n",
|
||||
itemType, flagType, err)
|
||||
result["status"] = "failed"
|
||||
result["error"] = err.Error()
|
||||
lastErr = err
|
||||
@@ -78,8 +76,12 @@ var ImFlagCancel = common.Shortcut{
|
||||
results = append(results, result)
|
||||
}
|
||||
|
||||
runtime.Out(map[string]any{"results": results}, nil)
|
||||
return lastErr
|
||||
payload := map[string]any{"results": results}
|
||||
if lastErr != nil {
|
||||
return runtime.OutPartialFailure(payload, nil)
|
||||
}
|
||||
runtime.Out(payload, nil)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
@@ -203,20 +205,20 @@ func buildSingleCancelItem(id, itOverride, ftOverride string) (flagItem, error)
|
||||
// Provide more specific hints for common mistakes
|
||||
if itOverride != "" && ftOverride == "" {
|
||||
if itemType == ItemTypeThread || itemType == ItemTypeMsgThread {
|
||||
return flagItem{}, output.ErrValidation(
|
||||
return flagItem{}, errs.NewValidationError(errs.SubtypeInvalidArgument,
|
||||
"invalid combination: --item-type=%s requires --flag-type=feed (feed-layer flags are the only valid type for threads)",
|
||||
itOverride)
|
||||
itOverride).WithParam("--item-type")
|
||||
}
|
||||
return flagItem{}, output.ErrValidation(
|
||||
return flagItem{}, errs.NewValidationError(errs.SubtypeInvalidArgument,
|
||||
"invalid combination: --item-type=%s with inferred --flag-type=%s; specify --flag-type explicitly to override",
|
||||
itOverride, flagTypeString(flagType))
|
||||
itOverride, flagTypeString(flagType)).WithParam("--item-type")
|
||||
}
|
||||
if itOverride == "" && ftOverride != "" {
|
||||
return flagItem{}, output.ErrValidation(
|
||||
return flagItem{}, errs.NewValidationError(errs.SubtypeInvalidArgument,
|
||||
"invalid combination: --flag-type=%s with inferred --item-type=%s; specify --item-type explicitly to override",
|
||||
ftOverride, itemTypeString(itemType))
|
||||
ftOverride, itemTypeString(itemType)).WithParam("--flag-type")
|
||||
}
|
||||
return flagItem{}, output.ErrValidation(
|
||||
return flagItem{}, errs.NewValidationError(errs.SubtypeInvalidArgument,
|
||||
"invalid --item-type/--flag-type combination: supported pairs are default+message, thread+feed, and msg_thread+feed")
|
||||
}
|
||||
return newFlagItem(id, itemType, flagType), nil
|
||||
|
||||
@@ -8,7 +8,7 @@ import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
@@ -50,12 +50,14 @@ var ImFlagCreate = common.Shortcut{
|
||||
}
|
||||
// Combo validation already done in Validate, but double-check as a safety net.
|
||||
if !isValidCombo(parseItemTypeFromRaw(item.ItemType), parseFlagTypeFromRaw(item.FlagType)) {
|
||||
return output.ErrValidation(
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument,
|
||||
"invalid (item_type=%s, flag_type=%s) combination; the server only accepts "+
|
||||
"(default, message), (thread, feed), or (msg_thread, feed)",
|
||||
item.ItemType, item.FlagType)
|
||||
item.ItemType, item.FlagType).WithParams(
|
||||
errs.InvalidParam{Name: "--item-type", Reason: "unsupported with the given --flag-type"},
|
||||
errs.InvalidParam{Name: "--flag-type", Reason: "unsupported with the given --item-type"})
|
||||
}
|
||||
data, err := runtime.DoAPIJSON("POST", "/open-apis/im/v1/flags", nil,
|
||||
data, err := runtime.DoAPIJSONTyped("POST", "/open-apis/im/v1/flags", nil,
|
||||
map[string]any{"flag_items": []flagItem{item}})
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -138,18 +140,16 @@ func buildCreateItem(rt *common.RuntimeContext) (flagItem, error) {
|
||||
|
||||
chatID, err := getMessageChatID(rt, id)
|
||||
if err != nil {
|
||||
return flagItem{}, output.ErrValidation(
|
||||
"failed to query message for feed-layer flag: %v; if you know the chat type, specify --item-type explicitly", err)
|
||||
return flagItem{}, appendIMRecoveryHint(err, "specify --item-type explicitly")
|
||||
}
|
||||
if chatID == "" {
|
||||
return flagItem{}, output.ErrValidation(
|
||||
return flagItem{}, errs.NewValidationError(errs.SubtypeInvalidArgument,
|
||||
"message does not belong to a chat; feed-layer flags are only for messages in chats")
|
||||
}
|
||||
|
||||
feedIT, err := resolveThreadFeedItemType(rt, chatID)
|
||||
if err != nil {
|
||||
return flagItem{}, output.ErrValidation(
|
||||
"failed to determine chat type: %v; if you know the chat type, specify --item-type explicitly", err)
|
||||
return flagItem{}, appendIMRecoveryHint(err, "specify --item-type explicitly")
|
||||
}
|
||||
return newFlagItem(id, feedIT, FlagTypeFeed), nil
|
||||
}
|
||||
@@ -186,18 +186,24 @@ func parseExplicitFlagCombo(itOverride, ftOverride string) (explicitFlagCombo, e
|
||||
if combo.ItemTypeSet && !combo.FlagTypeSet {
|
||||
switch combo.ItemType {
|
||||
case ItemTypeThread, ItemTypeMsgThread:
|
||||
return explicitFlagCombo{}, output.ErrValidation(
|
||||
"--item-type=%s requires --flag-type=feed; message-layer flags always use item-type=default", itOverride)
|
||||
return explicitFlagCombo{}, errs.NewValidationError(errs.SubtypeInvalidArgument,
|
||||
"--item-type=%s requires --flag-type=feed; message-layer flags always use item-type=default", itOverride).WithParams(
|
||||
errs.InvalidParam{Name: "--item-type", Reason: "requires --flag-type=feed"},
|
||||
errs.InvalidParam{Name: "--flag-type", Reason: "must be feed for this --item-type"})
|
||||
case ItemTypeDefault:
|
||||
return explicitFlagCombo{}, output.ErrValidation(
|
||||
"--item-type=default requires --flag-type=message; or omit both to use default behavior")
|
||||
return explicitFlagCombo{}, errs.NewValidationError(errs.SubtypeInvalidArgument,
|
||||
"--item-type=default requires --flag-type=message; or omit both to use default behavior").WithParams(
|
||||
errs.InvalidParam{Name: "--item-type", Reason: "default requires --flag-type=message"},
|
||||
errs.InvalidParam{Name: "--flag-type", Reason: "must be message for --item-type=default"})
|
||||
}
|
||||
}
|
||||
|
||||
if combo.ItemTypeSet && combo.FlagTypeSet && !isValidCombo(combo.ItemType, combo.FlagType) {
|
||||
return explicitFlagCombo{}, output.ErrValidation(
|
||||
return explicitFlagCombo{}, errs.NewValidationError(errs.SubtypeInvalidArgument,
|
||||
"invalid --item-type=%s --flag-type=%s combination; supported pairs are default+message, thread+feed, and msg_thread+feed",
|
||||
itOverride, ftOverride)
|
||||
itOverride, ftOverride).WithParams(
|
||||
errs.InvalidParam{Name: "--item-type", Reason: "unsupported pairing"},
|
||||
errs.InvalidParam{Name: "--flag-type", Reason: "unsupported pairing"})
|
||||
}
|
||||
|
||||
return combo, nil
|
||||
|
||||
@@ -9,7 +9,7 @@ import (
|
||||
"fmt"
|
||||
"strconv"
|
||||
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
larkcore "github.com/larksuite/oapi-sdk-go/v3/core"
|
||||
)
|
||||
@@ -56,7 +56,7 @@ var ImFlagList = common.Shortcut{
|
||||
return executeListAllPages(runtime)
|
||||
}
|
||||
|
||||
data, err := runtime.DoAPIJSON("GET", "/open-apis/im/v1/flags", listQuery(runtime), nil)
|
||||
data, err := runtime.DoAPIJSONTyped("GET", "/open-apis/im/v1/flags", listQuery(runtime), nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -72,10 +72,10 @@ var ImFlagList = common.Shortcut{
|
||||
|
||||
func validateListOptions(rt *common.RuntimeContext) error {
|
||||
if n := rt.Int("page-size"); n < 1 || n > 50 {
|
||||
return output.ErrValidation("--page-size must be an integer between 1 and 50")
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--page-size must be an integer between 1 and 50").WithParam("--page-size")
|
||||
}
|
||||
if n := rt.Int("page-limit"); n < 1 || n > 1000 {
|
||||
return output.ErrValidation("--page-limit must be an integer between 1 and 1000")
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--page-limit must be an integer between 1 and 1000").WithParam("--page-limit")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -159,7 +159,7 @@ func enrichFeedThreadItems(rt *common.RuntimeContext, data map[string]any) error
|
||||
end = len(ids)
|
||||
}
|
||||
batch := ids[i:end]
|
||||
got, err := rt.DoAPIJSON("GET", "/open-apis/im/v1/messages/mget",
|
||||
got, err := rt.DoAPIJSONTyped("GET", "/open-apis/im/v1/messages/mget",
|
||||
larkcore.QueryParams{"message_ids": batch}, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -244,7 +244,7 @@ func executeListAllPages(rt *common.RuntimeContext) error {
|
||||
if page > 0 {
|
||||
token = lastPageToken
|
||||
}
|
||||
data, err := rt.DoAPIJSON("GET", "/open-apis/im/v1/flags",
|
||||
data, err := rt.DoAPIJSONTyped("GET", "/open-apis/im/v1/flags",
|
||||
larkcore.QueryParams{
|
||||
"page_size": []string{strconv.Itoa(rt.Int("page-size"))},
|
||||
"page_token": []string{token},
|
||||
|
||||
@@ -15,6 +15,7 @@ import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/credential"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
@@ -593,18 +594,18 @@ func TestCheckFlagRequiredScopesReportsTokenResolutionError(t *testing.T) {
|
||||
setRuntimeTokenError(t, rt, errors.New("token cache unavailable"))
|
||||
|
||||
err := checkFlagRequiredScopes(context.Background(), rt, flagMessageReadScopes)
|
||||
var exitErr *output.ExitError
|
||||
if !errors.As(err, &exitErr) {
|
||||
t.Fatalf("checkFlagRequiredScopes() error = %T %v, want ExitError", err, err)
|
||||
var authErr *errs.AuthenticationError
|
||||
if !errors.As(err, &authErr) {
|
||||
t.Fatalf("checkFlagRequiredScopes() error = %T %v, want *errs.AuthenticationError", err, err)
|
||||
}
|
||||
if exitErr.Code != output.ExitAuth || exitErr.Detail.Type != "auth" {
|
||||
t.Fatalf("checkFlagRequiredScopes() detail = %+v code=%d, want auth exit", exitErr.Detail, exitErr.Code)
|
||||
if authErr.Subtype != errs.SubtypeTokenMissing {
|
||||
t.Fatalf("checkFlagRequiredScopes() subtype = %q, want %q", authErr.Subtype, errs.SubtypeTokenMissing)
|
||||
}
|
||||
if !strings.Contains(exitErr.Detail.Message, "cannot verify required scope") {
|
||||
t.Fatalf("message = %q, want scope verification context", exitErr.Detail.Message)
|
||||
if !strings.Contains(authErr.Message, "cannot verify required scope") {
|
||||
t.Fatalf("message = %q, want scope verification context", authErr.Message)
|
||||
}
|
||||
if !strings.Contains(exitErr.Detail.Hint, strings.Join(flagMessageReadScopes, " ")) {
|
||||
t.Fatalf("hint = %q, want required scopes", exitErr.Detail.Hint)
|
||||
if !strings.Contains(authErr.Hint, strings.Join(flagMessageReadScopes, " ")) {
|
||||
t.Fatalf("hint = %q, want required scopes", authErr.Hint)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1337,6 +1338,10 @@ func TestFlagCancelExecuteSummarizesPartialFailure(t *testing.T) {
|
||||
if err == nil {
|
||||
t.Fatalf("Execute() expected partial failure error, got nil")
|
||||
}
|
||||
var partialErr *output.PartialFailureError
|
||||
if !errors.As(err, &partialErr) {
|
||||
t.Fatalf("Execute() error = %T, want *output.PartialFailureError", err)
|
||||
}
|
||||
|
||||
out := rt.Factory.IOStreams.Out.(*bytes.Buffer).String()
|
||||
for _, want := range []string{`"results"`, `"item_type": "default"`, `"flag_type": "message"`, `"status": "ok"`, `"item_type": "msg_thread"`, `"flag_type": "feed"`, `"status": "failed"`, "feed failed"} {
|
||||
@@ -1346,6 +1351,7 @@ func TestFlagCancelExecuteSummarizesPartialFailure(t *testing.T) {
|
||||
}
|
||||
|
||||
var envelope struct {
|
||||
OK bool `json:"ok"`
|
||||
Data struct {
|
||||
Results []map[string]any `json:"results"`
|
||||
} `json:"data"`
|
||||
@@ -1356,6 +1362,12 @@ func TestFlagCancelExecuteSummarizesPartialFailure(t *testing.T) {
|
||||
if len(envelope.Data.Results) != 2 {
|
||||
t.Fatalf("results len = %d, want 2", len(envelope.Data.Results))
|
||||
}
|
||||
if envelope.OK {
|
||||
t.Fatalf("stdout ok = true, want false for partial failure")
|
||||
}
|
||||
if errOut := rt.Factory.IOStreams.ErrOut.(*bytes.Buffer).String(); errOut != "" {
|
||||
t.Fatalf("stderr = %q, want empty for partial failure result envelope", errOut)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildCancelItems_OnlyItemTypeOverride(t *testing.T) {
|
||||
|
||||
@@ -9,6 +9,7 @@ import (
|
||||
"io"
|
||||
"net/http"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
convertlib "github.com/larksuite/cli/shortcuts/im/convert_lib"
|
||||
@@ -42,10 +43,10 @@ var ImMessagesMGet = common.Shortcut{
|
||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
ids := common.SplitCSV(runtime.Str("message-ids"))
|
||||
if len(ids) == 0 {
|
||||
return output.ErrValidation("--message-ids is required (comma-separated om_xxx)")
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--message-ids is required (comma-separated om_xxx)").WithParam("--message-ids")
|
||||
}
|
||||
if len(ids) > maxMGetMessageIDs {
|
||||
return output.ErrValidation("--message-ids supports at most %d IDs per request (got %d)", maxMGetMessageIDs, len(ids))
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--message-ids supports at most %d IDs per request (got %d)", maxMGetMessageIDs, len(ids)).WithParam("--message-ids")
|
||||
}
|
||||
for _, id := range ids {
|
||||
if _, err := validateMessageID(id); err != nil {
|
||||
@@ -58,7 +59,7 @@ var ImMessagesMGet = common.Shortcut{
|
||||
ids := common.SplitCSV(runtime.Str("message-ids"))
|
||||
mgetURL := buildMGetURL(ids)
|
||||
|
||||
data, err := runtime.DoAPIJSON(http.MethodGet, mgetURL, nil, nil)
|
||||
data, err := runtime.DoAPIJSONTyped(http.MethodGet, mgetURL, nil, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -9,7 +9,7 @@ import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/validate"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
@@ -102,20 +102,20 @@ var ImMessagesReply = common.Shortcut{
|
||||
}
|
||||
|
||||
if messageId == "" {
|
||||
return output.ErrValidation("--message-id is required (om_xxx)")
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--message-id is required (om_xxx)").WithParam("--message-id")
|
||||
}
|
||||
if _, err := validateMessageID(messageId); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if msg := validateContentFlags(text, markdown, content, imageKey, fileKey, videoKey, videoCoverKey, audioKey); msg != "" {
|
||||
return output.ErrValidation(msg)
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "%s", msg)
|
||||
}
|
||||
if content != "" && !json.Valid([]byte(content)) {
|
||||
return output.ErrValidation("--content is not valid JSON: %s\nexample: --content '{\"text\":\"hello\"}' or --text 'hello'", content)
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--content is not valid JSON: %s\nexample: --content '{\"text\":\"hello\"}' or --text 'hello'", content).WithParam("--content")
|
||||
}
|
||||
if msg := validateExplicitMsgType(runtime.Cmd, msgType, text, markdown, imageKey, fileKey, videoKey, audioKey); msg != "" {
|
||||
return output.ErrValidation(msg)
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "%s", msg).WithParam("--msg-type")
|
||||
}
|
||||
|
||||
return nil
|
||||
@@ -167,7 +167,7 @@ var ImMessagesReply = common.Shortcut{
|
||||
data["uuid"] = idempotencyKey
|
||||
}
|
||||
|
||||
resData, err := runtime.DoAPIJSON(http.MethodPost,
|
||||
resData, err := runtime.DoAPIJSONTyped(http.MethodPost,
|
||||
fmt.Sprintf("/open-apis/im/v1/messages/%s/reply", validate.EncodePathSegment(messageId)),
|
||||
nil, data)
|
||||
if err != nil {
|
||||
|
||||
@@ -14,9 +14,9 @@ import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/extension/fileio"
|
||||
"github.com/larksuite/cli/internal/client"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
larkcore "github.com/larksuite/oapi-sdk-go/v3/core"
|
||||
)
|
||||
@@ -48,16 +48,16 @@ var ImMessagesResourcesDownload = common.Shortcut{
|
||||
},
|
||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
if messageId := runtime.Str("message-id"); messageId == "" {
|
||||
return output.ErrValidation("--message-id is required (om_xxx)")
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--message-id is required (om_xxx)").WithParam("--message-id")
|
||||
} else if _, err := validateMessageID(messageId); err != nil {
|
||||
return err
|
||||
}
|
||||
relPath, err := normalizeDownloadOutputPath(runtime.Str("file-key"), runtime.Str("output"))
|
||||
if err != nil {
|
||||
return output.ErrValidation("%s", err)
|
||||
return err
|
||||
}
|
||||
if _, err := runtime.ResolveSavePath(relPath); err != nil {
|
||||
return output.ErrValidation("unsafe output path: %s", err)
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "unsafe output path: %s", err).WithParam("--output").WithCause(err)
|
||||
}
|
||||
return nil
|
||||
},
|
||||
@@ -67,10 +67,10 @@ var ImMessagesResourcesDownload = common.Shortcut{
|
||||
fileType := runtime.Str("type")
|
||||
relPath, err := normalizeDownloadOutputPath(fileKey, runtime.Str("output"))
|
||||
if err != nil {
|
||||
return output.ErrValidation("invalid output path: %s", err)
|
||||
return err
|
||||
}
|
||||
if _, err := runtime.ResolveSavePath(relPath); err != nil {
|
||||
return output.ErrValidation("unsafe output path: %s", err)
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "unsafe output path: %s", err).WithParam("--output").WithCause(err)
|
||||
}
|
||||
|
||||
userSpecifiedOutput := runtime.Str("output") != ""
|
||||
@@ -87,23 +87,23 @@ var ImMessagesResourcesDownload = common.Shortcut{
|
||||
func normalizeDownloadOutputPath(fileKey, outputPath string) (string, error) {
|
||||
fileKey = strings.TrimSpace(fileKey)
|
||||
if fileKey == "" {
|
||||
return "", fmt.Errorf("file-key cannot be empty")
|
||||
return "", errs.NewValidationError(errs.SubtypeInvalidArgument, "file-key cannot be empty").WithParam("--file-key")
|
||||
}
|
||||
if strings.ContainsAny(fileKey, "/\\") {
|
||||
return "", fmt.Errorf("file-key cannot contain path separators")
|
||||
return "", errs.NewValidationError(errs.SubtypeInvalidArgument, "file-key cannot contain path separators").WithParam("--file-key")
|
||||
}
|
||||
if outputPath == "" {
|
||||
return fileKey, nil
|
||||
}
|
||||
outputPath = filepath.Clean(strings.TrimSpace(outputPath))
|
||||
if outputPath == "." {
|
||||
return "", fmt.Errorf("path cannot be empty")
|
||||
return "", errs.NewValidationError(errs.SubtypeInvalidArgument, "path cannot be empty").WithParam("--output")
|
||||
}
|
||||
if filepath.IsAbs(outputPath) {
|
||||
return "", fmt.Errorf("absolute paths are not allowed")
|
||||
return "", errs.NewValidationError(errs.SubtypeInvalidArgument, "absolute paths are not allowed").WithParam("--output")
|
||||
}
|
||||
if outputPath == ".." || strings.HasPrefix(outputPath, ".."+string(filepath.Separator)) {
|
||||
return "", fmt.Errorf("path cannot escape the current working directory")
|
||||
return "", errs.NewValidationError(errs.SubtypeInvalidArgument, "path cannot escape the current working directory").WithParam("--output")
|
||||
}
|
||||
return outputPath, nil
|
||||
}
|
||||
@@ -192,7 +192,7 @@ func (r *rangeChunkReader) Read(p []byte) (int, error) {
|
||||
return 0, closeErr
|
||||
}
|
||||
}
|
||||
return 0, output.ErrNetwork("chunk overflow: delivered %d, expected %d", r.delivered, r.totalSize)
|
||||
return 0, errs.NewNetworkError(errs.SubtypeNetworkTransport, "chunk overflow: delivered %d, expected %d", r.delivered, r.totalSize)
|
||||
}
|
||||
|
||||
switch err {
|
||||
@@ -222,7 +222,7 @@ func (r *rangeChunkReader) Read(p []byte) (int, error) {
|
||||
if r.delivered == r.totalSize {
|
||||
return 0, io.EOF
|
||||
}
|
||||
return 0, output.ErrNetwork("file size mismatch: expected %d, got %d", r.totalSize, r.delivered)
|
||||
return 0, errs.NewNetworkError(errs.SubtypeNetworkTransport, "file size mismatch: expected %d, got %d", r.totalSize, r.delivered)
|
||||
}
|
||||
|
||||
end := min(r.nextOffset+normalChunkSize-1, r.totalSize-1)
|
||||
@@ -238,7 +238,7 @@ func (r *rangeChunkReader) Read(p []byte) (int, error) {
|
||||
}
|
||||
if resp.StatusCode != http.StatusPartialContent {
|
||||
resp.Body.Close()
|
||||
return 0, output.ErrNetwork("unexpected status code: %d", resp.StatusCode)
|
||||
return 0, errs.NewNetworkError(errs.SubtypeNetworkTransport, "unexpected status code: %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
r.current = resp.Body
|
||||
@@ -270,7 +270,7 @@ func downloadIMResourceToPath(ctx context.Context, runtime *common.RuntimeContex
|
||||
return "", 0, err
|
||||
}
|
||||
if downloadResp == nil {
|
||||
return "", 0, output.ErrNetwork("download failed: empty response")
|
||||
return "", 0, errs.NewNetworkError(errs.SubtypeNetworkTransport, "download failed: empty response")
|
||||
}
|
||||
|
||||
if downloadResp.StatusCode >= 400 {
|
||||
@@ -289,7 +289,7 @@ func downloadIMResourceToPath(ctx context.Context, runtime *common.RuntimeContex
|
||||
totalSize, err := parseTotalSize(downloadResp.Header.Get("Content-Range"))
|
||||
if err != nil {
|
||||
downloadResp.Body.Close()
|
||||
return "", 0, output.ErrNetwork("invalid Content-Range header on range response: %s", err)
|
||||
return "", 0, errs.NewNetworkError(errs.SubtypeNetworkTransport, "invalid Content-Range header on range response: %s", err)
|
||||
}
|
||||
body = newRangeChunkReader(ctx, runtime, messageID, fileKey, fileType, downloadResp.Body, totalSize)
|
||||
sizeBytes = totalSize
|
||||
@@ -300,7 +300,7 @@ func downloadIMResourceToPath(ctx context.Context, runtime *common.RuntimeContex
|
||||
|
||||
default:
|
||||
downloadResp.Body.Close()
|
||||
return "", 0, output.ErrNetwork("unexpected status code: %d", downloadResp.StatusCode)
|
||||
return "", 0, errs.NewNetworkError(errs.SubtypeNetworkTransport, "unexpected status code: %d", downloadResp.StatusCode)
|
||||
}
|
||||
defer body.Close()
|
||||
|
||||
@@ -309,10 +309,10 @@ func downloadIMResourceToPath(ctx context.Context, runtime *common.RuntimeContex
|
||||
ContentLength: sizeBytes,
|
||||
}, body)
|
||||
if err != nil {
|
||||
return "", 0, common.WrapSaveErrorByCategory(err, "api_error")
|
||||
return "", 0, common.WrapSaveErrorTyped(err)
|
||||
}
|
||||
if sizeBytes >= 0 && result.Size() != sizeBytes {
|
||||
return "", 0, output.ErrNetwork("file size mismatch: expected %d, got %d", sizeBytes, result.Size())
|
||||
return "", 0, errs.NewNetworkError(errs.SubtypeNetworkTransport, "file size mismatch: expected %d, got %d", sizeBytes, result.Size())
|
||||
}
|
||||
savedPath, resolveErr := runtime.ResolveSavePath(finalPath)
|
||||
if resolveErr != nil || savedPath == "" {
|
||||
@@ -404,7 +404,7 @@ func doIMResourceDownloadRequest(ctx context.Context, runtime *common.RuntimeCon
|
||||
return resp, nil
|
||||
}
|
||||
if ctx.Err() != nil {
|
||||
return nil, ctx.Err()
|
||||
return nil, imContextError(ctx.Err())
|
||||
}
|
||||
lastErr = err
|
||||
if attempt == imDownloadRequestRetries {
|
||||
@@ -415,7 +415,7 @@ func doIMResourceDownloadRequest(ctx context.Context, runtime *common.RuntimeCon
|
||||
if lastErr != nil {
|
||||
return nil, lastErr
|
||||
}
|
||||
return nil, output.ErrNetwork("download request failed")
|
||||
return nil, errs.NewNetworkError(errs.SubtypeNetworkTransport, "download request failed")
|
||||
}
|
||||
|
||||
func sleepIMDownloadRetry(ctx context.Context, attempt int) {
|
||||
@@ -431,37 +431,37 @@ func sleepIMDownloadRetry(ctx context.Context, attempt int) {
|
||||
func downloadResponseError(resp *http.Response) error {
|
||||
body, _ := io.ReadAll(io.LimitReader(resp.Body, 4096))
|
||||
if len(body) > 0 {
|
||||
return output.ErrNetwork("download failed: HTTP %d: %s", resp.StatusCode, strings.TrimSpace(string(body)))
|
||||
return errs.NewNetworkError(errs.SubtypeNetworkTransport, "download failed: HTTP %d: %s", resp.StatusCode, strings.TrimSpace(string(body)))
|
||||
}
|
||||
return output.ErrNetwork("download failed: HTTP %d", resp.StatusCode)
|
||||
return errs.NewNetworkError(errs.SubtypeNetworkTransport, "download failed: HTTP %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
func parseTotalSize(contentRange string) (int64, error) {
|
||||
contentRange = strings.TrimSpace(contentRange)
|
||||
if contentRange == "" {
|
||||
return 0, fmt.Errorf("content-range is empty")
|
||||
return 0, fmt.Errorf("content-range is empty") //nolint:forbidigo // intermediate Content-Range parse; caller wraps it as a typed network error
|
||||
}
|
||||
if !strings.HasPrefix(contentRange, "bytes ") {
|
||||
return 0, fmt.Errorf("unsupported content-range: %q", contentRange)
|
||||
return 0, fmt.Errorf("unsupported content-range: %q", contentRange) //nolint:forbidigo // intermediate Content-Range parse; caller wraps it as a typed network error
|
||||
}
|
||||
|
||||
parts := strings.SplitN(strings.TrimPrefix(contentRange, "bytes "), "/", 2)
|
||||
if len(parts) != 2 || parts[1] == "" {
|
||||
return 0, fmt.Errorf("unsupported content-range: %q", contentRange)
|
||||
return 0, fmt.Errorf("unsupported content-range: %q", contentRange) //nolint:forbidigo // intermediate Content-Range parse; caller wraps it as a typed network error
|
||||
}
|
||||
if parts[0] == "*" {
|
||||
return 0, fmt.Errorf("unsupported content-range: %q", contentRange)
|
||||
return 0, fmt.Errorf("unsupported content-range: %q", contentRange) //nolint:forbidigo // intermediate Content-Range parse; caller wraps it as a typed network error
|
||||
}
|
||||
if parts[1] == "*" {
|
||||
return 0, fmt.Errorf("unknown total size in content-range: %q", contentRange)
|
||||
return 0, fmt.Errorf("unknown total size in content-range: %q", contentRange) //nolint:forbidigo // intermediate Content-Range parse; caller wraps it as a typed network error
|
||||
}
|
||||
|
||||
totalSize, err := strconv.ParseInt(parts[1], 10, 64)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("parse total size: %w", err)
|
||||
return 0, fmt.Errorf("parse total size: %w", err) //nolint:forbidigo // intermediate Content-Range parse; caller wraps it as a typed network error
|
||||
}
|
||||
if totalSize <= 0 {
|
||||
return 0, fmt.Errorf("invalid total size: %d", totalSize)
|
||||
return 0, fmt.Errorf("invalid total size: %d", totalSize) //nolint:forbidigo // intermediate Content-Range parse; caller wraps it as a typed network error
|
||||
}
|
||||
return totalSize, nil
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@ import (
|
||||
"net/http"
|
||||
"strconv"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
convertlib "github.com/larksuite/cli/shortcuts/im/convert_lib"
|
||||
@@ -22,7 +23,6 @@ const (
|
||||
messagesSearchDefaultPageLimit = 20
|
||||
messagesSearchMaxPageLimit = 40
|
||||
messagesSearchMGetBatchSize = 50
|
||||
messagesSearchChatBatchSize = 50
|
||||
)
|
||||
|
||||
var ImMessagesSearch = common.Shortcut{
|
||||
@@ -269,7 +269,7 @@ func buildMessagesSearchRequest(runtime *common.RuntimeContext) (*messagesSearch
|
||||
if runtime.Cmd != nil && runtime.Cmd.Flags().Changed("page-limit") {
|
||||
pageLimit := runtime.Int("page-limit")
|
||||
if pageLimit < 1 || pageLimit > messagesSearchMaxPageLimit {
|
||||
return nil, output.ErrValidation("--page-limit must be an integer between 1 and 40")
|
||||
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "--page-limit must be an integer between 1 and 40").WithParam("--page-limit")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -279,7 +279,7 @@ func buildMessagesSearchRequest(runtime *common.RuntimeContext) (*messagesSearch
|
||||
if startFlag != "" {
|
||||
ts, err := common.ParseTime(startFlag)
|
||||
if err != nil {
|
||||
return nil, output.ErrValidation("--start: %v", err)
|
||||
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "--start: %v", err).WithParam("--start")
|
||||
}
|
||||
startTs = ts
|
||||
start := startFlag
|
||||
@@ -288,7 +288,7 @@ func buildMessagesSearchRequest(runtime *common.RuntimeContext) (*messagesSearch
|
||||
if endFlag != "" {
|
||||
ts, err := common.ParseTime(endFlag, "end")
|
||||
if err != nil {
|
||||
return nil, output.ErrValidation("--end: %v", err)
|
||||
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "--end: %v", err).WithParam("--end")
|
||||
}
|
||||
endTs = ts
|
||||
end := endFlag
|
||||
@@ -298,7 +298,7 @@ func buildMessagesSearchRequest(runtime *common.RuntimeContext) (*messagesSearch
|
||||
sv, _ := strconv.ParseInt(startTs, 10, 64)
|
||||
ev, _ := strconv.ParseInt(endTs, 10, 64)
|
||||
if sv > ev {
|
||||
return nil, output.ErrValidation("--start cannot be later than --end")
|
||||
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "--start cannot be later than --end")
|
||||
}
|
||||
}
|
||||
if len(timeRange) > 0 {
|
||||
@@ -307,12 +307,12 @@ func buildMessagesSearchRequest(runtime *common.RuntimeContext) (*messagesSearch
|
||||
|
||||
if senderTypeFlag != "" && excludeSenderTypeFlag != "" {
|
||||
if senderTypeFlag == excludeSenderTypeFlag {
|
||||
return nil, output.ErrValidation("--sender-type and --exclude-sender-type cannot be the same value")
|
||||
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "--sender-type and --exclude-sender-type cannot be the same value")
|
||||
}
|
||||
}
|
||||
if chatFlag != "" {
|
||||
for _, chatID := range common.SplitCSV(chatFlag) {
|
||||
if _, err := common.ValidateChatID(chatID); err != nil {
|
||||
if _, err := common.ValidateChatIDTyped("--chat-id", chatID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
@@ -320,7 +320,7 @@ func buildMessagesSearchRequest(runtime *common.RuntimeContext) (*messagesSearch
|
||||
}
|
||||
if senderFlag != "" {
|
||||
for _, userID := range common.SplitCSV(senderFlag) {
|
||||
if _, err := common.ValidateUserID(userID); err != nil {
|
||||
if _, err := common.ValidateUserIDTyped("--sender", userID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
@@ -344,7 +344,7 @@ func buildMessagesSearchRequest(runtime *common.RuntimeContext) (*messagesSearch
|
||||
if atChatterIdsFlag != "" {
|
||||
ids := common.SplitCSV(atChatterIdsFlag)
|
||||
for _, id := range ids {
|
||||
if _, err := common.ValidateUserID(id); err != nil {
|
||||
if _, err := common.ValidateUserIDTyped("--at-chatter-ids", id); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
@@ -358,7 +358,7 @@ func buildMessagesSearchRequest(runtime *common.RuntimeContext) (*messagesSearch
|
||||
|
||||
pageSize := runtime.Int("page-size")
|
||||
if pageSize < 1 {
|
||||
return nil, output.ErrValidation("--page-size must be an integer between 1 and 50")
|
||||
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "--page-size must be an integer between 1 and 50").WithParam("--page-size")
|
||||
}
|
||||
if pageSize > messagesSearchMaxPageSize {
|
||||
pageSize = messagesSearchMaxPageSize
|
||||
@@ -421,7 +421,7 @@ func searchMessages(runtime *common.RuntimeContext, req *messagesSearchRequest)
|
||||
params["page_token"] = []string{pageToken}
|
||||
}
|
||||
|
||||
searchData, err := runtime.DoAPIJSON(http.MethodPost, "/open-apis/im/v1/messages/search", params, req.body)
|
||||
searchData, err := runtime.DoAPIJSONTyped(http.MethodPost, "/open-apis/im/v1/messages/search", params, req.body)
|
||||
if err != nil {
|
||||
return nil, false, "", false, pageLimit, err
|
||||
}
|
||||
@@ -447,7 +447,7 @@ func searchMessages(runtime *common.RuntimeContext, req *messagesSearchRequest)
|
||||
func batchMGetMessages(runtime *common.RuntimeContext, messageIds []string) ([]interface{}, error) {
|
||||
var items []interface{}
|
||||
for _, batch := range chunkStrings(messageIds, messagesSearchMGetBatchSize) {
|
||||
mgetData, err := runtime.DoAPIJSON(http.MethodGet, buildMGetURL(batch), nil, nil)
|
||||
mgetData, err := runtime.DoAPIJSONTyped(http.MethodGet, buildMGetURL(batch), nil, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -459,23 +459,9 @@ func batchMGetMessages(runtime *common.RuntimeContext, messageIds []string) ([]i
|
||||
|
||||
func batchQueryChatContexts(runtime *common.RuntimeContext, chatIds []string) map[string]map[string]interface{} {
|
||||
chatContexts := map[string]map[string]interface{}{}
|
||||
for _, batch := range chunkStrings(chatIds, messagesSearchChatBatchSize) {
|
||||
chatRes, chatErr := runtime.DoAPIJSON(
|
||||
http.MethodPost, "/open-apis/im/v1/chats/batch_query",
|
||||
larkcore.QueryParams{"user_id_type": []string{"open_id"}},
|
||||
map[string]interface{}{"chat_ids": batch},
|
||||
)
|
||||
if chatErr != nil {
|
||||
continue
|
||||
}
|
||||
if chatItems, ok := chatRes["items"].([]interface{}); ok {
|
||||
for _, ci := range chatItems {
|
||||
cm, _ := ci.(map[string]interface{})
|
||||
if cid, _ := cm["chat_id"].(string); cid != "" {
|
||||
chatContexts[cid] = cm
|
||||
}
|
||||
}
|
||||
}
|
||||
// Best-effort: a failed chunk only loses its own entries.
|
||||
for _, batch := range chunkStrings(chatIds, chatBatchQuerySize) {
|
||||
_ = queryChatBatch(runtime, batch, chatContexts)
|
||||
}
|
||||
return chatContexts
|
||||
}
|
||||
|
||||
@@ -10,8 +10,8 @@ import (
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/extension/fileio"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
larkcore "github.com/larksuite/oapi-sdk-go/v3/core"
|
||||
)
|
||||
@@ -113,30 +113,30 @@ var ImMessagesSend = common.Shortcut{
|
||||
}
|
||||
}
|
||||
|
||||
if err := common.ExactlyOne(runtime, "chat-id", "user-id"); err != nil {
|
||||
if err := common.ExactlyOneTyped(runtime, "chat-id", "user-id"); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Validate ID formats
|
||||
if chatFlag != "" {
|
||||
if _, err := common.ValidateChatID(chatFlag); err != nil {
|
||||
if _, err := common.ValidateChatIDTyped("--chat-id", chatFlag); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if userFlag != "" {
|
||||
if _, err := common.ValidateUserID(userFlag); err != nil {
|
||||
if _, err := common.ValidateUserIDTyped("--user-id", userFlag); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
if msg := validateContentFlags(text, markdown, content, imageKey, fileKey, videoKey, videoCoverKey, audioKey); msg != "" {
|
||||
return common.FlagErrorf(msg)
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, msg)
|
||||
}
|
||||
if content != "" && !json.Valid([]byte(content)) {
|
||||
return common.FlagErrorf("--content is not valid JSON: %s\nexample: --content '{\"text\":\"hello\"}' or --text 'hello'", content)
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--content is not valid JSON: %s\nexample: --content '{\"text\":\"hello\"}' or --text 'hello'", content).WithParam("--content")
|
||||
}
|
||||
if msg := validateExplicitMsgType(runtime.Cmd, msgType, text, markdown, imageKey, fileKey, videoKey, audioKey); msg != "" {
|
||||
return common.FlagErrorf(msg)
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, msg).WithParam("--msg-type")
|
||||
}
|
||||
|
||||
return nil
|
||||
@@ -193,7 +193,7 @@ var ImMessagesSend = common.Shortcut{
|
||||
data["uuid"] = idempotencyKey
|
||||
}
|
||||
|
||||
resData, err := runtime.DoAPIJSON(http.MethodPost, "/open-apis/im/v1/messages",
|
||||
resData, err := runtime.DoAPIJSONTyped(http.MethodPost, "/open-apis/im/v1/messages",
|
||||
larkcore.QueryParams{"receive_id_type": []string{receiveIdType}}, data)
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -220,7 +220,7 @@ func validateMediaFlagPath(fio fileio.FileIO, flagName, value string) error {
|
||||
return nil
|
||||
}
|
||||
if _, err := fio.Stat(value); err != nil && !os.IsNotExist(err) {
|
||||
return output.ErrValidation("%s: %v", flagName, err)
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "%s: %v", flagName, err).WithParam(flagName)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@ import (
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
convertlib "github.com/larksuite/cli/shortcuts/im/convert_lib"
|
||||
@@ -46,7 +47,7 @@ var ImThreadsMessagesList = common.Shortcut{
|
||||
sortType = "ByCreateTimeDesc"
|
||||
}
|
||||
|
||||
pageSize, _ := common.ValidatePageSize(runtime, "page-size", threadsMessagesMaxPageSize, 1, threadsMessagesMaxPageSize)
|
||||
pageSize, _ := common.ValidatePageSizeTyped(runtime, "page-size", threadsMessagesMaxPageSize, 1, threadsMessagesMaxPageSize)
|
||||
|
||||
d := common.NewDryRunAPI()
|
||||
containerID := threadFlag
|
||||
@@ -79,12 +80,12 @@ var ImThreadsMessagesList = common.Shortcut{
|
||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
threadId := runtime.Str("thread")
|
||||
if threadId == "" {
|
||||
return output.ErrValidation("--thread is required (om_xxx or omt_xxx)")
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--thread is required (om_xxx or omt_xxx)").WithParam("--thread")
|
||||
}
|
||||
if !strings.HasPrefix(threadId, "om_") && !strings.HasPrefix(threadId, "omt_") {
|
||||
return output.ErrValidation("invalid --thread %q: must start with om_ or omt_", threadId)
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "invalid --thread %q: must start with om_ or omt_", threadId).WithParam("--thread")
|
||||
}
|
||||
_, err := common.ValidatePageSize(runtime, "page-size", threadsMessagesMaxPageSize, 1, threadsMessagesMaxPageSize)
|
||||
_, err := common.ValidatePageSizeTyped(runtime, "page-size", threadsMessagesMaxPageSize, 1, threadsMessagesMaxPageSize)
|
||||
return err
|
||||
},
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
@@ -100,7 +101,7 @@ var ImThreadsMessagesList = common.Shortcut{
|
||||
sortType = "ByCreateTimeDesc"
|
||||
}
|
||||
|
||||
pageSize, _ := common.ValidatePageSize(runtime, "page-size", threadsMessagesMaxPageSize, 1, threadsMessagesMaxPageSize)
|
||||
pageSize, _ := common.ValidatePageSizeTyped(runtime, "page-size", threadsMessagesMaxPageSize, 1, threadsMessagesMaxPageSize)
|
||||
|
||||
params := map[string][]string{
|
||||
"container_id_type": []string{"thread"},
|
||||
@@ -113,7 +114,7 @@ var ImThreadsMessagesList = common.Shortcut{
|
||||
params["page_token"] = []string{pageToken}
|
||||
}
|
||||
|
||||
data, err := runtime.DoAPIJSON(http.MethodGet, "/open-apis/im/v1/messages", params, nil)
|
||||
data, err := runtime.DoAPIJSONTyped(http.MethodGet, "/open-apis/im/v1/messages", params, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -16,7 +16,7 @@ package im
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
@@ -240,14 +240,14 @@ func FetchMuteStatus(runtime *common.RuntimeContext, chatIDs []string) (map[stri
|
||||
return map[string]bool{}, nil, nil
|
||||
}
|
||||
if len(chatIDs) > MaxMuteStatusBatchSize {
|
||||
return nil, nil, output.ErrValidation(
|
||||
return nil, nil, errs.NewValidationError(errs.SubtypeInvalidArgument,
|
||||
"batch_get_mute_status accepts at most %d chat_ids per call (got %d)",
|
||||
MaxMuteStatusBatchSize, len(chatIDs))
|
||||
}
|
||||
body := BuildBatchGetMuteStatusBody(chatIDs)
|
||||
resp, err := runtime.CallAPI("POST", BatchGetMuteStatusPath, nil, body)
|
||||
resp, err := runtime.CallAPITyped("POST", BatchGetMuteStatusPath, nil, body)
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("fetch mute status: %w", err)
|
||||
return nil, nil, wrapIMNetworkErr(err, "fetch mute status")
|
||||
}
|
||||
muted, unknown := ParseBatchGetMuteStatusResponse(chatIDs, resp)
|
||||
return muted, unknown, nil
|
||||
|
||||
@@ -22,5 +22,8 @@ func Shortcuts() []common.Shortcut {
|
||||
ImFlagCreate,
|
||||
ImFlagCancel,
|
||||
ImFlagList,
|
||||
ImFeedShortcutCreate,
|
||||
ImFeedShortcutRemove,
|
||||
ImFeedShortcutList,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,8 +11,8 @@ import (
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
larkcore "github.com/larksuite/oapi-sdk-go/v3/core"
|
||||
)
|
||||
|
||||
// OKRCycleDetail lists all objectives and their key results under a given OKR cycle.
|
||||
@@ -30,10 +30,10 @@ var OKRCycleDetail = common.Shortcut{
|
||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
cycleID := runtime.Str("cycle-id")
|
||||
if cycleID == "" {
|
||||
return common.FlagErrorf("--cycle-id is required")
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--cycle-id is required").WithParam("--cycle-id")
|
||||
}
|
||||
if id, err := strconv.ParseInt(cycleID, 10, 64); err != nil || id <= 0 {
|
||||
return common.FlagErrorf("--cycle-id must be a positive int64")
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--cycle-id must be a positive int64").WithParam("--cycle-id")
|
||||
}
|
||||
return nil
|
||||
},
|
||||
@@ -52,8 +52,7 @@ var OKRCycleDetail = common.Shortcut{
|
||||
cycleID := runtime.Str("cycle-id")
|
||||
|
||||
// Paginate objectives under the cycle.
|
||||
queryParams := make(larkcore.QueryParams)
|
||||
queryParams.Set("page_size", "100")
|
||||
queryParams := map[string]interface{}{"page_size": "100"}
|
||||
|
||||
var objectives []Objective
|
||||
page := 0
|
||||
@@ -71,7 +70,7 @@ var OKRCycleDetail = common.Shortcut{
|
||||
page++
|
||||
|
||||
path := fmt.Sprintf("/open-apis/okr/v2/cycles/%s/objectives", cycleID)
|
||||
data, err := runtime.DoAPIJSON("GET", path, queryParams, nil)
|
||||
data, err := runtime.CallAPITyped("GET", path, queryParams, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -93,7 +92,7 @@ var OKRCycleDetail = common.Shortcut{
|
||||
if !hasMore || pageToken == "" {
|
||||
break
|
||||
}
|
||||
queryParams.Set("page_token", pageToken)
|
||||
queryParams["page_token"] = pageToken
|
||||
}
|
||||
|
||||
// For each objective, paginate key results and convert to response format.
|
||||
@@ -104,8 +103,7 @@ var OKRCycleDetail = common.Shortcut{
|
||||
}
|
||||
obj := &objectives[i]
|
||||
|
||||
krQuery := make(larkcore.QueryParams)
|
||||
krQuery.Set("page_size", "100")
|
||||
krQuery := map[string]interface{}{"page_size": "100"}
|
||||
|
||||
var keyResults []KeyResult
|
||||
krPage := 0
|
||||
@@ -123,7 +121,7 @@ var OKRCycleDetail = common.Shortcut{
|
||||
krPage++
|
||||
|
||||
path := fmt.Sprintf("/open-apis/okr/v2/objectives/%s/key_results", obj.ID)
|
||||
data, err := runtime.DoAPIJSON("GET", path, krQuery, nil)
|
||||
data, err := runtime.CallAPITyped("GET", path, krQuery, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -145,7 +143,7 @@ var OKRCycleDetail = common.Shortcut{
|
||||
if !hasMore || pageToken == "" {
|
||||
break
|
||||
}
|
||||
krQuery.Set("page_token", pageToken)
|
||||
krQuery["page_token"] = pageToken
|
||||
}
|
||||
|
||||
respObj := obj.ToResp()
|
||||
|
||||
@@ -6,11 +6,13 @@ package okr
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/internal/httpmock"
|
||||
@@ -106,6 +108,31 @@ func TestCycleDetailValidate_InvalidCycleID_Negative(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestCycleDetailValidate_TypedError locks the typed-envelope contract shared by
|
||||
// every okr flag check: an invalid flag surfaces as *errs.ValidationError carrying
|
||||
// SubtypeInvalidArgument and the offending --flag (readable via errors.As /
|
||||
// errs.ProblemOf), and maps to the validation exit code rather than a legacy api error.
|
||||
func TestCycleDetailValidate_TypedError(t *testing.T) {
|
||||
t.Parallel()
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, cycleDetailTestConfig(t))
|
||||
err := runCycleDetailShortcut(t, f, stdout, []string{"+cycle-detail", "--cycle-id", "0"})
|
||||
|
||||
var ve *errs.ValidationError
|
||||
if !errors.As(err, &ve) {
|
||||
t.Fatalf("error is not *errs.ValidationError: %T (%v)", err, err)
|
||||
}
|
||||
if ve.Subtype != errs.SubtypeInvalidArgument {
|
||||
t.Errorf("Subtype = %q, want %q", ve.Subtype, errs.SubtypeInvalidArgument)
|
||||
}
|
||||
if ve.Param != "--cycle-id" {
|
||||
t.Errorf("Param = %q, want %q", ve.Param, "--cycle-id")
|
||||
}
|
||||
p, ok := errs.ProblemOf(err)
|
||||
if !ok || p.Category != errs.CategoryValidation {
|
||||
t.Errorf("ProblemOf category = %v (ok=%v), want %q", p, ok, errs.CategoryValidation)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCycleDetailValidate_ValidCycleID(t *testing.T) {
|
||||
t.Parallel()
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, cycleDetailTestConfig(t))
|
||||
|
||||
@@ -12,9 +12,8 @@ import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/larksuite/cli/internal/validate"
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
larkcore "github.com/larksuite/oapi-sdk-go/v3/core"
|
||||
)
|
||||
|
||||
// parseTimeRange parses a "YYYY-MM--YYYY-MM" string into two time.Time values.
|
||||
@@ -22,20 +21,20 @@ import (
|
||||
func parseTimeRange(s string) (start, end time.Time, err error) {
|
||||
parts := strings.SplitN(s, "--", 2)
|
||||
if len(parts) != 2 {
|
||||
return time.Time{}, time.Time{}, fmt.Errorf("invalid time-range format %q, expected YYYY-MM--YYYY-MM", s)
|
||||
return time.Time{}, time.Time{}, errs.NewValidationError(errs.SubtypeInvalidArgument, "invalid --time-range format %q, expected YYYY-MM--YYYY-MM", s).WithParam("--time-range")
|
||||
}
|
||||
start, err = time.Parse("2006-01", strings.TrimSpace(parts[0]))
|
||||
if err != nil {
|
||||
return time.Time{}, time.Time{}, fmt.Errorf("invalid start month %q: %w", parts[0], err)
|
||||
return time.Time{}, time.Time{}, errs.NewValidationError(errs.SubtypeInvalidArgument, "invalid --time-range start month %q: %v", parts[0], err).WithParam("--time-range").WithCause(err)
|
||||
}
|
||||
end, err = time.Parse("2006-01", strings.TrimSpace(parts[1]))
|
||||
if err != nil {
|
||||
return time.Time{}, time.Time{}, fmt.Errorf("invalid end month %q: %w", parts[1], err)
|
||||
return time.Time{}, time.Time{}, errs.NewValidationError(errs.SubtypeInvalidArgument, "invalid --time-range end month %q: %v", parts[1], err).WithParam("--time-range").WithCause(err)
|
||||
}
|
||||
// end is the last moment of the end month
|
||||
end = end.AddDate(0, 1, 0).Add(-time.Millisecond)
|
||||
if start.After(end) {
|
||||
return time.Time{}, time.Time{}, fmt.Errorf("start month %s is after end month %s", parts[0], parts[1])
|
||||
return time.Time{}, time.Time{}, errs.NewValidationError(errs.SubtypeInvalidArgument, "invalid --time-range: start month %s is after end month %s", parts[0], parts[1]).WithParam("--time-range")
|
||||
}
|
||||
return start, end, nil
|
||||
}
|
||||
@@ -69,20 +68,20 @@ var OKRListCycles = common.Shortcut{
|
||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
idType := runtime.Str("user-id-type")
|
||||
if idType != "open_id" && idType != "union_id" && idType != "user_id" {
|
||||
return common.FlagErrorf("--user-id-type must be one of: open_id | union_id | user_id")
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--user-id-type must be one of: open_id | union_id | user_id").WithParam("--user-id-type")
|
||||
}
|
||||
userID := runtime.Str("user-id")
|
||||
if err := validate.RejectControlChars(userID, "user-id"); err != nil {
|
||||
if err := common.RejectDangerousCharsTyped("--user-id", userID); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
tr := runtime.Str("time-range")
|
||||
if tr != "" {
|
||||
if err := validate.RejectControlChars(tr, "time-range"); err != nil {
|
||||
if err := common.RejectDangerousCharsTyped("--time-range", tr); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, _, err := parseTimeRange(tr); err != nil {
|
||||
return common.FlagErrorf("--time-range: %s", err)
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
@@ -110,16 +109,17 @@ var OKRListCycles = common.Shortcut{
|
||||
var err error
|
||||
rangeStart, rangeEnd, err = parseTimeRange(timeRange)
|
||||
if err != nil {
|
||||
return common.FlagErrorf("--time-range: %s", err)
|
||||
return err
|
||||
}
|
||||
hasRange = true
|
||||
}
|
||||
|
||||
// Paginated fetch of all cycles
|
||||
queryParams := make(larkcore.QueryParams)
|
||||
queryParams.Set("user_id", userID)
|
||||
queryParams.Set("user_id_type", userIDType)
|
||||
queryParams.Set("page_size", "100")
|
||||
queryParams := map[string]interface{}{
|
||||
"user_id": userID,
|
||||
"user_id_type": userIDType,
|
||||
"page_size": "100",
|
||||
}
|
||||
|
||||
var allCycles []Cycle
|
||||
page := 0
|
||||
@@ -136,7 +136,7 @@ var OKRListCycles = common.Shortcut{
|
||||
}
|
||||
page++
|
||||
|
||||
data, err := runtime.DoAPIJSON("GET", "/open-apis/okr/v2/cycles", queryParams, nil)
|
||||
data, err := runtime.CallAPITyped("GET", "/open-apis/okr/v2/cycles", queryParams, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -158,7 +158,7 @@ var OKRListCycles = common.Shortcut{
|
||||
if !hasMore || pageToken == "" {
|
||||
break
|
||||
}
|
||||
queryParams.Set("page_token", pageToken)
|
||||
queryParams["page_token"] = pageToken
|
||||
}
|
||||
|
||||
// Filter by time-range overlap
|
||||
|
||||
38
shortcuts/okr/okr_errors.go
Normal file
38
shortcuts/okr/okr_errors.go
Normal file
@@ -0,0 +1,38 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package okr
|
||||
|
||||
import (
|
||||
"errors"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/extension/fileio"
|
||||
)
|
||||
|
||||
// okrInputStatError maps a FileIO.Stat/Open error for input file validation to
|
||||
// a typed validation error: path validation failures read as "unsafe file
|
||||
// path", other errors as "cannot read file".
|
||||
func okrInputStatError(err error) error {
|
||||
if err == nil {
|
||||
return nil
|
||||
}
|
||||
if errors.Is(err, fileio.ErrPathValidation) {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "unsafe file path: %s", err).
|
||||
WithParam("--file").
|
||||
WithCause(err)
|
||||
}
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "cannot read file: %s", err).
|
||||
WithParam("--file").
|
||||
WithCause(err)
|
||||
}
|
||||
|
||||
// wrapOkrNetworkErr returns err unchanged when it is already a typed errs.*
|
||||
// error (preserving subtype / code / log_id from the runtime boundary) and only
|
||||
// wraps a raw, unclassified error as a transport-level network error.
|
||||
func wrapOkrNetworkErr(err error, format string, args ...any) error {
|
||||
if _, ok := errs.ProblemOf(err); ok {
|
||||
return err
|
||||
}
|
||||
return errs.NewNetworkError(errs.SubtypeNetworkTransport, format, args...).WithCause(err)
|
||||
}
|
||||
61
shortcuts/okr/okr_errors_test.go
Normal file
61
shortcuts/okr/okr_errors_test.go
Normal file
@@ -0,0 +1,61 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package okr
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/extension/fileio"
|
||||
)
|
||||
|
||||
func TestOkrInputStatError(t *testing.T) {
|
||||
if okrInputStatError(nil) != nil {
|
||||
t.Fatal("nil error should map to nil")
|
||||
}
|
||||
|
||||
var ve *errs.ValidationError
|
||||
|
||||
pathCause := errors.New("traversal")
|
||||
pathErr := okrInputStatError(&fileio.PathValidationError{Err: pathCause})
|
||||
if !errors.As(pathErr, &ve) || ve.Subtype != errs.SubtypeInvalidArgument {
|
||||
t.Fatalf("path validation: got %T (%v)", pathErr, pathErr)
|
||||
}
|
||||
if ve.Param != "--file" {
|
||||
t.Fatalf("path validation param = %q, want --file", ve.Param)
|
||||
}
|
||||
if !errors.Is(pathErr, fileio.ErrPathValidation) || !errors.Is(pathErr, pathCause) {
|
||||
t.Fatal("path validation cause should be retained")
|
||||
}
|
||||
|
||||
genericCause := errors.New("permission denied")
|
||||
genericErr := okrInputStatError(genericCause)
|
||||
if !errors.As(genericErr, &ve) || ve.Subtype != errs.SubtypeInvalidArgument {
|
||||
t.Fatalf("generic: got %T (%v)", genericErr, genericErr)
|
||||
}
|
||||
if ve.Param != "--file" {
|
||||
t.Fatalf("generic param = %q, want --file", ve.Param)
|
||||
}
|
||||
if !errors.Is(genericErr, genericCause) {
|
||||
t.Fatal("generic cause should be retained")
|
||||
}
|
||||
}
|
||||
|
||||
func TestWrapOkrNetworkErr(t *testing.T) {
|
||||
typed := errs.NewValidationError(errs.SubtypeInvalidArgument, "already typed")
|
||||
if got := wrapOkrNetworkErr(typed, "wrap %v", typed); got != error(typed) {
|
||||
t.Fatalf("typed error must pass through unchanged, got %v", got)
|
||||
}
|
||||
|
||||
raw := errors.New("dial tcp: i/o timeout")
|
||||
got := wrapOkrNetworkErr(raw, "upload failed: %v", raw)
|
||||
var ne *errs.NetworkError
|
||||
if !errors.As(got, &ne) || ne.Subtype != errs.SubtypeNetworkTransport {
|
||||
t.Fatalf("raw error: got %T (%v)", got, got)
|
||||
}
|
||||
if !errors.Is(got, raw) {
|
||||
t.Fatal("raw cause should be retained via WithCause")
|
||||
}
|
||||
}
|
||||
@@ -5,8 +5,6 @@ package okr
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
@@ -14,7 +12,7 @@ import (
|
||||
|
||||
larkcore "github.com/larksuite/oapi-sdk-go/v3/core"
|
||||
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
@@ -43,24 +41,24 @@ var OKRUploadImage = common.Shortcut{
|
||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
filePath := runtime.Str("file")
|
||||
if filePath == "" {
|
||||
return common.FlagErrorf("--file is required")
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--file is required").WithParam("--file")
|
||||
}
|
||||
ext := strings.ToLower(filepath.Ext(filePath))
|
||||
if !allowedImageExts[ext] {
|
||||
return common.FlagErrorf("--file must be an image (supported: JPG, JPEG, PNG, GIF, BMP), got %q", ext)
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--file must be an image (supported: JPG, JPEG, PNG, GIF, BMP), got %q", ext).WithParam("--file")
|
||||
}
|
||||
|
||||
targetID := runtime.Str("target-id")
|
||||
if targetID == "" {
|
||||
return common.FlagErrorf("--target-id is required")
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--target-id is required").WithParam("--target-id")
|
||||
}
|
||||
if id, err := strconv.ParseInt(targetID, 10, 64); err != nil || id <= 0 {
|
||||
return common.FlagErrorf("--target-id must be a positive int64")
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--target-id must be a positive int64").WithParam("--target-id")
|
||||
}
|
||||
|
||||
targetType := runtime.Str("target-type")
|
||||
if _, ok := targetTypeAllowed[targetType]; !ok {
|
||||
return common.FlagErrorf("--target-type must be one of: objective | key_result")
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--target-type must be one of: objective | key_result").WithParam("--target-type")
|
||||
}
|
||||
return nil
|
||||
},
|
||||
@@ -87,12 +85,12 @@ var OKRUploadImage = common.Shortcut{
|
||||
|
||||
info, err := runtime.FileIO().Stat(filePath)
|
||||
if err != nil {
|
||||
return common.WrapInputStatError(err)
|
||||
return okrInputStatError(err)
|
||||
}
|
||||
|
||||
f, err := runtime.FileIO().Open(filePath)
|
||||
if err != nil {
|
||||
return common.WrapInputStatError(err)
|
||||
return okrInputStatError(err)
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
@@ -110,30 +108,22 @@ var OKRUploadImage = common.Shortcut{
|
||||
Body: fd,
|
||||
}, larkcore.WithFileUpload())
|
||||
if err != nil {
|
||||
var exitErr *output.ExitError
|
||||
if errors.As(err, &exitErr) {
|
||||
return err
|
||||
}
|
||||
return output.ErrNetwork("upload failed: %v", err)
|
||||
// The DoAPI boundary already returns typed errs.* (auth →
|
||||
// AuthenticationError, transport → NetworkError, etc.); wrapOkrNetworkErr
|
||||
// passes those through via ProblemOf and only wraps a still-untyped error.
|
||||
return wrapOkrNetworkErr(err, "upload failed: %v", err)
|
||||
}
|
||||
|
||||
var result map[string]interface{}
|
||||
if err := json.Unmarshal(apiResp.RawBody, &result); err != nil {
|
||||
return output.Errorf(output.ExitAPI, "api_error", "upload failed: invalid response JSON: %v", err)
|
||||
data, err := runtime.ClassifyAPIResponse(apiResp)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if larkCode := int(common.GetFloat(result, "code")); larkCode != 0 {
|
||||
msg, _ := result["msg"].(string)
|
||||
return output.ErrAPI(larkCode, fmt.Sprintf("upload failed: [%d] %s", larkCode, msg), result["error"])
|
||||
}
|
||||
|
||||
data, _ := result["data"].(map[string]interface{})
|
||||
fileToken, _ := data["file_token"].(string)
|
||||
url, _ := data["url"].(string)
|
||||
|
||||
fileToken := common.GetString(data, "file_token")
|
||||
if fileToken == "" {
|
||||
return output.Errorf(output.ExitAPI, "api_error", "upload failed: no file_token returned")
|
||||
return errs.NewInternalError(errs.SubtypeInvalidResponse, "upload failed: no file_token returned")
|
||||
}
|
||||
url := common.GetString(data, "url")
|
||||
|
||||
runtime.Out(map[string]interface{}{
|
||||
"file_token": fileToken,
|
||||
|
||||
@@ -5,6 +5,7 @@ package okr
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"errors"
|
||||
"mime"
|
||||
"mime/multipart"
|
||||
"os"
|
||||
@@ -13,6 +14,7 @@ import (
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/internal/httpmock"
|
||||
@@ -360,6 +362,15 @@ func TestUploadImageExecute_APIError(t *testing.T) {
|
||||
if err == nil {
|
||||
t.Fatal("expected error for API failure")
|
||||
}
|
||||
// The upload boundary now classifies the Lark error code into a typed
|
||||
// envelope carrying the numeric code, instead of a flat legacy error.
|
||||
p, ok := errs.ProblemOf(err)
|
||||
if !ok {
|
||||
t.Fatalf("error is not a typed errs.* envelope: %T (%v)", err, err)
|
||||
}
|
||||
if p.Code != 1001001 {
|
||||
t.Errorf("Problem.Code = %d, want 1001001", p.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUploadImageExecute_FileNotFound(t *testing.T) {
|
||||
@@ -407,6 +418,15 @@ func TestUploadImageExecute_NoFileTokenInResponse(t *testing.T) {
|
||||
if err == nil {
|
||||
t.Fatal("expected error for missing file_token in response")
|
||||
}
|
||||
// A 2xx response that omits the expected file_token is a malformed response,
|
||||
// surfaced as a typed internal/invalid_response error.
|
||||
var ie *errs.InternalError
|
||||
if !errors.As(err, &ie) {
|
||||
t.Fatalf("error is not *errs.InternalError: %T (%v)", err, err)
|
||||
}
|
||||
if ie.Subtype != errs.SubtypeInvalidResponse {
|
||||
t.Errorf("Subtype = %q, want %q", ie.Subtype, errs.SubtypeInvalidResponse)
|
||||
}
|
||||
if !strings.Contains(err.Error(), "no file_token returned") {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
@@ -11,10 +11,9 @@ import (
|
||||
"math"
|
||||
"strconv"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/internal/validate"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
larkcore "github.com/larksuite/oapi-sdk-go/v3/core"
|
||||
)
|
||||
|
||||
// targetTypeAllowed values for --target-type flag
|
||||
@@ -39,7 +38,7 @@ func parseCreateProgressRecordParams(runtime *common.RuntimeContext) (*createPro
|
||||
content := runtime.Str("content")
|
||||
var cb ContentBlock
|
||||
if err := json.Unmarshal([]byte(content), &cb); err != nil {
|
||||
return nil, common.FlagErrorf("--content must be valid ContentBlock JSON: %s", err)
|
||||
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "--content must be valid ContentBlock JSON: %s", err).WithParam("--content").WithCause(err)
|
||||
}
|
||||
contentV1 := cb.ToV1()
|
||||
|
||||
@@ -60,13 +59,13 @@ func parseCreateProgressRecordParams(runtime *common.RuntimeContext) (*createPro
|
||||
if v := runtime.Str("progress-percent"); v != "" {
|
||||
percent, err := strconv.ParseFloat(v, 64)
|
||||
if err != nil || math.IsNaN(percent) || math.IsInf(percent, 0) || percent < -99999999999 || percent > 99999999999 {
|
||||
return nil, common.FlagErrorf("--progress-percent must be a number between -99999999999 and 99999999999")
|
||||
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "--progress-percent must be a number between -99999999999 and 99999999999").WithParam("--progress-percent")
|
||||
}
|
||||
progressRate = &ProgressRateV1{Percent: &percent}
|
||||
if s := runtime.Str("progress-status"); s != "" {
|
||||
status, ok := ParseProgressStatus(s)
|
||||
if !ok {
|
||||
return nil, common.FlagErrorf("--progress-status must be one of: normal | overdue | done")
|
||||
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "--progress-status must be one of: normal | overdue | done").WithParam("--progress-status")
|
||||
}
|
||||
progressRate.Status = int32Ptr(int32(status))
|
||||
}
|
||||
@@ -105,40 +104,40 @@ var OKRCreateProgressRecord = common.Shortcut{
|
||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
content := runtime.Str("content")
|
||||
if content == "" {
|
||||
return common.FlagErrorf("--content is required")
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--content is required").WithParam("--content")
|
||||
}
|
||||
if err := validate.RejectControlChars(content, "content"); err != nil {
|
||||
if err := common.RejectDangerousCharsTyped("--content", content); err != nil {
|
||||
return err
|
||||
}
|
||||
// Validate content is valid JSON and can be parsed as ContentBlock
|
||||
var cb ContentBlock
|
||||
if err := json.Unmarshal([]byte(content), &cb); err != nil {
|
||||
return common.FlagErrorf("--content must be valid ContentBlock JSON: %s", err)
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--content must be valid ContentBlock JSON: %s", err).WithParam("--content").WithCause(err)
|
||||
}
|
||||
|
||||
targetID := runtime.Str("target-id")
|
||||
if targetID == "" {
|
||||
return common.FlagErrorf("--target-id is required")
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--target-id is required").WithParam("--target-id")
|
||||
}
|
||||
if err := validate.RejectControlChars(targetID, "target-id"); err != nil {
|
||||
if err := common.RejectDangerousCharsTyped("--target-id", targetID); err != nil {
|
||||
return err
|
||||
}
|
||||
if id, err := strconv.ParseInt(targetID, 10, 64); err != nil || id <= 0 {
|
||||
return common.FlagErrorf("--target-id must be a positive int64")
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--target-id must be a positive int64").WithParam("--target-id")
|
||||
}
|
||||
|
||||
targetType := runtime.Str("target-type")
|
||||
if _, ok := targetTypeAllowed[targetType]; !ok {
|
||||
return common.FlagErrorf("--target-type must be one of: objective | key_result")
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--target-type must be one of: objective | key_result").WithParam("--target-type")
|
||||
}
|
||||
|
||||
if v := runtime.Str("source-title"); v != "" {
|
||||
if err := validate.RejectControlChars(v, "source-title"); err != nil {
|
||||
if err := common.RejectDangerousCharsTyped("--source-title", v); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if v := runtime.Str("source-url"); v != "" {
|
||||
if err := validate.RejectControlChars(v, "source-url"); err != nil {
|
||||
if err := common.RejectDangerousCharsTyped("--source-url", v); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
@@ -146,21 +145,21 @@ var OKRCreateProgressRecord = common.Shortcut{
|
||||
if v := runtime.Str("progress-percent"); v != "" {
|
||||
percent, err := strconv.ParseFloat(v, 64)
|
||||
if err != nil || math.IsNaN(percent) || math.IsInf(percent, 0) || percent < -99999999999 || percent > 99999999999 {
|
||||
return common.FlagErrorf("--progress-percent must be a number between -99999999999 and 99999999999")
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--progress-percent must be a number between -99999999999 and 99999999999").WithParam("--progress-percent")
|
||||
}
|
||||
}
|
||||
if v := runtime.Str("progress-status"); v != "" {
|
||||
if _, ok := ParseProgressStatus(v); !ok {
|
||||
return common.FlagErrorf("--progress-status must be one of: normal | overdue | done")
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--progress-status must be one of: normal | overdue | done").WithParam("--progress-status")
|
||||
}
|
||||
if v := runtime.Str("progress-percent"); v == "" {
|
||||
return common.FlagErrorf("--progress-percent must provided with --progress-status")
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--progress-percent must provided with --progress-status").WithParam("--progress-percent")
|
||||
}
|
||||
}
|
||||
|
||||
idType := runtime.Str("user-id-type")
|
||||
if idType != "open_id" && idType != "union_id" && idType != "user_id" {
|
||||
return common.FlagErrorf("--user-id-type must be one of: open_id | union_id | user_id")
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--user-id-type must be one of: open_id | union_id | user_id").WithParam("--user-id-type")
|
||||
}
|
||||
return nil
|
||||
},
|
||||
@@ -202,10 +201,9 @@ var OKRCreateProgressRecord = common.Shortcut{
|
||||
body["progress_rate"] = p.ProgressRate
|
||||
}
|
||||
|
||||
queryParams := make(larkcore.QueryParams)
|
||||
queryParams.Set("user_id_type", p.UserIDType)
|
||||
queryParams := map[string]interface{}{"user_id_type": p.UserIDType}
|
||||
|
||||
data, err := runtime.DoAPIJSON("POST", "/open-apis/okr/v1/progress_records/", queryParams, body)
|
||||
data, err := runtime.CallAPITyped("POST", "/open-apis/okr/v1/progress_records/", queryParams, body)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -9,8 +9,8 @@ import (
|
||||
"io"
|
||||
"strconv"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
larkcore "github.com/larksuite/oapi-sdk-go/v3/core"
|
||||
)
|
||||
|
||||
// OKRDeleteProgressRecord deletes a progress by ID.
|
||||
@@ -28,10 +28,10 @@ var OKRDeleteProgressRecord = common.Shortcut{
|
||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
progressID := runtime.Str("progress-id")
|
||||
if progressID == "" {
|
||||
return common.FlagErrorf("--progress-id is required")
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--progress-id is required").WithParam("--progress-id")
|
||||
}
|
||||
if id, err := strconv.ParseInt(progressID, 10, 64); err != nil || id <= 0 {
|
||||
return common.FlagErrorf("--progress-id must be a positive int64")
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--progress-id must be a positive int64").WithParam("--progress-id")
|
||||
}
|
||||
return nil
|
||||
},
|
||||
@@ -46,7 +46,7 @@ var OKRDeleteProgressRecord = common.Shortcut{
|
||||
progressID := runtime.Str("progress-id")
|
||||
|
||||
path := fmt.Sprintf("/open-apis/okr/v1/progress_records/%s", progressID)
|
||||
_, err := runtime.DoAPIJSON("DELETE", path, larkcore.QueryParams{}, nil)
|
||||
_, err := runtime.CallAPITyped("DELETE", path, nil, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -10,8 +10,8 @@ import (
|
||||
"io"
|
||||
"strconv"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
larkcore "github.com/larksuite/oapi-sdk-go/v3/core"
|
||||
)
|
||||
|
||||
// OKRGetProgressRecord gets a progress by ID.
|
||||
@@ -30,14 +30,14 @@ var OKRGetProgressRecord = common.Shortcut{
|
||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
progressID := runtime.Str("progress-id")
|
||||
if progressID == "" {
|
||||
return common.FlagErrorf("--progress-id is required")
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--progress-id is required").WithParam("--progress-id")
|
||||
}
|
||||
if id, err := strconv.ParseInt(progressID, 10, 64); err != nil || id <= 0 {
|
||||
return common.FlagErrorf("--progress-id must be a positive int64")
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--progress-id must be a positive int64").WithParam("--progress-id")
|
||||
}
|
||||
idType := runtime.Str("user-id-type")
|
||||
if idType != "open_id" && idType != "union_id" && idType != "user_id" {
|
||||
return common.FlagErrorf("--user-id-type must be one of: open_id | union_id | user_id")
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--user-id-type must be one of: open_id | union_id | user_id").WithParam("--user-id-type")
|
||||
}
|
||||
return nil
|
||||
},
|
||||
@@ -56,11 +56,10 @@ var OKRGetProgressRecord = common.Shortcut{
|
||||
progressID := runtime.Str("progress-id")
|
||||
userIDType := runtime.Str("user-id-type")
|
||||
|
||||
queryParams := make(larkcore.QueryParams)
|
||||
queryParams.Set("user_id_type", userIDType)
|
||||
queryParams := map[string]interface{}{"user_id_type": userIDType}
|
||||
|
||||
path := fmt.Sprintf("/open-apis/okr/v1/progress_records/%s", progressID)
|
||||
data, err := runtime.DoAPIJSON("GET", path, queryParams, nil)
|
||||
data, err := runtime.CallAPITyped("GET", path, queryParams, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -93,11 +92,11 @@ var OKRGetProgressRecord = common.Shortcut{
|
||||
func parseProgressRecord(data map[string]any) (*ProgressV1, error) {
|
||||
raw, err := json.Marshal(data)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
return nil, errs.NewInternalError(errs.SubtypeInvalidResponse, "invalid progress response: marshal failed: %s", err).WithCause(err)
|
||||
}
|
||||
var record ProgressV1
|
||||
if err := json.Unmarshal(raw, &record); err != nil {
|
||||
return nil, err
|
||||
return nil, errs.NewInternalError(errs.SubtypeInvalidResponse, "invalid progress response: unmarshal failed: %s", err).WithCause(err)
|
||||
}
|
||||
return &record, nil
|
||||
}
|
||||
|
||||
@@ -5,11 +5,13 @@ package okr
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/internal/httpmock"
|
||||
@@ -190,3 +192,19 @@ func TestProgressGetExecute_APIError(t *testing.T) {
|
||||
t.Fatal("expected error for API failure")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseProgressRecord_InvalidResponseTypedError(t *testing.T) {
|
||||
_, err := parseProgressRecord(map[string]any{
|
||||
"progress_rate": "not-an-object",
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("expected invalid response error")
|
||||
}
|
||||
var ie *errs.InternalError
|
||||
if !errors.As(err, &ie) {
|
||||
t.Fatalf("error is not *errs.InternalError: %T (%v)", err, err)
|
||||
}
|
||||
if ie.Subtype != errs.SubtypeInvalidResponse {
|
||||
t.Fatalf("Subtype = %q, want %q", ie.Subtype, errs.SubtypeInvalidResponse)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,9 +10,8 @@ import (
|
||||
"io"
|
||||
"strconv"
|
||||
|
||||
"github.com/larksuite/cli/internal/validate"
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
larkcore "github.com/larksuite/oapi-sdk-go/v3/core"
|
||||
)
|
||||
|
||||
// OKRListProgress lists progress for an objective or key result.
|
||||
@@ -33,28 +32,28 @@ var OKRListProgress = common.Shortcut{
|
||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
targetID := runtime.Str("target-id")
|
||||
if targetID == "" {
|
||||
return common.FlagErrorf("--target-id is required")
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--target-id is required").WithParam("--target-id")
|
||||
}
|
||||
if err := validate.RejectControlChars(targetID, "target-id"); err != nil {
|
||||
if err := common.RejectDangerousCharsTyped("--target-id", targetID); err != nil {
|
||||
return err
|
||||
}
|
||||
if id, err := strconv.ParseInt(targetID, 10, 64); err != nil || id <= 0 {
|
||||
return common.FlagErrorf("--target-id must be a positive int64")
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--target-id must be a positive int64").WithParam("--target-id")
|
||||
}
|
||||
|
||||
targetType := runtime.Str("target-type")
|
||||
if _, ok := targetTypeAllowed[targetType]; !ok {
|
||||
return common.FlagErrorf("--target-type must be one of: objective | key_result")
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--target-type must be one of: objective | key_result").WithParam("--target-type")
|
||||
}
|
||||
|
||||
idType := runtime.Str("user-id-type")
|
||||
if idType != "open_id" && idType != "union_id" && idType != "user_id" {
|
||||
return common.FlagErrorf("--user-id-type must be one of: open_id | union_id | user_id")
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--user-id-type must be one of: open_id | union_id | user_id").WithParam("--user-id-type")
|
||||
}
|
||||
|
||||
deptIDType := runtime.Str("department-id-type")
|
||||
if deptIDType != "department_id" && deptIDType != "open_department_id" {
|
||||
return common.FlagErrorf("--department-id-type must be one of: department_id | open_department_id")
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--department-id-type must be one of: department_id | open_department_id").WithParam("--department-id-type")
|
||||
}
|
||||
return nil
|
||||
},
|
||||
@@ -89,10 +88,11 @@ var OKRListProgress = common.Shortcut{
|
||||
userIDType := runtime.Str("user-id-type")
|
||||
deptIDType := runtime.Str("department-id-type")
|
||||
|
||||
queryParams := make(larkcore.QueryParams)
|
||||
queryParams.Set("user_id_type", userIDType)
|
||||
queryParams.Set("department_id_type", deptIDType)
|
||||
queryParams.Set("page_size", "100")
|
||||
queryParams := map[string]interface{}{
|
||||
"user_id_type": userIDType,
|
||||
"department_id_type": deptIDType,
|
||||
"page_size": "100",
|
||||
}
|
||||
|
||||
var apiPath string
|
||||
switch targetType {
|
||||
@@ -108,7 +108,7 @@ var OKRListProgress = common.Shortcut{
|
||||
return err
|
||||
}
|
||||
|
||||
data, err := runtime.DoAPIJSON("GET", apiPath, queryParams, nil)
|
||||
data, err := runtime.CallAPITyped("GET", apiPath, queryParams, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -130,7 +130,7 @@ var OKRListProgress = common.Shortcut{
|
||||
if !hasMore || pageToken == "" {
|
||||
break
|
||||
}
|
||||
queryParams.Set("page_token", pageToken)
|
||||
queryParams["page_token"] = pageToken
|
||||
}
|
||||
|
||||
// Convert to response format
|
||||
|
||||
@@ -11,9 +11,8 @@ import (
|
||||
"math"
|
||||
"strconv"
|
||||
|
||||
"github.com/larksuite/cli/internal/validate"
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
larkcore "github.com/larksuite/oapi-sdk-go/v3/core"
|
||||
)
|
||||
|
||||
// updateProgressRecordParams holds the parsed parameters for updating a progress.
|
||||
@@ -29,7 +28,7 @@ func parseUpdateProgressRecordParams(runtime *common.RuntimeContext) (*updatePro
|
||||
content := runtime.Str("content")
|
||||
var cb ContentBlock
|
||||
if err := json.Unmarshal([]byte(content), &cb); err != nil {
|
||||
return nil, common.FlagErrorf("--content must be valid ContentBlock JSON: %s", err)
|
||||
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "--content must be valid ContentBlock JSON: %s", err).WithParam("--content").WithCause(err)
|
||||
}
|
||||
contentV1 := cb.ToV1()
|
||||
|
||||
@@ -37,13 +36,13 @@ func parseUpdateProgressRecordParams(runtime *common.RuntimeContext) (*updatePro
|
||||
if v := runtime.Str("progress-percent"); v != "" {
|
||||
percent, err := strconv.ParseFloat(v, 64)
|
||||
if err != nil || math.IsNaN(percent) || math.IsInf(percent, 0) || percent < -99999999999 || percent > 99999999999 {
|
||||
return nil, common.FlagErrorf("--progress-percent must be a number between -99999999999 and 99999999999")
|
||||
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "--progress-percent must be a number between -99999999999 and 99999999999").WithParam("--progress-percent")
|
||||
}
|
||||
progressRate = &ProgressRateV1{Percent: &percent}
|
||||
if s := runtime.Str("progress-status"); s != "" {
|
||||
status, ok := ParseProgressStatus(s)
|
||||
if !ok {
|
||||
return nil, common.FlagErrorf("--progress-status must be one of: normal | overdue | done")
|
||||
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "--progress-status must be one of: normal | overdue | done").WithParam("--progress-status")
|
||||
}
|
||||
progressRate.Status = int32Ptr(int32(status))
|
||||
}
|
||||
@@ -76,42 +75,42 @@ var OKRUpdateProgressRecord = common.Shortcut{
|
||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
progressID := runtime.Str("progress-id")
|
||||
if progressID == "" {
|
||||
return common.FlagErrorf("--progress-id is required")
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--progress-id is required").WithParam("--progress-id")
|
||||
}
|
||||
if id, err := strconv.ParseInt(progressID, 10, 64); err != nil || id <= 0 {
|
||||
return common.FlagErrorf("--progress-id must be a positive int64")
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--progress-id must be a positive int64").WithParam("--progress-id")
|
||||
}
|
||||
|
||||
content := runtime.Str("content")
|
||||
if content == "" {
|
||||
return common.FlagErrorf("--content is required")
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--content is required").WithParam("--content")
|
||||
}
|
||||
if err := validate.RejectControlChars(content, "content"); err != nil {
|
||||
if err := common.RejectDangerousCharsTyped("--content", content); err != nil {
|
||||
return err
|
||||
}
|
||||
var cb ContentBlock
|
||||
if err := json.Unmarshal([]byte(content), &cb); err != nil {
|
||||
return common.FlagErrorf("--content must be valid ContentBlock JSON: %s", err)
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--content must be valid ContentBlock JSON: %s", err).WithParam("--content").WithCause(err)
|
||||
}
|
||||
|
||||
if v := runtime.Str("progress-percent"); v != "" {
|
||||
percent, err := strconv.ParseFloat(v, 64)
|
||||
if err != nil || math.IsNaN(percent) || math.IsInf(percent, 0) || percent < -99999999999 || percent > 99999999999 {
|
||||
return common.FlagErrorf("--progress-percent must be a number between -99999999999 and 99999999999")
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--progress-percent must be a number between -99999999999 and 99999999999").WithParam("--progress-percent")
|
||||
}
|
||||
}
|
||||
if v := runtime.Str("progress-status"); v != "" {
|
||||
if _, ok := ParseProgressStatus(v); !ok {
|
||||
return common.FlagErrorf("--progress-status must be one of: normal | overdue | done")
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--progress-status must be one of: normal | overdue | done").WithParam("--progress-status")
|
||||
}
|
||||
if v := runtime.Str("progress-percent"); v == "" {
|
||||
return common.FlagErrorf("--progress-percent must provided with --progress-status")
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--progress-percent must provided with --progress-status").WithParam("--progress-percent")
|
||||
}
|
||||
}
|
||||
|
||||
idType := runtime.Str("user-id-type")
|
||||
if idType != "open_id" && idType != "union_id" && idType != "user_id" {
|
||||
return common.FlagErrorf("--user-id-type must be one of: open_id | union_id | user_id")
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--user-id-type must be one of: open_id | union_id | user_id").WithParam("--user-id-type")
|
||||
}
|
||||
return nil
|
||||
},
|
||||
@@ -146,11 +145,10 @@ var OKRUpdateProgressRecord = common.Shortcut{
|
||||
body["progress_rate"] = p.ProgressRate
|
||||
}
|
||||
|
||||
queryParams := make(larkcore.QueryParams)
|
||||
queryParams.Set("user_id_type", p.UserIDType)
|
||||
queryParams := map[string]interface{}{"user_id_type": p.UserIDType}
|
||||
|
||||
path := fmt.Sprintf("/open-apis/okr/v1/progress_records/%s", p.ProgressID)
|
||||
data, err := runtime.DoAPIJSON("PUT", path, queryParams, body)
|
||||
data, err := runtime.CallAPITyped("PUT", path, queryParams, body)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -13,9 +13,8 @@ import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
larkcore "github.com/larksuite/oapi-sdk-go/v3/core"
|
||||
)
|
||||
|
||||
func inferTaskMemberType(id string) string {
|
||||
@@ -107,7 +106,7 @@ func buildTaskCreateBody(runtime *common.RuntimeContext) (map[string]interface{}
|
||||
// Handle generic JSON payload if provided
|
||||
if dataStr := runtime.Str("data"); dataStr != "" {
|
||||
if err := json.Unmarshal([]byte(dataStr), &body); err != nil {
|
||||
return nil, output.ErrValidation("--data must be a valid JSON object: %v", err)
|
||||
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "--data must be a valid JSON object: %v", err).WithParam("--data")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -143,7 +142,7 @@ func buildTaskCreateBody(runtime *common.RuntimeContext) (map[string]interface{}
|
||||
if dueStr := runtime.Str("due"); dueStr != "" {
|
||||
dueObj, err := parseTaskTime(dueStr)
|
||||
if err != nil {
|
||||
return nil, output.ErrValidation("failed to parse due time: %v", err)
|
||||
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "failed to parse due time: %v", err).WithParam("--due")
|
||||
}
|
||||
body["due"] = dueObj
|
||||
}
|
||||
@@ -154,7 +153,7 @@ func buildTaskCreateBody(runtime *common.RuntimeContext) (map[string]interface{}
|
||||
|
||||
summary, _ := body["summary"].(string)
|
||||
if strings.TrimSpace(summary) == "" {
|
||||
return nil, output.ErrValidation("task summary is required")
|
||||
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "task summary is required").WithParam("--summary")
|
||||
}
|
||||
|
||||
return body, nil
|
||||
@@ -194,27 +193,11 @@ var CreateTask = common.Shortcut{
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
body, err := buildTaskCreateBody(runtime)
|
||||
if err != nil {
|
||||
return WrapTaskError(ErrCodeTaskInvalidParams, err.Error(), "create task")
|
||||
return err
|
||||
}
|
||||
|
||||
queryParams := make(larkcore.QueryParams)
|
||||
queryParams.Set("user_id_type", "open_id")
|
||||
|
||||
apiResp, err := runtime.DoAPI(&larkcore.ApiReq{
|
||||
HttpMethod: http.MethodPost,
|
||||
ApiPath: "/open-apis/task/v2/tasks",
|
||||
QueryParams: queryParams,
|
||||
Body: body,
|
||||
})
|
||||
|
||||
var result map[string]interface{}
|
||||
if err == nil {
|
||||
if parseErr := json.Unmarshal(apiResp.RawBody, &result); parseErr != nil {
|
||||
return output.Errorf(output.ExitAPI, "api_error", "failed to parse response: %v", parseErr)
|
||||
}
|
||||
}
|
||||
|
||||
data, err := HandleTaskApiResult(result, err, "create task")
|
||||
params := map[string]interface{}{"user_id_type": "open_id"}
|
||||
data, err := callTaskAPITyped(runtime, http.MethodPost, "/open-apis/task/v2/tasks", params, body)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -5,15 +5,13 @@ package task
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
|
||||
larkcore "github.com/larksuite/oapi-sdk-go/v3/core"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
@@ -35,7 +33,7 @@ var AssignTask = common.Shortcut{
|
||||
|
||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
if runtime.Str("add") == "" && runtime.Str("remove") == "" {
|
||||
return WrapTaskError(ErrCodeTaskInvalidParams, "must specify either --add or --remove", "validate assign")
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "must specify either --add or --remove")
|
||||
}
|
||||
return nil
|
||||
},
|
||||
@@ -62,28 +60,13 @@ var AssignTask = common.Shortcut{
|
||||
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
taskId := url.PathEscape(runtime.Str("task-id"))
|
||||
queryParams := make(larkcore.QueryParams)
|
||||
queryParams.Set("user_id_type", "open_id")
|
||||
params := map[string]interface{}{"user_id_type": "open_id"}
|
||||
|
||||
var lastData map[string]interface{}
|
||||
|
||||
if addStr := runtime.Str("add"); addStr != "" {
|
||||
body := buildMembersBody(addStr, "assignee", runtime.Str("idempotency-key"))
|
||||
apiResp, err := runtime.DoAPI(&larkcore.ApiReq{
|
||||
HttpMethod: http.MethodPost,
|
||||
ApiPath: "/open-apis/task/v2/tasks/" + taskId + "/add_members",
|
||||
QueryParams: queryParams,
|
||||
Body: body,
|
||||
})
|
||||
|
||||
var result map[string]interface{}
|
||||
if err == nil {
|
||||
if parseErr := json.Unmarshal(apiResp.RawBody, &result); parseErr != nil {
|
||||
return WrapTaskError(ErrCodeTaskInternalError, fmt.Sprintf("failed to parse response: %v", parseErr), "parse add members")
|
||||
}
|
||||
}
|
||||
|
||||
data, err := HandleTaskApiResult(result, err, "add task members")
|
||||
data, err := callTaskAPITyped(runtime, http.MethodPost, "/open-apis/task/v2/tasks/"+taskId+"/add_members", params, body)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -92,21 +75,7 @@ var AssignTask = common.Shortcut{
|
||||
|
||||
if removeStr := runtime.Str("remove"); removeStr != "" {
|
||||
body := buildMembersBody(removeStr, "assignee", "")
|
||||
apiResp, err := runtime.DoAPI(&larkcore.ApiReq{
|
||||
HttpMethod: http.MethodPost,
|
||||
ApiPath: "/open-apis/task/v2/tasks/" + taskId + "/remove_members",
|
||||
QueryParams: queryParams,
|
||||
Body: body,
|
||||
})
|
||||
|
||||
var result map[string]interface{}
|
||||
if err == nil {
|
||||
if parseErr := json.Unmarshal(apiResp.RawBody, &result); parseErr != nil {
|
||||
return WrapTaskError(ErrCodeTaskInternalError, fmt.Sprintf("failed to parse response: %v", parseErr), "parse remove members")
|
||||
}
|
||||
}
|
||||
|
||||
data, err := HandleTaskApiResult(result, err, "remove task members")
|
||||
data, err := callTaskAPITyped(runtime, http.MethodPost, "/open-apis/task/v2/tasks/"+taskId+"/remove_members", params, body)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -4,14 +4,100 @@
|
||||
package task
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/httpmock"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
"github.com/smartystreets/goconvey/convey"
|
||||
)
|
||||
|
||||
// TestAssignTask_RequiresAddOrRemove covers the Validate guard: neither --add
|
||||
// nor --remove yields a typed validation error (exit 2) before any API call.
|
||||
func TestAssignTask_RequiresAddOrRemove(t *testing.T) {
|
||||
f, stdout, _, _ := taskShortcutTestFactory(t)
|
||||
|
||||
s := AssignTask
|
||||
args := []string{"+assign", "--task-id", "task-1", "--as", "bot", "--format", "json"}
|
||||
err := runMountedTaskShortcut(t, s, args, f, stdout)
|
||||
|
||||
var ve *errs.ValidationError
|
||||
if !errors.As(err, &ve) {
|
||||
t.Fatalf("err = %T, want *errs.ValidationError; err = %v", err, err)
|
||||
}
|
||||
if ve.Subtype != errs.SubtypeInvalidArgument {
|
||||
t.Errorf("subtype = %q, want %q", ve.Subtype, errs.SubtypeInvalidArgument)
|
||||
}
|
||||
if got := output.ExitCodeOf(err); got != output.ExitValidation {
|
||||
t.Errorf("exit code = %d, want %d", got, output.ExitValidation)
|
||||
}
|
||||
}
|
||||
|
||||
// TestAssignTask_MalformedResponse covers the Execute parse-response arm: a
|
||||
// 200 with an unparseable body surfaces a typed internal invalid_response
|
||||
// error (exit 5).
|
||||
func TestAssignTask_MalformedResponse(t *testing.T) {
|
||||
f, stdout, _, reg := taskShortcutTestFactory(t)
|
||||
warmTenantToken(t, f, reg)
|
||||
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/task/v2/tasks/task-1/add_members",
|
||||
Status: 200,
|
||||
RawBody: []byte("{not-json"),
|
||||
})
|
||||
|
||||
s := AssignTask
|
||||
args := []string{"+assign", "--task-id", "task-1", "--add", "ou_user_1", "--as", "bot", "--format", "json"}
|
||||
err := runMountedTaskShortcut(t, s, args, f, stdout)
|
||||
|
||||
var ie *errs.InternalError
|
||||
if !errors.As(err, &ie) {
|
||||
t.Fatalf("err = %T, want *errs.InternalError; err = %v", err, err)
|
||||
}
|
||||
if ie.Subtype != errs.SubtypeInvalidResponse {
|
||||
t.Errorf("subtype = %q, want %q", ie.Subtype, errs.SubtypeInvalidResponse)
|
||||
}
|
||||
if got := output.ExitCodeOf(err); got != output.ExitInternal {
|
||||
t.Errorf("exit code = %d, want %d", got, output.ExitInternal)
|
||||
}
|
||||
}
|
||||
|
||||
// TestAssignTask_MalformedResponse_RemoveArm covers the Execute remove-members
|
||||
// parse arm: with only --remove set, the add arm is skipped and the
|
||||
// remove_members POST returns a 200 with an unparseable body, which must
|
||||
// surface a typed internal invalid_response error (exit 5).
|
||||
func TestAssignTask_MalformedResponse_RemoveArm(t *testing.T) {
|
||||
f, stdout, _, reg := taskShortcutTestFactory(t)
|
||||
warmTenantToken(t, f, reg)
|
||||
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/task/v2/tasks/task-1/remove_members",
|
||||
Status: 200,
|
||||
RawBody: []byte("{not-json"),
|
||||
})
|
||||
|
||||
s := AssignTask
|
||||
args := []string{"+assign", "--task-id", "task-1", "--remove", "ou_user_1", "--as", "bot", "--format", "json"}
|
||||
err := runMountedTaskShortcut(t, s, args, f, stdout)
|
||||
|
||||
var ie *errs.InternalError
|
||||
if !errors.As(err, &ie) {
|
||||
t.Fatalf("err = %T, want *errs.InternalError; err = %v", err, err)
|
||||
}
|
||||
if ie.Subtype != errs.SubtypeInvalidResponse {
|
||||
t.Errorf("subtype = %q, want %q", ie.Subtype, errs.SubtypeInvalidResponse)
|
||||
}
|
||||
if got := output.ExitCodeOf(err); got != output.ExitInternal {
|
||||
t.Errorf("exit code = %d, want %d", got, output.ExitInternal)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildMembersBody(t *testing.T) {
|
||||
convey.Convey("Build with ids and token", t, func() {
|
||||
body := buildMembersBody("u1, u2 , ", "assignee", "token1")
|
||||
|
||||
@@ -5,8 +5,10 @@ package task
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
@@ -19,33 +21,25 @@ func TestBuildTaskCreateBody_StructuredErrors(t *testing.T) {
|
||||
data string
|
||||
summary string
|
||||
due string
|
||||
wantCode int
|
||||
wantType string
|
||||
wantSubstr string
|
||||
}{
|
||||
{
|
||||
name: "invalid JSON data returns ErrValidation",
|
||||
name: "invalid JSON data returns validation error",
|
||||
data: "not-json",
|
||||
summary: "test",
|
||||
wantCode: output.ExitValidation,
|
||||
wantType: "validation",
|
||||
wantSubstr: "--data must be a valid JSON object",
|
||||
},
|
||||
{
|
||||
name: "missing summary returns ErrValidation",
|
||||
name: "missing summary returns validation error",
|
||||
data: "",
|
||||
summary: "",
|
||||
wantCode: output.ExitValidation,
|
||||
wantType: "validation",
|
||||
wantSubstr: "task summary is required",
|
||||
},
|
||||
{
|
||||
name: "invalid due time returns ErrValidation",
|
||||
name: "invalid due time returns validation error",
|
||||
data: "",
|
||||
summary: "test task",
|
||||
due: "not-a-valid-time",
|
||||
wantCode: output.ExitValidation,
|
||||
wantType: "validation",
|
||||
wantSubstr: "failed to parse due time",
|
||||
},
|
||||
}
|
||||
@@ -68,18 +62,22 @@ func TestBuildTaskCreateBody_StructuredErrors(t *testing.T) {
|
||||
t.Fatal("expected error, got nil")
|
||||
}
|
||||
|
||||
var exitErr *output.ExitError
|
||||
if !errors.As(err, &exitErr) {
|
||||
t.Fatalf("error type = %T, want *output.ExitError; error = %v", err, err)
|
||||
var ve *errs.ValidationError
|
||||
if !errors.As(err, &ve) {
|
||||
t.Fatalf("error type = %T, want *errs.ValidationError; error = %v", err, err)
|
||||
}
|
||||
if exitErr.Code != tt.wantCode {
|
||||
t.Errorf("exit code = %d, want %d", exitErr.Code, tt.wantCode)
|
||||
p, ok := errs.ProblemOf(err)
|
||||
if !ok {
|
||||
t.Fatalf("ProblemOf(%T) returned !ok", err)
|
||||
}
|
||||
if exitErr.Detail == nil {
|
||||
t.Fatal("expected non-nil error detail")
|
||||
if p.Subtype != errs.SubtypeInvalidArgument {
|
||||
t.Errorf("subtype = %q, want %q", p.Subtype, errs.SubtypeInvalidArgument)
|
||||
}
|
||||
if exitErr.Detail.Type != tt.wantType {
|
||||
t.Errorf("error type = %q, want %q", exitErr.Detail.Type, tt.wantType)
|
||||
if got := output.ExitCodeOf(err); got != output.ExitValidation {
|
||||
t.Errorf("exit code = %d, want %d", got, output.ExitValidation)
|
||||
}
|
||||
if !strings.Contains(err.Error(), tt.wantSubstr) {
|
||||
t.Errorf("message = %q, want substring %q", err.Error(), tt.wantSubstr)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -91,35 +89,27 @@ func TestBuildTaskUpdateBody_StructuredErrors(t *testing.T) {
|
||||
data string
|
||||
summary string
|
||||
due string
|
||||
wantCode int
|
||||
wantType string
|
||||
wantSubstr string
|
||||
}{
|
||||
{
|
||||
name: "invalid JSON data returns ErrValidation",
|
||||
name: "invalid JSON data returns validation error",
|
||||
data: "not-json",
|
||||
summary: "",
|
||||
due: "",
|
||||
wantCode: output.ExitValidation,
|
||||
wantType: "validation",
|
||||
wantSubstr: "--data must be a valid JSON object",
|
||||
},
|
||||
{
|
||||
name: "no fields to update returns ErrValidation",
|
||||
name: "no fields to update returns validation error",
|
||||
data: "",
|
||||
summary: "",
|
||||
due: "",
|
||||
wantCode: output.ExitValidation,
|
||||
wantType: "validation",
|
||||
wantSubstr: "no fields to update",
|
||||
},
|
||||
{
|
||||
name: "invalid due time returns ErrValidation",
|
||||
name: "invalid due time returns validation error",
|
||||
data: "",
|
||||
summary: "",
|
||||
due: "not-a-valid-time",
|
||||
wantCode: output.ExitValidation,
|
||||
wantType: "validation",
|
||||
wantSubstr: "failed to parse due time",
|
||||
},
|
||||
}
|
||||
@@ -138,18 +128,22 @@ func TestBuildTaskUpdateBody_StructuredErrors(t *testing.T) {
|
||||
t.Fatal("expected error, got nil")
|
||||
}
|
||||
|
||||
var exitErr *output.ExitError
|
||||
if !errors.As(err, &exitErr) {
|
||||
t.Fatalf("error type = %T, want *output.ExitError; error = %v", err, err)
|
||||
var ve *errs.ValidationError
|
||||
if !errors.As(err, &ve) {
|
||||
t.Fatalf("error type = %T, want *errs.ValidationError; error = %v", err, err)
|
||||
}
|
||||
if exitErr.Code != tt.wantCode {
|
||||
t.Errorf("exit code = %d, want %d", exitErr.Code, tt.wantCode)
|
||||
p, ok := errs.ProblemOf(err)
|
||||
if !ok {
|
||||
t.Fatalf("ProblemOf(%T) returned !ok", err)
|
||||
}
|
||||
if exitErr.Detail == nil {
|
||||
t.Fatal("expected non-nil error detail")
|
||||
if p.Subtype != errs.SubtypeInvalidArgument {
|
||||
t.Errorf("subtype = %q, want %q", p.Subtype, errs.SubtypeInvalidArgument)
|
||||
}
|
||||
if exitErr.Detail.Type != tt.wantType {
|
||||
t.Errorf("error type = %q, want %q", exitErr.Detail.Type, tt.wantType)
|
||||
if got := output.ExitCodeOf(err); got != output.ExitValidation {
|
||||
t.Errorf("exit code = %d, want %d", got, output.ExitValidation)
|
||||
}
|
||||
if !strings.Contains(err.Error(), tt.wantSubstr) {
|
||||
t.Errorf("message = %q, want substring %q", err.Error(), tt.wantSubstr)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -5,13 +5,10 @@ package task
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
|
||||
larkcore "github.com/larksuite/oapi-sdk-go/v3/core"
|
||||
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
@@ -48,24 +45,9 @@ var CommentTask = common.Shortcut{
|
||||
"resource_type": "task",
|
||||
}
|
||||
|
||||
queryParams := make(larkcore.QueryParams)
|
||||
queryParams.Set("user_id_type", "open_id")
|
||||
params := map[string]interface{}{"user_id_type": "open_id"}
|
||||
|
||||
apiResp, err := runtime.DoAPI(&larkcore.ApiReq{
|
||||
HttpMethod: http.MethodPost,
|
||||
ApiPath: "/open-apis/task/v2/comments",
|
||||
QueryParams: queryParams,
|
||||
Body: body,
|
||||
})
|
||||
|
||||
var result map[string]interface{}
|
||||
if err == nil {
|
||||
if parseErr := json.Unmarshal(apiResp.RawBody, &result); parseErr != nil {
|
||||
return WrapTaskError(ErrCodeTaskInternalError, fmt.Sprintf("failed to parse response: %v", parseErr), "parse comment response")
|
||||
}
|
||||
}
|
||||
|
||||
data, err := HandleTaskApiResult(result, err, "add task comment")
|
||||
data, err := callTaskAPITyped(runtime, http.MethodPost, "/open-apis/task/v2/comments", params, body)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -5,15 +5,12 @@ package task
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"time"
|
||||
|
||||
larkcore "github.com/larksuite/oapi-sdk-go/v3/core"
|
||||
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
@@ -47,28 +44,14 @@ var CompleteTask = common.Shortcut{
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
taskId := url.PathEscape(runtime.Str("task-id"))
|
||||
|
||||
queryParams := make(larkcore.QueryParams)
|
||||
queryParams.Set("user_id_type", "open_id")
|
||||
params := map[string]interface{}{"user_id_type": "open_id"}
|
||||
|
||||
var data map[string]interface{}
|
||||
|
||||
// 1. Get current task status
|
||||
getResp, getErr := runtime.DoAPI(&larkcore.ApiReq{
|
||||
HttpMethod: http.MethodGet,
|
||||
ApiPath: "/open-apis/task/v2/tasks/" + taskId,
|
||||
QueryParams: queryParams,
|
||||
})
|
||||
|
||||
var getResult map[string]interface{}
|
||||
if getErr == nil {
|
||||
if parseErr := json.Unmarshal(getResp.RawBody, &getResult); parseErr != nil {
|
||||
return WrapTaskError(ErrCodeTaskInternalError, fmt.Sprintf("failed to parse get response: %v", parseErr), "parse get response")
|
||||
}
|
||||
}
|
||||
|
||||
getData, getErr := HandleTaskApiResult(getResult, getErr, "get task")
|
||||
if getErr != nil {
|
||||
return getErr
|
||||
getData, err := callTaskAPITyped(runtime, http.MethodGet, "/open-apis/task/v2/tasks/"+taskId, params, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
taskData, _ := getData["task"].(map[string]interface{})
|
||||
@@ -80,21 +63,7 @@ var CompleteTask = common.Shortcut{
|
||||
} else {
|
||||
// 3. Complete the task
|
||||
body := buildCompleteBody()
|
||||
apiResp, err := runtime.DoAPI(&larkcore.ApiReq{
|
||||
HttpMethod: http.MethodPatch,
|
||||
ApiPath: "/open-apis/task/v2/tasks/" + taskId,
|
||||
QueryParams: queryParams,
|
||||
Body: body,
|
||||
})
|
||||
|
||||
var result map[string]interface{}
|
||||
if err == nil {
|
||||
if parseErr := json.Unmarshal(apiResp.RawBody, &result); parseErr != nil {
|
||||
return WrapTaskError(ErrCodeTaskInternalError, fmt.Sprintf("failed to parse response: %v", parseErr), "parse complete response")
|
||||
}
|
||||
}
|
||||
|
||||
data, err = HandleTaskApiResult(result, err, "complete task")
|
||||
data, err = callTaskAPITyped(runtime, http.MethodPatch, "/open-apis/task/v2/tasks/"+taskId, params, body)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
51
shortcuts/task/task_errors.go
Normal file
51
shortcuts/task/task_errors.go
Normal file
@@ -0,0 +1,51 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package task
|
||||
|
||||
import (
|
||||
"errors"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/extension/fileio"
|
||||
)
|
||||
|
||||
// wrapTaskNetworkErr returns err unchanged when it is already a typed errs.*
|
||||
// error (preserving its subtype / code / log_id from the runtime boundary),
|
||||
// and only wraps a raw, unclassified error as a transport-level network error.
|
||||
func wrapTaskNetworkErr(err error, format string, args ...any) error {
|
||||
if _, ok := errs.ProblemOf(err); ok {
|
||||
return err
|
||||
}
|
||||
return errs.NewNetworkError(errs.SubtypeNetworkTransport, format, args...).WithCause(err)
|
||||
}
|
||||
|
||||
// taskInputStatError maps a FileIO.Stat/Open error for input file validation
|
||||
// to a typed validation error:
|
||||
// - Path validation failures → "unsafe file path: ..."
|
||||
// - Other errors → readMsg prefix (default "cannot read file")
|
||||
//
|
||||
// param names the input flag/path field that failed (for example "--file").
|
||||
// Pass an optional readMsg to override the non-path-validation message prefix,
|
||||
// mirroring the shared input-stat helper so call-site context is preserved.
|
||||
func taskInputStatError(err error, param string, readMsg ...string) error {
|
||||
if err == nil {
|
||||
return nil
|
||||
}
|
||||
if errors.Is(err, fileio.ErrPathValidation) {
|
||||
validationErr := errs.NewValidationError(errs.SubtypeInvalidArgument, "unsafe file path: %s", err).WithCause(err)
|
||||
if param != "" {
|
||||
validationErr = validationErr.WithParam(param)
|
||||
}
|
||||
return validationErr
|
||||
}
|
||||
msg := "cannot read file"
|
||||
if len(readMsg) > 0 && readMsg[0] != "" {
|
||||
msg = readMsg[0]
|
||||
}
|
||||
validationErr := errs.NewValidationError(errs.SubtypeInvalidArgument, "%s: %s", msg, err).WithCause(err)
|
||||
if param != "" {
|
||||
validationErr = validationErr.WithParam(param)
|
||||
}
|
||||
return validationErr
|
||||
}
|
||||
103
shortcuts/task/task_errors_test.go
Normal file
103
shortcuts/task/task_errors_test.go
Normal file
@@ -0,0 +1,103 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package task
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/extension/fileio"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
)
|
||||
|
||||
func TestTaskInputStatError(t *testing.T) {
|
||||
t.Run("nil error returns nil", func(t *testing.T) {
|
||||
if err := taskInputStatError(nil, "--file"); err != nil {
|
||||
t.Errorf("taskInputStatError(nil) = %v, want nil", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("path validation failure maps to unsafe file path", func(t *testing.T) {
|
||||
err := taskInputStatError(fmt.Errorf("bad: %w", fileio.ErrPathValidation), "--file")
|
||||
var ve *errs.ValidationError
|
||||
if !errors.As(err, &ve) {
|
||||
t.Fatalf("err = %T, want *errs.ValidationError", err)
|
||||
}
|
||||
if ve.Subtype != errs.SubtypeInvalidArgument {
|
||||
t.Errorf("subtype = %q, want %q", ve.Subtype, errs.SubtypeInvalidArgument)
|
||||
}
|
||||
if output.ExitCodeOf(err) != output.ExitValidation {
|
||||
t.Errorf("exit = %d, want %d", output.ExitCodeOf(err), output.ExitValidation)
|
||||
}
|
||||
if !strings.Contains(err.Error(), "unsafe file path") {
|
||||
t.Errorf("message = %q, want 'unsafe file path'", err.Error())
|
||||
}
|
||||
if ve.Param != "--file" {
|
||||
t.Errorf("param = %q, want --file", ve.Param)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("generic error uses readMsg prefix", func(t *testing.T) {
|
||||
err := taskInputStatError(errors.New("permission denied"), "--file", "cannot access file")
|
||||
var ve *errs.ValidationError
|
||||
if !errors.As(err, &ve) {
|
||||
t.Fatalf("err = %T, want *errs.ValidationError", err)
|
||||
}
|
||||
if !strings.Contains(err.Error(), "cannot access file") {
|
||||
t.Errorf("message = %q, want 'cannot access file' prefix", err.Error())
|
||||
}
|
||||
if ve.Param != "--file" {
|
||||
t.Errorf("param = %q, want --file", ve.Param)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("default prefix when no readMsg", func(t *testing.T) {
|
||||
err := taskInputStatError(errors.New("boom"), "--file")
|
||||
var ve *errs.ValidationError
|
||||
if !errors.As(err, &ve) {
|
||||
t.Fatalf("err = %T, want *errs.ValidationError", err)
|
||||
}
|
||||
if !strings.Contains(err.Error(), "cannot read file") {
|
||||
t.Errorf("message = %q, want default 'cannot read file'", err.Error())
|
||||
}
|
||||
if ve.Param != "--file" {
|
||||
t.Errorf("param = %q, want --file", ve.Param)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestWrapTaskNetworkErr(t *testing.T) {
|
||||
// wrapTaskNetworkErr is only ever called inside an `if err != nil` guard
|
||||
// (DoAPIStream failure), mirroring drive's wrapDriveNetworkErr, so it does
|
||||
// not special-case a nil cause.
|
||||
t.Run("untyped cause becomes typed network error wrapping the cause", func(t *testing.T) {
|
||||
cause := errors.New("dial timeout")
|
||||
err := wrapTaskNetworkErr(cause, "upload failed")
|
||||
var ne *errs.NetworkError
|
||||
if !errors.As(err, &ne) {
|
||||
t.Fatalf("err = %T, want *errs.NetworkError", err)
|
||||
}
|
||||
if ne.Subtype != errs.SubtypeNetworkTransport {
|
||||
t.Errorf("subtype = %q, want %q", ne.Subtype, errs.SubtypeNetworkTransport)
|
||||
}
|
||||
if !errors.Is(err, cause) {
|
||||
t.Error("expected the original cause to be wrapped (errors.Is)")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("already-typed cause is passed through unchanged", func(t *testing.T) {
|
||||
typed := errs.NewAPIError(errs.SubtypeNotFound, "missing")
|
||||
err := wrapTaskNetworkErr(typed, "upload failed")
|
||||
var ae *errs.APIError
|
||||
if !errors.As(err, &ae) {
|
||||
t.Fatalf("err = %T, want the original *errs.APIError passed through", err)
|
||||
}
|
||||
if ae.Subtype != errs.SubtypeNotFound {
|
||||
t.Errorf("subtype = %q, want %q (not re-wrapped as network)", ae.Subtype, errs.SubtypeNotFound)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -5,15 +5,13 @@ package task
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
|
||||
larkcore "github.com/larksuite/oapi-sdk-go/v3/core"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
@@ -35,7 +33,7 @@ var FollowersTask = common.Shortcut{
|
||||
|
||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
if runtime.Str("add") == "" && runtime.Str("remove") == "" {
|
||||
return WrapTaskError(ErrCodeTaskInvalidParams, "must specify either --add or --remove", "validate followers")
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "must specify either --add or --remove")
|
||||
}
|
||||
return nil
|
||||
},
|
||||
@@ -63,28 +61,13 @@ var FollowersTask = common.Shortcut{
|
||||
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
taskId := url.PathEscape(runtime.Str("task-id"))
|
||||
queryParams := make(larkcore.QueryParams)
|
||||
queryParams.Set("user_id_type", "open_id")
|
||||
params := map[string]interface{}{"user_id_type": "open_id"}
|
||||
|
||||
var lastData map[string]interface{}
|
||||
|
||||
if addStr := runtime.Str("add"); addStr != "" {
|
||||
body := buildFollowersBody(addStr, runtime.Str("idempotency-key"))
|
||||
apiResp, err := runtime.DoAPI(&larkcore.ApiReq{
|
||||
HttpMethod: http.MethodPost,
|
||||
ApiPath: "/open-apis/task/v2/tasks/" + taskId + "/add_members",
|
||||
QueryParams: queryParams,
|
||||
Body: body,
|
||||
})
|
||||
|
||||
var result map[string]interface{}
|
||||
if err == nil {
|
||||
if parseErr := json.Unmarshal(apiResp.RawBody, &result); parseErr != nil {
|
||||
return WrapTaskError(ErrCodeTaskInternalError, fmt.Sprintf("failed to parse response: %v", parseErr), "parse add followers")
|
||||
}
|
||||
}
|
||||
|
||||
data, err := HandleTaskApiResult(result, err, "add task followers")
|
||||
data, err := callTaskAPITyped(runtime, http.MethodPost, "/open-apis/task/v2/tasks/"+taskId+"/add_members", params, body)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -93,21 +76,7 @@ var FollowersTask = common.Shortcut{
|
||||
|
||||
if removeStr := runtime.Str("remove"); removeStr != "" {
|
||||
body := buildFollowersBody(removeStr, "")
|
||||
apiResp, err := runtime.DoAPI(&larkcore.ApiReq{
|
||||
HttpMethod: http.MethodPost,
|
||||
ApiPath: "/open-apis/task/v2/tasks/" + taskId + "/remove_members",
|
||||
QueryParams: queryParams,
|
||||
Body: body,
|
||||
})
|
||||
|
||||
var result map[string]interface{}
|
||||
if err == nil {
|
||||
if parseErr := json.Unmarshal(apiResp.RawBody, &result); parseErr != nil {
|
||||
return WrapTaskError(ErrCodeTaskInternalError, fmt.Sprintf("failed to parse response: %v", parseErr), "parse remove followers")
|
||||
}
|
||||
}
|
||||
|
||||
data, err := HandleTaskApiResult(result, err, "remove task followers")
|
||||
data, err := callTaskAPITyped(runtime, http.MethodPost, "/open-apis/task/v2/tasks/"+taskId+"/remove_members", params, body)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -4,11 +4,36 @@
|
||||
package task
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"github.com/smartystreets/goconvey/convey"
|
||||
)
|
||||
|
||||
// TestFollowersTask_RequiresAddOrRemove covers the Validate guard: neither
|
||||
// --add nor --remove yields a typed validation error (exit 2) before any API
|
||||
// call.
|
||||
func TestFollowersTask_RequiresAddOrRemove(t *testing.T) {
|
||||
f, stdout, _, _ := taskShortcutTestFactory(t)
|
||||
|
||||
s := FollowersTask
|
||||
args := []string{"+followers", "--task-id", "task-1", "--as", "bot", "--format", "json"}
|
||||
err := runMountedTaskShortcut(t, s, args, f, stdout)
|
||||
|
||||
var ve *errs.ValidationError
|
||||
if !errors.As(err, &ve) {
|
||||
t.Fatalf("err = %T, want *errs.ValidationError; err = %v", err, err)
|
||||
}
|
||||
if ve.Subtype != errs.SubtypeInvalidArgument {
|
||||
t.Errorf("subtype = %q, want %q", ve.Subtype, errs.SubtypeInvalidArgument)
|
||||
}
|
||||
if got := output.ExitCodeOf(err); got != output.ExitValidation {
|
||||
t.Errorf("exit code = %d, want %d", got, output.ExitValidation)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildFollowersBody(t *testing.T) {
|
||||
convey.Convey("Build with ids and token", t, func() {
|
||||
body := buildFollowersBody("u1, u2 , ", "token1")
|
||||
|
||||
@@ -5,15 +5,14 @@ package task
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
larkcore "github.com/larksuite/oapi-sdk-go/v3/core"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
@@ -59,19 +58,20 @@ var GetMyTasks = common.Shortcut{
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
startTime := time.Now()
|
||||
|
||||
queryParams := make(larkcore.QueryParams)
|
||||
queryParams.Set("type", "my_tasks")
|
||||
queryParams.Set("user_id_type", "open_id")
|
||||
queryParams.Set("page_size", "50")
|
||||
params := map[string]interface{}{
|
||||
"type": "my_tasks",
|
||||
"user_id_type": "open_id",
|
||||
"page_size": 50,
|
||||
}
|
||||
if runtime.Cmd.Flags().Changed("complete") {
|
||||
if runtime.Bool("complete") {
|
||||
queryParams.Set("completed", "true")
|
||||
params["completed"] = "true"
|
||||
} else {
|
||||
queryParams.Set("completed", "false")
|
||||
params["completed"] = "false"
|
||||
}
|
||||
}
|
||||
if pageToken := runtime.Str("page-token"); pageToken != "" {
|
||||
queryParams.Set("page_token", pageToken)
|
||||
params["page_token"] = pageToken
|
||||
}
|
||||
|
||||
// parse time flags to ms timestamp if provided
|
||||
@@ -79,7 +79,7 @@ var GetMyTasks = common.Shortcut{
|
||||
if createdStr := runtime.Str("created_at"); createdStr != "" {
|
||||
tStr, err := parseTimeFlagSec(createdStr, "start")
|
||||
if err != nil {
|
||||
return WrapTaskError(ErrCodeTaskInvalidParams, fmt.Sprintf("invalid created_at: %v", err), "parse created_at")
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "invalid created_at: %v", err).WithParam("--created_at")
|
||||
}
|
||||
createdAfterMs, _ = strconv.ParseInt(tStr, 10, 64)
|
||||
createdAfterMs *= 1000 // Convert sec to ms
|
||||
@@ -88,7 +88,7 @@ var GetMyTasks = common.Shortcut{
|
||||
if dueStartStr := runtime.Str("due-start"); dueStartStr != "" {
|
||||
tStr, err := parseTimeFlagSec(dueStartStr, "start")
|
||||
if err != nil {
|
||||
return WrapTaskError(ErrCodeTaskInvalidParams, fmt.Sprintf("invalid due-start: %v", err), "parse due-start")
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "invalid due-start: %v", err).WithParam("--due-start")
|
||||
}
|
||||
dueStartMs, _ = strconv.ParseInt(tStr, 10, 64)
|
||||
dueStartMs *= 1000
|
||||
@@ -97,7 +97,7 @@ var GetMyTasks = common.Shortcut{
|
||||
if dueEndStr := runtime.Str("due-end"); dueEndStr != "" {
|
||||
tStr, err := parseTimeFlagSec(dueEndStr, "end")
|
||||
if err != nil {
|
||||
return WrapTaskError(ErrCodeTaskInvalidParams, fmt.Sprintf("invalid due-end: %v", err), "parse due-end")
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "invalid due-end: %v", err).WithParam("--due-end")
|
||||
}
|
||||
dueEndMs, _ = strconv.ParseInt(tStr, 10, 64)
|
||||
dueEndMs *= 1000
|
||||
@@ -114,22 +114,7 @@ var GetMyTasks = common.Shortcut{
|
||||
|
||||
for {
|
||||
pageCount++
|
||||
apiReq := &larkcore.ApiReq{
|
||||
HttpMethod: "GET",
|
||||
ApiPath: "/open-apis/task/v2/tasks",
|
||||
QueryParams: queryParams,
|
||||
}
|
||||
|
||||
apiResp, err := runtime.DoAPI(apiReq)
|
||||
|
||||
var result map[string]interface{}
|
||||
if err == nil {
|
||||
if parseErr := json.Unmarshal(apiResp.RawBody, &result); parseErr != nil {
|
||||
return WrapTaskError(ErrCodeTaskInternalError, fmt.Sprintf("failed to parse response: %v", parseErr), "parse my tasks")
|
||||
}
|
||||
}
|
||||
|
||||
data, err := HandleTaskApiResult(result, err, "list tasks")
|
||||
data, err := callTaskAPITyped(runtime, http.MethodGet, "/open-apis/task/v2/tasks", params, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -150,7 +135,7 @@ var GetMyTasks = common.Shortcut{
|
||||
}
|
||||
|
||||
// Set page_token for next iteration
|
||||
queryParams.Set("page_token", lastPageToken)
|
||||
params["page_token"] = lastPageToken
|
||||
}
|
||||
|
||||
var filteredItems []map[string]interface{}
|
||||
|
||||
@@ -4,12 +4,15 @@
|
||||
package task
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/httpmock"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
)
|
||||
|
||||
func TestGetMyTasks_LocalTimeFormatting(t *testing.T) {
|
||||
@@ -106,3 +109,50 @@ func TestGetMyTasks_LocalTimeFormatting(t *testing.T) {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// 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
|
||||
// *errs.ValidationError (exit 2) regardless of credentials — the command runs
|
||||
// as user with a throwaway token. Each error carries the corresponding --flag
|
||||
// param so the caller can point at the offending input.
|
||||
func TestGetMyTasks_InvalidTimeFlags(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
flag string
|
||||
wantParam string
|
||||
}{
|
||||
{name: "created_at", flag: "--created_at", wantParam: "--created_at"},
|
||||
{name: "due-start", flag: "--due-start", wantParam: "--due-start"},
|
||||
{name: "due-end", flag: "--due-end", wantParam: "--due-end"},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
f, stdout, _, _ := taskShortcutTestFactory(t)
|
||||
|
||||
s := GetMyTasks
|
||||
s.AuthTypes = []string{"bot", "user"}
|
||||
|
||||
args := []string{"+get-my-tasks", tt.flag, "not-a-time", "--as", "user"}
|
||||
err := runMountedTaskShortcut(t, s, args, f, stdout)
|
||||
if err == nil {
|
||||
t.Fatalf("expected validation error for %s, got nil", tt.flag)
|
||||
}
|
||||
|
||||
var ve *errs.ValidationError
|
||||
if !errors.As(err, &ve) {
|
||||
t.Fatalf("error type = %T, want *errs.ValidationError; error = %v", err, err)
|
||||
}
|
||||
if ve.Subtype != errs.SubtypeInvalidArgument {
|
||||
t.Errorf("subtype = %q, want %q", ve.Subtype, errs.SubtypeInvalidArgument)
|
||||
}
|
||||
if got := output.ExitCodeOf(err); got != output.ExitValidation {
|
||||
t.Errorf("exit code = %d, want %d", got, output.ExitValidation)
|
||||
}
|
||||
if ve.Param != tt.wantParam {
|
||||
t.Errorf("param = %q, want %q", ve.Param, tt.wantParam)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,14 +5,11 @@ package task
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
larkcore "github.com/larksuite/oapi-sdk-go/v3/core"
|
||||
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
@@ -55,14 +52,15 @@ var GetRelatedTasks = common.Shortcut{
|
||||
Params(params)
|
||||
},
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
queryParams := make(larkcore.QueryParams)
|
||||
queryParams.Set("user_id_type", "open_id")
|
||||
queryParams.Set("page_size", fmt.Sprintf("%d", relatedTasksPageSize))
|
||||
params := map[string]interface{}{
|
||||
"user_id_type": "open_id",
|
||||
"page_size": relatedTasksPageSize,
|
||||
}
|
||||
if runtime.Cmd.Flags().Changed("include-complete") && !runtime.Bool("include-complete") {
|
||||
queryParams.Set("completed", "false")
|
||||
params["completed"] = "false"
|
||||
}
|
||||
if pageToken := runtime.Str("page-token"); pageToken != "" {
|
||||
queryParams.Set("page_token", pageToken)
|
||||
params["page_token"] = pageToken
|
||||
}
|
||||
|
||||
pageLimit := runtime.Int("page-limit")
|
||||
@@ -80,18 +78,7 @@ var GetRelatedTasks = common.Shortcut{
|
||||
var lastPageToken string
|
||||
var lastHasMore bool
|
||||
for page := 0; page < pageLimit; page++ {
|
||||
apiResp, err := runtime.DoAPI(&larkcore.ApiReq{
|
||||
HttpMethod: http.MethodGet,
|
||||
ApiPath: "/open-apis/task/v2/task_v2/list_related_task",
|
||||
QueryParams: queryParams,
|
||||
})
|
||||
var result map[string]interface{}
|
||||
if err == nil {
|
||||
if parseErr := json.Unmarshal(apiResp.RawBody, &result); parseErr != nil {
|
||||
return WrapTaskError(ErrCodeTaskInternalError, fmt.Sprintf("failed to parse response: %v", parseErr), "parse related tasks")
|
||||
}
|
||||
}
|
||||
data, err := HandleTaskApiResult(result, err, "list related tasks")
|
||||
data, err := callTaskAPITyped(runtime, http.MethodGet, "/open-apis/task/v2/task_v2/list_related_task", params, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -103,7 +90,7 @@ var GetRelatedTasks = common.Shortcut{
|
||||
if !lastHasMore || lastPageToken == "" {
|
||||
break
|
||||
}
|
||||
queryParams.Set("page_token", lastPageToken)
|
||||
params["page_token"] = lastPageToken
|
||||
}
|
||||
|
||||
userOpenID := runtime.UserOpenId()
|
||||
|
||||
@@ -9,7 +9,7 @@ import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"github.com/larksuite/cli/errs"
|
||||
)
|
||||
|
||||
func splitAndTrimCSV(input string) []string {
|
||||
@@ -46,7 +46,7 @@ func parseTimeRangeMillis(input string) (string, string, error) {
|
||||
}
|
||||
startSecInt, err = strconv.ParseInt(startSec, 10, 64)
|
||||
if err != nil {
|
||||
return "", "", output.ErrValidation("invalid start timestamp: %v", err)
|
||||
return "", "", errs.NewValidationError(errs.SubtypeInvalidArgument, "invalid start timestamp: %v", err)
|
||||
}
|
||||
hasStart = true
|
||||
startMillis = startSec + "000"
|
||||
@@ -58,13 +58,13 @@ func parseTimeRangeMillis(input string) (string, string, error) {
|
||||
}
|
||||
endSecInt, err = strconv.ParseInt(endSec, 10, 64)
|
||||
if err != nil {
|
||||
return "", "", output.ErrValidation("invalid end timestamp: %v", err)
|
||||
return "", "", errs.NewValidationError(errs.SubtypeInvalidArgument, "invalid end timestamp: %v", err)
|
||||
}
|
||||
hasEnd = true
|
||||
endMillis = endSec + "000"
|
||||
}
|
||||
if hasStart && hasEnd && startSecInt > endSecInt {
|
||||
return "", "", output.ErrValidation("start time must be earlier than or equal to end time")
|
||||
return "", "", errs.NewValidationError(errs.SubtypeInvalidArgument, "start time must be earlier than or equal to end time")
|
||||
}
|
||||
return startMillis, endMillis, nil
|
||||
}
|
||||
@@ -91,7 +91,7 @@ func parseTimeRangeRFC3339(input string) (string, string, error) {
|
||||
}
|
||||
startSecInt, err = strconv.ParseInt(startSec, 10, 64)
|
||||
if err != nil {
|
||||
return "", "", output.ErrValidation("invalid start timestamp: %v", err)
|
||||
return "", "", errs.NewValidationError(errs.SubtypeInvalidArgument, "invalid start timestamp: %v", err)
|
||||
}
|
||||
hasStart = true
|
||||
startTime = time.Unix(startSecInt, 0).Local().Format(time.RFC3339)
|
||||
@@ -103,13 +103,13 @@ func parseTimeRangeRFC3339(input string) (string, string, error) {
|
||||
}
|
||||
endSecInt, err = strconv.ParseInt(endSec, 10, 64)
|
||||
if err != nil {
|
||||
return "", "", output.ErrValidation("invalid end timestamp: %v", err)
|
||||
return "", "", errs.NewValidationError(errs.SubtypeInvalidArgument, "invalid end timestamp: %v", err)
|
||||
}
|
||||
hasEnd = true
|
||||
endTime = time.Unix(endSecInt, 0).Local().Format(time.RFC3339)
|
||||
}
|
||||
if hasStart && hasEnd && startSecInt > endSecInt {
|
||||
return "", "", output.ErrValidation("start time must be earlier than or equal to end time")
|
||||
return "", "", errs.NewValidationError(errs.SubtypeInvalidArgument, "start time must be earlier than or equal to end time")
|
||||
}
|
||||
return startTime, endTime, nil
|
||||
}
|
||||
@@ -220,7 +220,7 @@ func requireSearchFilter(query string, filter map[string]interface{}, action str
|
||||
if len(filter) > 0 {
|
||||
return nil
|
||||
}
|
||||
return WrapTaskError(ErrCodeTaskInvalidParams, "query is empty and no filter is provided", action)
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "%s: query is empty and no filter is provided", action)
|
||||
}
|
||||
|
||||
func renderRelatedTasksPretty(items []map[string]interface{}, hasMore bool, pageToken string) string {
|
||||
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
)
|
||||
|
||||
@@ -88,6 +89,7 @@ func TestParseTimeRangeMillisAndRequireSearchFilter(t *testing.T) {
|
||||
}{
|
||||
{name: "empty input", input: "", wantStart: "", wantEnd: ""},
|
||||
{name: "invalid input", input: "bad-time", wantErr: true},
|
||||
{name: "invalid end input", input: "-1d,bad-time", wantErr: true},
|
||||
{name: "range input", input: "-1d,+1d", wantStart: "non-empty", wantEnd: "non-empty"},
|
||||
{name: "reversed range fails fast", input: "+1d,-1d", wantErr: true},
|
||||
}
|
||||
@@ -99,15 +101,16 @@ func TestParseTimeRangeMillisAndRequireSearchFilter(t *testing.T) {
|
||||
t.Fatalf("parseTimeRangeMillis(%q) expected error, got nil", tt.input)
|
||||
}
|
||||
if tt.name == "reversed range fails fast" {
|
||||
var exitErr *output.ExitError
|
||||
if !errors.As(err, &exitErr) {
|
||||
t.Fatalf("error type = %T, want *output.ExitError; error = %v", err, err)
|
||||
var ve *errs.ValidationError
|
||||
if !errors.As(err, &ve) {
|
||||
t.Fatalf("error type = %T, want *errs.ValidationError; error = %v", err, err)
|
||||
}
|
||||
if exitErr.Code != output.ExitValidation {
|
||||
t.Errorf("exit code = %d, want %d", exitErr.Code, output.ExitValidation)
|
||||
p, ok := errs.ProblemOf(err)
|
||||
if !ok || p.Subtype != errs.SubtypeInvalidArgument {
|
||||
t.Errorf("subtype = %q, want %q", p.Subtype, errs.SubtypeInvalidArgument)
|
||||
}
|
||||
if exitErr.Detail == nil || exitErr.Detail.Type != "validation" {
|
||||
t.Errorf("error detail type = %q, want %q", exitErr.Detail.Type, "validation")
|
||||
if got := output.ExitCodeOf(err); got != output.ExitValidation {
|
||||
t.Errorf("exit code = %d, want %d", got, output.ExitValidation)
|
||||
}
|
||||
}
|
||||
return
|
||||
@@ -264,6 +267,7 @@ func TestRenderRelatedTasksPretty(t *testing.T) {
|
||||
}{
|
||||
{name: "empty input", input: "", wantStart: "", wantEnd: ""},
|
||||
{name: "invalid input", input: "bad-time", wantErr: true},
|
||||
{name: "invalid end input", input: "-1d,bad-time", wantErr: true},
|
||||
{name: "range input", input: "-1d,+1d", wantStart: "rfc3339", wantEnd: "rfc3339"},
|
||||
{name: "reversed range fails fast", input: "+1d,-1d", wantErr: true},
|
||||
}
|
||||
@@ -276,12 +280,16 @@ func TestRenderRelatedTasksPretty(t *testing.T) {
|
||||
t.Fatal("expected error, got nil")
|
||||
}
|
||||
if tt.name == "reversed range fails fast" {
|
||||
var exitErr *output.ExitError
|
||||
if !errors.As(err, &exitErr) {
|
||||
t.Fatalf("error type = %T, want *output.ExitError; error = %v", err, err)
|
||||
var ve *errs.ValidationError
|
||||
if !errors.As(err, &ve) {
|
||||
t.Fatalf("error type = %T, want *errs.ValidationError; error = %v", err, err)
|
||||
}
|
||||
if exitErr.Code != output.ExitValidation {
|
||||
t.Errorf("exit code = %d, want %d", exitErr.Code, output.ExitValidation)
|
||||
p, ok := errs.ProblemOf(err)
|
||||
if !ok || p.Subtype != errs.SubtypeInvalidArgument {
|
||||
t.Errorf("subtype = %q, want %q", p.Subtype, errs.SubtypeInvalidArgument)
|
||||
}
|
||||
if got := output.ExitCodeOf(err); got != output.ExitValidation {
|
||||
t.Errorf("exit code = %d, want %d", got, output.ExitValidation)
|
||||
}
|
||||
}
|
||||
return
|
||||
|
||||
@@ -5,7 +5,6 @@ package task
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
@@ -13,8 +12,7 @@ import (
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
larkcore "github.com/larksuite/oapi-sdk-go/v3/core"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
@@ -35,10 +33,10 @@ var ReminderTask = common.Shortcut{
|
||||
|
||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
if runtime.Str("set") == "" && !runtime.Bool("remove") {
|
||||
return WrapTaskError(ErrCodeTaskInvalidParams, "must specify either --set or --remove", "validate reminder")
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "must specify either --set or --remove")
|
||||
}
|
||||
if runtime.Str("set") != "" && runtime.Bool("remove") {
|
||||
return WrapTaskError(ErrCodeTaskInvalidParams, "cannot specify both --set and --remove", "validate reminder")
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "cannot specify both --set and --remove")
|
||||
}
|
||||
return nil
|
||||
},
|
||||
@@ -66,24 +64,10 @@ var ReminderTask = common.Shortcut{
|
||||
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
taskId := url.PathEscape(runtime.Str("task-id"))
|
||||
queryParams := make(larkcore.QueryParams)
|
||||
queryParams.Set("user_id_type", "open_id")
|
||||
params := map[string]interface{}{"user_id_type": "open_id"}
|
||||
|
||||
// First, get the task to find existing reminders
|
||||
getResp, err := runtime.DoAPI(&larkcore.ApiReq{
|
||||
HttpMethod: http.MethodGet,
|
||||
ApiPath: "/open-apis/task/v2/tasks/" + taskId,
|
||||
QueryParams: queryParams,
|
||||
})
|
||||
|
||||
var getResult map[string]interface{}
|
||||
if err == nil {
|
||||
if parseErr := json.Unmarshal(getResp.RawBody, &getResult); parseErr != nil {
|
||||
return WrapTaskError(ErrCodeTaskInternalError, fmt.Sprintf("failed to parse task details: %v", parseErr), "parse task details")
|
||||
}
|
||||
}
|
||||
|
||||
data, err := HandleTaskApiResult(getResult, err, "get task reminders")
|
||||
data, err := callTaskAPITyped(runtime, http.MethodGet, "/open-apis/task/v2/tasks/"+taskId, params, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -112,21 +96,7 @@ var ReminderTask = common.Shortcut{
|
||||
body := map[string]interface{}{
|
||||
"reminder_ids": reminderIds,
|
||||
}
|
||||
apiResp, err := runtime.DoAPI(&larkcore.ApiReq{
|
||||
HttpMethod: http.MethodPost,
|
||||
ApiPath: "/open-apis/task/v2/tasks/" + taskId + "/remove_reminders",
|
||||
QueryParams: queryParams,
|
||||
Body: body,
|
||||
})
|
||||
|
||||
var removeResult map[string]interface{}
|
||||
if err == nil {
|
||||
if parseErr := json.Unmarshal(apiResp.RawBody, &removeResult); parseErr != nil {
|
||||
return WrapTaskError(ErrCodeTaskInternalError, fmt.Sprintf("failed to parse response: %v", parseErr), "parse remove response")
|
||||
}
|
||||
}
|
||||
|
||||
if _, err := HandleTaskApiResult(removeResult, err, "remove task reminders"); err != nil {
|
||||
if _, err := callTaskAPITyped(runtime, http.MethodPost, "/open-apis/task/v2/tasks/"+taskId+"/remove_reminders", params, body); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
@@ -155,7 +125,7 @@ var ReminderTask = common.Shortcut{
|
||||
}
|
||||
|
||||
if parseErr != nil {
|
||||
return WrapTaskError(ErrCodeTaskInvalidParams, parseErr.Error(), "set reminder")
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "%v", parseErr)
|
||||
}
|
||||
|
||||
// If any reminders exist, remove them first
|
||||
@@ -173,21 +143,7 @@ var ReminderTask = common.Shortcut{
|
||||
body := map[string]interface{}{
|
||||
"reminder_ids": reminderIds,
|
||||
}
|
||||
apiResp, err := runtime.DoAPI(&larkcore.ApiReq{
|
||||
HttpMethod: http.MethodPost,
|
||||
ApiPath: "/open-apis/task/v2/tasks/" + taskId + "/remove_reminders",
|
||||
QueryParams: queryParams,
|
||||
Body: body,
|
||||
})
|
||||
|
||||
var removeResult map[string]interface{}
|
||||
if err == nil {
|
||||
if parseErr := json.Unmarshal(apiResp.RawBody, &removeResult); parseErr != nil {
|
||||
return WrapTaskError(ErrCodeTaskInternalError, fmt.Sprintf("failed to parse response: %v", parseErr), "parse remove response")
|
||||
}
|
||||
}
|
||||
|
||||
if _, err := HandleTaskApiResult(removeResult, err, "remove existing task reminders before setting new one"); err != nil {
|
||||
if _, err := callTaskAPITyped(runtime, http.MethodPost, "/open-apis/task/v2/tasks/"+taskId+"/remove_reminders", params, body); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
@@ -200,21 +156,7 @@ var ReminderTask = common.Shortcut{
|
||||
},
|
||||
},
|
||||
}
|
||||
apiResp, err := runtime.DoAPI(&larkcore.ApiReq{
|
||||
HttpMethod: http.MethodPost,
|
||||
ApiPath: "/open-apis/task/v2/tasks/" + taskId + "/add_reminders",
|
||||
QueryParams: queryParams,
|
||||
Body: body,
|
||||
})
|
||||
|
||||
var addResult map[string]interface{}
|
||||
if err == nil {
|
||||
if parseErr := json.Unmarshal(apiResp.RawBody, &addResult); parseErr != nil {
|
||||
return WrapTaskError(ErrCodeTaskInternalError, fmt.Sprintf("failed to parse response: %v", parseErr), "parse add response")
|
||||
}
|
||||
}
|
||||
|
||||
if _, err := HandleTaskApiResult(addResult, err, "add task reminder"); err != nil {
|
||||
if _, err := callTaskAPITyped(runtime, http.MethodPost, "/open-apis/task/v2/tasks/"+taskId+"/add_reminders", params, body); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
55
shortcuts/task/task_reminder_test.go
Normal file
55
shortcuts/task/task_reminder_test.go
Normal file
@@ -0,0 +1,55 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package task
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
)
|
||||
|
||||
// TestReminderTask_RequiresSetOrRemove covers the first Validate guard: neither
|
||||
// --set nor --remove yields a typed validation error (exit 2) before any API
|
||||
// call.
|
||||
func TestReminderTask_RequiresSetOrRemove(t *testing.T) {
|
||||
f, stdout, _, _ := taskShortcutTestFactory(t)
|
||||
|
||||
s := ReminderTask
|
||||
args := []string{"+reminder", "--task-id", "task-1", "--as", "bot", "--format", "json"}
|
||||
err := runMountedTaskShortcut(t, s, args, f, stdout)
|
||||
|
||||
var ve *errs.ValidationError
|
||||
if !errors.As(err, &ve) {
|
||||
t.Fatalf("err = %T, want *errs.ValidationError; err = %v", err, err)
|
||||
}
|
||||
if ve.Subtype != errs.SubtypeInvalidArgument {
|
||||
t.Errorf("subtype = %q, want %q", ve.Subtype, errs.SubtypeInvalidArgument)
|
||||
}
|
||||
if got := output.ExitCodeOf(err); got != output.ExitValidation {
|
||||
t.Errorf("exit code = %d, want %d", got, output.ExitValidation)
|
||||
}
|
||||
}
|
||||
|
||||
// TestReminderTask_CannotSpecifyBoth covers the second Validate guard: passing
|
||||
// both --set and --remove yields a typed validation error (exit 2).
|
||||
func TestReminderTask_CannotSpecifyBoth(t *testing.T) {
|
||||
f, stdout, _, _ := taskShortcutTestFactory(t)
|
||||
|
||||
s := ReminderTask
|
||||
args := []string{"+reminder", "--task-id", "task-1", "--set", "15m", "--remove", "--as", "bot", "--format", "json"}
|
||||
err := runMountedTaskShortcut(t, s, args, f, stdout)
|
||||
|
||||
var ve *errs.ValidationError
|
||||
if !errors.As(err, &ve) {
|
||||
t.Fatalf("err = %T, want *errs.ValidationError; err = %v", err, err)
|
||||
}
|
||||
if ve.Subtype != errs.SubtypeInvalidArgument {
|
||||
t.Errorf("subtype = %q, want %q", ve.Subtype, errs.SubtypeInvalidArgument)
|
||||
}
|
||||
if got := output.ExitCodeOf(err); got != output.ExitValidation {
|
||||
t.Errorf("exit code = %d, want %d", got, output.ExitValidation)
|
||||
}
|
||||
}
|
||||
@@ -5,14 +5,11 @@ package task
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
|
||||
larkcore "github.com/larksuite/oapi-sdk-go/v3/core"
|
||||
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
@@ -42,24 +39,8 @@ var ReopenTask = common.Shortcut{
|
||||
taskId := url.PathEscape(runtime.Str("task-id"))
|
||||
body := buildReopenBody()
|
||||
|
||||
queryParams := make(larkcore.QueryParams)
|
||||
queryParams.Set("user_id_type", "open_id")
|
||||
|
||||
apiResp, err := runtime.DoAPI(&larkcore.ApiReq{
|
||||
HttpMethod: http.MethodPatch,
|
||||
ApiPath: "/open-apis/task/v2/tasks/" + taskId,
|
||||
QueryParams: queryParams,
|
||||
Body: body,
|
||||
})
|
||||
|
||||
var result map[string]interface{}
|
||||
if err == nil {
|
||||
if parseErr := json.Unmarshal(apiResp.RawBody, &result); parseErr != nil {
|
||||
return WrapTaskError(ErrCodeTaskInternalError, fmt.Sprintf("failed to parse response: %v", parseErr), "parse reopen response")
|
||||
}
|
||||
}
|
||||
|
||||
data, err := HandleTaskApiResult(result, err, "reopen task")
|
||||
params := map[string]interface{}{"user_id_type": "open_id"}
|
||||
data, err := callTaskAPITyped(runtime, http.MethodPatch, "/open-apis/task/v2/tasks/"+taskId, params, body)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -5,14 +5,12 @@ package task
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
|
||||
larkcore "github.com/larksuite/oapi-sdk-go/v3/core"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
@@ -77,18 +75,7 @@ var SearchTask = common.Shortcut{
|
||||
var lastHasMore bool
|
||||
currentBody := body
|
||||
for page := 0; page < pageLimit; page++ {
|
||||
apiResp, err := runtime.DoAPI(&larkcore.ApiReq{
|
||||
HttpMethod: http.MethodPost,
|
||||
ApiPath: "/open-apis/task/v2/tasks/search",
|
||||
Body: currentBody,
|
||||
})
|
||||
var result map[string]interface{}
|
||||
if err == nil {
|
||||
if parseErr := json.Unmarshal(apiResp.RawBody, &result); parseErr != nil {
|
||||
return WrapTaskError(ErrCodeTaskInternalError, fmt.Sprintf("failed to parse response: %v", parseErr), "parse task search")
|
||||
}
|
||||
}
|
||||
data, err := HandleTaskApiResult(result, err, "search tasks")
|
||||
data, err := callTaskAPITyped(runtime, http.MethodPost, "/open-apis/task/v2/tasks/search", nil, currentBody)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -173,7 +160,7 @@ func buildTaskSearchBody(runtime *common.RuntimeContext) (map[string]interface{}
|
||||
if dueRange := runtime.Str("due"); dueRange != "" {
|
||||
start, end, err := parseTimeRangeRFC3339(dueRange)
|
||||
if err != nil {
|
||||
return nil, WrapTaskError(ErrCodeTaskInvalidParams, fmt.Sprintf("invalid due: %v", err), "build task search")
|
||||
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "invalid due: %v", err).WithParam("--due")
|
||||
}
|
||||
if dueFilter := buildTimeRangeFilter("due_time", start, end); dueFilter != nil {
|
||||
mergeIntoFilter(filter, dueFilter)
|
||||
@@ -196,27 +183,15 @@ func buildTaskSearchBody(runtime *common.RuntimeContext) (map[string]interface{}
|
||||
}
|
||||
|
||||
func getTaskDetail(runtime *common.RuntimeContext, taskID string) (map[string]interface{}, error) {
|
||||
queryParams := make(larkcore.QueryParams)
|
||||
queryParams.Set("user_id_type", "open_id")
|
||||
params := map[string]interface{}{"user_id_type": "open_id"}
|
||||
|
||||
apiResp, err := runtime.DoAPI(&larkcore.ApiReq{
|
||||
HttpMethod: http.MethodGet,
|
||||
ApiPath: "/open-apis/task/v2/tasks/" + url.PathEscape(taskID),
|
||||
QueryParams: queryParams,
|
||||
})
|
||||
var result map[string]interface{}
|
||||
if err == nil {
|
||||
if parseErr := json.Unmarshal(apiResp.RawBody, &result); parseErr != nil {
|
||||
return nil, WrapTaskError(ErrCodeTaskInternalError, fmt.Sprintf("failed to parse task detail response: %v", parseErr), "parse task detail")
|
||||
}
|
||||
}
|
||||
data, err := HandleTaskApiResult(result, err, "get task detail "+taskID)
|
||||
data, err := callTaskAPITyped(runtime, http.MethodGet, "/open-apis/task/v2/tasks/"+url.PathEscape(taskID), params, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
task, _ := data["task"].(map[string]interface{})
|
||||
if task == nil {
|
||||
return nil, WrapTaskError(ErrCodeTaskInternalError, "task detail response missing task object", "get task detail")
|
||||
return nil, errs.NewInternalError(errs.SubtypeInvalidResponse, "task detail response missing task object")
|
||||
}
|
||||
return task, nil
|
||||
}
|
||||
|
||||
@@ -4,12 +4,17 @@
|
||||
package task
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/internal/httpmock"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
@@ -298,3 +303,129 @@ func TestSearchTask_Execute(t *testing.T) {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestSearchTask_InvalidDue_Validation drives the --due validation arm through
|
||||
// the mounted command. buildTaskSearchBody runs before any API call, so a
|
||||
// malformed range deterministically surfaces a typed *errs.ValidationError
|
||||
// (invalid_argument, exit 2) carrying the --due param.
|
||||
func TestSearchTask_InvalidDue_Validation(t *testing.T) {
|
||||
f, stdout, _, _ := taskShortcutTestFactory(t)
|
||||
|
||||
s := SearchTask
|
||||
s.AuthTypes = []string{"bot", "user"}
|
||||
|
||||
args := []string{"+search", "--query", "release", "--due", "not-a-time", "--as", "user"}
|
||||
err := runMountedTaskShortcut(t, s, args, f, stdout)
|
||||
if err == nil {
|
||||
t.Fatal("expected validation error for malformed --due, got nil")
|
||||
}
|
||||
|
||||
var ve *errs.ValidationError
|
||||
if !errors.As(err, &ve) {
|
||||
t.Fatalf("error type = %T, want *errs.ValidationError; error = %v", err, err)
|
||||
}
|
||||
if ve.Subtype != errs.SubtypeInvalidArgument {
|
||||
t.Errorf("subtype = %q, want %q", ve.Subtype, errs.SubtypeInvalidArgument)
|
||||
}
|
||||
if got := output.ExitCodeOf(err); got != output.ExitValidation {
|
||||
t.Errorf("exit code = %d, want %d", got, output.ExitValidation)
|
||||
}
|
||||
if ve.Param != "--due" {
|
||||
t.Errorf("param = %q, want %q", ve.Param, "--due")
|
||||
}
|
||||
}
|
||||
|
||||
// TestSearchTask_MalformedSearchResponse covers the search raw-body parse arm:
|
||||
// the SDK returns a 200 with a non-JSON body and nil error, so the shortcut's
|
||||
// own json.Unmarshal fails and must surface a typed *errs.InternalError
|
||||
// (invalid_response, exit 5).
|
||||
func TestSearchTask_MalformedSearchResponse(t *testing.T) {
|
||||
f, stdout, _, reg := taskShortcutTestFactory(t)
|
||||
warmTenantToken(t, f, reg)
|
||||
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/task/v2/tasks/search",
|
||||
RawBody: []byte("{not-json"),
|
||||
})
|
||||
|
||||
s := SearchTask
|
||||
s.AuthTypes = []string{"bot", "user"}
|
||||
|
||||
args := []string{"+search", "--query", "release", "--as", "bot", "--format", "json"}
|
||||
err := runMountedTaskShortcut(t, s, args, f, stdout)
|
||||
if err == nil {
|
||||
t.Fatal("expected internal error for malformed response, got nil")
|
||||
}
|
||||
|
||||
var ie *errs.InternalError
|
||||
if !errors.As(err, &ie) {
|
||||
t.Fatalf("error type = %T, want *errs.InternalError; error = %v", err, err)
|
||||
}
|
||||
if ie.Subtype != errs.SubtypeInvalidResponse {
|
||||
t.Errorf("subtype = %q, want %q", ie.Subtype, errs.SubtypeInvalidResponse)
|
||||
}
|
||||
if got := output.ExitCodeOf(err); got != output.ExitInternal {
|
||||
t.Errorf("exit code = %d, want %d", got, output.ExitInternal)
|
||||
}
|
||||
}
|
||||
|
||||
// TestGetTaskDetail_MalformedResponse exercises getTaskDetail directly. In the
|
||||
// +search Execute loop a detail-fetch error is intentionally swallowed (the hit
|
||||
// falls back to its app_link), so the only way to lock the helper's two
|
||||
// internal arms — a non-JSON body and a code-0 response missing the task object
|
||||
// — is to call it directly. Both must surface a typed *errs.InternalError
|
||||
// (invalid_response, exit 5).
|
||||
func TestGetTaskDetail_MalformedResponse(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
stub *httpmock.Stub
|
||||
}{
|
||||
{
|
||||
name: "body not json",
|
||||
stub: &httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/task/v2/tasks/task-123",
|
||||
RawBody: []byte("{not-json"),
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "missing task object",
|
||||
stub: &httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/task/v2/tasks/task-123",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"msg": "success",
|
||||
"data": map[string]interface{}{},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
f, _, _, reg := taskShortcutTestFactory(t)
|
||||
warmTenantToken(t, f, reg)
|
||||
reg.Register(tt.stub)
|
||||
|
||||
runtime := common.TestNewRuntimeContextForAPI(context.Background(), &cobra.Command{Use: "test"}, taskTestConfig(t), f, core.AsBot)
|
||||
|
||||
_, err := getTaskDetail(runtime, "task-123")
|
||||
if err == nil {
|
||||
t.Fatal("expected internal error, got nil")
|
||||
}
|
||||
|
||||
var ie *errs.InternalError
|
||||
if !errors.As(err, &ie) {
|
||||
t.Fatalf("error type = %T, want *errs.InternalError; error = %v", err, err)
|
||||
}
|
||||
if ie.Subtype != errs.SubtypeInvalidResponse {
|
||||
t.Errorf("subtype = %q, want %q", ie.Subtype, errs.SubtypeInvalidResponse)
|
||||
}
|
||||
if got := output.ExitCodeOf(err); got != output.ExitInternal {
|
||||
t.Errorf("exit code = %d, want %d", got, output.ExitInternal)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,14 +5,11 @@ package task
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
|
||||
larkcore "github.com/larksuite/oapi-sdk-go/v3/core"
|
||||
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
@@ -37,22 +34,9 @@ var SetAncestorTask = common.Shortcut{
|
||||
},
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
taskID := runtime.Str("task-id")
|
||||
queryParams := make(larkcore.QueryParams)
|
||||
queryParams.Set("user_id_type", "open_id")
|
||||
params := map[string]interface{}{"user_id_type": "open_id"}
|
||||
|
||||
apiResp, err := runtime.DoAPI(&larkcore.ApiReq{
|
||||
HttpMethod: http.MethodPost,
|
||||
ApiPath: "/open-apis/task/v2/tasks/" + url.PathEscape(taskID) + "/set_ancestor_task",
|
||||
QueryParams: queryParams,
|
||||
Body: buildSetAncestorBody(runtime.Str("ancestor-id")),
|
||||
})
|
||||
var result map[string]interface{}
|
||||
if err == nil {
|
||||
if parseErr := json.Unmarshal(apiResp.RawBody, &result); parseErr != nil {
|
||||
return WrapTaskError(ErrCodeTaskInternalError, fmt.Sprintf("failed to parse response: %v", parseErr), "set ancestor task")
|
||||
}
|
||||
}
|
||||
if _, err = HandleTaskApiResult(result, err, "set ancestor task"); err != nil {
|
||||
if _, err := callTaskAPITyped(runtime, http.MethodPost, "/open-apis/task/v2/tasks/"+url.PathEscape(taskID)+"/set_ancestor_task", params, buildSetAncestorBody(runtime.Str("ancestor-id"))); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
|
||||
@@ -5,13 +5,10 @@ package task
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
|
||||
larkcore "github.com/larksuite/oapi-sdk-go/v3/core"
|
||||
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
@@ -29,23 +26,8 @@ var SubscribeTaskEvent = common.Shortcut{
|
||||
Params(map[string]interface{}{"user_id_type": "open_id"})
|
||||
},
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
queryParams := make(larkcore.QueryParams)
|
||||
queryParams.Set("user_id_type", "open_id")
|
||||
apiResp, err := runtime.DoAPI(&larkcore.ApiReq{
|
||||
HttpMethod: http.MethodPost,
|
||||
ApiPath: "/open-apis/task/v2/task_v2/task_subscription",
|
||||
QueryParams: queryParams,
|
||||
})
|
||||
|
||||
// DoAPI may return HTTP 200 while the JSON body contains a non-zero business "code".
|
||||
// Parse and validate the envelope to avoid false-success output.
|
||||
var result map[string]interface{}
|
||||
if err == nil {
|
||||
if parseErr := json.Unmarshal(apiResp.RawBody, &result); parseErr != nil {
|
||||
return WrapTaskError(ErrCodeTaskInternalError, fmt.Sprintf("failed to parse response: %v", parseErr), "subscribe task events")
|
||||
}
|
||||
}
|
||||
if _, err := HandleTaskApiResult(result, err, "subscribe task events"); err != nil {
|
||||
params := map[string]interface{}{"user_id_type": "open_id"}
|
||||
if _, err := callTaskAPITyped(runtime, http.MethodPost, "/open-apis/task/v2/task_v2/task_subscription", params, nil); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
|
||||
@@ -4,12 +4,15 @@
|
||||
package task
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/httpmock"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
@@ -129,3 +132,32 @@ func TestSubscribeTaskEvent(t *testing.T) {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestSubscribeTaskEvent_MalformedResponse covers the parse-response arm: a 200
|
||||
// with an unparseable body surfaces a typed internal invalid_response error
|
||||
// (exit 5).
|
||||
func TestSubscribeTaskEvent_MalformedResponse(t *testing.T) {
|
||||
f, stdout, _, reg := taskShortcutTestFactory(t)
|
||||
warmTenantToken(t, f, reg)
|
||||
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/task/v2/task_v2/task_subscription",
|
||||
Status: 200,
|
||||
RawBody: []byte("{not-json"),
|
||||
})
|
||||
|
||||
args := []string{"+subscribe-event", "--as", "bot", "--format", "json"}
|
||||
err := runMountedTaskShortcut(t, SubscribeTaskEvent, args, f, stdout)
|
||||
|
||||
var ie *errs.InternalError
|
||||
if !errors.As(err, &ie) {
|
||||
t.Fatalf("err = %T, want *errs.InternalError; err = %v", err, err)
|
||||
}
|
||||
if ie.Subtype != errs.SubtypeInvalidResponse {
|
||||
t.Errorf("subtype = %q, want %q", ie.Subtype, errs.SubtypeInvalidResponse)
|
||||
}
|
||||
if got := output.ExitCodeOf(err); got != output.ExitInternal {
|
||||
t.Errorf("exit code = %d, want %d", got, output.ExitInternal)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,14 +5,12 @@ package task
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
|
||||
larkcore "github.com/larksuite/oapi-sdk-go/v3/core"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
@@ -74,18 +72,7 @@ var SearchTasklist = common.Shortcut{
|
||||
var lastHasMore bool
|
||||
currentBody := body
|
||||
for page := 0; page < pageLimit; page++ {
|
||||
apiResp, err := runtime.DoAPI(&larkcore.ApiReq{
|
||||
HttpMethod: http.MethodPost,
|
||||
ApiPath: "/open-apis/task/v2/tasklists/search",
|
||||
Body: currentBody,
|
||||
})
|
||||
var result map[string]interface{}
|
||||
if err == nil {
|
||||
if parseErr := json.Unmarshal(apiResp.RawBody, &result); parseErr != nil {
|
||||
return WrapTaskError(ErrCodeTaskInternalError, fmt.Sprintf("failed to parse response: %v", parseErr), "parse tasklist search")
|
||||
}
|
||||
}
|
||||
data, err := HandleTaskApiResult(result, err, "search tasklists")
|
||||
data, err := callTaskAPITyped(runtime, http.MethodPost, "/open-apis/task/v2/tasklists/search", nil, currentBody)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -160,7 +147,7 @@ func buildTasklistSearchBody(runtime *common.RuntimeContext) (map[string]interfa
|
||||
if createTime := runtime.Str("create-time"); createTime != "" {
|
||||
start, end, err := parseTimeRangeRFC3339(createTime)
|
||||
if err != nil {
|
||||
return nil, WrapTaskError(ErrCodeTaskInvalidParams, fmt.Sprintf("invalid create-time: %v", err), "build tasklist search")
|
||||
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "invalid create-time: %v", err).WithParam("--create-time")
|
||||
}
|
||||
if timeFilter := buildTimeRangeFilter("create_time", start, end); timeFilter != nil {
|
||||
mergeIntoFilter(filter, timeFilter)
|
||||
@@ -183,27 +170,15 @@ func buildTasklistSearchBody(runtime *common.RuntimeContext) (map[string]interfa
|
||||
}
|
||||
|
||||
func getTasklistDetail(runtime *common.RuntimeContext, tasklistID string) (map[string]interface{}, error) {
|
||||
queryParams := make(larkcore.QueryParams)
|
||||
queryParams.Set("user_id_type", "open_id")
|
||||
params := map[string]interface{}{"user_id_type": "open_id"}
|
||||
|
||||
apiResp, err := runtime.DoAPI(&larkcore.ApiReq{
|
||||
HttpMethod: http.MethodGet,
|
||||
ApiPath: "/open-apis/task/v2/tasklists/" + url.PathEscape(tasklistID),
|
||||
QueryParams: queryParams,
|
||||
})
|
||||
var result map[string]interface{}
|
||||
if err == nil {
|
||||
if parseErr := json.Unmarshal(apiResp.RawBody, &result); parseErr != nil {
|
||||
return nil, WrapTaskError(ErrCodeTaskInternalError, fmt.Sprintf("failed to parse tasklist detail response: %v", parseErr), "parse tasklist detail")
|
||||
}
|
||||
}
|
||||
data, err := HandleTaskApiResult(result, err, "get tasklist detail "+tasklistID)
|
||||
data, err := callTaskAPITyped(runtime, http.MethodGet, "/open-apis/task/v2/tasklists/"+url.PathEscape(tasklistID), params, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
tasklist, _ := data["tasklist"].(map[string]interface{})
|
||||
if tasklist == nil {
|
||||
return nil, WrapTaskError(ErrCodeTaskInternalError, "tasklist detail response missing tasklist object", "get tasklist detail")
|
||||
return nil, errs.NewInternalError(errs.SubtypeInvalidResponse, "tasklist detail response missing tasklist object")
|
||||
}
|
||||
return tasklist, nil
|
||||
}
|
||||
|
||||
@@ -4,12 +4,15 @@
|
||||
package task
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/httpmock"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
@@ -261,3 +264,35 @@ func TestSearchTasklist_Execute(t *testing.T) {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestSearchTasklist_MalformedResponse covers the search parse arm: a 200 with
|
||||
// an unparseable search body surfaces a typed internal invalid_response error
|
||||
// (exit 5). The detail parse arm is swallowed into the fallback path, so only
|
||||
// the top-level search parse propagates.
|
||||
func TestSearchTasklist_MalformedResponse(t *testing.T) {
|
||||
f, stdout, _, reg := taskShortcutTestFactory(t)
|
||||
warmTenantToken(t, f, reg)
|
||||
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/task/v2/tasklists/search",
|
||||
Status: 200,
|
||||
RawBody: []byte("{not-json"),
|
||||
})
|
||||
|
||||
s := SearchTasklist
|
||||
s.AuthTypes = []string{"bot", "user"}
|
||||
args := []string{"+tasklist-search", "--query", "Q2", "--as", "bot", "--format", "json"}
|
||||
err := runMountedTaskShortcut(t, s, args, f, stdout)
|
||||
|
||||
var ie *errs.InternalError
|
||||
if !errors.As(err, &ie) {
|
||||
t.Fatalf("err = %T, want *errs.InternalError; err = %v", err, err)
|
||||
}
|
||||
if ie.Subtype != errs.SubtypeInvalidResponse {
|
||||
t.Errorf("subtype = %q, want %q", ie.Subtype, errs.SubtypeInvalidResponse)
|
||||
}
|
||||
if got := output.ExitCodeOf(err); got != output.ExitInternal {
|
||||
t.Errorf("exit code = %d, want %d", got, output.ExitInternal)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,8 +12,7 @@ import (
|
||||
"net/url"
|
||||
"strings"
|
||||
|
||||
larkcore "github.com/larksuite/oapi-sdk-go/v3/core"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
@@ -51,7 +50,9 @@ var UpdateTask = common.Shortcut{
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
body, err := buildTaskUpdateBody(runtime)
|
||||
if err != nil {
|
||||
return WrapTaskError(ErrCodeTaskInvalidParams, err.Error(), "update task")
|
||||
// buildTaskUpdateBody already returns a typed validation error;
|
||||
// propagate it directly instead of re-wrapping as an API error.
|
||||
return err
|
||||
}
|
||||
|
||||
taskIds := strings.Split(runtime.Str("task-id"), ",")
|
||||
@@ -63,24 +64,8 @@ var UpdateTask = common.Shortcut{
|
||||
continue
|
||||
}
|
||||
|
||||
queryParams := make(larkcore.QueryParams)
|
||||
queryParams.Set("user_id_type", "open_id")
|
||||
|
||||
apiResp, err := runtime.DoAPI(&larkcore.ApiReq{
|
||||
HttpMethod: http.MethodPatch,
|
||||
ApiPath: "/open-apis/task/v2/tasks/" + url.PathEscape(taskId),
|
||||
QueryParams: queryParams,
|
||||
Body: body,
|
||||
})
|
||||
|
||||
var result map[string]interface{}
|
||||
if err == nil {
|
||||
if parseErr := json.Unmarshal(apiResp.RawBody, &result); parseErr != nil {
|
||||
return output.Errorf(output.ExitAPI, "api_error", "failed to parse response for task %s: %v", taskId, parseErr)
|
||||
}
|
||||
}
|
||||
|
||||
data, err := HandleTaskApiResult(result, err, "update task "+taskId)
|
||||
params := map[string]interface{}{"user_id_type": "open_id"}
|
||||
data, err := callTaskAPITyped(runtime, http.MethodPatch, "/open-apis/task/v2/tasks/"+url.PathEscape(taskId), params, body)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -133,7 +118,7 @@ func buildTaskUpdateBody(runtime *common.RuntimeContext) (map[string]interface{}
|
||||
|
||||
if dataStr := runtime.Str("data"); dataStr != "" {
|
||||
if err := json.Unmarshal([]byte(dataStr), &taskObj); err != nil {
|
||||
return nil, output.ErrValidation("--data must be a valid JSON object: %v", err)
|
||||
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "--data must be a valid JSON object: %v", err).WithParam("--data")
|
||||
}
|
||||
// If data is provided, assume keys are update fields
|
||||
for k := range taskObj {
|
||||
@@ -158,7 +143,7 @@ func buildTaskUpdateBody(runtime *common.RuntimeContext) (map[string]interface{}
|
||||
if dueStr := runtime.Str("due"); dueStr != "" {
|
||||
dueObj, err := parseTaskTime(dueStr)
|
||||
if err != nil {
|
||||
return nil, output.ErrValidation("failed to parse due time: %v", err)
|
||||
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "failed to parse due time: %v", err).WithParam("--due")
|
||||
}
|
||||
taskObj["due"] = dueObj
|
||||
if !contains(updateFields, "due") {
|
||||
@@ -167,7 +152,7 @@ func buildTaskUpdateBody(runtime *common.RuntimeContext) (map[string]interface{}
|
||||
}
|
||||
|
||||
if len(updateFields) == 0 {
|
||||
return nil, output.ErrValidation("no fields to update")
|
||||
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "no fields to update")
|
||||
}
|
||||
|
||||
return map[string]interface{}{
|
||||
|
||||
@@ -14,8 +14,8 @@ import (
|
||||
|
||||
larkcore "github.com/larksuite/oapi-sdk-go/v3/core"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/client"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
@@ -91,20 +91,22 @@ var UploadAttachmentTask = common.Shortcut{
|
||||
|
||||
fio := runtime.FileIO()
|
||||
if fio == nil {
|
||||
return output.ErrValidation("file operations require a FileIO provider")
|
||||
// A nil FileIO is a runtime wiring fault, not user input.
|
||||
return errs.NewInternalError(errs.SubtypeUnknown, "file operations require a FileIO provider")
|
||||
}
|
||||
stat, err := fio.Stat(filePath)
|
||||
if err != nil {
|
||||
return common.WrapInputStatError(err, "file not found")
|
||||
return taskInputStatError(err, "--file", "cannot access file")
|
||||
}
|
||||
if !stat.Mode().IsRegular() {
|
||||
return output.ErrValidation("file must be a regular file: %s", filePath)
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "file must be a regular file: %s", filePath).WithParam("--file")
|
||||
}
|
||||
if stat.Size() > taskAttachmentUploadMaxSize {
|
||||
return output.ErrValidation(
|
||||
return errs.NewValidationError(
|
||||
errs.SubtypeInvalidArgument,
|
||||
"attachment %s exceeds the 50MB per-file limit",
|
||||
common.FormatSize(stat.Size()),
|
||||
)
|
||||
).WithParam("--file")
|
||||
}
|
||||
|
||||
fileName := filepath.Base(filePath)
|
||||
@@ -118,7 +120,7 @@ var UploadAttachmentTask = common.Shortcut{
|
||||
|
||||
f, err := fio.Open(filePath)
|
||||
if err != nil {
|
||||
return common.WrapInputStatError(err, "cannot open file")
|
||||
return taskInputStatError(err, "--file", "cannot open file")
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
@@ -129,20 +131,20 @@ var UploadAttachmentTask = common.Shortcut{
|
||||
var bodyBuf bytes.Buffer
|
||||
mw := common.NewMultipartWriter(&bodyBuf)
|
||||
if err := mw.WriteField("resource_type", resourceType); err != nil {
|
||||
return output.Errorf(output.ExitInternal, "internal", "build multipart body: %s", err)
|
||||
return errs.NewInternalError(errs.SubtypeFileIO, "build multipart body: %s", err)
|
||||
}
|
||||
if err := mw.WriteField("resource_id", resourceID); err != nil {
|
||||
return output.Errorf(output.ExitInternal, "internal", "build multipart body: %s", err)
|
||||
return errs.NewInternalError(errs.SubtypeFileIO, "build multipart body: %s", err)
|
||||
}
|
||||
filePart, err := mw.CreateFormFile("file", fileName)
|
||||
if err != nil {
|
||||
return output.Errorf(output.ExitInternal, "internal", "build multipart body: %s", err)
|
||||
return errs.NewInternalError(errs.SubtypeFileIO, "build multipart body: %s", err)
|
||||
}
|
||||
if _, err := io.Copy(filePart, f); err != nil {
|
||||
return output.Errorf(output.ExitInternal, "internal", "write file to multipart body: %s", err)
|
||||
return errs.NewInternalError(errs.SubtypeFileIO, "write file to multipart body: %s", err)
|
||||
}
|
||||
if err := mw.Close(); err != nil {
|
||||
return output.Errorf(output.ExitInternal, "internal", "finalize multipart body: %s", err)
|
||||
return errs.NewInternalError(errs.SubtypeFileIO, "finalize multipart body: %s", err)
|
||||
}
|
||||
|
||||
queryParams := make(larkcore.QueryParams)
|
||||
@@ -167,7 +169,7 @@ var UploadAttachmentTask = common.Shortcut{
|
||||
if err != nil {
|
||||
fmt.Fprintf(runtime.IO().ErrOut,
|
||||
"[+upload-attachment] http response: error=%v\n", err)
|
||||
return err
|
||||
return wrapTaskNetworkErr(err, "upload attachment request failed")
|
||||
}
|
||||
defer httpResp.Body.Close()
|
||||
|
||||
@@ -175,21 +177,17 @@ var UploadAttachmentTask = common.Shortcut{
|
||||
if readErr != nil {
|
||||
fmt.Fprintf(runtime.IO().ErrOut,
|
||||
"[+upload-attachment] http response: read_error=%v\n", readErr)
|
||||
return WrapTaskError(ErrCodeTaskInternalError,
|
||||
fmt.Sprintf("failed to read response: %v", readErr),
|
||||
"upload task attachment")
|
||||
return errs.NewInternalError(errs.SubtypeInvalidResponse, "failed to read response: %v", readErr)
|
||||
}
|
||||
|
||||
var result map[string]interface{}
|
||||
if parseErr := json.Unmarshal(rawBody, &result); parseErr != nil {
|
||||
fmt.Fprintf(runtime.IO().ErrOut,
|
||||
"[+upload-attachment] http response: parse_error=%v\n", parseErr)
|
||||
return WrapTaskError(ErrCodeTaskInternalError,
|
||||
fmt.Sprintf("failed to parse response: %v", parseErr),
|
||||
"upload task attachment")
|
||||
return errs.NewInternalError(errs.SubtypeInvalidResponse, "failed to parse response: %v", parseErr)
|
||||
}
|
||||
|
||||
data, err := HandleTaskApiResult(result, nil, "upload task attachment")
|
||||
data, err := HandleTaskApiResultWithContext(result, nil, "upload task attachment", runtime.APIClassifyContext())
|
||||
if err != nil {
|
||||
code, _ := result["code"]
|
||||
msg, _ := result["msg"].(string)
|
||||
|
||||
@@ -14,6 +14,7 @@ import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/httpmock"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
@@ -246,12 +247,15 @@ func TestUploadAttachmentTask_SizeLimit(t *testing.T) {
|
||||
if err == nil {
|
||||
t.Fatal("expected error, got nil")
|
||||
}
|
||||
var exitErr *output.ExitError
|
||||
if !errors.As(err, &exitErr) {
|
||||
t.Fatalf("expected ExitError, got %T: %v", err, err)
|
||||
var ve *errs.ValidationError
|
||||
if !errors.As(err, &ve) {
|
||||
t.Fatalf("expected *errs.ValidationError, got %T: %v", err, err)
|
||||
}
|
||||
if exitErr.Code != output.ExitValidation {
|
||||
t.Fatalf("exit code = %d, want %d", exitErr.Code, output.ExitValidation)
|
||||
if ve.Subtype != errs.SubtypeInvalidArgument {
|
||||
t.Fatalf("subtype = %q, want %q", ve.Subtype, errs.SubtypeInvalidArgument)
|
||||
}
|
||||
if got := output.ExitCodeOf(err); got != output.ExitValidation {
|
||||
t.Fatalf("exit code = %d, want %d", got, output.ExitValidation)
|
||||
}
|
||||
if !strings.Contains(err.Error(), "50MB") {
|
||||
t.Fatalf("error message should mention 50MB limit, got: %v", err)
|
||||
@@ -274,12 +278,139 @@ func TestUploadAttachmentTask_FileMissing(t *testing.T) {
|
||||
if err == nil {
|
||||
t.Fatal("expected error, got nil")
|
||||
}
|
||||
var exitErr *output.ExitError
|
||||
if !errors.As(err, &exitErr) {
|
||||
t.Fatalf("expected ExitError, got %T: %v", err, err)
|
||||
var ve *errs.ValidationError
|
||||
if !errors.As(err, &ve) {
|
||||
t.Fatalf("expected *errs.ValidationError, got %T: %v", err, err)
|
||||
}
|
||||
if exitErr.Code != output.ExitValidation {
|
||||
t.Fatalf("exit code = %d, want %d", exitErr.Code, output.ExitValidation)
|
||||
if ve.Subtype != errs.SubtypeInvalidArgument {
|
||||
t.Fatalf("subtype = %q, want %q", ve.Subtype, errs.SubtypeInvalidArgument)
|
||||
}
|
||||
if got := output.ExitCodeOf(err); got != output.ExitValidation {
|
||||
t.Fatalf("exit code = %d, want %d", got, output.ExitValidation)
|
||||
}
|
||||
if ve.Param != "--file" {
|
||||
t.Fatalf("param = %q, want %q", ve.Param, "--file")
|
||||
}
|
||||
}
|
||||
|
||||
func TestUploadAttachmentTask_NotRegularFile(t *testing.T) {
|
||||
f, stdout, _, _ := taskShortcutTestFactory(t)
|
||||
|
||||
dir := t.TempDir()
|
||||
cmdutil.TestChdir(t, dir)
|
||||
|
||||
// A directory stats fine but is not a regular file; we must reject it as
|
||||
// invalid --file input before any HTTP call (no stub registered).
|
||||
if err := os.Mkdir("a-dir", 0o755); err != nil {
|
||||
t.Fatalf("Mkdir error: %v", err)
|
||||
}
|
||||
|
||||
err := runMountedTaskShortcut(t, UploadAttachmentTask, []string{
|
||||
"+upload-attachment",
|
||||
"--resource-id", "task-guid-123",
|
||||
"--file", "a-dir",
|
||||
"--as", "bot",
|
||||
"--format", "json",
|
||||
}, f, stdout)
|
||||
if err == nil {
|
||||
t.Fatal("expected error, got nil")
|
||||
}
|
||||
var ve *errs.ValidationError
|
||||
if !errors.As(err, &ve) {
|
||||
t.Fatalf("expected *errs.ValidationError, got %T: %v", err, err)
|
||||
}
|
||||
if ve.Subtype != errs.SubtypeInvalidArgument {
|
||||
t.Fatalf("subtype = %q, want %q", ve.Subtype, errs.SubtypeInvalidArgument)
|
||||
}
|
||||
if ve.Param != "--file" {
|
||||
t.Fatalf("param = %q, want %q", ve.Param, "--file")
|
||||
}
|
||||
if got := output.ExitCodeOf(err); got != output.ExitValidation {
|
||||
t.Fatalf("exit code = %d, want %d", got, output.ExitValidation)
|
||||
}
|
||||
if !strings.Contains(err.Error(), "regular file") {
|
||||
t.Fatalf("error message should mention regular file, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUploadAttachmentTask_StatErrorMessage(t *testing.T) {
|
||||
f, stdout, _, _ := taskShortcutTestFactory(t)
|
||||
|
||||
dir := t.TempDir()
|
||||
cmdutil.TestChdir(t, dir)
|
||||
|
||||
// A missing path fails Stat → taskInputStatError surfaces "cannot access
|
||||
// file" (no longer "file not found"). No HTTP stub: must fail before any call.
|
||||
err := runMountedTaskShortcut(t, UploadAttachmentTask, []string{
|
||||
"+upload-attachment",
|
||||
"--resource-id", "task-guid-123",
|
||||
"--file", "missing.bin",
|
||||
"--as", "bot",
|
||||
"--format", "json",
|
||||
}, f, stdout)
|
||||
if err == nil {
|
||||
t.Fatal("expected error, got nil")
|
||||
}
|
||||
var ve *errs.ValidationError
|
||||
if !errors.As(err, &ve) {
|
||||
t.Fatalf("expected *errs.ValidationError, got %T: %v", err, err)
|
||||
}
|
||||
if ve.Subtype != errs.SubtypeInvalidArgument {
|
||||
t.Fatalf("subtype = %q, want %q", ve.Subtype, errs.SubtypeInvalidArgument)
|
||||
}
|
||||
if got := output.ExitCodeOf(err); got != output.ExitValidation {
|
||||
t.Fatalf("exit code = %d, want %d", got, output.ExitValidation)
|
||||
}
|
||||
if !strings.Contains(err.Error(), "cannot access file") {
|
||||
t.Fatalf("error message should contain %q, got: %v", "cannot access file", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUploadAttachmentTask_MalformedResponse(t *testing.T) {
|
||||
f, stdout, stderr, reg := taskShortcutTestFactory(t)
|
||||
warmTenantToken(t, f, reg)
|
||||
|
||||
dir := t.TempDir()
|
||||
cmdutil.TestChdir(t, dir)
|
||||
|
||||
filePath := writeTestFile(t, "note.txt", 4)
|
||||
|
||||
// A 200 response whose body is not valid JSON: the parse failure must
|
||||
// surface a typed internal invalid_response error (exit 5), not a panic
|
||||
// or a silent success.
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/task/v2/attachments/upload",
|
||||
RawBody: []byte("this is not json{"),
|
||||
})
|
||||
|
||||
err := runMountedTaskShortcut(t, UploadAttachmentTask, []string{
|
||||
"+upload-attachment",
|
||||
"--resource-id", "task-guid-123",
|
||||
"--file", filePath,
|
||||
"--as", "bot",
|
||||
"--format", "json",
|
||||
}, f, stdout)
|
||||
if err == nil {
|
||||
t.Fatal("expected error, got nil")
|
||||
}
|
||||
var ie *errs.InternalError
|
||||
if !errors.As(err, &ie) {
|
||||
t.Fatalf("expected *errs.InternalError, got %T: %v", err, err)
|
||||
}
|
||||
if ie.Subtype != errs.SubtypeInvalidResponse {
|
||||
t.Fatalf("subtype = %q, want %q", ie.Subtype, errs.SubtypeInvalidResponse)
|
||||
}
|
||||
if got := output.ExitCodeOf(err); got != output.ExitInternal {
|
||||
t.Fatalf("exit code = %d, want %d", got, output.ExitInternal)
|
||||
}
|
||||
if !strings.Contains(err.Error(), "parse response") {
|
||||
t.Fatalf("error message should mention parse response, got: %v", err)
|
||||
}
|
||||
|
||||
// The parse-error observability log should be emitted on stderr.
|
||||
if errOut := stderr.String(); !strings.Contains(errOut, "parse_error") {
|
||||
t.Errorf("stderr missing parse_error log; got:\n%s", errOut)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -311,12 +442,23 @@ func TestUploadAttachmentTask_APIError(t *testing.T) {
|
||||
if err == nil {
|
||||
t.Fatal("expected error, got nil")
|
||||
}
|
||||
var exitErr *output.ExitError
|
||||
if !errors.As(err, &exitErr) {
|
||||
t.Fatalf("expected ExitError, got %T: %v", err, err)
|
||||
var pe *errs.PermissionError
|
||||
if !errors.As(err, &pe) {
|
||||
t.Fatalf("expected *errs.PermissionError, got %T: %v", err, err)
|
||||
}
|
||||
if exitErr.Detail == nil || exitErr.Detail.Code != ErrCodeTaskPermissionDenied {
|
||||
t.Fatalf("expected task permission denied code %d, got: %+v", ErrCodeTaskPermissionDenied, exitErr.Detail)
|
||||
p, ok := errs.ProblemOf(err)
|
||||
if !ok {
|
||||
t.Fatalf("ProblemOf(err) = !ok, want typed errs.* error; err = %v", err)
|
||||
}
|
||||
if p.Subtype != errs.SubtypePermissionDenied {
|
||||
t.Fatalf("subtype = %q, want %q", p.Subtype, errs.SubtypePermissionDenied)
|
||||
}
|
||||
if p.Code != ErrCodeTaskPermissionDenied {
|
||||
t.Fatalf("code = %d, want %d", p.Code, ErrCodeTaskPermissionDenied)
|
||||
}
|
||||
// permission_denied maps to CategoryAuthorization → exit 3 (was exit 1 under legacy).
|
||||
if got := output.ExitCodeOf(err); got != output.ExitAuth {
|
||||
t.Fatalf("exit code = %d, want %d", got, output.ExitAuth)
|
||||
}
|
||||
|
||||
// Key-path log should still be emitted on failure.
|
||||
|
||||
@@ -10,7 +10,8 @@ import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/errclass"
|
||||
"github.com/larksuite/cli/internal/util"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
@@ -24,7 +25,7 @@ func isRelativeTime(s string) bool {
|
||||
func parseRelativeTime(s string) (time.Time, error) {
|
||||
matches := relativeTimeRe.FindStringSubmatch(s)
|
||||
if len(matches) == 0 {
|
||||
return time.Time{}, output.ErrValidation("invalid relative time format: %s", s)
|
||||
return time.Time{}, errs.NewValidationError(errs.SubtypeInvalidArgument, "invalid relative time format: %s", s)
|
||||
}
|
||||
|
||||
sign := matches[1]
|
||||
@@ -75,75 +76,53 @@ const (
|
||||
ErrCodeTaskReminderExists = 1470613
|
||||
)
|
||||
|
||||
// TaskErrorCode maps Lark error codes to standardized error info.
|
||||
type TaskErrorInfo struct {
|
||||
Type string
|
||||
Message string
|
||||
Hint string
|
||||
ExitCode int
|
||||
// taskAPIHints carries the task-specific recovery hint for each known Lark API
|
||||
// code, layered onto the typed error after errclass.BuildAPIError classifies
|
||||
// it. errclass.APIHint only covers context-free subtypes (e.g. conflict); these
|
||||
// hints carry the resource context APIHint intentionally leaves to the caller.
|
||||
// Authorization (1470403) is omitted: BuildAPIError already attaches the
|
||||
// canonical permission hint.
|
||||
var taskAPIHints = map[int]string{
|
||||
ErrCodeTaskInvalidParams: "Please check required fields, field lengths, or parameter logic (e.g., reminders require a due date).",
|
||||
ErrCodeTaskNotFound: "Please verify if the task, tasklist, or group ID is correct and has not been deleted.",
|
||||
ErrCodeTaskConflict: "Avoid making concurrent API calls using the same client_token.",
|
||||
ErrCodeTaskInternalError: "Please try again. If the error persists, check the content validity or contact support.",
|
||||
ErrCodeTaskAssigneeLimit: "The current task has reached the maximum number of assignees.",
|
||||
ErrCodeTaskFollowerLimit: "The current task has reached the maximum number of followers.",
|
||||
ErrCodeTasklistMemberLimit: "The current tasklist has reached the maximum number of members.",
|
||||
ErrCodeTaskReminderExists: "The task already has a reminder set. Remove the existing reminder before adding a new one.",
|
||||
}
|
||||
|
||||
var taskErrorMap = map[int]TaskErrorInfo{
|
||||
// Generic Task errors from docs
|
||||
ErrCodeTaskInvalidParams: {"validation_error", "Invalid request parameters", "Please check required fields, field lengths, or parameter logic (e.g., reminders require a due date).", output.ExitValidation},
|
||||
ErrCodeTaskNotFound: {"not_found", "Resource not found", "Please verify if the task, tasklist, or group ID is correct and has not been deleted.", output.ExitAPI},
|
||||
ErrCodeTaskPermissionDenied: {"permission_error", "Permission denied", "Please check if the calling identity has the necessary edit or read permissions for the resource (task/tasklist).", output.ExitAPI},
|
||||
ErrCodeTaskInternalError: {"api_error", "Internal server error", "Please try again. If the error persists, check the content validity or contact support.", output.ExitAPI},
|
||||
ErrCodeTaskConflict: {"conflict", "Concurrent call conflict", "Avoid making concurrent API calls using the same client_token.", output.ExitAPI},
|
||||
ErrCodeTaskAssigneeLimit: {"api_error", "Assignee limit exceeded", "The current task has reached the maximum number of assignees.", output.ExitAPI},
|
||||
ErrCodeTaskFollowerLimit: {"api_error", "Follower limit exceeded", "The current task has reached the maximum number of followers.", output.ExitAPI},
|
||||
ErrCodeTasklistMemberLimit: {"api_error", "Tasklist member limit exceeded", "The current tasklist has reached the maximum number of members.", output.ExitAPI},
|
||||
ErrCodeTaskReminderExists: {"api_error", "Reminder already exists", "The task already has a reminder set. Remove the existing reminder before adding a new one.", output.ExitAPI},
|
||||
func callTaskAPITyped(runtime *common.RuntimeContext, method, url string, params map[string]interface{}, body interface{}) (map[string]interface{}, error) {
|
||||
data, err := runtime.CallAPITyped(method, url, params, body)
|
||||
return data, applyTaskAPIHint(err)
|
||||
}
|
||||
|
||||
// WrapTaskError wraps a Lark API error into a standardized ExitError based on task-specific rules.
|
||||
func WrapTaskError(larkCode int, rawMsg string, action string) error {
|
||||
info, ok := taskErrorMap[larkCode]
|
||||
if !ok {
|
||||
// Fallback to generic classification if not in task-specific map
|
||||
exitCode, errType, hint := output.ClassifyLarkError(larkCode, rawMsg)
|
||||
|
||||
// Generic message based on type
|
||||
genericMsg := ""
|
||||
switch errType {
|
||||
case "permission":
|
||||
genericMsg = "Permission denied"
|
||||
case "auth":
|
||||
genericMsg = "Authentication failed"
|
||||
case "config":
|
||||
genericMsg = "Configuration error"
|
||||
case "rate_limit":
|
||||
genericMsg = "Rate limit exceeded"
|
||||
default:
|
||||
genericMsg = "API error"
|
||||
}
|
||||
|
||||
displayMsg := fmt.Sprintf("%s: %s [%d] (Details: %s)", action, genericMsg, larkCode, rawMsg)
|
||||
|
||||
return &output.ExitError{
|
||||
Code: exitCode,
|
||||
Detail: &output.ErrDetail{
|
||||
Type: errType,
|
||||
Code: larkCode,
|
||||
Message: displayMsg,
|
||||
Hint: hint,
|
||||
},
|
||||
func applyTaskAPIHint(err error) error {
|
||||
if err == nil {
|
||||
return nil
|
||||
}
|
||||
if p, ok := errs.ProblemOf(err); ok {
|
||||
if hint := taskAPIHints[p.Code]; hint != "" {
|
||||
p.Hint = hint
|
||||
}
|
||||
}
|
||||
|
||||
return &output.ExitError{
|
||||
Code: info.ExitCode,
|
||||
Detail: &output.ErrDetail{
|
||||
Type: info.Type,
|
||||
Code: larkCode,
|
||||
Message: fmt.Sprintf("%s: %s (Details: %s)", action, info.Message, rawMsg),
|
||||
Hint: info.Hint,
|
||||
},
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
// HandleTaskApiResult is a wrapper around common.HandleApiResult that applies task-specific error mapping.
|
||||
// HandleTaskApiResult interprets a parsed Lark API response. A non-zero code is
|
||||
// classified into a typed errs.* error by errclass.BuildAPIError — Category,
|
||||
// Subtype, Code, and log_id are sourced from internal/errclass/codemeta_task.go
|
||||
// — with the task-specific recovery hint (taskAPIHints) layered on top.
|
||||
func HandleTaskApiResult(result interface{}, err error, action string) (map[string]interface{}, error) {
|
||||
return handleTaskAPIResult(result, err, action, errclass.ClassifyContext{})
|
||||
}
|
||||
|
||||
func HandleTaskApiResultWithContext(result interface{}, err error, action string, cc errclass.ClassifyContext) (map[string]interface{}, error) {
|
||||
return handleTaskAPIResult(result, err, action, cc)
|
||||
}
|
||||
|
||||
func handleTaskAPIResult(result interface{}, err error, action string, cc errclass.ClassifyContext) (map[string]interface{}, error) {
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -151,16 +130,19 @@ func HandleTaskApiResult(result interface{}, err error, action string) (map[stri
|
||||
resultMap, _ := result.(map[string]interface{})
|
||||
codeVal, hasCode := resultMap["code"]
|
||||
if !hasCode {
|
||||
// Try to see if it's already an error from common.HandleApiResult (e.g. network error)
|
||||
data, err := common.HandleApiResult(result, err, action)
|
||||
return data, err
|
||||
// A Lark response always carries a top-level code; its absence (with no
|
||||
// transport error) means a malformed or unexpected body.
|
||||
return nil, errs.NewInternalError(errs.SubtypeInvalidResponse, "%s: unexpected response (missing code field)", action)
|
||||
}
|
||||
|
||||
code, _ := util.ToFloat64(codeVal)
|
||||
code, ok := util.ToFloat64(codeVal)
|
||||
if !ok {
|
||||
return nil, errs.NewInternalError(errs.SubtypeInvalidResponse, "%s: malformed response (non-numeric code %v)", action, codeVal)
|
||||
}
|
||||
larkCode := int(code)
|
||||
if larkCode != 0 {
|
||||
rawMsg, _ := resultMap["msg"].(string)
|
||||
return nil, WrapTaskError(larkCode, rawMsg, action)
|
||||
typedErr := errclass.BuildAPIError(resultMap, cc)
|
||||
return nil, applyTaskAPIHint(typedErr)
|
||||
}
|
||||
|
||||
data, _ := resultMap["data"].(map[string]interface{})
|
||||
|
||||
@@ -4,10 +4,21 @@
|
||||
package task
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"net/http"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/internal/errclass"
|
||||
"github.com/larksuite/cli/internal/httpmock"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
"github.com/smartystreets/goconvey/convey"
|
||||
)
|
||||
|
||||
@@ -20,43 +31,235 @@ func TestContains(t *testing.T) {
|
||||
})
|
||||
}
|
||||
|
||||
func TestParseRelativeTime_StructuredErrors(t *testing.T) {
|
||||
func TestParseRelativeTime_TypedError(t *testing.T) {
|
||||
_, err := parseRelativeTime("not-relative")
|
||||
if err == nil {
|
||||
t.Fatal("parseRelativeTime(\"not-relative\") expected error, got nil")
|
||||
}
|
||||
|
||||
var ve *errs.ValidationError
|
||||
if !errors.As(err, &ve) {
|
||||
t.Fatalf("error type = %T, want *errs.ValidationError; error = %v", err, err)
|
||||
}
|
||||
if ve.Subtype != errs.SubtypeInvalidArgument {
|
||||
t.Errorf("subtype = %q, want %q", ve.Subtype, errs.SubtypeInvalidArgument)
|
||||
}
|
||||
if got := output.ExitCodeOf(err); got != output.ExitValidation {
|
||||
t.Errorf("exit code = %d, want %d", got, output.ExitValidation)
|
||||
}
|
||||
if !strings.Contains(err.Error(), "invalid relative time format") {
|
||||
t.Errorf("message = %q, want substring %q", err.Error(), "invalid relative time format")
|
||||
}
|
||||
}
|
||||
|
||||
// apiResult builds a parsed Lark API response with a non-zero code, as
|
||||
// HandleTaskApiResult receives it after json.Unmarshal.
|
||||
func apiResult(code int, msg string) map[string]interface{} {
|
||||
return map[string]interface{}{"code": float64(code), "msg": msg}
|
||||
}
|
||||
|
||||
// TestHandleTaskApiResult_TypedMapping locks the API code → typed
|
||||
// category/subtype/exit mapping. Classification is sourced from
|
||||
// internal/errclass/codemeta_task.go via errclass.BuildAPIError; the
|
||||
// task-specific recovery hint is layered on from taskAPIHints. 1470400 surfaces
|
||||
// exit 1 (API-side parameter rejection, was exit 2 under legacy); 1470403
|
||||
// routes to CategoryAuthorization and surfaces exit 3 (was exit 1).
|
||||
func TestHandleTaskApiResult_TypedMapping(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
input string
|
||||
wantCode int
|
||||
wantType string
|
||||
wantSubstr string
|
||||
name string
|
||||
code int
|
||||
wantSubtype errs.Subtype
|
||||
wantExit int
|
||||
wantRetry bool
|
||||
}{
|
||||
{
|
||||
name: "invalid format returns ErrValidation",
|
||||
input: "not-relative",
|
||||
wantCode: output.ExitValidation,
|
||||
wantType: "validation",
|
||||
wantSubstr: "invalid relative time format",
|
||||
},
|
||||
{"invalid_params", ErrCodeTaskInvalidParams, errs.SubtypeInvalidParameters, output.ExitAPI, false},
|
||||
{"not_found", ErrCodeTaskNotFound, errs.SubtypeNotFound, output.ExitAPI, false},
|
||||
{"conflict", ErrCodeTaskConflict, errs.SubtypeConflict, output.ExitAPI, true},
|
||||
{"internal", ErrCodeTaskInternalError, errs.SubtypeServerError, output.ExitAPI, true},
|
||||
{"assignee_limit", ErrCodeTaskAssigneeLimit, errs.SubtypeQuotaExceeded, output.ExitAPI, false},
|
||||
{"follower_limit", ErrCodeTaskFollowerLimit, errs.SubtypeQuotaExceeded, output.ExitAPI, false},
|
||||
{"member_limit", ErrCodeTasklistMemberLimit, errs.SubtypeQuotaExceeded, output.ExitAPI, false},
|
||||
{"reminder_exists", ErrCodeTaskReminderExists, errs.SubtypeAlreadyExists, output.ExitAPI, false},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
_, err := parseRelativeTime(tt.input)
|
||||
if err == nil {
|
||||
t.Fatalf("parseRelativeTime(%q) expected error, got nil", tt.input)
|
||||
data, err := HandleTaskApiResult(apiResult(tt.code, "raw upstream detail"), nil, "do thing")
|
||||
if data != nil {
|
||||
t.Errorf("data = %v, want nil on error", data)
|
||||
}
|
||||
|
||||
var exitErr *output.ExitError
|
||||
if !errors.As(err, &exitErr) {
|
||||
t.Fatalf("error type = %T, want *output.ExitError; error = %v", err, err)
|
||||
p, ok := errs.ProblemOf(err)
|
||||
if !ok {
|
||||
t.Fatalf("err = %T, want typed errs.* error", err)
|
||||
}
|
||||
if exitErr.Code != tt.wantCode {
|
||||
t.Errorf("exit code = %d, want %d", exitErr.Code, tt.wantCode)
|
||||
if p.Subtype != tt.wantSubtype {
|
||||
t.Errorf("subtype = %q, want %q", p.Subtype, tt.wantSubtype)
|
||||
}
|
||||
if exitErr.Detail == nil {
|
||||
t.Fatal("expected non-nil error detail")
|
||||
if p.Code != tt.code {
|
||||
t.Errorf("code = %d, want %d", p.Code, tt.code)
|
||||
}
|
||||
if exitErr.Detail.Type != tt.wantType {
|
||||
t.Errorf("error type = %q, want %q", exitErr.Detail.Type, tt.wantType)
|
||||
if got := output.ExitCodeOf(err); got != tt.wantExit {
|
||||
t.Errorf("exit code = %d, want %d", got, tt.wantExit)
|
||||
}
|
||||
if p.Retryable != tt.wantRetry {
|
||||
t.Errorf("retryable = %v, want %v", p.Retryable, tt.wantRetry)
|
||||
}
|
||||
// These CategoryAPI codes carry the task-specific recovery hint.
|
||||
if p.Hint != taskAPIHints[tt.code] {
|
||||
t.Errorf("hint = %q, want %q", p.Hint, taskAPIHints[tt.code])
|
||||
}
|
||||
// BuildAPIError uses the raw upstream msg as the message.
|
||||
if !strings.Contains(err.Error(), "raw upstream detail") {
|
||||
t.Errorf("message = %q, want raw upstream detail", err.Error())
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestHandleTaskApiResult_PermissionDenied verifies 1470403 routes to a typed
|
||||
// *errs.PermissionError (exit 3) carrying the canonical permission hint from
|
||||
// BuildAPIError — taskAPIHints intentionally omits it so the canonical hint
|
||||
// stands.
|
||||
func TestHandleTaskApiResult_PermissionDenied(t *testing.T) {
|
||||
_, err := HandleTaskApiResult(apiResult(ErrCodeTaskPermissionDenied, "no permission"), nil, "do thing")
|
||||
var pe *errs.PermissionError
|
||||
if !errors.As(err, &pe) {
|
||||
t.Fatalf("err = %T, want *errs.PermissionError", err)
|
||||
}
|
||||
if pe.Subtype != errs.SubtypePermissionDenied {
|
||||
t.Errorf("subtype = %q, want %q", pe.Subtype, errs.SubtypePermissionDenied)
|
||||
}
|
||||
if got := output.ExitCodeOf(err); got != output.ExitAuth {
|
||||
t.Errorf("exit code = %d, want %d", got, output.ExitAuth)
|
||||
}
|
||||
if strings.TrimSpace(pe.Hint) == "" {
|
||||
t.Error("expected a canonical permission hint, got empty")
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleTaskApiResultWithContext_PermissionConsoleURL(t *testing.T) {
|
||||
_, err := HandleTaskApiResultWithContext(map[string]interface{}{
|
||||
"code": float64(99991672),
|
||||
"msg": "access denied",
|
||||
"error": map[string]interface{}{
|
||||
"permission_violations": []interface{}{
|
||||
map[string]interface{}{"subject": "task:attachment:write"},
|
||||
},
|
||||
},
|
||||
}, nil, "upload task attachment", errclass.ClassifyContext{
|
||||
Brand: "lark",
|
||||
AppID: "cli_a123",
|
||||
Identity: "bot",
|
||||
})
|
||||
|
||||
var pe *errs.PermissionError
|
||||
if !errors.As(err, &pe) {
|
||||
t.Fatalf("err = %T, want *errs.PermissionError", err)
|
||||
}
|
||||
if pe.Subtype != errs.SubtypeAppScopeNotApplied {
|
||||
t.Errorf("subtype = %q, want %q", pe.Subtype, errs.SubtypeAppScopeNotApplied)
|
||||
}
|
||||
if pe.ConsoleURL == "" || !strings.Contains(pe.ConsoleURL, "open.larksuite.com/app/cli_a123/auth") {
|
||||
t.Errorf("ConsoleURL = %q, want Lark developer console URL", pe.ConsoleURL)
|
||||
}
|
||||
if len(pe.MissingScopes) != 1 || pe.MissingScopes[0] != "task:attachment:write" {
|
||||
t.Errorf("MissingScopes = %#v, want task:attachment:write", pe.MissingScopes)
|
||||
}
|
||||
if !strings.Contains(pe.Hint, pe.ConsoleURL) {
|
||||
t.Errorf("hint = %q, want to include console URL %q", pe.Hint, pe.ConsoleURL)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCallTaskAPITyped_TaskHint(t *testing.T) {
|
||||
cfg := taskTestConfig(t)
|
||||
f, _, _, reg := cmdutil.TestFactory(t, cfg)
|
||||
rt := common.TestNewRuntimeContextForAPI(context.Background(), &cobra.Command{Use: "+x"}, cfg, f, core.AsUser)
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: http.MethodGet,
|
||||
URL: "/open-apis/task/v2/tasks/t-1",
|
||||
Body: map[string]interface{}{
|
||||
"code": float64(ErrCodeTaskReminderExists),
|
||||
"msg": "reminder exists",
|
||||
},
|
||||
})
|
||||
|
||||
_, err := callTaskAPITyped(rt, http.MethodGet, "/open-apis/task/v2/tasks/t-1", nil, nil)
|
||||
p, ok := errs.ProblemOf(err)
|
||||
if !ok {
|
||||
t.Fatalf("err = %T, want typed errs.* error", err)
|
||||
}
|
||||
if p.Hint != taskAPIHints[ErrCodeTaskReminderExists] {
|
||||
t.Errorf("hint = %q, want %q", p.Hint, taskAPIHints[ErrCodeTaskReminderExists])
|
||||
}
|
||||
}
|
||||
|
||||
// TestHandleTaskApiResult_MalformedResponse covers the two malformed-body arms:
|
||||
// a response with no top-level code, and one whose code is non-numeric. Both
|
||||
// must surface a typed internal invalid_response error (exit 5) rather than
|
||||
// silently passing through as a success.
|
||||
func TestHandleTaskApiResult_MalformedResponse(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
result map[string]interface{}
|
||||
}{
|
||||
{"missing code field", map[string]interface{}{"msg": "weird", "data": map[string]interface{}{}}},
|
||||
{"non-numeric code", map[string]interface{}{"code": "oops", "msg": "weird"}},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
data, err := HandleTaskApiResult(tc.result, nil, "do thing")
|
||||
if data != nil {
|
||||
t.Errorf("data = %v, want nil", data)
|
||||
}
|
||||
var ie *errs.InternalError
|
||||
if !errors.As(err, &ie) {
|
||||
t.Fatalf("err = %T, want *errs.InternalError", err)
|
||||
}
|
||||
if ie.Subtype != errs.SubtypeInvalidResponse {
|
||||
t.Errorf("subtype = %q, want %q", ie.Subtype, errs.SubtypeInvalidResponse)
|
||||
}
|
||||
if got := output.ExitCodeOf(err); got != output.ExitInternal {
|
||||
t.Errorf("exit code = %d, want %d", got, output.ExitInternal)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestHandleTaskApiResult_Success returns the data map unchanged when code == 0.
|
||||
func TestHandleTaskApiResult_Success(t *testing.T) {
|
||||
want := map[string]interface{}{"guid": "t-1"}
|
||||
data, err := HandleTaskApiResult(map[string]interface{}{
|
||||
"code": float64(0),
|
||||
"data": want,
|
||||
}, nil, "do thing")
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if data["guid"] != "t-1" {
|
||||
t.Errorf("data = %v, want guid=t-1", data)
|
||||
}
|
||||
}
|
||||
|
||||
// TestHandleTaskApiResult_UnknownCode covers the fallback arm: an uncatalogued
|
||||
// code becomes a generic CategoryAPI error with SubtypeUnknown and no layered
|
||||
// hint.
|
||||
func TestHandleTaskApiResult_UnknownCode(t *testing.T) {
|
||||
_, err := HandleTaskApiResult(apiResult(9999999, "weird"), nil, "do thing")
|
||||
p, ok := errs.ProblemOf(err)
|
||||
if !ok {
|
||||
t.Fatalf("err = %T, want typed errs.* error", err)
|
||||
}
|
||||
if p.Subtype != errs.SubtypeUnknown {
|
||||
t.Errorf("subtype = %q, want %q", p.Subtype, errs.SubtypeUnknown)
|
||||
}
|
||||
if p.Code != 9999999 {
|
||||
t.Errorf("code = %d, want 9999999", p.Code)
|
||||
}
|
||||
if got := output.ExitCodeOf(err); got != output.ExitAPI {
|
||||
t.Errorf("exit code = %d, want %d", got, output.ExitAPI)
|
||||
}
|
||||
var ae *errs.APIError
|
||||
if !errors.As(err, &ae) {
|
||||
t.Errorf("error type = %T, want *errs.APIError", err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,16 +5,13 @@ package task
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
|
||||
larkcore "github.com/larksuite/oapi-sdk-go/v3/core"
|
||||
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
@@ -55,8 +52,7 @@ var AddTaskToTasklist = common.Shortcut{
|
||||
tasklistGuid := extractTasklistGuid(runtime.Str("tasklist-id"))
|
||||
taskIds := strings.Split(runtime.Str("task-id"), ",")
|
||||
|
||||
queryParams := make(larkcore.QueryParams)
|
||||
queryParams.Set("user_id_type", "open_id")
|
||||
params := map[string]interface{}{"user_id_type": "open_id"}
|
||||
|
||||
body := map[string]interface{}{
|
||||
"tasklist_guid": tasklistGuid,
|
||||
@@ -75,30 +71,16 @@ var AddTaskToTasklist = common.Shortcut{
|
||||
continue
|
||||
}
|
||||
|
||||
apiResp, err := runtime.DoAPI(&larkcore.ApiReq{
|
||||
HttpMethod: http.MethodPost,
|
||||
ApiPath: "/open-apis/task/v2/tasks/" + url.PathEscape(taskId) + "/add_tasklist",
|
||||
QueryParams: queryParams,
|
||||
Body: body,
|
||||
})
|
||||
|
||||
var result map[string]interface{}
|
||||
if err == nil {
|
||||
if parseErr := json.Unmarshal(apiResp.RawBody, &result); parseErr != nil {
|
||||
err = WrapTaskError(ErrCodeTaskInternalError, fmt.Sprintf("failed to parse response: %v", parseErr), "parse add task response")
|
||||
}
|
||||
}
|
||||
|
||||
data, err := HandleTaskApiResult(result, err, "add task to tasklist")
|
||||
data, err := callTaskAPITyped(runtime, http.MethodPost, "/open-apis/task/v2/tasks/"+url.PathEscape(taskId)+"/add_tasklist", params, body)
|
||||
if err != nil {
|
||||
failDetail := map[string]interface{}{
|
||||
"guid": taskId,
|
||||
}
|
||||
if exitErr, ok := err.(*output.ExitError); ok && exitErr.Detail != nil {
|
||||
failDetail["type"] = exitErr.Detail.Type
|
||||
failDetail["code"] = exitErr.Detail.Code
|
||||
failDetail["message"] = exitErr.Detail.Message
|
||||
failDetail["hint"] = exitErr.Detail.Hint
|
||||
if p, ok := errs.ProblemOf(err); ok {
|
||||
failDetail["type"] = string(p.Subtype)
|
||||
failDetail["code"] = p.Code
|
||||
failDetail["message"] = p.Message
|
||||
failDetail["hint"] = p.Hint
|
||||
} else {
|
||||
failDetail["type"] = "api_error"
|
||||
failDetail["message"] = err.Error()
|
||||
@@ -123,6 +105,13 @@ var AddTaskToTasklist = common.Shortcut{
|
||||
"tasklist_guid": tasklistGuid,
|
||||
}
|
||||
|
||||
// Item-level failures surface as a non-zero exit (ok:false) so callers
|
||||
// don't have to inspect failed_tasks to detect a partial add; the full
|
||||
// payload (successful + failed) stays on stdout either way.
|
||||
if len(failed) > 0 {
|
||||
return runtime.OutPartialFailure(resultData, nil)
|
||||
}
|
||||
|
||||
runtime.OutFormat(resultData, nil, func(w io.Writer) {
|
||||
fmt.Fprintf(w, "✅ Tasks added to tasklist %s!\n", tasklistGuid)
|
||||
fmt.Fprintf(w, "Successful: %d, Failed: %d\n", len(successful), len(failed))
|
||||
|
||||
@@ -4,10 +4,13 @@
|
||||
package task
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/httpmock"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
)
|
||||
|
||||
func TestAddTaskToTasklist_Success(t *testing.T) {
|
||||
@@ -41,3 +44,76 @@ func TestAddTaskToTasklist_Success(t *testing.T) {
|
||||
t.Errorf("expected tasklist_guid in output, got: %s", out)
|
||||
}
|
||||
}
|
||||
|
||||
// TestAddTaskToTasklist_PartialFailure exercises the batch path: some tasks
|
||||
// succeed, others fail with typed API errors. Successful and failed tasks both
|
||||
// land in stdout as an ok:false envelope, and the command returns the typed
|
||||
// partial-failure exit signal (exit 1) via runtime.OutPartialFailure. The
|
||||
// failed_tasks[].type carries the typed subtype (e.g. "permission_denied",
|
||||
// "not_found") read off errs.ProblemOf, not the legacy
|
||||
// *output.ExitError.Detail.Type ("permission_error" etc).
|
||||
func TestAddTaskToTasklist_PartialFailure(t *testing.T) {
|
||||
f, stdout, _, reg := taskShortcutTestFactory(t)
|
||||
warmTenantToken(t, f, reg)
|
||||
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/task/v2/tasks/task-ok/add_tasklist",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0, "msg": "success",
|
||||
"data": map[string]interface{}{
|
||||
"task": map[string]interface{}{
|
||||
"guid": "task-ok",
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/task/v2/tasks/task-perm/add_tasklist",
|
||||
Body: map[string]interface{}{
|
||||
"code": ErrCodeTaskPermissionDenied, "msg": "no permission",
|
||||
},
|
||||
})
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/task/v2/tasks/task-missing/add_tasklist",
|
||||
Body: map[string]interface{}{
|
||||
"code": ErrCodeTaskNotFound, "msg": "task not found",
|
||||
},
|
||||
})
|
||||
|
||||
s := AddTaskToTasklist
|
||||
s.AuthTypes = []string{"bot", "user"}
|
||||
|
||||
args := []string{"+tasklist-task-add", "--tasklist-id", "tl-123", "--task-id", "task-ok,task-perm,task-missing", "--as", "bot", "--format", "json"}
|
||||
err := runMountedTaskShortcut(t, s, args, f, stdout)
|
||||
// Partial failure now surfaces as a non-zero exit (ok:false), not nil.
|
||||
var pfErr *output.PartialFailureError
|
||||
if !errors.As(err, &pfErr) {
|
||||
t.Fatalf("expected *output.PartialFailureError on partial failure, got %T: %v", err, err)
|
||||
}
|
||||
if pfErr.Code != output.ExitAPI {
|
||||
t.Errorf("exit code = %d, want %d (ExitAPI)", pfErr.Code, output.ExitAPI)
|
||||
}
|
||||
|
||||
out := stdout.String()
|
||||
|
||||
// Successful task is in stdout.
|
||||
if !strings.Contains(out, "task-ok") {
|
||||
t.Errorf("expected successful task-ok in output, got: %s", out)
|
||||
}
|
||||
|
||||
// Failed tasks carry the typed subtype, not the legacy Detail.Type.
|
||||
if !strings.Contains(out, string(errs.SubtypePermissionDenied)) {
|
||||
t.Errorf("expected typed subtype %q in failed_tasks, got: %s", errs.SubtypePermissionDenied, out)
|
||||
}
|
||||
if !strings.Contains(out, string(errs.SubtypeNotFound)) {
|
||||
t.Errorf("expected typed subtype %q in failed_tasks, got: %s", errs.SubtypeNotFound, out)
|
||||
}
|
||||
|
||||
// The legacy shapes must not leak.
|
||||
if strings.Contains(out, "permission_error") {
|
||||
t.Errorf("legacy type \"permission_error\" leaked into output: %s", out)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,8 +12,8 @@ import (
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
larkcore "github.com/larksuite/oapi-sdk-go/v3/core"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
@@ -50,24 +50,18 @@ var CreateTasklist = common.Shortcut{
|
||||
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
body := buildTasklistCreateBody(runtime)
|
||||
queryParams := make(larkcore.QueryParams)
|
||||
queryParams.Set("user_id_type", "open_id")
|
||||
params := map[string]interface{}{"user_id_type": "open_id"}
|
||||
|
||||
apiResp, err := runtime.DoAPI(&larkcore.ApiReq{
|
||||
HttpMethod: http.MethodPost,
|
||||
ApiPath: "/open-apis/task/v2/tasklists",
|
||||
QueryParams: queryParams,
|
||||
Body: body,
|
||||
})
|
||||
|
||||
var result map[string]interface{}
|
||||
if err == nil {
|
||||
if parseErr := json.Unmarshal(apiResp.RawBody, &result); parseErr != nil {
|
||||
return WrapTaskError(ErrCodeTaskInternalError, fmt.Sprintf("failed to parse response: %v", parseErr), "parse create tasklist")
|
||||
// Validate --data (client input) before any remote write, so a malformed
|
||||
// payload fails fast without creating an orphan tasklist.
|
||||
var tasks []map[string]interface{}
|
||||
if dataStr := runtime.Str("data"); dataStr != "" {
|
||||
if err := json.Unmarshal([]byte(dataStr), &tasks); err != nil {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "failed to parse --data as JSON array: %v", err).WithParam("--data")
|
||||
}
|
||||
}
|
||||
|
||||
data, err := HandleTaskApiResult(result, err, "create tasklist")
|
||||
data, err := callTaskAPITyped(runtime, http.MethodPost, "/open-apis/task/v2/tasklists", params, body)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -78,16 +72,10 @@ var CreateTasklist = common.Shortcut{
|
||||
tasklistUrl, _ := tasklist["url"].(string)
|
||||
tasklistUrl = truncateTaskURL(tasklistUrl)
|
||||
|
||||
// Create tasks if data is provided
|
||||
var tasks []map[string]interface{}
|
||||
var createdTasks []map[string]interface{}
|
||||
var failedTasks []string
|
||||
|
||||
if dataStr := runtime.Str("data"); dataStr != "" {
|
||||
if err := json.Unmarshal([]byte(dataStr), &tasks); err != nil {
|
||||
return WrapTaskError(ErrCodeTaskInvalidParams, fmt.Sprintf("failed to parse --data as JSON array: %v", err), "parse data")
|
||||
}
|
||||
var failedTasks []map[string]interface{}
|
||||
|
||||
if len(tasks) > 0 {
|
||||
var wg sync.WaitGroup
|
||||
var mu sync.Mutex
|
||||
|
||||
@@ -120,27 +108,14 @@ var CreateTasklist = common.Shortcut{
|
||||
delete(tDef, "assignee")
|
||||
}
|
||||
|
||||
tResp, tErr := runtime.DoAPI(&larkcore.ApiReq{
|
||||
HttpMethod: http.MethodPost,
|
||||
ApiPath: "/open-apis/task/v2/tasks",
|
||||
QueryParams: queryParams,
|
||||
Body: tDef,
|
||||
})
|
||||
tData, tErr := callTaskAPITyped(runtime, http.MethodPost, "/open-apis/task/v2/tasks", params, tDef)
|
||||
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
|
||||
var tResult map[string]interface{}
|
||||
if tErr == nil {
|
||||
if json.Unmarshal(tResp.RawBody, &tResult) != nil {
|
||||
tErr = WrapTaskError(ErrCodeTaskInternalError, "failed to parse task response", "parse task")
|
||||
}
|
||||
}
|
||||
|
||||
tData, tErr := HandleTaskApiResult(tResult, tErr, "create task in tasklist")
|
||||
if tErr != nil {
|
||||
summary, _ := tDef["summary"].(string)
|
||||
failedTasks = append(failedTasks, fmt.Sprintf("Index %d (%s): %v", idx, summary, tErr))
|
||||
failedTasks = append(failedTasks, buildTaskCreateFailure(idx, summary, tErr))
|
||||
return
|
||||
}
|
||||
|
||||
@@ -163,9 +138,10 @@ var CreateTasklist = common.Shortcut{
|
||||
"guid": tasklistGuid,
|
||||
"url": tasklistUrl,
|
||||
"created_tasks": createdTasks,
|
||||
"failed_tasks": failedTasks,
|
||||
}
|
||||
|
||||
runtime.OutFormat(outData, nil, func(w io.Writer) {
|
||||
pretty := func(w io.Writer) {
|
||||
fmt.Fprintf(w, "✅ Tasklist created successfully!\n")
|
||||
fmt.Fprintf(w, "Tasklist Name: %s\n", tasklistName)
|
||||
fmt.Fprintf(w, "Tasklist ID: %s\n", tasklistGuid)
|
||||
@@ -188,15 +164,45 @@ var CreateTasklist = common.Shortcut{
|
||||
if len(failedTasks) > 0 {
|
||||
fmt.Fprintf(w, "\nFailed tasks:\n")
|
||||
for _, f := range failedTasks {
|
||||
fmt.Fprintf(w, " - %s\n", f)
|
||||
fmt.Fprintf(w, " - Index %v (%s): %s\n", f["index"], f["summary"], f["message"])
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// Sub-task creation failures surface as a non-zero exit. JSON/JQ callers
|
||||
// need an ok:false envelope, while pretty output should preserve the
|
||||
// command-specific human-readable summary.
|
||||
if len(failedTasks) > 0 {
|
||||
if runtime.Format == "pretty" && runtime.JqExpr == "" {
|
||||
runtime.OutFormat(outData, nil, pretty)
|
||||
return output.PartialFailure(output.ExitAPI)
|
||||
}
|
||||
return runtime.OutPartialFailure(outData, nil)
|
||||
}
|
||||
|
||||
runtime.OutFormat(outData, nil, pretty)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
func buildTaskCreateFailure(index int, summary string, err error) map[string]interface{} {
|
||||
failDetail := map[string]interface{}{
|
||||
"index": index,
|
||||
"summary": summary,
|
||||
}
|
||||
if p, ok := errs.ProblemOf(err); ok {
|
||||
failDetail["type"] = string(p.Subtype)
|
||||
failDetail["code"] = p.Code
|
||||
failDetail["message"] = p.Message
|
||||
failDetail["hint"] = p.Hint
|
||||
} else {
|
||||
failDetail["type"] = "api_error"
|
||||
failDetail["message"] = err.Error()
|
||||
}
|
||||
return failDetail
|
||||
}
|
||||
|
||||
func buildTasklistCreateBody(runtime *common.RuntimeContext) map[string]interface{} {
|
||||
body := map[string]interface{}{
|
||||
"name": runtime.Str("name"),
|
||||
|
||||
236
shortcuts/task/tasklist_create_test.go
Normal file
236
shortcuts/task/tasklist_create_test.go
Normal file
@@ -0,0 +1,236 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package task
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/httpmock"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
)
|
||||
|
||||
// TestCreateTasklist_PartialFailure exercises the batch sub-task path: the
|
||||
// tasklist is created (code 0), then two sub-tasks are created concurrently —
|
||||
// one succeeds, one fails with a typed API error. The command returns the typed
|
||||
// partial-failure exit signal (*output.PartialFailureError, ExitAPI) via
|
||||
// runtime.OutPartialFailure, and stdout carries both created_tasks (the
|
||||
// success) and failed_tasks (the failure) so the partial result is inspectable.
|
||||
// Sub-tasks are routed by summary via BodyFilter because both POST the same
|
||||
// /tasks URL and run on separate goroutines.
|
||||
func TestCreateTasklist_PartialFailure(t *testing.T) {
|
||||
f, stdout, _, reg := taskShortcutTestFactory(t)
|
||||
warmTenantToken(t, f, reg)
|
||||
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/task/v2/tasklists",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0, "msg": "success",
|
||||
"data": map[string]interface{}{
|
||||
"tasklist": map[string]interface{}{
|
||||
"guid": "tl-new",
|
||||
"name": "My List",
|
||||
"url": "https://example.feishu.cn/tl-new",
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
// Succeeding sub-task (summary "ok-task").
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/task/v2/tasks",
|
||||
BodyFilter: func(b []byte) bool { return bytes.Contains(b, []byte("ok-task")) },
|
||||
Body: map[string]interface{}{
|
||||
"code": 0, "msg": "success",
|
||||
"data": map[string]interface{}{
|
||||
"task": map[string]interface{}{
|
||||
"guid": "task-ok",
|
||||
"url": "https://example.feishu.cn/task-ok",
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
// Failing sub-task (summary "bad-task") → typed permission_denied.
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/task/v2/tasks",
|
||||
BodyFilter: func(b []byte) bool { return bytes.Contains(b, []byte("bad-task")) },
|
||||
Body: map[string]interface{}{
|
||||
"code": ErrCodeTaskPermissionDenied, "msg": "no permission",
|
||||
},
|
||||
})
|
||||
|
||||
s := CreateTasklist
|
||||
s.AuthTypes = []string{"bot", "user"}
|
||||
|
||||
data := `[{"summary":"ok-task"},{"summary":"bad-task"}]`
|
||||
args := []string{"+tasklist-create", "--name", "My List", "--data", data, "--as", "bot", "--format", "json"}
|
||||
err := runMountedTaskShortcut(t, s, args, f, stdout)
|
||||
|
||||
var pfErr *output.PartialFailureError
|
||||
if !errors.As(err, &pfErr) {
|
||||
t.Fatalf("err = %T, want *output.PartialFailureError; err = %v", err, err)
|
||||
}
|
||||
if pfErr.Code != output.ExitAPI {
|
||||
t.Errorf("exit code = %d, want %d (ExitAPI)", pfErr.Code, output.ExitAPI)
|
||||
}
|
||||
|
||||
out := stdout.String()
|
||||
|
||||
// The tasklist itself is created and stays in the payload.
|
||||
if !strings.Contains(out, "tl-new") {
|
||||
t.Errorf("expected created tasklist guid tl-new in output, got: %s", out)
|
||||
}
|
||||
// Success lands in created_tasks.
|
||||
if !strings.Contains(out, "task-ok") {
|
||||
t.Errorf("expected created sub-task task-ok in output, got: %s", out)
|
||||
}
|
||||
// Failure lands in failed_tasks (keyed by index + summary).
|
||||
if !strings.Contains(out, "bad-task") {
|
||||
t.Errorf("expected failed sub-task bad-task in output, got: %s", out)
|
||||
}
|
||||
if !strings.Contains(out, string(errs.SubtypePermissionDenied)) {
|
||||
t.Errorf("expected typed subtype %q in failed_tasks, got: %s", errs.SubtypePermissionDenied, out)
|
||||
}
|
||||
if !strings.Contains(out, `"code": 1470403`) && !strings.Contains(out, `"code":1470403`) {
|
||||
t.Errorf("expected task permission code in failed_tasks, got: %s", out)
|
||||
}
|
||||
if strings.Contains(out, "permission_error") {
|
||||
t.Errorf("legacy type \"permission_error\" leaked into output: %s", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateTasklist_PartialFailurePrettyOutput(t *testing.T) {
|
||||
f, stdout, _, reg := taskShortcutTestFactory(t)
|
||||
warmTenantToken(t, f, reg)
|
||||
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/task/v2/tasklists",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0, "msg": "success",
|
||||
"data": map[string]interface{}{
|
||||
"tasklist": map[string]interface{}{
|
||||
"guid": "tl-new",
|
||||
"name": "My List",
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/task/v2/tasks",
|
||||
BodyFilter: func(b []byte) bool { return bytes.Contains(b, []byte("ok-task")) },
|
||||
Body: map[string]interface{}{
|
||||
"code": 0, "msg": "success",
|
||||
"data": map[string]interface{}{
|
||||
"task": map[string]interface{}{"guid": "task-ok"},
|
||||
},
|
||||
},
|
||||
})
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/task/v2/tasks",
|
||||
BodyFilter: func(b []byte) bool { return bytes.Contains(b, []byte("bad-task")) },
|
||||
Body: map[string]interface{}{"code": ErrCodeTaskPermissionDenied, "msg": "no permission"},
|
||||
})
|
||||
|
||||
s := CreateTasklist
|
||||
s.AuthTypes = []string{"bot", "user"}
|
||||
|
||||
err := runMountedTaskShortcut(t, s, []string{
|
||||
"+tasklist-create",
|
||||
"--name", "My List",
|
||||
"--data", `[{"summary":"ok-task"},{"summary":"bad-task"}]`,
|
||||
"--as", "bot",
|
||||
"--format", "pretty",
|
||||
}, f, stdout)
|
||||
|
||||
var pfErr *output.PartialFailureError
|
||||
if !errors.As(err, &pfErr) {
|
||||
t.Fatalf("err = %T, want *output.PartialFailureError; err = %v", err, err)
|
||||
}
|
||||
out := stdout.String()
|
||||
for _, want := range []string{
|
||||
"Tasklist created successfully",
|
||||
"Tasks created: 1/2",
|
||||
"Failed tasks:",
|
||||
"Index",
|
||||
"bad-task",
|
||||
"user lacks permission",
|
||||
} {
|
||||
if !strings.Contains(out, want) {
|
||||
t.Errorf("pretty output missing %q; got:\n%s", want, out)
|
||||
}
|
||||
}
|
||||
if strings.Contains(out, `"ok":`) {
|
||||
t.Errorf("pretty partial failure should use text output, got JSON envelope:\n%s", out)
|
||||
}
|
||||
}
|
||||
|
||||
// TestCreateTasklist_InvalidDataJSON covers the --data validation arm: a string
|
||||
// that is not a JSON array must surface a typed *errs.ValidationError
|
||||
// (invalid_argument, exit 2) after the tasklist create succeeds.
|
||||
func TestCreateTasklist_InvalidDataJSON(t *testing.T) {
|
||||
f, stdout, _, reg := taskShortcutTestFactory(t)
|
||||
warmTenantToken(t, f, reg)
|
||||
|
||||
// No POST /tasklists stub is registered on purpose: invalid --data must be
|
||||
// rejected before any remote write, leaving no orphan tasklist. If the
|
||||
// ordering regressed (create first), the POST would hit no stub and surface
|
||||
// as a non-validation transport error, failing the assertion below.
|
||||
s := CreateTasklist
|
||||
s.AuthTypes = []string{"bot", "user"}
|
||||
|
||||
args := []string{"+tasklist-create", "--name", "My List", "--data", "{not-an-array", "--as", "bot", "--format", "json"}
|
||||
err := runMountedTaskShortcut(t, s, args, f, stdout)
|
||||
|
||||
var ve *errs.ValidationError
|
||||
if !errors.As(err, &ve) {
|
||||
t.Fatalf("err = %T, want *errs.ValidationError; err = %v", err, err)
|
||||
}
|
||||
if ve.Subtype != errs.SubtypeInvalidArgument {
|
||||
t.Errorf("subtype = %q, want %q", ve.Subtype, errs.SubtypeInvalidArgument)
|
||||
}
|
||||
if got := output.ExitCodeOf(err); got != output.ExitValidation {
|
||||
t.Errorf("exit code = %d, want %d (ExitValidation)", got, output.ExitValidation)
|
||||
}
|
||||
}
|
||||
|
||||
// TestCreateTasklist_MalformedResponse covers the create-tasklist parse arm: a
|
||||
// 200 with a non-JSON body must surface a typed
|
||||
// *errs.InternalError(invalid_response) (exit 5) from the json.Unmarshal guard.
|
||||
func TestCreateTasklist_MalformedResponse(t *testing.T) {
|
||||
f, stdout, _, reg := taskShortcutTestFactory(t)
|
||||
warmTenantToken(t, f, reg)
|
||||
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/task/v2/tasklists",
|
||||
RawBody: []byte("not json"),
|
||||
})
|
||||
|
||||
s := CreateTasklist
|
||||
s.AuthTypes = []string{"bot", "user"}
|
||||
|
||||
args := []string{"+tasklist-create", "--name", "My List", "--as", "bot", "--format", "json"}
|
||||
err := runMountedTaskShortcut(t, s, args, f, stdout)
|
||||
|
||||
var ie *errs.InternalError
|
||||
if !errors.As(err, &ie) {
|
||||
t.Fatalf("err = %T, want *errs.InternalError; err = %v", err, err)
|
||||
}
|
||||
if ie.Subtype != errs.SubtypeInvalidResponse {
|
||||
t.Errorf("subtype = %q, want %q", ie.Subtype, errs.SubtypeInvalidResponse)
|
||||
}
|
||||
if got := output.ExitCodeOf(err); got != output.ExitInternal {
|
||||
t.Errorf("exit code = %d, want %d (ExitInternal)", got, output.ExitInternal)
|
||||
}
|
||||
}
|
||||
@@ -5,15 +5,13 @@ package task
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
|
||||
larkcore "github.com/larksuite/oapi-sdk-go/v3/core"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
@@ -39,7 +37,7 @@ var MembersTasklist = common.Shortcut{
|
||||
hasRemove := runtime.Str("remove") != ""
|
||||
|
||||
if hasSet && (hasAdd || hasRemove) {
|
||||
return WrapTaskError(ErrCodeTaskInvalidParams, "cannot combine --set with --add or --remove", "validate tasklist members")
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "cannot combine --set with --add or --remove")
|
||||
}
|
||||
return nil
|
||||
},
|
||||
@@ -74,8 +72,7 @@ var MembersTasklist = common.Shortcut{
|
||||
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
tlId := url.PathEscape(extractTasklistGuid(runtime.Str("tasklist-id")))
|
||||
queryParams := make(larkcore.QueryParams)
|
||||
queryParams.Set("user_id_type", "open_id")
|
||||
params := map[string]interface{}{"user_id_type": "open_id"}
|
||||
|
||||
setStr := runtime.Str("set")
|
||||
addStr := runtime.Str("add")
|
||||
@@ -83,20 +80,7 @@ var MembersTasklist = common.Shortcut{
|
||||
|
||||
// If no modifications, just list
|
||||
if setStr == "" && addStr == "" && removeStr == "" {
|
||||
getResp, err := runtime.DoAPI(&larkcore.ApiReq{
|
||||
HttpMethod: http.MethodGet,
|
||||
ApiPath: "/open-apis/task/v2/tasklists/" + tlId,
|
||||
QueryParams: queryParams,
|
||||
})
|
||||
|
||||
var getResult map[string]interface{}
|
||||
if err == nil {
|
||||
if parseErr := json.Unmarshal(getResp.RawBody, &getResult); parseErr != nil {
|
||||
return WrapTaskError(ErrCodeTaskInternalError, fmt.Sprintf("failed to parse response: %v", parseErr), "parse tasklist details")
|
||||
}
|
||||
}
|
||||
|
||||
data, err := HandleTaskApiResult(getResult, err, "get tasklist members")
|
||||
data, err := callTaskAPITyped(runtime, http.MethodGet, "/open-apis/task/v2/tasklists/"+tlId, params, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -142,20 +126,7 @@ var MembersTasklist = common.Shortcut{
|
||||
var lastTasklist map[string]interface{}
|
||||
if setStr != "" {
|
||||
// Query existing to diff for "set" behavior
|
||||
getResp, err := runtime.DoAPI(&larkcore.ApiReq{
|
||||
HttpMethod: http.MethodGet,
|
||||
ApiPath: "/open-apis/task/v2/tasklists/" + tlId,
|
||||
QueryParams: queryParams,
|
||||
})
|
||||
|
||||
var getResult map[string]interface{}
|
||||
if err == nil {
|
||||
if parseErr := json.Unmarshal(getResp.RawBody, &getResult); parseErr != nil {
|
||||
return WrapTaskError(ErrCodeTaskInternalError, fmt.Sprintf("failed to parse response: %v", parseErr), "parse tasklist details")
|
||||
}
|
||||
}
|
||||
|
||||
data, err := HandleTaskApiResult(getResult, err, "get tasklist details for set")
|
||||
data, err := callTaskAPITyped(runtime, http.MethodGet, "/open-apis/task/v2/tasklists/"+tlId, params, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -198,21 +169,7 @@ var MembersTasklist = common.Shortcut{
|
||||
|
||||
if len(toAdd) > 0 {
|
||||
body := buildTlMembersBody(strings.Join(toAdd, ","))
|
||||
apiResp, err := runtime.DoAPI(&larkcore.ApiReq{
|
||||
HttpMethod: http.MethodPost,
|
||||
ApiPath: "/open-apis/task/v2/tasklists/" + tlId + "/add_members",
|
||||
QueryParams: queryParams,
|
||||
Body: body,
|
||||
})
|
||||
|
||||
var addResult map[string]interface{}
|
||||
if err == nil {
|
||||
if parseErr := json.Unmarshal(apiResp.RawBody, &addResult); parseErr != nil {
|
||||
return WrapTaskError(ErrCodeTaskInternalError, fmt.Sprintf("failed to parse response: %v", parseErr), "parse add members")
|
||||
}
|
||||
}
|
||||
|
||||
data, err := HandleTaskApiResult(addResult, err, "add tasklist members")
|
||||
data, err := callTaskAPITyped(runtime, http.MethodPost, "/open-apis/task/v2/tasklists/"+tlId+"/add_members", params, body)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -221,21 +178,7 @@ var MembersTasklist = common.Shortcut{
|
||||
|
||||
if len(toRemove) > 0 {
|
||||
body := buildTlMembersBody(strings.Join(toRemove, ","))
|
||||
apiResp, err := runtime.DoAPI(&larkcore.ApiReq{
|
||||
HttpMethod: http.MethodPost,
|
||||
ApiPath: "/open-apis/task/v2/tasklists/" + tlId + "/remove_members",
|
||||
QueryParams: queryParams,
|
||||
Body: body,
|
||||
})
|
||||
|
||||
var removeResult map[string]interface{}
|
||||
if err == nil {
|
||||
if parseErr := json.Unmarshal(apiResp.RawBody, &removeResult); parseErr != nil {
|
||||
return WrapTaskError(ErrCodeTaskInternalError, fmt.Sprintf("failed to parse response: %v", parseErr), "parse remove members")
|
||||
}
|
||||
}
|
||||
|
||||
data, err := HandleTaskApiResult(removeResult, err, "remove tasklist members")
|
||||
data, err := callTaskAPITyped(runtime, http.MethodPost, "/open-apis/task/v2/tasklists/"+tlId+"/remove_members", params, body)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -246,21 +189,7 @@ var MembersTasklist = common.Shortcut{
|
||||
// Add / Remove mode
|
||||
if addStr != "" {
|
||||
body := buildTlMembersBody(addStr)
|
||||
apiResp, err := runtime.DoAPI(&larkcore.ApiReq{
|
||||
HttpMethod: http.MethodPost,
|
||||
ApiPath: "/open-apis/task/v2/tasklists/" + tlId + "/add_members",
|
||||
QueryParams: queryParams,
|
||||
Body: body,
|
||||
})
|
||||
|
||||
var addResult map[string]interface{}
|
||||
if err == nil {
|
||||
if parseErr := json.Unmarshal(apiResp.RawBody, &addResult); parseErr != nil {
|
||||
return WrapTaskError(ErrCodeTaskInternalError, fmt.Sprintf("failed to parse response: %v", parseErr), "parse add members")
|
||||
}
|
||||
}
|
||||
|
||||
data, err := HandleTaskApiResult(addResult, err, "add tasklist members")
|
||||
data, err := callTaskAPITyped(runtime, http.MethodPost, "/open-apis/task/v2/tasklists/"+tlId+"/add_members", params, body)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -269,21 +198,7 @@ var MembersTasklist = common.Shortcut{
|
||||
|
||||
if removeStr != "" {
|
||||
body := buildTlMembersBody(removeStr)
|
||||
apiResp, err := runtime.DoAPI(&larkcore.ApiReq{
|
||||
HttpMethod: http.MethodPost,
|
||||
ApiPath: "/open-apis/task/v2/tasklists/" + tlId + "/remove_members",
|
||||
QueryParams: queryParams,
|
||||
Body: body,
|
||||
})
|
||||
|
||||
var removeResult map[string]interface{}
|
||||
if err == nil {
|
||||
if parseErr := json.Unmarshal(apiResp.RawBody, &removeResult); parseErr != nil {
|
||||
return WrapTaskError(ErrCodeTaskInternalError, fmt.Sprintf("failed to parse response: %v", parseErr), "parse remove members")
|
||||
}
|
||||
}
|
||||
|
||||
data, err := HandleTaskApiResult(removeResult, err, "remove tasklist members")
|
||||
data, err := callTaskAPITyped(runtime, http.MethodPost, "/open-apis/task/v2/tasklists/"+tlId+"/remove_members", params, body)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -4,9 +4,14 @@
|
||||
package task
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
"github.com/smartystreets/goconvey/convey"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/httpmock"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
)
|
||||
|
||||
func TestBuildTlMembersBody(t *testing.T) {
|
||||
@@ -16,3 +21,179 @@ func TestBuildTlMembersBody(t *testing.T) {
|
||||
convey.So(len(members), convey.ShouldEqual, 2)
|
||||
})
|
||||
}
|
||||
|
||||
// TestMembersTasklist_SetCombinedWithAddRejected covers the Validate guard:
|
||||
// --set is mutually exclusive with --add/--remove. It must surface a typed
|
||||
// *errs.ValidationError (exit 2) before any API call is made.
|
||||
func TestMembersTasklist_SetCombinedWithAddRejected(t *testing.T) {
|
||||
f, stdout, _, reg := taskShortcutTestFactory(t)
|
||||
warmTenantToken(t, f, reg)
|
||||
|
||||
s := MembersTasklist
|
||||
s.AuthTypes = []string{"bot", "user"}
|
||||
|
||||
args := []string{"+tasklist-members", "--tasklist-id", "tl-123", "--set", "ou_a", "--add", "ou_b", "--as", "bot", "--format", "json"}
|
||||
err := runMountedTaskShortcut(t, s, args, f, stdout)
|
||||
|
||||
var ve *errs.ValidationError
|
||||
if !errors.As(err, &ve) {
|
||||
t.Fatalf("err = %T, want *errs.ValidationError; err = %v", err, err)
|
||||
}
|
||||
if ve.Subtype != errs.SubtypeInvalidArgument {
|
||||
t.Errorf("subtype = %q, want %q", ve.Subtype, errs.SubtypeInvalidArgument)
|
||||
}
|
||||
if got := output.ExitCodeOf(err); got != output.ExitValidation {
|
||||
t.Errorf("exit code = %d, want %d (ExitValidation)", got, output.ExitValidation)
|
||||
}
|
||||
}
|
||||
|
||||
// TestMembersTasklist_ListMalformedResponse covers the list arm (no
|
||||
// set/add/remove): a 200 with a non-JSON body must surface a typed
|
||||
// *errs.InternalError(invalid_response) (exit 5) from the json.Unmarshal guard,
|
||||
// not a silent success.
|
||||
func TestMembersTasklist_ListMalformedResponse(t *testing.T) {
|
||||
f, stdout, _, reg := taskShortcutTestFactory(t)
|
||||
warmTenantToken(t, f, reg)
|
||||
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/task/v2/tasklists/tl-123",
|
||||
RawBody: []byte("not json"),
|
||||
})
|
||||
|
||||
s := MembersTasklist
|
||||
s.AuthTypes = []string{"bot", "user"}
|
||||
|
||||
args := []string{"+tasklist-members", "--tasklist-id", "tl-123", "--as", "bot", "--format", "json"}
|
||||
err := runMountedTaskShortcut(t, s, args, f, stdout)
|
||||
|
||||
assertInvalidResponse(t, err)
|
||||
}
|
||||
|
||||
// TestMembersTasklist_SetMalformedResponse covers the --set arm: the diff path
|
||||
// first GETs the tasklist; a non-JSON body there must surface the typed
|
||||
// internal invalid_response error.
|
||||
func TestMembersTasklist_SetMalformedResponse(t *testing.T) {
|
||||
f, stdout, _, reg := taskShortcutTestFactory(t)
|
||||
warmTenantToken(t, f, reg)
|
||||
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/task/v2/tasklists/tl-123",
|
||||
RawBody: []byte("not json"),
|
||||
})
|
||||
|
||||
s := MembersTasklist
|
||||
s.AuthTypes = []string{"bot", "user"}
|
||||
|
||||
args := []string{"+tasklist-members", "--tasklist-id", "tl-123", "--set", "ou_a", "--as", "bot", "--format", "json"}
|
||||
err := runMountedTaskShortcut(t, s, args, f, stdout)
|
||||
|
||||
assertInvalidResponse(t, err)
|
||||
}
|
||||
|
||||
// TestMembersTasklist_AddMalformedResponse covers the add/remove arm: the POST
|
||||
// to add_members returns a non-JSON body, which must surface the typed internal
|
||||
// invalid_response error.
|
||||
func TestMembersTasklist_AddMalformedResponse(t *testing.T) {
|
||||
f, stdout, _, reg := taskShortcutTestFactory(t)
|
||||
warmTenantToken(t, f, reg)
|
||||
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/task/v2/tasklists/tl-123/add_members",
|
||||
RawBody: []byte("not json"),
|
||||
})
|
||||
|
||||
s := MembersTasklist
|
||||
s.AuthTypes = []string{"bot", "user"}
|
||||
|
||||
args := []string{"+tasklist-members", "--tasklist-id", "tl-123", "--add", "ou_a", "--as", "bot", "--format", "json"}
|
||||
err := runMountedTaskShortcut(t, s, args, f, stdout)
|
||||
|
||||
assertInvalidResponse(t, err)
|
||||
}
|
||||
|
||||
// TestMembersTasklist_SetRemoveDiffMalformedResponse covers the --set diff's
|
||||
// remove_members arm: the GET returns an existing member absent from the target
|
||||
// set, so the shortcut issues a remove_members POST whose 200 carries a
|
||||
// non-JSON body, which must surface the typed internal invalid_response error.
|
||||
// The target equals one existing member, so no add_members call precedes it.
|
||||
func TestMembersTasklist_SetRemoveDiffMalformedResponse(t *testing.T) {
|
||||
f, stdout, _, reg := taskShortcutTestFactory(t)
|
||||
warmTenantToken(t, f, reg)
|
||||
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/task/v2/tasklists/tl-123",
|
||||
Status: 200,
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"msg": "success",
|
||||
"data": map[string]interface{}{
|
||||
"tasklist": map[string]interface{}{
|
||||
"url": "https://example.com/tl-123",
|
||||
"members": []interface{}{
|
||||
map[string]interface{}{"id": "ou_keep"},
|
||||
map[string]interface{}{"id": "ou_drop"},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/task/v2/tasklists/tl-123/remove_members",
|
||||
Status: 200,
|
||||
RawBody: []byte("not json"),
|
||||
})
|
||||
|
||||
s := MembersTasklist
|
||||
s.AuthTypes = []string{"bot", "user"}
|
||||
|
||||
args := []string{"+tasklist-members", "--tasklist-id", "tl-123", "--set", "ou_keep", "--as", "bot", "--format", "json"}
|
||||
err := runMountedTaskShortcut(t, s, args, f, stdout)
|
||||
|
||||
assertInvalidResponse(t, err)
|
||||
}
|
||||
|
||||
// TestMembersTasklist_RemoveMalformedResponse covers the add/remove mode's
|
||||
// remove_members arm: with only --remove set, the add arm is skipped and the
|
||||
// remove_members POST returns a non-JSON body, which must surface the typed
|
||||
// internal invalid_response error.
|
||||
func TestMembersTasklist_RemoveMalformedResponse(t *testing.T) {
|
||||
f, stdout, _, reg := taskShortcutTestFactory(t)
|
||||
warmTenantToken(t, f, reg)
|
||||
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/task/v2/tasklists/tl-123/remove_members",
|
||||
Status: 200,
|
||||
RawBody: []byte("not json"),
|
||||
})
|
||||
|
||||
s := MembersTasklist
|
||||
s.AuthTypes = []string{"bot", "user"}
|
||||
|
||||
args := []string{"+tasklist-members", "--tasklist-id", "tl-123", "--remove", "ou_a", "--as", "bot", "--format", "json"}
|
||||
err := runMountedTaskShortcut(t, s, args, f, stdout)
|
||||
|
||||
assertInvalidResponse(t, err)
|
||||
}
|
||||
|
||||
// assertInvalidResponse asserts a typed *errs.InternalError(invalid_response)
|
||||
// with exit 5 — the contract for a parse-response failure across the members
|
||||
// arms.
|
||||
func assertInvalidResponse(t *testing.T, err error) {
|
||||
t.Helper()
|
||||
var ie *errs.InternalError
|
||||
if !errors.As(err, &ie) {
|
||||
t.Fatalf("err = %T, want *errs.InternalError; err = %v", err, err)
|
||||
}
|
||||
if ie.Subtype != errs.SubtypeInvalidResponse {
|
||||
t.Errorf("subtype = %q, want %q", ie.Subtype, errs.SubtypeInvalidResponse)
|
||||
}
|
||||
if got := output.ExitCodeOf(err); got != output.ExitInternal {
|
||||
t.Errorf("exit code = %d, want %d (ExitInternal)", got, output.ExitInternal)
|
||||
}
|
||||
}
|
||||
|
||||
45
shortcuts/whiteboard/whiteboard_errors.go
Normal file
45
shortcuts/whiteboard/whiteboard_errors.go
Normal file
@@ -0,0 +1,45 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package whiteboard
|
||||
|
||||
import (
|
||||
"errors"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/extension/fileio"
|
||||
)
|
||||
|
||||
// wrapWbNetworkErr returns err unchanged when it is already a typed errs.* error
|
||||
// (preserving subtype / code / log_id from the runtime boundary) and only wraps
|
||||
// a raw, unclassified error as a transport-level network error.
|
||||
func wrapWbNetworkErr(err error, format string, args ...any) error {
|
||||
if _, ok := errs.ProblemOf(err); ok {
|
||||
return err
|
||||
}
|
||||
return errs.NewNetworkError(errs.SubtypeNetworkTransport, format, args...).WithCause(err)
|
||||
}
|
||||
|
||||
// wbSaveError maps a FileIO.Save error to a typed error. Path validation
|
||||
// failures are validation errors (exit code 2); mkdir / write failures are
|
||||
// internal file-I/O errors (exit code 5).
|
||||
func wbSaveError(err error) error {
|
||||
if err == nil {
|
||||
return nil
|
||||
}
|
||||
var me *fileio.MkdirError
|
||||
switch {
|
||||
case errors.Is(err, fileio.ErrPathValidation):
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "unsafe output path: %s", err).
|
||||
WithParam("--output").
|
||||
WithCause(err)
|
||||
case errors.As(err, &me):
|
||||
return errs.NewInternalError(errs.SubtypeFileIO, "cannot create parent directory: %s", err).WithCause(err)
|
||||
default:
|
||||
return errs.NewInternalError(errs.SubtypeFileIO, "cannot create file: %s", err).WithCause(err)
|
||||
}
|
||||
}
|
||||
|
||||
func wbInvalidResponse(format string, args ...any) error {
|
||||
return errs.NewInternalError(errs.SubtypeInvalidResponse, format, args...)
|
||||
}
|
||||
54
shortcuts/whiteboard/whiteboard_errors_test.go
Normal file
54
shortcuts/whiteboard/whiteboard_errors_test.go
Normal file
@@ -0,0 +1,54 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package whiteboard
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/extension/fileio"
|
||||
)
|
||||
|
||||
func TestWrapWbNetworkErr(t *testing.T) {
|
||||
typed := errs.NewAPIError(errs.SubtypeRateLimit, "already typed")
|
||||
if got := wrapWbNetworkErr(typed, "wrap"); got != error(typed) {
|
||||
t.Fatalf("typed error must pass through unchanged, got %v", got)
|
||||
}
|
||||
|
||||
raw := errors.New("connection reset by peer")
|
||||
got := wrapWbNetworkErr(raw, "fetch failed: %v", raw)
|
||||
var ne *errs.NetworkError
|
||||
if !errors.As(got, &ne) || ne.Subtype != errs.SubtypeNetworkTransport {
|
||||
t.Fatalf("raw error: got %T (%v)", got, got)
|
||||
}
|
||||
if !errors.Is(got, raw) {
|
||||
t.Fatal("raw cause should be retained via WithCause")
|
||||
}
|
||||
}
|
||||
|
||||
func TestWbSaveError(t *testing.T) {
|
||||
if wbSaveError(nil) != nil {
|
||||
t.Fatal("nil error should map to nil")
|
||||
}
|
||||
|
||||
var ve *errs.ValidationError
|
||||
pathCause := errors.New("escape")
|
||||
if got := wbSaveError(&fileio.PathValidationError{Err: pathCause}); !errors.As(got, &ve) || ve.Subtype != errs.SubtypeInvalidArgument || !errors.Is(got, pathCause) {
|
||||
t.Fatalf("path validation: got %T (%v)", got, got)
|
||||
}
|
||||
if ve.Param != "--output" {
|
||||
t.Fatalf("path validation param = %q, want --output", ve.Param)
|
||||
}
|
||||
|
||||
var ie *errs.InternalError
|
||||
mkdirCause := errors.New("mkdir denied")
|
||||
if got := wbSaveError(&fileio.MkdirError{Err: mkdirCause}); !errors.As(got, &ie) || ie.Subtype != errs.SubtypeFileIO || !errors.Is(got, mkdirCause) {
|
||||
t.Fatalf("mkdir: got %T (%v)", got, got)
|
||||
}
|
||||
writeCause := errors.New("disk full")
|
||||
if got := wbSaveError(writeCause); !errors.As(got, &ie) || ie.Subtype != errs.SubtypeFileIO || !errors.Is(got, writeCause) {
|
||||
t.Fatalf("default: got %T (%v)", got, got)
|
||||
}
|
||||
}
|
||||
@@ -14,9 +14,8 @@ import (
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/extension/fileio"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"github.com/larksuite/cli/internal/validate"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
larkcore "github.com/larksuite/oapi-sdk-go/v3/core"
|
||||
)
|
||||
@@ -73,22 +72,22 @@ var WhiteboardQuery = common.Shortcut{
|
||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
// Check if token contains control characters
|
||||
token := runtime.Str("whiteboard-token")
|
||||
if err := validate.RejectControlChars(token, "whiteboard-token"); err != nil {
|
||||
if err := common.RejectDangerousCharsTyped("--whiteboard-token", token); err != nil {
|
||||
return err
|
||||
}
|
||||
out := runtime.Str("output")
|
||||
if out != "" {
|
||||
if err := runtime.ValidatePath(out); err != nil {
|
||||
return output.ErrValidation("invalid output path: %s", err)
|
||||
if _, err := runtime.ResolveSavePath(out); err != nil {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "invalid output path: %s", err).WithParam("--output").WithCause(err)
|
||||
}
|
||||
}
|
||||
if out == "" && runtime.Str("output_as") == WhiteboardQueryAsImage {
|
||||
return output.ErrValidation("need a output directory to query whiteboard as image")
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "need a output directory to query whiteboard as image").WithParam("--output")
|
||||
}
|
||||
|
||||
as := runtime.Str("output_as")
|
||||
if as != WhiteboardQueryAsImage && as != WhiteboardQueryAsCode && as != WhiteboardQueryAsRaw {
|
||||
return common.FlagErrorf("--output_as flag must be one of: image | code | raw")
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--output_as flag must be one of: image | code | raw").WithParam("--output_as")
|
||||
}
|
||||
return nil
|
||||
},
|
||||
@@ -125,7 +124,7 @@ var WhiteboardQuery = common.Shortcut{
|
||||
case WhiteboardQueryAsRaw:
|
||||
return exportWhiteboardRaw(runtime, token, outDir)
|
||||
default:
|
||||
return output.ErrValidation("--as flag must be one of: image | code | raw")
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--output_as flag must be one of: image | code | raw").WithParam("--output_as")
|
||||
}
|
||||
|
||||
},
|
||||
@@ -136,14 +135,26 @@ func exportWhiteboardPreview(ctx context.Context, runtime *common.RuntimeContext
|
||||
HttpMethod: http.MethodGet,
|
||||
ApiPath: fmt.Sprintf("/open-apis/board/v1/whiteboards/%s/download_as_image", url.PathEscape(wbToken)),
|
||||
}
|
||||
// Execute API request
|
||||
// Execute API request. The preview endpoint streams raw image bytes (not a
|
||||
// JSON envelope), so classify by HTTP status: 5xx is retryable network,
|
||||
// while 4xx remains an API-side rejection.
|
||||
resp, err := runtime.DoAPI(req, larkcore.WithFileDownload())
|
||||
if err != nil {
|
||||
return output.ErrNetwork(fmt.Sprintf("get whiteboard preview failed: %v", err))
|
||||
return wrapWbNetworkErr(err, "get whiteboard preview failed: %v", err)
|
||||
}
|
||||
// Check response status code
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return output.ErrAPI(resp.StatusCode, string(resp.RawBody), nil)
|
||||
if resp.StatusCode >= 400 {
|
||||
body := common.TruncateStr(strings.TrimSpace(string(resp.RawBody)), 500)
|
||||
if resp.StatusCode >= 500 {
|
||||
return errs.NewNetworkError(errs.SubtypeNetworkServer, "get whiteboard preview failed: HTTP %d: %s", resp.StatusCode, body).
|
||||
WithCode(resp.StatusCode).
|
||||
WithRetryable()
|
||||
}
|
||||
subtype := errs.SubtypeUnknown
|
||||
if resp.StatusCode == http.StatusNotFound {
|
||||
subtype = errs.SubtypeNotFound
|
||||
}
|
||||
return errs.NewAPIError(subtype, "get whiteboard preview failed: HTTP %d: %s", resp.StatusCode, body).
|
||||
WithCode(resp.StatusCode)
|
||||
}
|
||||
|
||||
finalPath, size, err := saveOutputFile(outDir, ".png", wbToken, runtime, bytes.NewReader(resp.RawBody))
|
||||
@@ -162,33 +173,27 @@ func exportWhiteboardPreview(ctx context.Context, runtime *common.RuntimeContext
|
||||
}
|
||||
|
||||
type wbNodesResp struct {
|
||||
Code int `json:"code"`
|
||||
Msg string `json:"msg"`
|
||||
Data struct {
|
||||
Nodes []interface{} `json:"nodes"`
|
||||
} `json:"data"`
|
||||
}
|
||||
|
||||
func fetchWhiteboardNodes(runtime *common.RuntimeContext, wbToken string) (*wbNodesResp, error) {
|
||||
req := &larkcore.ApiReq{
|
||||
HttpMethod: http.MethodGet,
|
||||
ApiPath: fmt.Sprintf("/open-apis/board/v1/whiteboards/%s/nodes", wbToken),
|
||||
}
|
||||
resp, err := runtime.DoAPI(req)
|
||||
data, err := runtime.CallAPITyped(http.MethodGet, fmt.Sprintf("/open-apis/board/v1/whiteboards/%s/nodes", url.PathEscape(wbToken)), nil, nil)
|
||||
if err != nil {
|
||||
return nil, output.ErrNetwork(fmt.Sprintf("get whiteboard nodes failed: %v", err))
|
||||
}
|
||||
// 检查响应状态码
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, output.ErrAPI(resp.StatusCode, string(resp.RawBody), nil)
|
||||
return nil, err
|
||||
}
|
||||
var nodes wbNodesResp
|
||||
err = json.Unmarshal(resp.RawBody, &nodes)
|
||||
if err != nil {
|
||||
return nil, output.Errorf(output.ExitInternal, "parsing", fmt.Sprintf("parse whiteboard nodes failed: %v", err))
|
||||
rawNodes, ok := data["nodes"]
|
||||
if !ok {
|
||||
return nil, wbInvalidResponse("get whiteboard nodes failed: missing data.nodes")
|
||||
}
|
||||
if nodes.Code != 0 {
|
||||
return nil, output.ErrAPI(nodes.Code, "get whiteboard nodes failed", fmt.Sprintf("get whiteboard nodes failed: %s", nodes.Msg))
|
||||
if rawNodes != nil {
|
||||
var ok bool
|
||||
nodes.Data.Nodes, ok = rawNodes.([]interface{})
|
||||
if !ok {
|
||||
return nil, wbInvalidResponse("get whiteboard nodes failed: data.nodes must be an array")
|
||||
}
|
||||
}
|
||||
return &nodes, nil
|
||||
}
|
||||
@@ -229,6 +234,12 @@ func exportWhiteboardCode(runtime *common.RuntimeContext, wbToken, outDir string
|
||||
code, _ := syntaxMap["code"].(string)
|
||||
var syntaxType SyntaxType
|
||||
switch v := syntaxMap["syntax_type"].(type) {
|
||||
case json.Number:
|
||||
// runtime.ClassifyAPIResponse decodes the response with UseNumber,
|
||||
// so numeric fields arrive as json.Number rather than float64.
|
||||
if n, err := v.Int64(); err == nil {
|
||||
syntaxType = SyntaxType(n)
|
||||
}
|
||||
case float64:
|
||||
syntaxType = SyntaxType(v)
|
||||
case SyntaxType:
|
||||
@@ -299,7 +310,7 @@ func exportWhiteboardRaw(runtime *common.RuntimeContext, wbToken, outDir string)
|
||||
|
||||
jsonData, err := json.MarshalIndent(wbNodes.Data, "", " ")
|
||||
if err != nil {
|
||||
return output.Errorf(output.ExitInternal, "json_error", "cannot marshal whiteboard data: %s", err)
|
||||
return errs.NewInternalError(errs.SubtypeInvalidResponse, "cannot marshal whiteboard data: %s", err).WithCause(err)
|
||||
}
|
||||
|
||||
if outDir == "" {
|
||||
@@ -340,18 +351,18 @@ func saveOutputFile(outPath, ext, token string, runtime *common.RuntimeContext,
|
||||
}
|
||||
finalPath = outPath
|
||||
}
|
||||
if err := runtime.ValidatePath(finalPath); err != nil { // double check
|
||||
return "", 0, err
|
||||
if _, err := runtime.ResolveSavePath(finalPath); err != nil { // double check
|
||||
return "", 0, errs.NewValidationError(errs.SubtypeInvalidArgument, "invalid output path: %s", err).WithParam("--output").WithCause(err)
|
||||
}
|
||||
|
||||
// Step 2: Check overwrite
|
||||
_, err = runtime.FileIO().Stat(finalPath)
|
||||
if err == nil {
|
||||
if !runtime.Bool("overwrite") {
|
||||
return "", 0, output.ErrValidation(fmt.Sprintf("file already exists: %s (use --overwrite to overwrite)", finalPath))
|
||||
return "", 0, errs.NewValidationError(errs.SubtypeInvalidArgument, "file already exists: %s (use --overwrite to overwrite)", finalPath).WithParam("--overwrite")
|
||||
}
|
||||
} else if !os.IsNotExist(err) {
|
||||
return "", 0, output.Errorf(output.ExitInternal, "io_error", "cannot check file existence: %s", err)
|
||||
return "", 0, errs.NewInternalError(errs.SubtypeFileIO, "cannot check file existence: %s", err).WithCause(err)
|
||||
}
|
||||
|
||||
// Step 3: Save file
|
||||
@@ -369,7 +380,7 @@ func saveOutputFile(outPath, ext, token string, runtime *common.RuntimeContext,
|
||||
ContentType: contentType,
|
||||
}, data)
|
||||
if err != nil {
|
||||
return "", 0, common.WrapSaveError(err, "unsafe file path", "cannot create parent directory", "cannot create file")
|
||||
return "", 0, wbSaveError(err)
|
||||
}
|
||||
|
||||
return finalPath, savResult.Size(), nil
|
||||
|
||||
@@ -6,11 +6,13 @@ package whiteboard
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/internal/httpmock"
|
||||
@@ -149,6 +151,110 @@ func TestWhiteboardQuery_Validate(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestWhiteboardQuery_Validate_TypedErrors locks the typed-envelope contract:
|
||||
// input-validation failures surface as *errs.ValidationError carrying
|
||||
// SubtypeInvalidArgument and the offending --flag, readable via errs.ProblemOf
|
||||
// and errors.As — the shape downstream consumers (and exit-code mapping) rely on.
|
||||
func TestWhiteboardQuery_Validate_TypedErrors(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
chdirTemp(t)
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
flags map[string]string
|
||||
wantParam string
|
||||
}{
|
||||
{
|
||||
name: "image without output",
|
||||
flags: map[string]string{"whiteboard-token": "t", "output_as": "image"},
|
||||
wantParam: "--output",
|
||||
},
|
||||
{
|
||||
name: "bad output_as value",
|
||||
flags: map[string]string{"whiteboard-token": "t", "output_as": "invalid"},
|
||||
wantParam: "--output_as",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
err := WhiteboardQuery.Validate(ctx, newTestRuntime(tt.flags, nil))
|
||||
if err == nil {
|
||||
t.Fatalf("expected error, got nil")
|
||||
}
|
||||
var ve *errs.ValidationError
|
||||
if !errors.As(err, &ve) {
|
||||
t.Fatalf("error is not *errs.ValidationError: %T", err)
|
||||
}
|
||||
if ve.Subtype != errs.SubtypeInvalidArgument {
|
||||
t.Errorf("Subtype = %q, want %q", ve.Subtype, errs.SubtypeInvalidArgument)
|
||||
}
|
||||
if ve.Param != tt.wantParam {
|
||||
t.Errorf("Param = %q, want %q", ve.Param, tt.wantParam)
|
||||
}
|
||||
p, ok := errs.ProblemOf(err)
|
||||
if !ok {
|
||||
t.Fatalf("errs.ProblemOf returned false")
|
||||
}
|
||||
if p.Category != errs.CategoryValidation {
|
||||
t.Errorf("Category = %q, want %q", p.Category, errs.CategoryValidation)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestExportWhiteboardPreview_HTTPError locks the download-path failure
|
||||
// behavior: a failed preview download surfaces as a typed errs.* envelope, not
|
||||
// a flat legacy error.
|
||||
func TestExportWhiteboardPreview_HTTPError(t *testing.T) {
|
||||
factory, stdout, reg := newExecuteFactory(t)
|
||||
chdirTemp(t)
|
||||
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/board/v1/whiteboards/test-token-preview-5xx/download_as_image",
|
||||
Status: 500,
|
||||
RawBody: []byte("gateway error"),
|
||||
ContentType: "text/plain",
|
||||
})
|
||||
|
||||
args := []string{"+query", "--whiteboard-token", "test-token-preview-5xx", "--output_as", "image", "--output", "output"}
|
||||
err := runShortcut(t, WhiteboardQuery, args, factory, stdout)
|
||||
if err == nil {
|
||||
t.Fatal("expected error for HTTP 500 download")
|
||||
}
|
||||
if _, ok := errs.ProblemOf(err); !ok {
|
||||
t.Fatalf("error is not a typed errs.* envelope: %T (%v)", err, err)
|
||||
}
|
||||
var ne *errs.NetworkError
|
||||
if !errors.As(err, &ne) || ne.Subtype != errs.SubtypeNetworkServer || ne.Code != 500 || !ne.Retryable {
|
||||
t.Fatalf("HTTP 500 should be retryable network/server_error, got %T (%v)", err, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExportWhiteboardPreview_HTTPNotFoundIsAPIError(t *testing.T) {
|
||||
factory, stdout, reg := newExecuteFactory(t)
|
||||
chdirTemp(t)
|
||||
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/board/v1/whiteboards/missing-token/download_as_image",
|
||||
Status: 404,
|
||||
RawBody: []byte("not found"),
|
||||
ContentType: "text/plain",
|
||||
})
|
||||
|
||||
args := []string{"+query", "--whiteboard-token", "missing-token", "--output_as", "image", "--output", "output"}
|
||||
err := runShortcut(t, WhiteboardQuery, args, factory, stdout)
|
||||
if err == nil {
|
||||
t.Fatal("expected error for HTTP 404 download")
|
||||
}
|
||||
var apiErr *errs.APIError
|
||||
if !errors.As(err, &apiErr) || apiErr.Subtype != errs.SubtypeNotFound || apiErr.Code != 404 {
|
||||
t.Fatalf("HTTP 404 should be api/not_found, got %T (%v)", err, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWhiteboardQuery_DryRun(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
@@ -370,6 +476,23 @@ func TestSaveOutputFile(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestSaveOutputFile_InvalidFinalPathTypedError(t *testing.T) {
|
||||
chdirTemp(t)
|
||||
|
||||
rt := newTestRuntime(nil, nil)
|
||||
_, _, err := saveOutputFile("../escape", ".png", "token123", rt, strings.NewReader("test content"))
|
||||
if err == nil {
|
||||
t.Fatal("expected error for unsafe final path")
|
||||
}
|
||||
var ve *errs.ValidationError
|
||||
if !errors.As(err, &ve) {
|
||||
t.Fatalf("error is not *errs.ValidationError: %T (%v)", err, err)
|
||||
}
|
||||
if ve.Subtype != errs.SubtypeInvalidArgument || ve.Param != "--output" {
|
||||
t.Fatalf("validation details = subtype %q param %q, want %q --output", ve.Subtype, ve.Param, errs.SubtypeInvalidArgument)
|
||||
}
|
||||
}
|
||||
|
||||
func newExecuteFactory(t *testing.T) (*cmdutil.Factory, *bytes.Buffer, *httpmock.Registry) {
|
||||
t.Helper()
|
||||
config := &core.CliConfig{
|
||||
@@ -705,10 +828,70 @@ func TestFetchWhiteboardNodes_APIError(t *testing.T) {
|
||||
|
||||
args := []string{"+query", "--whiteboard-token", "test-token-api-error", "--output_as", "raw"}
|
||||
err := runShortcut(t, WhiteboardQuery, args, factory, stdout)
|
||||
// We expect an error here, but don't fail the test because it's testing error path
|
||||
if err == nil {
|
||||
t.Fatalf("Expected API error, but got none")
|
||||
}
|
||||
// The nodes fetch now classifies the Lark error code into a typed envelope
|
||||
// carrying the numeric code.
|
||||
p, ok := errs.ProblemOf(err)
|
||||
if !ok {
|
||||
t.Fatalf("error is not a typed errs.* envelope: %T (%v)", err, err)
|
||||
}
|
||||
if p.Code != 10001 {
|
||||
t.Errorf("Problem.Code = %d, want 10001", p.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFetchWhiteboardNodes_InvalidResponseTypedError(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
token string
|
||||
data map[string]interface{}
|
||||
}{
|
||||
{
|
||||
name: "missing nodes",
|
||||
token: "test-token-missing-nodes",
|
||||
data: map[string]interface{}{},
|
||||
},
|
||||
{
|
||||
name: "nodes not array",
|
||||
token: "test-token-bad-nodes",
|
||||
data: map[string]interface{}{"nodes": "not-an-array"},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
factory, stdout, reg := newExecuteFactory(t)
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/board/v1/whiteboards/" + tt.token + "/nodes",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"msg": "success",
|
||||
"data": tt.data,
|
||||
},
|
||||
})
|
||||
|
||||
args := []string{"+query", "--whiteboard-token", tt.token, "--output_as", "raw"}
|
||||
err := runShortcut(t, WhiteboardQuery, args, factory, stdout)
|
||||
assertInvalidResponse(t, err)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func assertInvalidResponse(t *testing.T, err error) {
|
||||
t.Helper()
|
||||
if err == nil {
|
||||
t.Fatal("expected invalid response error")
|
||||
}
|
||||
var ie *errs.InternalError
|
||||
if !errors.As(err, &ie) {
|
||||
t.Fatalf("error is not *errs.InternalError: %T (%v)", err, err)
|
||||
}
|
||||
if ie.Subtype != errs.SubtypeInvalidResponse {
|
||||
t.Fatalf("Subtype = %q, want %q", ie.Subtype, errs.SubtypeInvalidResponse)
|
||||
}
|
||||
}
|
||||
|
||||
// newTestRuntime creates a RuntimeContext with string flags for testing.
|
||||
|
||||
@@ -12,10 +12,8 @@ import (
|
||||
"net/url"
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"github.com/larksuite/cli/internal/validate"
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
larkcore "github.com/larksuite/oapi-sdk-go/v3/core"
|
||||
)
|
||||
|
||||
const (
|
||||
@@ -42,21 +40,21 @@ var wbUpdateFlags = []common.Flag{
|
||||
|
||||
func wbUpdateValidate(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
// 检查 token 是否包含控制字符(空字符串下自动跳过了)
|
||||
if err := validate.RejectControlChars(runtime.Str("whiteboard-token"), "whiteboard-token"); err != nil {
|
||||
if err := common.RejectDangerousCharsTyped("--whiteboard-token", runtime.Str("whiteboard-token")); err != nil {
|
||||
return err
|
||||
}
|
||||
itoken := runtime.Str("idempotent-token")
|
||||
if err := validate.RejectControlChars(itoken, "idempotent-token"); err != nil {
|
||||
if err := common.RejectDangerousCharsTyped("--idempotent-token", itoken); err != nil {
|
||||
return err
|
||||
}
|
||||
if itoken != "" && len(itoken) < 10 {
|
||||
return common.FlagErrorf("--idempotent-token must be at least 10 characters long.")
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--idempotent-token must be at least 10 characters long.").WithParam("--idempotent-token")
|
||||
}
|
||||
|
||||
// 检查 --input_format 标志
|
||||
format := getFormat(runtime)
|
||||
if format != FormatRaw && format != FormatPlantUML && format != FormatMermaid {
|
||||
return common.FlagErrorf("--input_format must be one of: raw | plantuml | mermaid")
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--input_format must be one of: raw | plantuml | mermaid").WithParam("--input_format")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -116,7 +114,7 @@ func wbUpdateExecute(ctx context.Context, runtime *common.RuntimeContext) error
|
||||
|
||||
input := runtime.Str("source")
|
||||
if input == "" {
|
||||
return output.ErrValidation("read input failed: source is required")
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "read input failed: source is required").WithParam("--source")
|
||||
}
|
||||
|
||||
switch format {
|
||||
@@ -125,7 +123,7 @@ func wbUpdateExecute(ctx context.Context, runtime *common.RuntimeContext) error
|
||||
case FormatPlantUML, FormatMermaid:
|
||||
return updateWhiteboardByCode(ctx, runtime, token, []byte(input), format, overwrite, idempotentToken)
|
||||
default:
|
||||
return output.ErrValidation(fmt.Sprintf("unsupported format: %s", format))
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "unsupported format: %s", format).WithParam("--input_format")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -160,15 +158,6 @@ var WhiteboardUpdateOld = common.Shortcut{
|
||||
Execute: wbUpdateExecute,
|
||||
}
|
||||
|
||||
type createResponse struct {
|
||||
Code int `json:"code"`
|
||||
Msg string `json:"msg"`
|
||||
Data struct {
|
||||
NodeIDs []string `json:"ids"`
|
||||
IdempotentToken string `json:"client_token"`
|
||||
} `json:"data"`
|
||||
}
|
||||
|
||||
type plantumlCreateReq struct {
|
||||
PlantUmlCode string `json:"plant_uml_code"`
|
||||
SyntaxType int `json:"syntax_type"`
|
||||
@@ -182,28 +171,20 @@ type rawNodesCreateReq struct {
|
||||
Overwrite bool `json:"overwrite,omitempty"`
|
||||
}
|
||||
|
||||
type plantumlCreateResp struct {
|
||||
Code int `json:"code"`
|
||||
Msg string `json:"msg"`
|
||||
Data struct {
|
||||
NodeID string `json:"node_id"`
|
||||
} `json:"data"`
|
||||
}
|
||||
|
||||
func parseWBcliNodes(rawjson []byte) (wbNodes []interface{}, err error, isRaw bool) {
|
||||
var wbOutput WbCliOutput
|
||||
if err := json.Unmarshal(rawjson, &wbOutput); err != nil {
|
||||
return nil, output.Errorf(output.ExitValidation, "parsing", fmt.Sprintf("unmarshal input json failed: %v", err)), false
|
||||
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "unmarshal input json failed: %v", err).WithParam("--source").WithCause(err), false
|
||||
}
|
||||
if (wbOutput.Code != 0 || wbOutput.Data.To != "openapi") && wbOutput.RawNodes == nil {
|
||||
return nil, output.Errorf(output.ExitValidation, "whiteboard-cli", "whiteboard-cli failed. please check previous log."), false
|
||||
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "whiteboard-cli failed. please check previous log.").WithParam("--source"), false
|
||||
}
|
||||
if wbOutput.RawNodes != nil {
|
||||
wbNodes = wbOutput.RawNodes
|
||||
isRaw = true
|
||||
} else {
|
||||
if wbOutput.Data.Result.Nodes == nil {
|
||||
return nil, output.Errorf(output.ExitValidation, "whiteboard-cli", "whiteboard-cli failed. please check previous log."), false
|
||||
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "whiteboard-cli failed. please check previous log.").WithParam("--source"), false
|
||||
}
|
||||
wbNodes = wbOutput.Data.Result.Nodes
|
||||
}
|
||||
@@ -221,39 +202,23 @@ func updateWhiteboardByCode(ctx context.Context, runtime *common.RuntimeContext,
|
||||
Overwrite: overwrite,
|
||||
}
|
||||
|
||||
req := &larkcore.ApiReq{
|
||||
HttpMethod: http.MethodPost,
|
||||
ApiPath: fmt.Sprintf("/open-apis/board/v1/whiteboards/%s/nodes/plantuml", url.PathEscape(wbToken)),
|
||||
Body: reqBody,
|
||||
QueryParams: map[string][]string{},
|
||||
}
|
||||
params := map[string]interface{}{}
|
||||
if idempotentToken != "" {
|
||||
req.QueryParams["client_token"] = []string{idempotentToken}
|
||||
params["client_token"] = idempotentToken
|
||||
}
|
||||
|
||||
resp, err := runtime.DoAPI(req)
|
||||
data, err := runtime.CallAPITyped(http.MethodPost, fmt.Sprintf("/open-apis/board/v1/whiteboards/%s/nodes/plantuml", url.PathEscape(wbToken)), params, reqBody)
|
||||
if err != nil {
|
||||
return output.ErrNetwork(fmt.Sprintf("update whiteboard by code failed: %v", err))
|
||||
}
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return output.ErrAPI(resp.StatusCode, string(resp.RawBody), nil)
|
||||
return err
|
||||
}
|
||||
|
||||
var createResp plantumlCreateResp
|
||||
err = json.Unmarshal(resp.RawBody, &createResp)
|
||||
if err != nil {
|
||||
return output.Errorf(output.ExitInternal, "parsing", fmt.Sprintf("parse whiteboard create response failed: %v", err))
|
||||
nodeID := common.GetString(data, "node_id")
|
||||
if nodeID == "" {
|
||||
return wbInvalidResponse("update whiteboard by code failed: missing data.node_id")
|
||||
}
|
||||
if createResp.Code != 0 {
|
||||
return output.ErrAPI(createResp.Code, "update whiteboard by code failed", fmt.Sprintf("update whiteboard by code failed: %s", createResp.Msg))
|
||||
}
|
||||
|
||||
outData := make(map[string]string)
|
||||
outData["created_node_id"] = createResp.Data.NodeID
|
||||
outData := map[string]string{"created_node_id": nodeID}
|
||||
runtime.OutFormat(outData, nil, func(w io.Writer) {
|
||||
if outData["created_node_id"] != "" {
|
||||
fmt.Fprintf(w, "New node created.\n")
|
||||
}
|
||||
fmt.Fprintf(w, "New node created.\n")
|
||||
fmt.Fprintf(w, "Update whiteboard success")
|
||||
})
|
||||
|
||||
@@ -266,56 +231,67 @@ func updateWhiteboardByRawNodes(ctx context.Context, runtime *common.RuntimeCont
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
outData := make(map[string]string)
|
||||
reqBody := rawNodesCreateReq{
|
||||
Nodes: nodes,
|
||||
Overwrite: overwrite,
|
||||
}
|
||||
|
||||
req := &larkcore.ApiReq{
|
||||
HttpMethod: http.MethodPost,
|
||||
ApiPath: fmt.Sprintf("/open-apis/board/v1/whiteboards/%s/nodes", url.PathEscape(wbToken)),
|
||||
Body: reqBody,
|
||||
QueryParams: map[string][]string{},
|
||||
}
|
||||
params := map[string]interface{}{}
|
||||
if idempotentToken != "" {
|
||||
req.QueryParams["client_token"] = []string{idempotentToken}
|
||||
params["client_token"] = idempotentToken
|
||||
}
|
||||
|
||||
resp, err := runtime.DoAPI(req)
|
||||
data, err := runtime.CallAPITyped(http.MethodPost, fmt.Sprintf("/open-apis/board/v1/whiteboards/%s/nodes", url.PathEscape(wbToken)), params, reqBody)
|
||||
if err != nil {
|
||||
return output.ErrNetwork(fmt.Sprintf("update whiteboard failed: %v", err))
|
||||
}
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
var detail string
|
||||
// Raw open-api JSON is hand-edited far more often than the DSL path, so
|
||||
// steer the user back to the recommended workflow on any API failure.
|
||||
if isRaw {
|
||||
detail = fmt.Sprintf("It is not advised to edit openapi format json directly. Please follow instruction in lark-whiteboard skill, " +
|
||||
"using whiteboard-cli to transcript Whiteboard DSL pattern instead.")
|
||||
if p, ok := errs.ProblemOf(err); ok {
|
||||
rawHint := "It is not advised to edit openapi format json directly. " +
|
||||
"Please follow instruction in lark-whiteboard skill, using whiteboard-cli " +
|
||||
"to transcript Whiteboard DSL pattern instead."
|
||||
if strings.TrimSpace(p.Hint) != "" {
|
||||
p.Hint = p.Hint + "\n" + rawHint
|
||||
} else {
|
||||
p.Hint = rawHint
|
||||
}
|
||||
}
|
||||
}
|
||||
return output.ErrAPI(resp.StatusCode, string(resp.RawBody), detail)
|
||||
return err
|
||||
}
|
||||
|
||||
var createResp createResponse
|
||||
err = json.Unmarshal(resp.RawBody, &createResp)
|
||||
nodeIDs, err := stringSlice(data["ids"])
|
||||
if err != nil {
|
||||
return output.Errorf(output.ExitInternal, "parsing", fmt.Sprintf("parse whiteboard create response failed: %v", err))
|
||||
return err
|
||||
}
|
||||
if createResp.Code != 0 {
|
||||
detail := fmt.Sprintf("update whiteboard failed: %s", createResp.Msg)
|
||||
if isRaw {
|
||||
detail += fmt.Sprintf("\n It is not advised to edit openapi format json directly. Please follow instruction in lark-whiteboard skill, " +
|
||||
"using whiteboard-cli to transcript Whiteboard DSL pattern instead.")
|
||||
}
|
||||
return output.ErrAPI(createResp.Code, "update whiteboard failed", detail)
|
||||
}
|
||||
|
||||
outData["created_node_ids"] = strings.Join(createResp.Data.NodeIDs, ",")
|
||||
outData := map[string]string{"created_node_ids": strings.Join(nodeIDs, ",")}
|
||||
runtime.OutFormat(outData, nil, func(w io.Writer) {
|
||||
if outData["created_node_ids"] != "" {
|
||||
fmt.Fprintf(w, "%d new nodes created.\n", len(createResp.Data.NodeIDs))
|
||||
fmt.Fprintf(w, "%d new nodes created.\n", len(nodeIDs))
|
||||
}
|
||||
fmt.Fprintf(w, "Update whiteboard success")
|
||||
})
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// stringSlice coerces the JSON ids array into []string. A missing or malformed
|
||||
// ids field is a response-shape bug, not a successful update with no output.
|
||||
func stringSlice(v interface{}) ([]string, error) {
|
||||
switch raw := v.(type) {
|
||||
case []interface{}:
|
||||
out := make([]string, 0, len(raw))
|
||||
for i, e := range raw {
|
||||
s, ok := e.(string)
|
||||
if !ok {
|
||||
return nil, wbInvalidResponse("update whiteboard failed: data.ids[%d] must be a string", i)
|
||||
}
|
||||
out = append(out, s)
|
||||
}
|
||||
return out, nil
|
||||
case []string:
|
||||
return append([]string(nil), raw...), nil
|
||||
default:
|
||||
return nil, wbInvalidResponse("update whiteboard failed: data.ids must be an array of strings")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,9 +6,11 @@ package whiteboard
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/internal/httpmock"
|
||||
@@ -102,6 +104,54 @@ func TestWhiteboardUpdate_Validate(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestWhiteboardUpdate_Validate_TypedErrors locks the typed-envelope contract
|
||||
// for +update input validation: failures are *errs.ValidationError with
|
||||
// SubtypeInvalidArgument and the offending --flag. parseWBcliNodes likewise
|
||||
// reports malformed --source input as a typed validation error.
|
||||
func TestWhiteboardUpdate_Validate_TypedErrors(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
|
||||
t.Run("idempotent-token too short", func(t *testing.T) {
|
||||
rt := newTestRuntime(map[string]string{
|
||||
"whiteboard-token": "t",
|
||||
"idempotent-token": "short",
|
||||
"source": "{}",
|
||||
}, nil)
|
||||
assertValidationParam(t, wbUpdateValidate(ctx, rt), "--idempotent-token")
|
||||
})
|
||||
|
||||
t.Run("bad input_format", func(t *testing.T) {
|
||||
rt := newTestRuntime(map[string]string{
|
||||
"whiteboard-token": "t",
|
||||
"input_format": "svg",
|
||||
"source": "{}",
|
||||
}, nil)
|
||||
assertValidationParam(t, wbUpdateValidate(ctx, rt), "--input_format")
|
||||
})
|
||||
|
||||
t.Run("malformed source json", func(t *testing.T) {
|
||||
_, err, _ := parseWBcliNodes([]byte("not-json"))
|
||||
assertValidationParam(t, err, "--source")
|
||||
})
|
||||
}
|
||||
|
||||
func assertValidationParam(t *testing.T, err error, wantParam string) {
|
||||
t.Helper()
|
||||
if err == nil {
|
||||
t.Fatalf("expected error, got nil")
|
||||
}
|
||||
var ve *errs.ValidationError
|
||||
if !errors.As(err, &ve) {
|
||||
t.Fatalf("error is not *errs.ValidationError: %T", err)
|
||||
}
|
||||
if ve.Subtype != errs.SubtypeInvalidArgument {
|
||||
t.Errorf("Subtype = %q, want %q", ve.Subtype, errs.SubtypeInvalidArgument)
|
||||
}
|
||||
if ve.Param != wantParam {
|
||||
t.Errorf("Param = %q, want %q", ve.Param, wantParam)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetFormat(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
@@ -360,6 +410,27 @@ Bob -> Alice : hello
|
||||
}
|
||||
}
|
||||
|
||||
func TestWhiteboardUpdateExecute_PlantUMLInvalidResponse(t *testing.T) {
|
||||
factory, stdout, reg := newUpdateExecuteFactory(t)
|
||||
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/board/v1/whiteboards/test-token-plantuml-invalid-response/nodes/plantuml",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"msg": "success",
|
||||
"data": map[string]interface{}{},
|
||||
},
|
||||
})
|
||||
|
||||
source := `@@startuml
|
||||
Bob -> Alice : hello
|
||||
@@enduml`
|
||||
args := []string{"+update", "--whiteboard-token", "test-token-plantuml-invalid-response", "--input_format", "plantuml", "--source", source}
|
||||
err := runUpdateShortcut(t, WhiteboardUpdate, args, factory, stdout)
|
||||
assertInvalidResponse(t, err)
|
||||
}
|
||||
|
||||
func TestWhiteboardUpdateExecute_MermaidFormat(t *testing.T) {
|
||||
factory, stdout, reg := newUpdateExecuteFactory(t)
|
||||
|
||||
@@ -384,6 +455,45 @@ A-->B`
|
||||
}
|
||||
}
|
||||
|
||||
func TestWhiteboardUpdateExecute_RawInvalidResponse(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
token string
|
||||
data map[string]interface{}
|
||||
}{
|
||||
{
|
||||
name: "missing ids",
|
||||
token: "test-token-raw-missing-ids",
|
||||
data: map[string]interface{}{},
|
||||
},
|
||||
{
|
||||
name: "non-string id",
|
||||
token: "test-token-raw-bad-id",
|
||||
data: map[string]interface{}{"ids": []interface{}{"node1", 2}},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
factory, stdout, reg := newUpdateExecuteFactory(t)
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/board/v1/whiteboards/" + tt.token + "/nodes",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"msg": "success",
|
||||
"data": tt.data,
|
||||
},
|
||||
})
|
||||
|
||||
source := `{"code":0,"data":{"to":"openapi","result":{"nodes":[]}}}`
|
||||
args := []string{"+update", "--whiteboard-token", tt.token, "--input_format", "raw", "--source", source}
|
||||
err := runUpdateShortcut(t, WhiteboardUpdate, args, factory, stdout)
|
||||
assertInvalidResponse(t, err)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestWhiteboardUpdateExecute_RawWithIdempotent(t *testing.T) {
|
||||
factory, stdout, reg := newUpdateExecuteFactory(t)
|
||||
|
||||
@@ -444,12 +554,26 @@ func TestWhiteboardUpdateExecute_RawAPIError(t *testing.T) {
|
||||
},
|
||||
})
|
||||
|
||||
source := `{"code":0,"data":{"to":"openapi","result":{"nodes":[]}}}`
|
||||
// Top-level "nodes" is the raw open-api format (isRaw=true), which triggers
|
||||
// the raw-edit recovery hint on API failure.
|
||||
source := `{"nodes":[{"type":"composite_shape"}]}`
|
||||
args := []string{"+update", "--whiteboard-token", "test-token-raw-api-error", "--input_format", "raw", "--source", source}
|
||||
err := runUpdateShortcut(t, WhiteboardUpdate, args, factory, stdout)
|
||||
// We expect an error here, but don't fail the test because it's testing error path
|
||||
if err == nil {
|
||||
t.Logf("Expected API error, but got none")
|
||||
t.Fatalf("expected API error, but got none")
|
||||
}
|
||||
// The update boundary now yields a typed envelope carrying the Lark code.
|
||||
p, ok := errs.ProblemOf(err)
|
||||
if !ok {
|
||||
t.Fatalf("error is not a typed errs.* envelope: %T (%v)", err, err)
|
||||
}
|
||||
if p.Code != 10001 {
|
||||
t.Errorf("Problem.Code = %d, want 10001", p.Code)
|
||||
}
|
||||
// Raw (open-api JSON) input failures steer the user back to the recommended
|
||||
// DSL workflow via a recovery hint on the typed envelope.
|
||||
if !strings.Contains(p.Hint, "not advised to edit openapi format json directly") {
|
||||
t.Errorf("Problem.Hint missing raw-edit guidance, got %q", p.Hint)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -471,9 +595,15 @@ invalid
|
||||
@@enduml`
|
||||
args := []string{"+update", "--whiteboard-token", "test-token-plantuml-error", "--input_format", "plantuml", "--source", source}
|
||||
err := runUpdateShortcut(t, WhiteboardUpdate, args, factory, stdout)
|
||||
// We expect an error here, but don't fail the test because it's testing error path
|
||||
if err == nil {
|
||||
t.Logf("Expected API error, but got none")
|
||||
t.Fatalf("expected API error, but got none")
|
||||
}
|
||||
p, ok := errs.ProblemOf(err)
|
||||
if !ok {
|
||||
t.Fatalf("error is not a typed errs.* envelope: %T (%v)", err, err)
|
||||
}
|
||||
if p.Code != 10001 {
|
||||
t.Errorf("Problem.Code = %d, want 10001", p.Code)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user