mirror of
https://github.com/larksuite/cli.git
synced 2026-09-14 18:42:53 +08:00
2b4c6349a1
Replace every command-facing error path in the event domain — the consume/schema command layer, the +subscribe shortcut, EventKey definitions, and the consume orchestration — with typed errs.* envelopes, so consumers get stable type, subtype, param, hint, and missing_scopes metadata for classification and recovery instead of free-form message text. - Input validation (--jq, --param, --output-dir, --filter, --route, unknown EventKey, EventKey params) reports validation / invalid_argument with the offending flag in param and an actionable hint. - Scope preflight reports authorization / missing_scope with the machine-readable missing_scopes list; console-subscription and single-bus preconditions report failed_precondition with recovery hints. - The consume API boundary passes already-typed errors through and classifies transport, non-JSON HTTP, and unparsable responses; the vc note-detail retry now matches the not-found code on typed errors (it silently never fired against the legacy envelope shape). - Previously-bare failures exited 1 with a plain-text "Error:" line and now exit with their category code (validation 2, auth 3, network 4, internal 5) alongside the typed stderr envelope. - forbidigo and errscontract guards now cover the event paths so regressions fail lint; AGENTS.md and the lark-event skill document the typed contract for agent consumers. Validation: make unit-test (race) green; event unit and e2e suites assert category/subtype/param/hint and cause preservation against the real binary; errscontract and golangci lint clean.
213 lines
7.5 KiB
Go
213 lines
7.5 KiB
Go
// 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/errs"
|
|
"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)
|
|
}
|
|
var ve *errs.ValidationError
|
|
if !errors.As(err, &ve) {
|
|
t.Fatalf("expected *errs.ValidationError, got %T: %v", err, err)
|
|
}
|
|
if ve.Subtype != errs.SubtypeInvalidArgument || ve.Param != "--param" {
|
|
t.Errorf("subtype/param = %s/%q, want %s/%q", ve.Subtype, ve.Param, errs.SubtypeInvalidArgument, "--param")
|
|
}
|
|
if ve.Hint == "" {
|
|
t.Error("missing whiteboard_id should carry a hint")
|
|
}
|
|
}
|
|
|
|
// 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")
|
|
}
|
|
if p, ok := errs.ProblemOf(err); !ok || p.Category != errs.CategoryInternal {
|
|
t.Errorf("nil-runtime invariant should be a typed internal error, got %T: %v", err, err)
|
|
}
|
|
}
|
|
|
|
// 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)
|