mirror of
https://github.com/larksuite/cli.git
synced 2026-07-03 14:02:43 +08:00
Compare commits
10 Commits
v1.0.51
...
feat/feed-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a7ccd4e636 | ||
|
|
e53f9d999e | ||
|
|
ae35b35693 | ||
|
|
c2e617fc96 | ||
|
|
3f77eded9d | ||
|
|
e64610f6d2 | ||
|
|
dfa26c38f6 | ||
|
|
154ecdb90f | ||
|
|
483043c88b | ||
|
|
6d8dc402ac |
@@ -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/base/|shortcuts/calendar/|shortcuts/contact/|shortcuts/doc/|shortcuts/drive/|shortcuts/im/|shortcuts/mail/|shortcuts/markdown/|shortcuts/minutes/|shortcuts/okr/|shortcuts/sheets/|shortcuts/slides/|shortcuts/task/|shortcuts/vc/|shortcuts/whiteboard/|internal/event/consume/|cmd/event/|events/|shortcuts/event/)
|
||||
- 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/contact/|shortcuts/doc/|shortcuts/drive/|shortcuts/im/|shortcuts/mail/|shortcuts/markdown/|shortcuts/minutes/|shortcuts/okr/|shortcuts/sheets/|shortcuts/slides/|shortcuts/task/|shortcuts/vc/|shortcuts/whiteboard/|shortcuts/wiki/|internal/event/consume/|cmd/event/|events/|shortcuts/event/)
|
||||
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/base/|shortcuts/calendar/|shortcuts/contact/|shortcuts/doc/|shortcuts/drive/|shortcuts/im/|shortcuts/mail/|shortcuts/markdown/|shortcuts/minutes/|shortcuts/okr/|shortcuts/sheets/|shortcuts/slides/|shortcuts/task/|shortcuts/vc/|shortcuts/whiteboard/|shortcuts/common/mcp_client\.go|cmd/event/|events/|shortcuts/event/)
|
||||
- path-except: (shortcuts/base/|shortcuts/calendar/|shortcuts/contact/|shortcuts/doc/|shortcuts/drive/|shortcuts/im/|shortcuts/mail/|shortcuts/markdown/|shortcuts/minutes/|shortcuts/okr/|shortcuts/sheets/|shortcuts/slides/|shortcuts/task/|shortcuts/vc/|shortcuts/whiteboard/|shortcuts/wiki/|shortcuts/common/mcp_client\.go|cmd/event/|events/|shortcuts/event/)
|
||||
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/base/|shortcuts/calendar/|shortcuts/contact/|shortcuts/doc/|shortcuts/drive/|shortcuts/im/|shortcuts/mail/|shortcuts/markdown/|shortcuts/minutes/|shortcuts/okr/|shortcuts/sheets/|shortcuts/slides/|shortcuts/task/|shortcuts/vc/|shortcuts/whiteboard/|cmd/event/|events/|shortcuts/event/)
|
||||
- path-except: (shortcuts/base/|shortcuts/calendar/|shortcuts/contact/|shortcuts/doc/|shortcuts/drive/|shortcuts/im/|shortcuts/mail/|shortcuts/markdown/|shortcuts/minutes/|shortcuts/okr/|shortcuts/sheets/|shortcuts/slides/|shortcuts/task/|shortcuts/vc/|shortcuts/whiteboard/|shortcuts/wiki/|cmd/event/|events/|shortcuts/event/)
|
||||
text: errs-no-legacy-helper
|
||||
linters:
|
||||
- forbidigo
|
||||
|
||||
@@ -143,6 +143,79 @@ func TestWriteStatusText_CoversAllStates(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestWriteStatusText_ShowsSubColumn(t *testing.T) {
|
||||
var buf bytes.Buffer
|
||||
writeStatusText(&buf, []appStatus{
|
||||
{
|
||||
AppID: "cli_RUNNINGXXXXXXXXX",
|
||||
State: stateRunning,
|
||||
PID: 1234,
|
||||
UptimeSec: 60,
|
||||
Active: 2,
|
||||
Consumers: []protocol.ConsumerInfo{
|
||||
{PID: 1001, EventKey: "mail.x", SubscriptionID: "mail.x:alice", Received: 5, Dropped: 0},
|
||||
{PID: 1002, EventKey: "mail.x", SubscriptionID: "mail.x:bob", Received: 3, Dropped: 0},
|
||||
},
|
||||
},
|
||||
})
|
||||
out := buf.String()
|
||||
if !strings.Contains(out, "SUB") {
|
||||
t.Errorf("missing SUB column header: %s", out)
|
||||
}
|
||||
if !strings.Contains(out, "alice") {
|
||||
t.Errorf("missing alice suffix in SUB column: %s", out)
|
||||
}
|
||||
if !strings.Contains(out, "bob") {
|
||||
t.Errorf("missing bob suffix in SUB column: %s", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWriteStatusText_LegacySubscriptionID_RendersDash(t *testing.T) {
|
||||
var buf bytes.Buffer
|
||||
writeStatusText(&buf, []appStatus{
|
||||
{
|
||||
AppID: "cli_RUNNINGXXXXXXXXX",
|
||||
State: stateRunning,
|
||||
PID: 1234,
|
||||
UptimeSec: 60,
|
||||
Active: 1,
|
||||
Consumers: []protocol.ConsumerInfo{
|
||||
{PID: 1001, EventKey: "im.x", SubscriptionID: "", Received: 5},
|
||||
},
|
||||
},
|
||||
})
|
||||
out := buf.String()
|
||||
if !strings.Contains(out, "SUB") {
|
||||
t.Errorf("missing SUB header: %s", out)
|
||||
}
|
||||
if !strings.Contains(out, "-") {
|
||||
t.Errorf("missing dash placeholder for empty SubscriptionID: %s", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWriteStatusText_EventKeyEqualSubscriptionID_RendersDash(t *testing.T) {
|
||||
var buf bytes.Buffer
|
||||
writeStatusText(&buf, []appStatus{
|
||||
{
|
||||
AppID: "cli_RUNNINGXXXXXXXXX",
|
||||
State: stateRunning,
|
||||
PID: 1234,
|
||||
UptimeSec: 60,
|
||||
Active: 1,
|
||||
Consumers: []protocol.ConsumerInfo{
|
||||
{PID: 1001, EventKey: "im.x", SubscriptionID: "im.x", Received: 5},
|
||||
},
|
||||
},
|
||||
})
|
||||
out := buf.String()
|
||||
if !strings.Contains(out, "SUB") {
|
||||
t.Errorf("missing SUB header: %s", out)
|
||||
}
|
||||
if !strings.Contains(out, "-") {
|
||||
t.Errorf("missing dash placeholder when SubscriptionID==EventKey: %s", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWriteStatusJSON_OrphanHint(t *testing.T) {
|
||||
var buf bytes.Buffer
|
||||
if err := writeStatusJSON(&buf, []appStatus{
|
||||
|
||||
@@ -134,12 +134,16 @@ func runSchema(f *cmdutil.Factory, key string, asJSON bool) error {
|
||||
if len(def.Params) > 0 {
|
||||
fmt.Fprintf(out, "\nParameters:\n")
|
||||
w := tabwriter.NewWriter(out, 0, 4, 2, ' ', 0)
|
||||
fmt.Fprintf(w, " NAME\tTYPE\tREQUIRED\tDEFAULT\tDESCRIPTION\n")
|
||||
fmt.Fprintf(w, " NAME\tTYPE\tREQUIRED\tSUB-KEY\tDEFAULT\tDESCRIPTION\n")
|
||||
for _, p := range def.Params {
|
||||
required := "no"
|
||||
if p.Required {
|
||||
required = "yes"
|
||||
}
|
||||
subKey := "no"
|
||||
if p.SubscriptionKey {
|
||||
subKey = "yes"
|
||||
}
|
||||
defaultVal := p.Default
|
||||
if defaultVal == "" {
|
||||
defaultVal = "-"
|
||||
@@ -148,7 +152,7 @@ func runSchema(f *cmdutil.Factory, key string, asJSON bool) error {
|
||||
if desc == "" {
|
||||
desc = "-"
|
||||
}
|
||||
fmt.Fprintf(w, " %s\t%s\t%s\t%s\t%s\n", p.Name, p.Type, required, defaultVal, desc)
|
||||
fmt.Fprintf(w, " %s\t%s\t%s\t%s\t%s\t%s\n", p.Name, p.Type, required, subKey, defaultVal, desc)
|
||||
}
|
||||
w.Flush()
|
||||
|
||||
|
||||
@@ -96,6 +96,79 @@ func TestRunSchema_JSONOutput(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestSchema_RendersSubscriptionKeyMarker(t *testing.T) {
|
||||
const syntheticKey = "test.evt_sub"
|
||||
t.Cleanup(func() { eventlib.UnregisterKeyForTest(syntheticKey) })
|
||||
|
||||
eventlib.RegisterKey(eventlib.KeyDefinition{
|
||||
Key: syntheticKey,
|
||||
EventType: syntheticKey,
|
||||
Params: []eventlib.ParamDef{
|
||||
{Name: "mailbox", SubscriptionKey: true, Description: "subscription id source"},
|
||||
{Name: "folders", Description: "filter only"},
|
||||
},
|
||||
Schema: eventlib.SchemaDef{Native: &eventlib.SchemaSpec{Type: reflect.TypeOf(struct{ X string }{})}},
|
||||
})
|
||||
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, &core.CliConfig{AppID: "test"})
|
||||
if err := runSchema(f, syntheticKey, false); err != nil {
|
||||
t.Fatalf("runSchema: %v", err)
|
||||
}
|
||||
|
||||
out := stdout.String()
|
||||
if !strings.Contains(out, "SUB-KEY") {
|
||||
t.Errorf("missing SUB-KEY column header in:\n%s", out)
|
||||
}
|
||||
|
||||
// Find the mailbox row and verify "yes" is present
|
||||
var mailboxRow string
|
||||
for _, ln := range strings.Split(out, "\n") {
|
||||
if strings.Contains(ln, "mailbox") && !strings.Contains(ln, "NAME") {
|
||||
mailboxRow = ln
|
||||
break
|
||||
}
|
||||
}
|
||||
if !strings.Contains(mailboxRow, "yes") {
|
||||
t.Errorf("mailbox row missing yes SUB-KEY marker: %q", mailboxRow)
|
||||
}
|
||||
|
||||
// Find the folders row and verify "no" is present
|
||||
var foldersRow string
|
||||
for _, ln := range strings.Split(out, "\n") {
|
||||
if strings.Contains(ln, "folders") && !strings.Contains(ln, "NAME") {
|
||||
foldersRow = ln
|
||||
break
|
||||
}
|
||||
}
|
||||
if !strings.Contains(foldersRow, "no") {
|
||||
t.Errorf("folders row missing no SUB-KEY marker: %q", foldersRow)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSchema_JSON_IncludesSubscriptionKey(t *testing.T) {
|
||||
const syntheticKey = "test.evt_json"
|
||||
t.Cleanup(func() { eventlib.UnregisterKeyForTest(syntheticKey) })
|
||||
|
||||
eventlib.RegisterKey(eventlib.KeyDefinition{
|
||||
Key: syntheticKey,
|
||||
EventType: syntheticKey,
|
||||
Params: []eventlib.ParamDef{{Name: "mailbox", SubscriptionKey: true}},
|
||||
Schema: eventlib.SchemaDef{Native: &eventlib.SchemaSpec{Type: reflect.TypeOf(struct{ X string }{})}},
|
||||
})
|
||||
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, &core.CliConfig{AppID: "test"})
|
||||
if err := runSchema(f, syntheticKey, true); err != nil {
|
||||
t.Fatalf("runSchema json: %v", err)
|
||||
}
|
||||
|
||||
if !strings.Contains(stdout.String(), `"subscription_key"`) {
|
||||
t.Errorf("JSON output missing subscription_key field: %s", stdout.String())
|
||||
}
|
||||
if !strings.Contains(stdout.String(), `true`) {
|
||||
t.Errorf("JSON output missing subscription_key: true value: %s", stdout.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveSchemaJSON_CustomWithOverlay(t *testing.T) {
|
||||
const syntheticKey = "t.custom.overlay"
|
||||
t.Cleanup(func() { eventlib.UnregisterKeyForTest(syntheticKey) })
|
||||
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
"fmt"
|
||||
"io"
|
||||
"sort"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
@@ -242,12 +243,17 @@ func writeStatusText(out io.Writer, statuses []appStatus) {
|
||||
s.PID, (time.Duration(s.UptimeSec) * time.Second).String())
|
||||
fmt.Fprintf(out, " Active consumers: %d\n", s.Active)
|
||||
if len(s.Consumers) > 0 {
|
||||
headers := []string{"CONSUMER", "EVENT KEY", "RECEIVED", "DROPPED"}
|
||||
headers := []string{"CONSUMER", "EVENT KEY", "SUB", "RECEIVED", "DROPPED"}
|
||||
rows := make([][]string, 0, len(s.Consumers))
|
||||
for _, c := range s.Consumers {
|
||||
subDisplay := "-"
|
||||
if c.SubscriptionID != "" && c.SubscriptionID != c.EventKey {
|
||||
subDisplay = strings.TrimPrefix(c.SubscriptionID, c.EventKey+":")
|
||||
}
|
||||
rows = append(rows, []string{
|
||||
fmt.Sprintf("pid=%d", c.PID),
|
||||
c.EventKey,
|
||||
subDisplay,
|
||||
fmt.Sprintf("%d", c.Received),
|
||||
fmt.Sprintf("%d", c.Dropped),
|
||||
})
|
||||
|
||||
@@ -13,8 +13,8 @@ import (
|
||||
|
||||
const cleanupTimeout = 5 * time.Second
|
||||
|
||||
func subscriptionPreConsume(eventType, subscribePath, unsubscribePath string) func(context.Context, event.APIClient, map[string]string) (func(), error) {
|
||||
return func(ctx context.Context, rt event.APIClient, _ map[string]string) (func(), error) {
|
||||
func subscriptionPreConsume(eventType, subscribePath, unsubscribePath string) func(context.Context, event.APIClient, map[string]string) (func() error, error) {
|
||||
return func(ctx context.Context, rt event.APIClient, _ map[string]string) (func() error, error) {
|
||||
if rt == nil {
|
||||
return nil, errs.NewInternalError(errs.SubtypeUnknown,
|
||||
"runtime API client is required for pre-consume subscription")
|
||||
@@ -25,10 +25,13 @@ func subscriptionPreConsume(eventType, subscribePath, unsubscribePath string) fu
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return func() {
|
||||
return func() error {
|
||||
cleanupCtx, cancel := context.WithTimeout(context.Background(), cleanupTimeout)
|
||||
defer cancel()
|
||||
_, _ = rt.CallAPI(cleanupCtx, "POST", unsubscribePath, body)
|
||||
if _, err := rt.CallAPI(cleanupCtx, "POST", unsubscribePath, body); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}, nil
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,8 +13,8 @@ import (
|
||||
|
||||
const cleanupTimeout = 5 * time.Second
|
||||
|
||||
func subscriptionPreConsume(eventType, subscribePath, unsubscribePath string) func(context.Context, event.APIClient, map[string]string) (func(), error) {
|
||||
return func(ctx context.Context, rt event.APIClient, _ map[string]string) (func(), error) {
|
||||
func subscriptionPreConsume(eventType, subscribePath, unsubscribePath string) func(context.Context, event.APIClient, map[string]string) (func() error, error) {
|
||||
return func(ctx context.Context, rt event.APIClient, _ map[string]string) (func() error, error) {
|
||||
if rt == nil {
|
||||
return nil, errs.NewInternalError(errs.SubtypeUnknown,
|
||||
"runtime API client is required for pre-consume subscription")
|
||||
@@ -25,10 +25,13 @@ func subscriptionPreConsume(eventType, subscribePath, unsubscribePath string) fu
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return func() {
|
||||
return func() error {
|
||||
cleanupCtx, cancel := context.WithTimeout(context.Background(), cleanupTimeout)
|
||||
defer cancel()
|
||||
_, _ = rt.CallAPI(cleanupCtx, "POST", unsubscribePath, body)
|
||||
if _, err := rt.CallAPI(cleanupCtx, "POST", unsubscribePath, body); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}, nil
|
||||
}
|
||||
}
|
||||
|
||||
@@ -22,8 +22,8 @@ const cleanupTimeout = 5 * time.Second
|
||||
//
|
||||
// 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) {
|
||||
func whiteboardSubscriptionPreConsume(eventType string) func(context.Context, event.APIClient, map[string]string) (func() error, error) {
|
||||
return func(ctx context.Context, rt event.APIClient, params map[string]string) (func() error, error) {
|
||||
if rt == nil {
|
||||
return nil, errs.NewInternalError(errs.SubtypeUnknown,
|
||||
"runtime API client is required for pre-consume subscription")
|
||||
@@ -44,10 +44,13 @@ func whiteboardSubscriptionPreConsume(eventType string) func(context.Context, ev
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return func() {
|
||||
return func() error {
|
||||
cleanupCtx, cancel := context.WithTimeout(context.Background(), cleanupTimeout)
|
||||
defer cancel()
|
||||
_, _ = rt.CallAPI(cleanupCtx, "POST", unsubscribePath, body)
|
||||
if _, err := rt.CallAPI(cleanupCtx, "POST", unsubscribePath, body); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}, nil
|
||||
}
|
||||
}
|
||||
|
||||
@@ -262,19 +262,23 @@ func (b *Bus) handleConn(conn net.Conn) {
|
||||
|
||||
// handleHello registers a consume connection with the hub; reader carries bytes already pulled off conn.
|
||||
func (b *Bus) handleHello(conn net.Conn, reader *bufio.Reader, hello *protocol.Hello) {
|
||||
bc := NewConn(conn, reader, hello.EventKey, hello.EventTypes, hello.PID)
|
||||
subID := hello.SubscriptionID
|
||||
if subID == "" {
|
||||
subID = hello.EventKey
|
||||
}
|
||||
bc := NewConn(conn, reader, hello.EventKey, hello.EventTypes, hello.PID, subID)
|
||||
bc.SetLogger(b.logger)
|
||||
|
||||
// Register + isFirst under one lock; blocks on any in-progress cleanup lock for the same EventKey.
|
||||
firstForKey := b.hub.RegisterAndIsFirst(bc)
|
||||
|
||||
bc.SetCheckLastForKey(func(eventKey string) bool {
|
||||
return b.hub.AcquireCleanupLock(eventKey)
|
||||
bc.SetCheckLastForKey(func(scope string) bool {
|
||||
return b.hub.AcquireCleanupLock(scope)
|
||||
})
|
||||
bc.SetOnClose(func(c *Conn) {
|
||||
b.hub.UnregisterAndIsLast(c)
|
||||
// Release is idempotent and must fire on every disconnect path so waiters don't block forever.
|
||||
b.hub.ReleaseCleanupLock(c.EventKey())
|
||||
b.hub.ReleaseCleanupLock(c.SubscriptionID())
|
||||
b.mu.Lock()
|
||||
delete(b.conns, c)
|
||||
remaining := len(b.conns)
|
||||
|
||||
@@ -33,7 +33,7 @@ func TestRunShutdownWithMultipleConns(t *testing.T) {
|
||||
server, client := net.Pipe()
|
||||
pipes = append(pipes, server, client)
|
||||
|
||||
bc := NewConn(server, nil, "im.msg", []string{"im.message.receive_v1"}, 1000+i)
|
||||
bc := NewConn(server, nil, "im.msg", []string{"im.message.receive_v1"}, 1000+i, "")
|
||||
bc.SetLogger(logger)
|
||||
hub.RegisterAndIsFirst(bc)
|
||||
|
||||
|
||||
@@ -29,9 +29,10 @@ type Conn struct {
|
||||
writeMu sync.Mutex // serialises all net.Conn writes (Encode+SetWriteDeadline is a 2-call sequence)
|
||||
eventKey string
|
||||
eventTypes []string
|
||||
subID string
|
||||
pid int
|
||||
onClose func(*Conn)
|
||||
checkLastForKey func(eventKey string) bool
|
||||
checkLastForKey func(scope string) bool
|
||||
logger *log.Logger
|
||||
closed chan struct{}
|
||||
closeOnce sync.Once
|
||||
@@ -41,7 +42,7 @@ type Conn struct {
|
||||
}
|
||||
|
||||
// NewConn creates a Conn; pass a reader with pre-buffered bytes (handoff from Bus.handleConn) or nil for a fresh one.
|
||||
func NewConn(conn net.Conn, reader *bufio.Reader, eventKey string, eventTypes []string, pid int) *Conn {
|
||||
func NewConn(conn net.Conn, reader *bufio.Reader, eventKey string, eventTypes []string, pid int, subID string) *Conn {
|
||||
if reader == nil {
|
||||
reader = bufio.NewReader(conn)
|
||||
}
|
||||
@@ -52,10 +53,20 @@ func NewConn(conn net.Conn, reader *bufio.Reader, eventKey string, eventTypes []
|
||||
eventKey: eventKey,
|
||||
eventTypes: eventTypes,
|
||||
pid: pid,
|
||||
subID: subID,
|
||||
closed: make(chan struct{}),
|
||||
}
|
||||
}
|
||||
|
||||
// SubscriptionID returns the subscription identity. Falls back to EventKey
|
||||
// when the stored subID is empty (legacy clients / no-SubscriptionKey EventKeys).
|
||||
func (c *Conn) SubscriptionID() string {
|
||||
if c.subID == "" {
|
||||
return c.eventKey
|
||||
}
|
||||
return c.subID
|
||||
}
|
||||
|
||||
func (c *Conn) SetOnClose(fn func(*Conn)) { c.onClose = fn }
|
||||
|
||||
// SetCheckLastForKey: returning true means "you are the last subscriber, run cleanup".
|
||||
@@ -132,13 +143,19 @@ func (c *Conn) ReaderLoop() {
|
||||
}
|
||||
|
||||
func (c *Conn) handleControlMessage(msg interface{}) {
|
||||
switch m := msg.(type) {
|
||||
switch msg.(type) {
|
||||
case *protocol.Bye:
|
||||
c.shutdown()
|
||||
case *protocol.PreShutdownCheck:
|
||||
// Use the connection's own authoritative subscription identity rather
|
||||
// than recomputing from the incoming message: a stale or mismatched
|
||||
// PreShutdownCheck must not ask about the wrong scope (which would
|
||||
// suppress or mistrigger per-subscription cleanup). Conn.SubscriptionID()
|
||||
// already falls back to EventKey when its stored subID is empty.
|
||||
scope := c.SubscriptionID()
|
||||
lastForKey := true
|
||||
if c.checkLastForKey != nil {
|
||||
lastForKey = c.checkLastForKey(m.EventKey)
|
||||
lastForKey = c.checkLastForKey(scope)
|
||||
}
|
||||
ack := protocol.NewPreShutdownAck(lastForKey)
|
||||
if err := c.writeFrame(ack); err != nil && c.logger != nil {
|
||||
|
||||
@@ -21,7 +21,7 @@ func TestConn_SenderWritesEvents(t *testing.T) {
|
||||
defer server.Close()
|
||||
defer client.Close()
|
||||
|
||||
bc := NewConn(server, nil, "im.msg", []string{"im.message.receive_v1"}, 12345)
|
||||
bc := NewConn(server, nil, "im.msg", []string{"im.message.receive_v1"}, 12345, "")
|
||||
go bc.SenderLoop()
|
||||
|
||||
bc.SendCh() <- &protocol.Event{
|
||||
@@ -62,7 +62,7 @@ func TestConn_ConcurrentWritesSerialised(t *testing.T) {
|
||||
defer client.Close()
|
||||
|
||||
det := &serializingDetector{Conn: server}
|
||||
bc := NewConn(det, nil, "im.msg", []string{"im.msg"}, 12345)
|
||||
bc := NewConn(det, nil, "im.msg", []string{"im.msg"}, 12345, "")
|
||||
|
||||
go func() { _, _ = io.Copy(io.Discard, client) }()
|
||||
|
||||
@@ -106,7 +106,7 @@ func TestConn_TrySend_NonEvicting(t *testing.T) {
|
||||
server, client := net.Pipe()
|
||||
defer server.Close()
|
||||
defer client.Close()
|
||||
bc := NewConn(server, nil, "im.msg", []string{"im.msg"}, 12345)
|
||||
bc := NewConn(server, nil, "im.msg", []string{"im.msg"}, 12345, "")
|
||||
|
||||
for i := 0; i < sendChCap; i++ {
|
||||
if !bc.TrySend(i) {
|
||||
@@ -126,7 +126,7 @@ func TestConn_ReaderDetectsEOF(t *testing.T) {
|
||||
server, client := net.Pipe()
|
||||
defer server.Close()
|
||||
|
||||
bc := NewConn(server, nil, "im.msg", []string{"im.msg"}, 12345)
|
||||
bc := NewConn(server, nil, "im.msg", []string{"im.msg"}, 12345, "")
|
||||
|
||||
done := make(chan struct{})
|
||||
go func() {
|
||||
@@ -142,3 +142,23 @@ func TestConn_ReaderDetectsEOF(t *testing.T) {
|
||||
t.Fatal("ReaderLoop did not exit on EOF")
|
||||
}
|
||||
}
|
||||
|
||||
func TestConn_SubscriptionID(t *testing.T) {
|
||||
c1, c2 := net.Pipe()
|
||||
defer c1.Close()
|
||||
defer c2.Close()
|
||||
conn := NewConn(c1, nil, "mail.x", []string{"mail.x"}, 999, "mail.x:abc")
|
||||
if got := conn.SubscriptionID(); got != "mail.x:abc" {
|
||||
t.Errorf("SubscriptionID() = %q, want %q", got, "mail.x:abc")
|
||||
}
|
||||
}
|
||||
|
||||
func TestConn_SubscriptionID_EmptyFallsBackToEventKey(t *testing.T) {
|
||||
c1, c2 := net.Pipe()
|
||||
defer c1.Close()
|
||||
defer c2.Close()
|
||||
conn := NewConn(c1, nil, "mail.x", []string{"mail.x"}, 999, "")
|
||||
if got := conn.SubscriptionID(); got != "mail.x" {
|
||||
t.Errorf("SubscriptionID() with empty input = %q, want fallback %q", got, "mail.x")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -63,3 +63,134 @@ func TestHandleHello_HelloAckWriteFailureUnregisters(t *testing.T) {
|
||||
t.Errorf("b.conns after failed HelloAck = %d entries, want 0", remaining)
|
||||
}
|
||||
}
|
||||
|
||||
// TestHandleHello_LegacyClient_FallsBackToEventKey: a Hello with empty
|
||||
// subscription_id registers under EventKey (today's behavior preserved).
|
||||
func TestHandleHello_LegacyClient_FallsBackToEventKey(t *testing.T) {
|
||||
logger := log.New(io.Discard, "", 0)
|
||||
hub := NewHub()
|
||||
b := &Bus{
|
||||
hub: hub,
|
||||
logger: logger,
|
||||
conns: make(map[*Conn]struct{}),
|
||||
idleTimer: time.NewTimer(30 * time.Second),
|
||||
shutdownCh: make(chan struct{}, 1),
|
||||
}
|
||||
|
||||
server, client := net.Pipe()
|
||||
defer server.Close()
|
||||
defer client.Close()
|
||||
|
||||
// Legacy client: no subscription_id field (empty string).
|
||||
hello := &protocol.Hello{
|
||||
PID: 9999,
|
||||
EventKey: "im.message",
|
||||
EventTypes: []string{"im.message.receive_v1"},
|
||||
SubscriptionID: "", // legacy: empty, should fallback to EventKey
|
||||
}
|
||||
|
||||
br := bufio.NewReader(server)
|
||||
|
||||
done := make(chan struct{})
|
||||
go func() {
|
||||
b.handleHello(server, br, hello)
|
||||
close(done)
|
||||
}()
|
||||
|
||||
// Read the HelloAck from client side to let handleHello complete.
|
||||
clientReader := bufio.NewReader(client)
|
||||
ackLine, err := clientReader.ReadString('\n')
|
||||
if err != nil {
|
||||
t.Fatalf("failed to read HelloAck: %v", err)
|
||||
}
|
||||
|
||||
select {
|
||||
case <-done:
|
||||
case <-time.After(3 * time.Second):
|
||||
t.Fatal("handleHello did not return within 3s")
|
||||
}
|
||||
|
||||
// Assertions: registered under EventKey (not a qualified subscription ID).
|
||||
if got := hub.ConnCount(); got != 1 {
|
||||
t.Errorf("hub.ConnCount = %d, want 1", got)
|
||||
}
|
||||
if got := hub.EventKeyCount("im.message"); got != 1 {
|
||||
t.Errorf("hub.EventKeyCount(im.message) = %d, want 1", got)
|
||||
}
|
||||
if got := hub.SubCount("im.message"); got != 1 {
|
||||
t.Errorf("hub.SubCount(im.message) = %d, want 1 (legacy fallback to EventKey)", got)
|
||||
}
|
||||
if got := hub.SubCount("im.message:something"); got != 0 {
|
||||
t.Errorf("hub.SubCount(im.message:something) = %d, want 0 (should not exist)", got)
|
||||
}
|
||||
|
||||
if ackLine == "" {
|
||||
t.Fatal("HelloAck was empty")
|
||||
}
|
||||
}
|
||||
|
||||
// TestHandleHello_ModernClient_UsesSubscriptionID: a Hello with
|
||||
// non-empty subscription_id registers under that ID, not EventKey.
|
||||
func TestHandleHello_ModernClient_UsesSubscriptionID(t *testing.T) {
|
||||
logger := log.New(io.Discard, "", 0)
|
||||
hub := NewHub()
|
||||
b := &Bus{
|
||||
hub: hub,
|
||||
logger: logger,
|
||||
conns: make(map[*Conn]struct{}),
|
||||
idleTimer: time.NewTimer(30 * time.Second),
|
||||
shutdownCh: make(chan struct{}, 1),
|
||||
}
|
||||
|
||||
server, client := net.Pipe()
|
||||
defer server.Close()
|
||||
defer client.Close()
|
||||
|
||||
// Modern client: subscription_id explicitly set.
|
||||
subscriptionID := "mail.message:alice@example.com"
|
||||
hello := &protocol.Hello{
|
||||
PID: 8888,
|
||||
EventKey: "mail.message",
|
||||
EventTypes: []string{"mail.message.receive_v1"},
|
||||
SubscriptionID: subscriptionID, // modern: per-resource subscription
|
||||
}
|
||||
|
||||
br := bufio.NewReader(server)
|
||||
|
||||
done := make(chan struct{})
|
||||
go func() {
|
||||
b.handleHello(server, br, hello)
|
||||
close(done)
|
||||
}()
|
||||
|
||||
// Read the HelloAck from client side to let handleHello complete.
|
||||
clientReader := bufio.NewReader(client)
|
||||
ackLine, err := clientReader.ReadString('\n')
|
||||
if err != nil {
|
||||
t.Fatalf("failed to read HelloAck: %v", err)
|
||||
}
|
||||
|
||||
select {
|
||||
case <-done:
|
||||
case <-time.After(3 * time.Second):
|
||||
t.Fatal("handleHello did not return within 3s")
|
||||
}
|
||||
|
||||
// Assertions: registered under the subscription_id, not bare EventKey.
|
||||
if got := hub.ConnCount(); got != 1 {
|
||||
t.Errorf("hub.ConnCount = %d, want 1", got)
|
||||
}
|
||||
if got := hub.EventKeyCount("mail.message"); got != 1 {
|
||||
t.Errorf("hub.EventKeyCount(mail.message) = %d, want 1", got)
|
||||
}
|
||||
if got := hub.SubCount(subscriptionID); got != 1 {
|
||||
t.Errorf("hub.SubCount(%q) = %d, want 1 (modern: uses SubscriptionID)", subscriptionID, got)
|
||||
}
|
||||
if got := hub.SubCount("mail.message"); got != 0 {
|
||||
t.Errorf("hub.SubCount(mail.message) = %d, want 0 (modern: NOT registered under bare EventKey)", got)
|
||||
}
|
||||
|
||||
if ackLine == "" {
|
||||
t.Fatal("HelloAck was empty")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,6 +16,9 @@ import (
|
||||
// Subscriber is the interface a connection must satisfy for Hub registration.
|
||||
type Subscriber interface {
|
||||
EventKey() string
|
||||
// SubscriptionID identifies the per-resource subscription for dedup purposes.
|
||||
// When no resource qualifier is needed it equals EventKey.
|
||||
SubscriptionID() string
|
||||
EventTypes() []string
|
||||
SendCh() chan interface{}
|
||||
PID() int
|
||||
@@ -34,8 +37,11 @@ type Subscriber interface {
|
||||
type Hub struct {
|
||||
mu sync.RWMutex
|
||||
subscribers map[Subscriber]struct{}
|
||||
keyCounts map[string]int
|
||||
// cleanupInProgress[key] holds a channel closed on release; presence means a cleanup lock is held.
|
||||
// subCounts is keyed by SubscriptionID (not EventKey) so that different
|
||||
// per-resource subscriptions sharing the same EventKey are deduped independently.
|
||||
subCounts map[string]int
|
||||
// cleanupInProgress[subscriptionID] holds a channel closed on release;
|
||||
// presence means a cleanup lock is held for that subscription.
|
||||
cleanupInProgress map[string]chan struct{}
|
||||
logger atomic.Pointer[log.Logger]
|
||||
}
|
||||
@@ -43,7 +49,7 @@ type Hub struct {
|
||||
func NewHub() *Hub {
|
||||
return &Hub{
|
||||
subscribers: make(map[Subscriber]struct{}),
|
||||
keyCounts: make(map[string]int),
|
||||
subCounts: make(map[string]int),
|
||||
cleanupInProgress: make(map[string]chan struct{}),
|
||||
}
|
||||
}
|
||||
@@ -51,7 +57,7 @@ func NewHub() *Hub {
|
||||
// SetLogger attaches a logger (nil tolerated).
|
||||
func (h *Hub) SetLogger(l *log.Logger) { h.logger.Store(l) }
|
||||
|
||||
// UnregisterAndIsLast removes s and reports whether it was last for its EventKey; stale unregisters are no-ops.
|
||||
// UnregisterAndIsLast removes s and reports whether it was last for its SubscriptionID; stale unregisters are no-ops.
|
||||
func (h *Hub) UnregisterAndIsLast(s Subscriber) bool {
|
||||
h.mu.Lock()
|
||||
defer h.mu.Unlock()
|
||||
@@ -59,34 +65,35 @@ func (h *Hub) UnregisterAndIsLast(s Subscriber) bool {
|
||||
return false
|
||||
}
|
||||
delete(h.subscribers, s)
|
||||
h.keyCounts[s.EventKey()]--
|
||||
isLast := h.keyCounts[s.EventKey()] == 0
|
||||
sid := s.SubscriptionID()
|
||||
h.subCounts[sid]--
|
||||
isLast := h.subCounts[sid] == 0
|
||||
if isLast {
|
||||
delete(h.keyCounts, s.EventKey())
|
||||
delete(h.subCounts, sid)
|
||||
}
|
||||
return isLast
|
||||
}
|
||||
|
||||
// AcquireCleanupLock reserves cleanup rights iff exactly one subscriber exists for eventKey and no lock is held.
|
||||
// AcquireCleanupLock reserves cleanup rights iff exactly one subscriber exists for subscriptionID and no lock is held.
|
||||
// Count==0 is rejected (would block future Register calls). On true return, caller MUST Release.
|
||||
func (h *Hub) AcquireCleanupLock(eventKey string) bool {
|
||||
func (h *Hub) AcquireCleanupLock(subscriptionID string) bool {
|
||||
h.mu.Lock()
|
||||
defer h.mu.Unlock()
|
||||
if h.keyCounts[eventKey] != 1 {
|
||||
if h.subCounts[subscriptionID] != 1 {
|
||||
return false
|
||||
}
|
||||
if _, alreadyLocked := h.cleanupInProgress[eventKey]; alreadyLocked {
|
||||
if _, alreadyLocked := h.cleanupInProgress[subscriptionID]; alreadyLocked {
|
||||
return false
|
||||
}
|
||||
h.cleanupInProgress[eventKey] = make(chan struct{})
|
||||
h.cleanupInProgress[subscriptionID] = make(chan struct{})
|
||||
return true
|
||||
}
|
||||
|
||||
// ReleaseCleanupLock is idempotent; OnClose calls unconditionally.
|
||||
func (h *Hub) ReleaseCleanupLock(eventKey string) {
|
||||
func (h *Hub) ReleaseCleanupLock(subscriptionID string) {
|
||||
h.mu.Lock()
|
||||
ch := h.cleanupInProgress[eventKey]
|
||||
delete(h.cleanupInProgress, eventKey)
|
||||
ch := h.cleanupInProgress[subscriptionID]
|
||||
delete(h.cleanupInProgress, subscriptionID)
|
||||
h.mu.Unlock()
|
||||
if ch != nil {
|
||||
close(ch)
|
||||
@@ -94,23 +101,24 @@ func (h *Hub) ReleaseCleanupLock(eventKey string) {
|
||||
}
|
||||
|
||||
// RegisterAndIsFirst adds s to the hub and reports whether it's the first
|
||||
// subscriber for its EventKey. If a cleanup is in progress for
|
||||
// s.EventKey() (another conn holds the cleanup lock), this waits until
|
||||
// subscriber for its SubscriptionID. If a cleanup is in progress for
|
||||
// s.SubscriptionID() (another conn holds the cleanup lock), this waits until
|
||||
// cleanup releases before registering — closing the PreShutdownCheck ×
|
||||
// Hello TOCTOU race. The wait releases h.mu before blocking on the
|
||||
// channel, so concurrent operations on other keys aren't stalled.
|
||||
// channel, so concurrent operations on other subscriptions aren't stalled.
|
||||
func (h *Hub) RegisterAndIsFirst(s Subscriber) bool {
|
||||
sid := s.SubscriptionID()
|
||||
for {
|
||||
h.mu.Lock()
|
||||
ch, locked := h.cleanupInProgress[s.EventKey()]
|
||||
ch, locked := h.cleanupInProgress[sid]
|
||||
if locked {
|
||||
h.mu.Unlock()
|
||||
<-ch // wait for release, then re-check (defensive against races)
|
||||
continue
|
||||
}
|
||||
isFirst := h.keyCounts[s.EventKey()] == 0
|
||||
isFirst := h.subCounts[sid] == 0
|
||||
h.subscribers[s] = struct{}{}
|
||||
h.keyCounts[s.EventKey()]++
|
||||
h.subCounts[sid]++
|
||||
h.mu.Unlock()
|
||||
return isFirst
|
||||
}
|
||||
@@ -176,11 +184,25 @@ func (h *Hub) ConnCount() int {
|
||||
return len(h.subscribers)
|
||||
}
|
||||
|
||||
// EventKeyCount returns the number of subscribers registered for eventKey.
|
||||
// EventKeyCount returns total subscribers for the given EventKey, aggregating
|
||||
// across all SubscriptionIDs. For per-subscription counts use SubCount.
|
||||
func (h *Hub) EventKeyCount(eventKey string) int {
|
||||
h.mu.RLock()
|
||||
defer h.mu.RUnlock()
|
||||
return h.keyCounts[eventKey]
|
||||
count := 0
|
||||
for s := range h.subscribers {
|
||||
if s.EventKey() == eventKey {
|
||||
count++
|
||||
}
|
||||
}
|
||||
return count
|
||||
}
|
||||
|
||||
// SubCount returns the count of subscribers for the given SubscriptionID.
|
||||
func (h *Hub) SubCount(subscriptionID string) int {
|
||||
h.mu.RLock()
|
||||
defer h.mu.RUnlock()
|
||||
return h.subCounts[subscriptionID]
|
||||
}
|
||||
|
||||
// BroadcastSourceStatus fans out a source-level status change to every
|
||||
@@ -205,10 +227,11 @@ func (h *Hub) Consumers() []protocol.ConsumerInfo {
|
||||
result := make([]protocol.ConsumerInfo, 0, len(h.subscribers))
|
||||
for s := range h.subscribers {
|
||||
result = append(result, protocol.ConsumerInfo{
|
||||
PID: s.PID(),
|
||||
EventKey: s.EventKey(),
|
||||
Received: s.Received(),
|
||||
Dropped: s.DroppedCount(),
|
||||
PID: s.PID(),
|
||||
EventKey: s.EventKey(),
|
||||
SubscriptionID: s.SubscriptionID(),
|
||||
Received: s.Received(),
|
||||
Dropped: s.DroppedCount(),
|
||||
})
|
||||
}
|
||||
return result
|
||||
|
||||
@@ -17,7 +17,7 @@ func TestHubDroppedCountIncrements(t *testing.T) {
|
||||
server, client := testNetPipe(t)
|
||||
defer server.Close()
|
||||
defer client.Close()
|
||||
c := NewConn(server, nil, "k", []string{"t"}, 1)
|
||||
c := NewConn(server, nil, "k", []string{"t"}, 1, "")
|
||||
c.sendCh = make(chan interface{}, 1)
|
||||
h.RegisterAndIsFirst(c)
|
||||
|
||||
@@ -35,7 +35,7 @@ func TestPublishAssignsIncrementalSeq(t *testing.T) {
|
||||
server, client := testNetPipe(t)
|
||||
defer server.Close()
|
||||
defer client.Close()
|
||||
c := NewConn(server, nil, "k", []string{"t"}, 1)
|
||||
c := NewConn(server, nil, "k", []string{"t"}, 1, "")
|
||||
c.sendCh = make(chan interface{}, 10)
|
||||
h.RegisterAndIsFirst(c)
|
||||
|
||||
@@ -60,7 +60,7 @@ func TestPublishPopulatesEventIDAndSourceTime(t *testing.T) {
|
||||
server, client := testNetPipe(t)
|
||||
defer server.Close()
|
||||
defer client.Close()
|
||||
c := NewConn(server, nil, "k", []string{"t"}, 1)
|
||||
c := NewConn(server, nil, "k", []string{"t"}, 1, "")
|
||||
c.sendCh = make(chan interface{}, 1)
|
||||
h.RegisterAndIsFirst(c)
|
||||
|
||||
@@ -87,7 +87,7 @@ func TestPublishSourceTimeTakesPrecedence(t *testing.T) {
|
||||
server, client := testNetPipe(t)
|
||||
defer server.Close()
|
||||
defer client.Close()
|
||||
c := NewConn(server, nil, "k", []string{"t"}, 1)
|
||||
c := NewConn(server, nil, "k", []string{"t"}, 1, "")
|
||||
c.sendCh = make(chan interface{}, 1)
|
||||
h.RegisterAndIsFirst(c)
|
||||
|
||||
@@ -111,7 +111,7 @@ func TestPublishSourceTimeFallback(t *testing.T) {
|
||||
server, client := testNetPipe(t)
|
||||
defer server.Close()
|
||||
defer client.Close()
|
||||
c := NewConn(server, nil, "k", []string{"t"}, 1)
|
||||
c := NewConn(server, nil, "k", []string{"t"}, 1, "")
|
||||
c.sendCh = make(chan interface{}, 1)
|
||||
h.RegisterAndIsFirst(c)
|
||||
|
||||
|
||||
@@ -111,6 +111,7 @@ type alwaysFailSubscriber struct {
|
||||
}
|
||||
|
||||
func (s *alwaysFailSubscriber) EventKey() string { return s.eventKey }
|
||||
func (s *alwaysFailSubscriber) SubscriptionID() string { return s.eventKey }
|
||||
func (s *alwaysFailSubscriber) EventTypes() []string { return s.eventTypes }
|
||||
func (s *alwaysFailSubscriber) SendCh() chan interface{} { return s.sendCh }
|
||||
func (s *alwaysFailSubscriber) PID() int { return 0 }
|
||||
@@ -153,6 +154,7 @@ func newRaceSubscriber(key string, types []string, capacity int) *raceSubscriber
|
||||
}
|
||||
|
||||
func (s *raceSubscriber) EventKey() string { return s.eventKey }
|
||||
func (s *raceSubscriber) SubscriptionID() string { return s.eventKey }
|
||||
func (s *raceSubscriber) EventTypes() []string { return s.eventTypes }
|
||||
func (s *raceSubscriber) SendCh() chan interface{} { return s.sendCh }
|
||||
func (s *raceSubscriber) PID() int { return s.pid }
|
||||
|
||||
@@ -5,6 +5,7 @@ package bus
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
@@ -235,7 +236,10 @@ func newTestConn(eventKey string, eventTypes []string) *testConn {
|
||||
}
|
||||
}
|
||||
|
||||
func (c *testConn) EventKey() string { return c.eventKey }
|
||||
func (c *testConn) EventKey() string { return c.eventKey }
|
||||
|
||||
// SubscriptionID falls back to EventKey for test mocks that don't set a separate subscription ID.
|
||||
func (c *testConn) SubscriptionID() string { return c.eventKey }
|
||||
func (c *testConn) EventTypes() []string { return c.eventTypes }
|
||||
func (c *testConn) SendCh() chan interface{} { return c.sendCh }
|
||||
func (c *testConn) PID() int { return c.pid }
|
||||
@@ -275,3 +279,79 @@ func (c *testConn) TrySend(msg interface{}) bool {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func TestHub_SubscriptionID_Isolation(t *testing.T) {
|
||||
h := NewHub()
|
||||
c1, _ := net.Pipe()
|
||||
c2, _ := net.Pipe()
|
||||
defer c1.Close()
|
||||
defer c2.Close()
|
||||
s1 := NewConn(c1, nil, "mail.x", []string{"mail.x"}, 1, "mail.x:alice")
|
||||
s2 := NewConn(c2, nil, "mail.x", []string{"mail.x"}, 2, "mail.x:bob")
|
||||
|
||||
if !h.RegisterAndIsFirst(s1) {
|
||||
t.Error("s1 should be first for its subscription")
|
||||
}
|
||||
if !h.RegisterAndIsFirst(s2) {
|
||||
t.Error("s2 should ALSO be first (different SubscriptionID)")
|
||||
}
|
||||
if !h.UnregisterAndIsLast(s1) {
|
||||
t.Error("s1 should be last for mail.x:alice")
|
||||
}
|
||||
if !h.UnregisterAndIsLast(s2) {
|
||||
t.Error("s2 should be last for mail.x:bob")
|
||||
}
|
||||
}
|
||||
|
||||
func TestHub_SameSubscriptionID_NotFirst(t *testing.T) {
|
||||
h := NewHub()
|
||||
c1, _ := net.Pipe()
|
||||
c2, _ := net.Pipe()
|
||||
defer c1.Close()
|
||||
defer c2.Close()
|
||||
s1 := NewConn(c1, nil, "mail.x", []string{"mail.x"}, 1, "mail.x:alice")
|
||||
s2 := NewConn(c2, nil, "mail.x", []string{"mail.x"}, 2, "mail.x:alice")
|
||||
|
||||
if !h.RegisterAndIsFirst(s1) {
|
||||
t.Error("s1 first")
|
||||
}
|
||||
if h.RegisterAndIsFirst(s2) {
|
||||
t.Error("s2 same SubscriptionID should NOT be first")
|
||||
}
|
||||
}
|
||||
|
||||
func TestHub_EventKeyCount_AggregatesAcrossSubscriptions(t *testing.T) {
|
||||
h := NewHub()
|
||||
c1, _ := net.Pipe()
|
||||
c2, _ := net.Pipe()
|
||||
defer c1.Close()
|
||||
defer c2.Close()
|
||||
s1 := NewConn(c1, nil, "mail.x", []string{"mail.x"}, 1, "mail.x:alice")
|
||||
s2 := NewConn(c2, nil, "mail.x", []string{"mail.x"}, 2, "mail.x:bob")
|
||||
h.RegisterAndIsFirst(s1)
|
||||
h.RegisterAndIsFirst(s2)
|
||||
if got := h.EventKeyCount("mail.x"); got != 2 {
|
||||
t.Errorf("EventKeyCount(mail.x) = %d, want 2 (aggregated across subscriptions)", got)
|
||||
}
|
||||
if got := h.SubCount("mail.x:alice"); got != 1 {
|
||||
t.Errorf("SubCount(mail.x:alice) = %d, want 1", got)
|
||||
}
|
||||
if got := h.SubCount("mail.x:bob"); got != 1 {
|
||||
t.Errorf("SubCount(mail.x:bob) = %d, want 1", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHub_Consumers_PopulatesSubscriptionID(t *testing.T) {
|
||||
h := NewHub()
|
||||
c1, _ := net.Pipe()
|
||||
defer c1.Close()
|
||||
s1 := NewConn(c1, nil, "mail.x", []string{"mail.x"}, 1, "mail.x:alice")
|
||||
h.RegisterAndIsFirst(s1)
|
||||
consumers := h.Consumers()
|
||||
if len(consumers) != 1 {
|
||||
t.Fatalf("got %d consumers, want 1", len(consumers))
|
||||
}
|
||||
if consumers[0].SubscriptionID != "mail.x:alice" {
|
||||
t.Errorf("Consumers()[0].SubscriptionID = %q, want %q", consumers[0].SubscriptionID, "mail.x:alice")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -61,6 +61,22 @@ func Run(ctx context.Context, tr transport.IPC, appID, profileName, domain strin
|
||||
}
|
||||
}
|
||||
|
||||
// Normalize params (resolve aliases like "me" -> real email) before fingerprint
|
||||
// compute, PreConsume, Match, Process. Must happen BEFORE doHello so the
|
||||
// SubscriptionID we send to bus reflects canonical values.
|
||||
if keyDef.NormalizeParams != nil {
|
||||
if err := keyDef.NormalizeParams(ctx, opts.Runtime, opts.Params); err != nil {
|
||||
if _, ok := errs.ProblemOf(err); ok {
|
||||
return err
|
||||
}
|
||||
return errs.NewInternalError(errs.SubtypeUnknown,
|
||||
"normalize params for %s: %s", opts.EventKey, err).WithCause(err)
|
||||
}
|
||||
}
|
||||
|
||||
// Compute subscription identity from normalized params + SubscriptionKey flags.
|
||||
subscriptionID := ComputeSubscriptionID(keyDef, opts.Params)
|
||||
|
||||
if opts.Timeout > 0 {
|
||||
var cancel context.CancelFunc
|
||||
ctx, cancel = context.WithTimeout(ctx, opts.Timeout)
|
||||
@@ -81,13 +97,13 @@ func Run(ctx context.Context, tr transport.IPC, appID, profileName, domain strin
|
||||
}
|
||||
defer conn.Close()
|
||||
|
||||
ack, br, err := doHello(conn, opts.EventKey, []string{keyDef.EventType})
|
||||
ack, br, err := doHello(conn, opts.EventKey, []string{keyDef.EventType}, subscriptionID)
|
||||
if err != nil {
|
||||
return errs.NewInternalError(errs.SubtypeUnknown,
|
||||
"event bus handshake failed: %s", err).WithCause(err)
|
||||
}
|
||||
|
||||
var cleanup func()
|
||||
var cleanup func() error
|
||||
if ack.FirstForKey && keyDef.PreConsume != nil {
|
||||
if !opts.Quiet {
|
||||
fmt.Fprintf(errOut, "[event] running pre-consume setup...\n")
|
||||
@@ -113,14 +129,22 @@ func Run(ctx context.Context, tr transport.IPC, appID, profileName, domain strin
|
||||
if cleanup != nil {
|
||||
switch {
|
||||
case r != nil:
|
||||
fmt.Fprintf(errOut, "WARN: panic recovered; running cleanup unconditionally (may affect other consumers of %s)\n", opts.EventKey)
|
||||
cleanup()
|
||||
fmt.Fprintf(errOut,
|
||||
"WARN: panic recovered; running cleanup unconditionally (may affect other consumers of %s)\n",
|
||||
opts.EventKey)
|
||||
if cleanupErr := cleanup(); cleanupErr != nil {
|
||||
fmt.Fprintf(errOut,
|
||||
"WARN: cleanup also failed during panic recovery: %v\n", cleanupErr)
|
||||
}
|
||||
case lastForKey:
|
||||
if !opts.Quiet {
|
||||
fmt.Fprintf(errOut, "[event] running cleanup...\n")
|
||||
}
|
||||
cleanup()
|
||||
if !opts.Quiet {
|
||||
if cleanupErr := cleanup(); cleanupErr != nil {
|
||||
fmt.Fprintf(errOut,
|
||||
"WARN: cleanup failed: %v (server-side subscribe is idempotent — residual record will be overwritten on next subscribe)\n",
|
||||
cleanupErr)
|
||||
} else if !opts.Quiet {
|
||||
fmt.Fprintf(errOut, "[event] cleanup done.\n")
|
||||
}
|
||||
}
|
||||
@@ -144,7 +168,7 @@ func Run(ctx context.Context, tr transport.IPC, appID, profileName, domain strin
|
||||
|
||||
writeReadyMarker(errOut, opts)
|
||||
|
||||
return consumeLoop(ctx, conn, br, keyDef, opts, &lastForKey, &emitted)
|
||||
return consumeLoop(ctx, conn, br, keyDef, opts, subscriptionID, &lastForKey, &emitted)
|
||||
}
|
||||
|
||||
func truncateDuration(d time.Duration) time.Duration {
|
||||
|
||||
101
internal/event/consume/consume_test.go
Normal file
101
internal/event/consume/consume_test.go
Normal file
@@ -0,0 +1,101 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package consume
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/internal/event"
|
||||
"github.com/larksuite/cli/internal/event/protocol"
|
||||
"github.com/larksuite/cli/internal/event/transport"
|
||||
)
|
||||
|
||||
// fakeRT is a minimal event.APIClient mock.
|
||||
type fakeRT struct {
|
||||
err error
|
||||
}
|
||||
|
||||
func (f *fakeRT) CallAPI(_ context.Context, _, _ string, _ interface{}) (json.RawMessage, error) {
|
||||
return nil, f.err
|
||||
}
|
||||
|
||||
func TestNormalizeParams_ErrorIsWrappedWithEventKey(t *testing.T) {
|
||||
// Drives the real Run() path: NormalizeParams fails before EnsureBus, so no
|
||||
// bus is contacted, yet the production error-wrapping is exercised — if Run()
|
||||
// ever stops wrapping, this test fails.
|
||||
const key = "test.evt_normalize_fail"
|
||||
event.RegisterKey(event.KeyDefinition{
|
||||
Key: key,
|
||||
EventType: key,
|
||||
Schema: event.SchemaDef{Custom: &event.SchemaSpec{Raw: json.RawMessage(`{"type":"object"}`)}},
|
||||
NormalizeParams: func(_ context.Context, _ event.APIClient, _ map[string]string) error {
|
||||
return errors.New("simulated normalize failure")
|
||||
},
|
||||
})
|
||||
defer event.UnregisterKeyForTest(key)
|
||||
|
||||
err := Run(context.Background(), transport.New(), "app", "", "", Options{
|
||||
EventKey: key,
|
||||
Runtime: &fakeRT{},
|
||||
Quiet: true,
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("expected Run to fail when NormalizeParams errors")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "normalize params for "+key+":") {
|
||||
t.Errorf("error not wrapped with EventKey prefix: %v", err)
|
||||
}
|
||||
if !strings.Contains(err.Error(), "simulated normalize failure") {
|
||||
t.Errorf("underlying error not propagated: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDoHello_PassesSubscriptionIDToWire(t *testing.T) {
|
||||
a, b := net.Pipe()
|
||||
defer a.Close()
|
||||
defer b.Close()
|
||||
|
||||
// Server-side: read Hello, decode, assert SubscriptionID, send ack
|
||||
done := make(chan string, 1)
|
||||
go func() {
|
||||
br := bufio.NewReader(b)
|
||||
line, err := protocol.ReadFrame(br)
|
||||
if err != nil {
|
||||
done <- "READ_ERR:" + err.Error()
|
||||
return
|
||||
}
|
||||
msg, err := protocol.Decode(bytes.TrimRight(line, "\n"))
|
||||
if err != nil {
|
||||
done <- "DECODE_ERR:" + err.Error()
|
||||
return
|
||||
}
|
||||
if hello, ok := msg.(*protocol.Hello); ok {
|
||||
done <- hello.SubscriptionID
|
||||
// send ack so client can return
|
||||
ack := protocol.NewHelloAck("v1", true)
|
||||
_ = protocol.EncodeWithDeadline(b, ack, protocol.WriteTimeout)
|
||||
} else {
|
||||
done <- "WRONG_TYPE"
|
||||
}
|
||||
}()
|
||||
|
||||
ack, _, err := doHello(a, "mail.x", []string{"mail.x"}, "mail.x:alice")
|
||||
if err != nil {
|
||||
t.Fatalf("doHello error: %v", err)
|
||||
}
|
||||
if ack == nil {
|
||||
t.Fatal("got nil ack")
|
||||
}
|
||||
got := <-done
|
||||
if got != "mail.x:alice" {
|
||||
t.Errorf("Hello.SubscriptionID on wire = %q, want %q", got, "mail.x:alice")
|
||||
}
|
||||
}
|
||||
41
internal/event/consume/fingerprint.go
Normal file
41
internal/event/consume/fingerprint.go
Normal file
@@ -0,0 +1,41 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package consume
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"sort"
|
||||
|
||||
"github.com/larksuite/cli/internal/event"
|
||||
)
|
||||
|
||||
// ComputeSubscriptionID returns a stable identifier scoped to (EventKey, values
|
||||
// of the ParamDefs marked SubscriptionKey); the framework uses it to dedup
|
||||
// PreConsume/cleanup gates and key Hub counts per-subscription. No SubscriptionKey
|
||||
// params -> returns def.Key verbatim (legacy one-dimensional behavior).
|
||||
//
|
||||
// Stability contract: same EventKey + same normalized param values -> same ID
|
||||
// across CLI versions; changing the encoding requires a wire-format bump.
|
||||
func ComputeSubscriptionID(def *event.KeyDefinition, params map[string]string) string {
|
||||
type kv struct {
|
||||
Name string `json:"name"`
|
||||
Value string `json:"value"`
|
||||
}
|
||||
var subParams []kv
|
||||
for _, p := range def.Params {
|
||||
if !p.SubscriptionKey {
|
||||
continue
|
||||
}
|
||||
subParams = append(subParams, kv{Name: p.Name, Value: params[p.Name]})
|
||||
}
|
||||
if len(subParams) == 0 {
|
||||
return def.Key
|
||||
}
|
||||
sort.Slice(subParams, func(i, j int) bool { return subParams[i].Name < subParams[j].Name })
|
||||
raw, _ := json.Marshal(subParams) // err impossible: kv has no unmarshalable fields
|
||||
sum := sha256.Sum256(raw)
|
||||
return def.Key + ":" + base64.RawURLEncoding.EncodeToString(sum[:12])
|
||||
}
|
||||
126
internal/event/consume/fingerprint_test.go
Normal file
126
internal/event/consume/fingerprint_test.go
Normal file
@@ -0,0 +1,126 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package consume
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/internal/event"
|
||||
)
|
||||
|
||||
func TestComputeSubscriptionID(t *testing.T) {
|
||||
makeDef := func(subKeyNames ...string) *event.KeyDefinition {
|
||||
def := &event.KeyDefinition{Key: "test.evt"}
|
||||
marked := make(map[string]bool, len(subKeyNames))
|
||||
for _, n := range subKeyNames {
|
||||
marked[n] = true
|
||||
}
|
||||
for _, n := range []string{"alpha", "beta", "gamma"} {
|
||||
def.Params = append(def.Params, event.ParamDef{Name: n, SubscriptionKey: marked[n]})
|
||||
}
|
||||
return def
|
||||
}
|
||||
|
||||
t.Run("no SubscriptionKey params returns EventKey verbatim", func(t *testing.T) {
|
||||
def := makeDef()
|
||||
got := ComputeSubscriptionID(def, map[string]string{"alpha": "x", "beta": "y"})
|
||||
if got != "test.evt" {
|
||||
t.Errorf("got %q, want %q", got, "test.evt")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("single SubscriptionKey param: non-sub params do not leak into ID", func(t *testing.T) {
|
||||
def := makeDef("alpha")
|
||||
id1 := ComputeSubscriptionID(def, map[string]string{"alpha": "value1", "beta": "ignored"})
|
||||
id2 := ComputeSubscriptionID(def, map[string]string{"alpha": "value1", "beta": "different"})
|
||||
if id1 != id2 {
|
||||
t.Errorf("non-SubscriptionKey param change leaked into ID: %q vs %q", id1, id2)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("different SubscriptionKey value produces different ID", func(t *testing.T) {
|
||||
def := makeDef("alpha")
|
||||
id1 := ComputeSubscriptionID(def, map[string]string{"alpha": "v1"})
|
||||
id2 := ComputeSubscriptionID(def, map[string]string{"alpha": "v2"})
|
||||
if id1 == id2 {
|
||||
t.Errorf("different values produced same ID: %q", id1)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestComputeSubscriptionID_Stability(t *testing.T) {
|
||||
// Param order in the ParamDef list must not affect the result (sorted by name internally).
|
||||
def1 := &event.KeyDefinition{
|
||||
Key: "test.evt",
|
||||
Params: []event.ParamDef{
|
||||
{Name: "b", SubscriptionKey: true},
|
||||
{Name: "a", SubscriptionKey: true},
|
||||
},
|
||||
}
|
||||
def2 := &event.KeyDefinition{
|
||||
Key: "test.evt",
|
||||
Params: []event.ParamDef{
|
||||
{Name: "a", SubscriptionKey: true},
|
||||
{Name: "b", SubscriptionKey: true},
|
||||
},
|
||||
}
|
||||
id1 := ComputeSubscriptionID(def1, map[string]string{"a": "1", "b": "2"})
|
||||
id2 := ComputeSubscriptionID(def2, map[string]string{"a": "1", "b": "2"})
|
||||
if id1 != id2 {
|
||||
t.Errorf("order-sensitive: id1=%q id2=%q", id1, id2)
|
||||
}
|
||||
}
|
||||
|
||||
func TestComputeSubscriptionID_Format(t *testing.T) {
|
||||
def := &event.KeyDefinition{
|
||||
Key: "mail.user_mailbox.event.message_received_v1",
|
||||
Params: []event.ParamDef{{Name: "mailbox", SubscriptionKey: true}},
|
||||
}
|
||||
id := ComputeSubscriptionID(def, map[string]string{"mailbox": "liuxinyang@example.com"})
|
||||
prefix := "mail.user_mailbox.event.message_received_v1:"
|
||||
if !strings.HasPrefix(id, prefix) {
|
||||
t.Fatalf("missing prefix: %q", id)
|
||||
}
|
||||
suffix := strings.TrimPrefix(id, prefix)
|
||||
if len(suffix) != 16 {
|
||||
t.Errorf("fingerprint length = %d, want 16", len(suffix))
|
||||
}
|
||||
for _, c := range suffix {
|
||||
isValid := (c >= 'A' && c <= 'Z') || (c >= 'a' && c <= 'z') || (c >= '0' && c <= '9') || c == '-' || c == '_'
|
||||
if !isValid {
|
||||
t.Errorf("non-base64URL char in fingerprint: %q", suffix)
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestComputeSubscriptionID_UnicodeAndSpecialChars(t *testing.T) {
|
||||
def := &event.KeyDefinition{
|
||||
Key: "test.evt",
|
||||
Params: []event.ParamDef{{Name: "value", SubscriptionKey: true}},
|
||||
}
|
||||
for _, val := range []string{"中文", "emoji🚀", "with spaces", "with:colons", "with\"quotes"} {
|
||||
id := ComputeSubscriptionID(def, map[string]string{"value": val})
|
||||
if !strings.HasPrefix(id, "test.evt:") || len(id) != len("test.evt:")+16 {
|
||||
t.Errorf("ID malformed for value=%q: %q (len=%d)", val, id, len(id))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestComputeSubscriptionID_EmptyValue(t *testing.T) {
|
||||
def := &event.KeyDefinition{
|
||||
Key: "test.evt",
|
||||
Params: []event.ParamDef{{Name: "x", SubscriptionKey: true}},
|
||||
}
|
||||
id1 := ComputeSubscriptionID(def, map[string]string{"x": ""})
|
||||
id2 := ComputeSubscriptionID(def, map[string]string{}) // missing entirely
|
||||
if id1 != id2 {
|
||||
t.Errorf("empty value should be indistinguishable from missing: %q vs %q", id1, id2)
|
||||
}
|
||||
id3 := ComputeSubscriptionID(def, map[string]string{"x": "nonempty"})
|
||||
if id1 == id3 {
|
||||
t.Errorf("empty and nonempty produced same ID: %q", id1)
|
||||
}
|
||||
}
|
||||
@@ -18,8 +18,8 @@ const helloAckTimeout = 5 * time.Second // symmetric with bus-side hello read de
|
||||
|
||||
// doHello returns a bufio.Reader holding any bytes already pulled off conn so events
|
||||
// buffered with the ack in one TCP segment aren't dropped.
|
||||
func doHello(conn net.Conn, eventKey string, eventTypes []string) (*protocol.HelloAck, *bufio.Reader, error) {
|
||||
hello := protocol.NewHello(os.Getpid(), eventKey, eventTypes, "v1")
|
||||
func doHello(conn net.Conn, eventKey string, eventTypes []string, subscriptionID string) (*protocol.HelloAck, *bufio.Reader, error) {
|
||||
hello := protocol.NewHello(os.Getpid(), eventKey, eventTypes, "v1", subscriptionID)
|
||||
if err := protocol.EncodeWithDeadline(conn, hello, protocol.WriteTimeout); err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
@@ -27,7 +27,7 @@ func TestDoHello_ReadDeadline(t *testing.T) {
|
||||
start := time.Now()
|
||||
done := make(chan error, 1)
|
||||
go func() {
|
||||
_, _, err := doHello(client, "im.msg", []string{"im.msg"})
|
||||
_, _, err := doHello(client, "im.msg", []string{"im.msg"}, "")
|
||||
done <- err
|
||||
}()
|
||||
|
||||
|
||||
@@ -22,7 +22,7 @@ import (
|
||||
)
|
||||
|
||||
// consumeLoop reads events and dispatches to workers; cancels on terminal sink errors.
|
||||
func consumeLoop(ctx context.Context, conn net.Conn, br *bufio.Reader, keyDef *event.KeyDefinition, opts Options, lastForKey *bool, emitted *atomic.Int64) error {
|
||||
func consumeLoop(ctx context.Context, conn net.Conn, br *bufio.Reader, keyDef *event.KeyDefinition, opts Options, subscriptionID string, lastForKey *bool, emitted *atomic.Int64) error {
|
||||
ctx, cancel := context.WithCancel(ctx)
|
||||
defer cancel()
|
||||
|
||||
@@ -185,7 +185,7 @@ func consumeLoop(ctx context.Context, conn net.Conn, br *bufio.Reader, keyDef *e
|
||||
close(stopReader)
|
||||
<-readerDone
|
||||
conn.SetReadDeadline(time.Time{})
|
||||
*lastForKey = checkLastForKey(conn, opts.EventKey)
|
||||
*lastForKey = checkLastForKey(conn, opts.EventKey, subscriptionID)
|
||||
conn.Close()
|
||||
case <-allDone:
|
||||
// bus-side close; can't query, assume last
|
||||
@@ -199,13 +199,19 @@ func consumeLoop(ctx context.Context, conn net.Conn, br *bufio.Reader, keyDef *e
|
||||
|
||||
// processAndOutput returns (wrote, err); err non-nil only for sink.Write failures.
|
||||
func processAndOutput(ctx context.Context, keyDef *event.KeyDefinition, evt *protocol.Event, opts Options, sink Sink, jqCode *gojq.Code) (bool, error) {
|
||||
raw := &event.RawEvent{
|
||||
EventType: evt.EventType,
|
||||
Payload: evt.Payload,
|
||||
}
|
||||
|
||||
// Synchronous Match filter runs before any work (Process / sink write).
|
||||
if keyDef.Match != nil && !keyDef.Match(raw, opts.Params) {
|
||||
return false, nil
|
||||
}
|
||||
|
||||
var result json.RawMessage
|
||||
|
||||
if keyDef.Process != nil {
|
||||
raw := &event.RawEvent{
|
||||
EventType: evt.EventType,
|
||||
Payload: evt.Payload,
|
||||
}
|
||||
var err error
|
||||
result, err = keyDef.Process(ctx, opts.Runtime, raw, opts.Params)
|
||||
if err != nil {
|
||||
|
||||
@@ -89,7 +89,7 @@ func TestConsumeLoop_DeliversEventsAndExitsOnMaxEvents(t *testing.T) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
|
||||
err := consumeLoop(ctx, client, bufio.NewReader(client), echoKeyDef("test.key"), opts, &lastForKey, &emitted)
|
||||
err := consumeLoop(ctx, client, bufio.NewReader(client), echoKeyDef("test.key"), opts, "", &lastForKey, &emitted)
|
||||
if err != nil {
|
||||
t.Fatalf("consumeLoop: %v", err)
|
||||
}
|
||||
@@ -132,7 +132,7 @@ func TestConsumeLoop_SeqGapEmitsWarning(t *testing.T) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
|
||||
if err := consumeLoop(ctx, client, bufio.NewReader(client), echoKeyDef("test.key"), opts, &lastForKey, &emitted); err != nil {
|
||||
if err := consumeLoop(ctx, client, bufio.NewReader(client), echoKeyDef("test.key"), opts, "", &lastForKey, &emitted); err != nil {
|
||||
t.Fatalf("consumeLoop: %v", err)
|
||||
}
|
||||
if got := emitted.Load(); got != 2 {
|
||||
@@ -169,7 +169,7 @@ func TestConsumeLoop_JQFilterAppliedPerEvent(t *testing.T) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
|
||||
if err := consumeLoop(ctx, client, bufio.NewReader(client), echoKeyDef("test.key"), opts, &lastForKey, &emitted); err != nil {
|
||||
if err := consumeLoop(ctx, client, bufio.NewReader(client), echoKeyDef("test.key"), opts, "", &lastForKey, &emitted); err != nil {
|
||||
t.Fatalf("consumeLoop: %v", err)
|
||||
}
|
||||
if got := emitted.Load(); got != 1 {
|
||||
@@ -196,12 +196,96 @@ func TestConsumeLoop_CompileJQFailsEarly(t *testing.T) {
|
||||
|
||||
var lastForKey bool
|
||||
var emitted atomic.Int64
|
||||
err := consumeLoop(context.Background(), client, bufio.NewReader(client), echoKeyDef("test.key"), opts, &lastForKey, &emitted)
|
||||
err := consumeLoop(context.Background(), client, bufio.NewReader(client), echoKeyDef("test.key"), opts, "", &lastForKey, &emitted)
|
||||
if err == nil {
|
||||
t.Fatal("consumeLoop should fail immediately on bad jq expression")
|
||||
}
|
||||
}
|
||||
|
||||
// captureSink is a minimal Sink for unit-testing processAndOutput directly.
|
||||
type captureSink struct {
|
||||
written []json.RawMessage
|
||||
}
|
||||
|
||||
func (s *captureSink) Write(data json.RawMessage) error {
|
||||
s.written = append(s.written, data)
|
||||
return nil
|
||||
}
|
||||
|
||||
func TestProcessAndOutput_Match_DropsEvent(t *testing.T) {
|
||||
calledProcess := false
|
||||
keyDef := &event.KeyDefinition{
|
||||
Key: "test.evt",
|
||||
Match: func(raw *event.RawEvent, params map[string]string) bool {
|
||||
return false
|
||||
},
|
||||
Process: func(ctx context.Context, rt event.APIClient, raw *event.RawEvent, params map[string]string) (json.RawMessage, error) {
|
||||
calledProcess = true
|
||||
return json.RawMessage(`{}`), nil
|
||||
},
|
||||
}
|
||||
sink := &captureSink{}
|
||||
wrote, err := processAndOutput(context.Background(), keyDef,
|
||||
&protocol.Event{Type: protocol.MsgTypeEvent, EventType: "test.evt", Payload: json.RawMessage(`{"x":1}`)},
|
||||
Options{}, sink, nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if wrote {
|
||||
t.Error("Match returned false but event was written")
|
||||
}
|
||||
if calledProcess {
|
||||
t.Error("Process was called even though Match returned false")
|
||||
}
|
||||
if len(sink.written) != 0 {
|
||||
t.Errorf("sink received %d events, want 0", len(sink.written))
|
||||
}
|
||||
}
|
||||
|
||||
func TestProcessAndOutput_Match_NilAcceptsAll(t *testing.T) {
|
||||
keyDef := &event.KeyDefinition{Key: "test.evt"} // no Match, no Process
|
||||
sink := &captureSink{}
|
||||
wrote, err := processAndOutput(context.Background(), keyDef,
|
||||
&protocol.Event{Type: protocol.MsgTypeEvent, EventType: "test.evt", Payload: json.RawMessage(`{"x":1}`)},
|
||||
Options{}, sink, nil)
|
||||
if err != nil || !wrote {
|
||||
t.Errorf("expected wrote=true err=nil; got wrote=%v err=%v", wrote, err)
|
||||
}
|
||||
if len(sink.written) != 1 {
|
||||
t.Errorf("sink received %d events, want 1", len(sink.written))
|
||||
}
|
||||
}
|
||||
|
||||
func TestProcessAndOutput_Match_RunsBeforeProcess(t *testing.T) {
|
||||
// Record the actual call sequence — a bare call-count check would still
|
||||
// pass if Process ran before Match.
|
||||
var order []string
|
||||
keyDef := &event.KeyDefinition{
|
||||
Key: "test.evt",
|
||||
Match: func(raw *event.RawEvent, params map[string]string) bool {
|
||||
order = append(order, "match")
|
||||
return true
|
||||
},
|
||||
Process: func(ctx context.Context, rt event.APIClient, raw *event.RawEvent, params map[string]string) (json.RawMessage, error) {
|
||||
order = append(order, "process")
|
||||
return raw.Payload, nil
|
||||
},
|
||||
}
|
||||
sink := &captureSink{}
|
||||
wrote, err := processAndOutput(context.Background(), keyDef,
|
||||
&protocol.Event{Type: protocol.MsgTypeEvent, EventType: "test.evt", Payload: json.RawMessage(`{}`)},
|
||||
Options{}, sink, nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !wrote {
|
||||
t.Error("expected wrote=true")
|
||||
}
|
||||
if len(order) != 2 || order[0] != "match" || order[1] != "process" {
|
||||
t.Errorf("call order = %v, want [match process]", order)
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsTerminalSinkError(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
|
||||
@@ -16,8 +16,8 @@ const preShutdownAckTimeout = 2 * time.Second
|
||||
|
||||
// checkLastForKey atomically reserves a cleanup lock; on any error defaults to true
|
||||
// (cleanup-on-error is safer than leaking server state). Discards non-ack frames in flight.
|
||||
func checkLastForKey(conn net.Conn, eventKey string) bool {
|
||||
msg := protocol.NewPreShutdownCheck(eventKey)
|
||||
func checkLastForKey(conn net.Conn, eventKey string, subscriptionID string) bool {
|
||||
msg := protocol.NewPreShutdownCheck(eventKey, subscriptionID)
|
||||
if err := protocol.EncodeWithDeadline(conn, msg, protocol.WriteTimeout); err != nil {
|
||||
return true
|
||||
}
|
||||
|
||||
@@ -4,6 +4,8 @@
|
||||
package consume
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net"
|
||||
@@ -38,7 +40,7 @@ func TestCheckLastForKey_IgnoresNonAckFrames(t *testing.T) {
|
||||
}
|
||||
}()
|
||||
|
||||
got := checkLastForKey(client, "im.msg")
|
||||
got := checkLastForKey(client, "im.msg", "")
|
||||
if got != false {
|
||||
t.Errorf("checkLastForKey = %v, want false", got)
|
||||
}
|
||||
@@ -62,7 +64,7 @@ func TestCheckLastForKey_ReturnsAckValue(t *testing.T) {
|
||||
_ = protocol.Encode(server, ack)
|
||||
}()
|
||||
|
||||
got := checkLastForKey(client, "im.msg")
|
||||
got := checkLastForKey(client, "im.msg", "")
|
||||
if got != true {
|
||||
t.Errorf("checkLastForKey = %v, want true", got)
|
||||
}
|
||||
@@ -83,7 +85,7 @@ func TestCheckLastForKey_DefaultsToTrueOnTimeout(t *testing.T) {
|
||||
}()
|
||||
|
||||
start := time.Now()
|
||||
got := checkLastForKey(client, "im.msg")
|
||||
got := checkLastForKey(client, "im.msg", "")
|
||||
elapsed := time.Since(start)
|
||||
|
||||
if got != true {
|
||||
@@ -93,3 +95,39 @@ func TestCheckLastForKey_DefaultsToTrueOnTimeout(t *testing.T) {
|
||||
t.Errorf("elapsed = %v, expected ~%v (timeout-bounded)", elapsed, preShutdownAckTimeout)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheckLastForKey_SendsSubscriptionID(t *testing.T) {
|
||||
a, b := net.Pipe()
|
||||
defer a.Close()
|
||||
defer b.Close()
|
||||
|
||||
done := make(chan string, 1)
|
||||
go func() {
|
||||
br := bufio.NewReader(b)
|
||||
line, err := protocol.ReadFrame(br)
|
||||
if err != nil {
|
||||
done <- "READ_ERR"
|
||||
return
|
||||
}
|
||||
msg, err := protocol.Decode(bytes.TrimRight(line, "\n"))
|
||||
if err != nil {
|
||||
done <- "DECODE_ERR"
|
||||
return
|
||||
}
|
||||
check, ok := msg.(*protocol.PreShutdownCheck)
|
||||
if !ok {
|
||||
done <- "WRONG_TYPE"
|
||||
return
|
||||
}
|
||||
done <- check.SubscriptionID
|
||||
// Reply with ack so client returns
|
||||
ack := protocol.NewPreShutdownAck(true)
|
||||
_ = protocol.EncodeWithDeadline(b, ack, protocol.WriteTimeout)
|
||||
}()
|
||||
|
||||
_ = checkLastForKey(a, "mail.x", "mail.x:alice")
|
||||
got := <-done
|
||||
if got != "mail.x:alice" {
|
||||
t.Errorf("PreShutdownCheck.SubscriptionID on wire = %q, want %q", got, "mail.x:alice")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -77,3 +77,88 @@ func TestDecodeUnknownType(t *testing.T) {
|
||||
t.Error("expected error for unknown type")
|
||||
}
|
||||
}
|
||||
|
||||
func TestEncodeDecodeHello_WithSubscriptionID(t *testing.T) {
|
||||
msg := &Hello{
|
||||
Type: MsgTypeHello,
|
||||
PID: 12345,
|
||||
EventKey: "mail.user_mailbox.event.message_received_v1",
|
||||
EventTypes: []string{"mail.user_mailbox.event.message_received_v1"},
|
||||
Version: "v1",
|
||||
SubscriptionID: "mail.user_mailbox.event.message_received_v1:a7Bx9Kp2Lm3Qv4Rs",
|
||||
}
|
||||
buf := &bytes.Buffer{}
|
||||
if err := Encode(buf, msg); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
line := buf.Bytes()
|
||||
if !bytes.Contains(line, []byte(`"subscription_id":"mail.user_mailbox.event.message_received_v1:a7Bx9Kp2Lm3Qv4Rs"`)) {
|
||||
t.Errorf("subscription_id not serialized: %s", string(line))
|
||||
}
|
||||
decoded, err := Decode(bytes.TrimRight(line, "\n"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
hello, ok := decoded.(*Hello)
|
||||
if !ok {
|
||||
t.Fatalf("expected *Hello, got %T", decoded)
|
||||
}
|
||||
if hello.SubscriptionID != msg.SubscriptionID {
|
||||
t.Errorf("roundtrip subscription_id: got %q want %q", hello.SubscriptionID, msg.SubscriptionID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEncodeDecodeHello_EmptySubscriptionIDOmitted(t *testing.T) {
|
||||
msg := &Hello{
|
||||
Type: MsgTypeHello,
|
||||
PID: 1,
|
||||
EventKey: "k",
|
||||
EventTypes: []string{"k"},
|
||||
Version: "v1",
|
||||
}
|
||||
buf := &bytes.Buffer{}
|
||||
if err := Encode(buf, msg); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if bytes.Contains(buf.Bytes(), []byte("subscription_id")) {
|
||||
t.Errorf("empty subscription_id should be omitted: %s", buf.String())
|
||||
}
|
||||
decoded, _ := Decode(bytes.TrimRight(buf.Bytes(), "\n"))
|
||||
hello := decoded.(*Hello)
|
||||
if hello.SubscriptionID != "" {
|
||||
t.Errorf("got %q, want empty", hello.SubscriptionID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEncodeDecodePreShutdownCheck_WithSubscriptionID(t *testing.T) {
|
||||
msg := &PreShutdownCheck{
|
||||
Type: MsgTypePreShutdownCheck,
|
||||
EventKey: "mail.x",
|
||||
SubscriptionID: "mail.x:abc",
|
||||
}
|
||||
buf := &bytes.Buffer{}
|
||||
if err := Encode(buf, msg); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
decoded, err := Decode(bytes.TrimRight(buf.Bytes(), "\n"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got := decoded.(*PreShutdownCheck)
|
||||
if got.SubscriptionID != msg.SubscriptionID {
|
||||
t.Errorf("roundtrip: got %q want %q", got.SubscriptionID, msg.SubscriptionID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStatusResponse_ConsumerInfo_SubscriptionID(t *testing.T) {
|
||||
msg := NewStatusResponse(7, 120, 1, []ConsumerInfo{
|
||||
{PID: 99, EventKey: "mail.x", SubscriptionID: "mail.x:abc", Received: 5, Dropped: 0},
|
||||
})
|
||||
buf := &bytes.Buffer{}
|
||||
if err := Encode(buf, msg); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !bytes.Contains(buf.Bytes(), []byte(`"subscription_id":"mail.x:abc"`)) {
|
||||
t.Errorf("ConsumerInfo.SubscriptionID missing from JSON: %s", buf.String())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -34,11 +34,12 @@ type SourceStatus struct {
|
||||
}
|
||||
|
||||
type Hello struct {
|
||||
Type string `json:"type"`
|
||||
PID int `json:"pid"`
|
||||
EventKey string `json:"event_key"`
|
||||
EventTypes []string `json:"event_types"`
|
||||
Version string `json:"version"`
|
||||
Type string `json:"type"`
|
||||
PID int `json:"pid"`
|
||||
EventKey string `json:"event_key"`
|
||||
EventTypes []string `json:"event_types"`
|
||||
Version string `json:"version"`
|
||||
SubscriptionID string `json:"subscription_id,omitempty"` // empty = fallback to EventKey on bus side
|
||||
}
|
||||
|
||||
type HelloAck struct {
|
||||
@@ -61,10 +62,11 @@ type Bye struct {
|
||||
Type string `json:"type"`
|
||||
}
|
||||
|
||||
// PreShutdownCheck atomically reserves the cleanup lock for EventKey.
|
||||
// PreShutdownCheck atomically reserves the cleanup lock for (EventKey, SubscriptionID).
|
||||
type PreShutdownCheck struct {
|
||||
Type string `json:"type"`
|
||||
EventKey string `json:"event_key"`
|
||||
Type string `json:"type"`
|
||||
EventKey string `json:"event_key"`
|
||||
SubscriptionID string `json:"subscription_id,omitempty"` // empty = fallback to EventKey
|
||||
}
|
||||
|
||||
type PreShutdownAck struct {
|
||||
@@ -77,10 +79,11 @@ type StatusQuery struct {
|
||||
}
|
||||
|
||||
type ConsumerInfo struct {
|
||||
PID int `json:"pid"`
|
||||
EventKey string `json:"event_key"`
|
||||
Received int64 `json:"received"`
|
||||
Dropped int64 `json:"dropped"`
|
||||
PID int `json:"pid"`
|
||||
EventKey string `json:"event_key"`
|
||||
SubscriptionID string `json:"subscription_id,omitempty"`
|
||||
Received int64 `json:"received"`
|
||||
Dropped int64 `json:"dropped"`
|
||||
}
|
||||
|
||||
type StatusResponse struct {
|
||||
@@ -95,13 +98,14 @@ type Shutdown struct {
|
||||
Type string `json:"type"`
|
||||
}
|
||||
|
||||
func NewHello(pid int, eventKey string, eventTypes []string, version string) *Hello {
|
||||
func NewHello(pid int, eventKey string, eventTypes []string, version string, subscriptionID string) *Hello {
|
||||
return &Hello{
|
||||
Type: MsgTypeHello,
|
||||
PID: pid,
|
||||
EventKey: eventKey,
|
||||
EventTypes: eventTypes,
|
||||
Version: version,
|
||||
Type: MsgTypeHello,
|
||||
PID: pid,
|
||||
EventKey: eventKey,
|
||||
EventTypes: eventTypes,
|
||||
Version: version,
|
||||
SubscriptionID: subscriptionID,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -124,8 +128,8 @@ func NewEvent(eventType, eventID, sourceTime string, seq uint64, payload json.Ra
|
||||
}
|
||||
}
|
||||
|
||||
func NewPreShutdownCheck(eventKey string) *PreShutdownCheck {
|
||||
return &PreShutdownCheck{Type: MsgTypePreShutdownCheck, EventKey: eventKey}
|
||||
func NewPreShutdownCheck(eventKey, subscriptionID string) *PreShutdownCheck {
|
||||
return &PreShutdownCheck{Type: MsgTypePreShutdownCheck, EventKey: eventKey, SubscriptionID: subscriptionID}
|
||||
}
|
||||
|
||||
func NewPreShutdownAck(lastForKey bool) *PreShutdownAck {
|
||||
|
||||
@@ -17,7 +17,7 @@ import (
|
||||
|
||||
// Every NewXxx helper must set the Type discriminator (Decode rejects messages without it).
|
||||
func TestConstructors_PinTypeField(t *testing.T) {
|
||||
if got := NewHello(1, "k", []string{"t"}, "v1"); got.Type != MsgTypeHello {
|
||||
if got := NewHello(1, "k", []string{"t"}, "v1", ""); got.Type != MsgTypeHello {
|
||||
t.Errorf("NewHello.Type = %q, want %q", got.Type, MsgTypeHello)
|
||||
}
|
||||
if got := NewHelloAck("v1", true); got.Type != MsgTypeHelloAck || !got.FirstForKey {
|
||||
@@ -26,7 +26,7 @@ func TestConstructors_PinTypeField(t *testing.T) {
|
||||
if got := NewEvent("im.msg", "e1", "", 7, json.RawMessage(`{}`)); got.Type != MsgTypeEvent || got.Seq != 7 {
|
||||
t.Errorf("NewEvent mismatch: %+v", got)
|
||||
}
|
||||
if got := NewPreShutdownCheck("k"); got.Type != MsgTypePreShutdownCheck || got.EventKey != "k" {
|
||||
if got := NewPreShutdownCheck("k", ""); got.Type != MsgTypePreShutdownCheck || got.EventKey != "k" {
|
||||
t.Errorf("NewPreShutdownCheck mismatch: %+v", got)
|
||||
}
|
||||
if got := NewPreShutdownAck(true); got.Type != MsgTypePreShutdownAck || !got.LastForKey {
|
||||
@@ -63,7 +63,7 @@ func TestEncode_DecodeRoundtripAllTypes(t *testing.T) {
|
||||
}
|
||||
}
|
||||
roundtrip(t, NewHelloAck("v1", true), &HelloAck{})
|
||||
roundtrip(t, NewPreShutdownCheck("im.msg"), &PreShutdownCheck{})
|
||||
roundtrip(t, NewPreShutdownCheck("im.msg", ""), &PreShutdownCheck{})
|
||||
roundtrip(t, NewPreShutdownAck(false), &PreShutdownAck{})
|
||||
roundtrip(t, NewStatusQuery(), &StatusQuery{})
|
||||
roundtrip(t, NewStatusResponse(7, 120, 1, []ConsumerInfo{{PID: 99, EventKey: "k"}}), &StatusResponse{})
|
||||
|
||||
@@ -55,6 +55,23 @@ type ParamDef struct {
|
||||
Default string `json:"default,omitempty"`
|
||||
Description string `json:"description"`
|
||||
Values []ParamValue `json:"values,omitempty"`
|
||||
|
||||
// SubscriptionKey marks this param as part of the subscription identity.
|
||||
// Two consumers of the same EventKey but different values for any
|
||||
// SubscriptionKey-marked param are treated as DISTINCT subscriptions:
|
||||
// PreConsume runs once per (EventKey, SubscriptionID), cleanup runs once per
|
||||
// (EventKey, SubscriptionID).
|
||||
//
|
||||
// CONTRACT: only mark a param SubscriptionKey if the EventKey's server-side
|
||||
// subscribe/unsubscribe API is itself scoped to that resource. Lark keys the
|
||||
// subscription record by (app, user, event_type) and overwrites it rather
|
||||
// than reference-counting, so for a non-per-resource API the cleanup of one
|
||||
// resource's last consumer unsubscribes the shared record and silently cuts
|
||||
// off every other resource sharing that event_type.
|
||||
//
|
||||
// Default false = the param is a filter / formatting / metadata param
|
||||
// and does not affect subscription identity.
|
||||
SubscriptionKey bool `json:"subscription_key,omitempty"`
|
||||
}
|
||||
|
||||
type ProcessFunc = func(ctx context.Context, rt APIClient, raw *RawEvent, params map[string]string) (json.RawMessage, error)
|
||||
@@ -83,10 +100,44 @@ type KeyDefinition struct {
|
||||
|
||||
Schema SchemaDef `json:"schema"`
|
||||
|
||||
// NormalizeParams canonicalizes param values BEFORE fingerprint compute,
|
||||
// PreConsume, Match, and Process. Mutates the params map in place.
|
||||
// May call OAPI; runs once per consumer at startup.
|
||||
//
|
||||
// Use cases: resolve aliases ("me" -> real email, a name -> an ID),
|
||||
// trim whitespace. On error, consume fails (no retry); caller gets the
|
||||
// wrapped error.
|
||||
//
|
||||
// Default nil = no normalization, params pass through unchanged.
|
||||
NormalizeParams func(ctx context.Context, rt APIClient, params map[string]string) error `json:"-"`
|
||||
|
||||
// Process required when Schema.Custom is Processed output; must be nil when Native is used.
|
||||
//
|
||||
// Convention: returning (nil, nil) signals "drop this event" — the
|
||||
// consumer loop will skip writing it to sink and not advance the
|
||||
// emitted counter. Useful for async filtering (e.g. fetch metadata,
|
||||
// drop if folder doesn't match). For sync filters that don't need
|
||||
// OAPI, use Match instead.
|
||||
Process func(ctx context.Context, rt APIClient, raw *RawEvent, params map[string]string) (json.RawMessage, error) `json:"-"`
|
||||
|
||||
PreConsume func(ctx context.Context, rt APIClient, params map[string]string) (cleanup func(), err error) `json:"-"`
|
||||
// Match is a synchronous payload filter run on every received event
|
||||
// BEFORE Process. Return false to drop the event without further work.
|
||||
//
|
||||
// Signature deliberately omits ctx/rt to physically enforce "no OAPI
|
||||
// calls in Match". For filters that need a metadata fetch first, use
|
||||
// Process and return nil to drop.
|
||||
//
|
||||
// Default nil = accept all events.
|
||||
Match func(raw *RawEvent, params map[string]string) bool `json:"-"`
|
||||
|
||||
// PreConsume runs once per (EventKey, SubscriptionID) when this consumer
|
||||
// is first for that scope. Returns a cleanup function that the framework
|
||||
// invokes when this consumer is the last for its scope.
|
||||
//
|
||||
// The cleanup's error return is honored: on nil the framework prints
|
||||
// "[event] cleanup done."; on non-nil it prints a WARN with an
|
||||
// idempotency note.
|
||||
PreConsume func(ctx context.Context, rt APIClient, params map[string]string) (cleanup func() error, err error) `json:"-"`
|
||||
|
||||
Scopes []string `json:"scopes,omitempty"`
|
||||
|
||||
|
||||
@@ -33,6 +33,7 @@ var migratedCommonHelperPaths = []string{
|
||||
"shortcuts/task/",
|
||||
"shortcuts/vc/",
|
||||
"shortcuts/whiteboard/",
|
||||
"shortcuts/wiki/",
|
||||
}
|
||||
|
||||
const commonImportPath = "github.com/larksuite/cli/shortcuts/common"
|
||||
|
||||
@@ -34,6 +34,7 @@ var migratedEnvelopePaths = []string{
|
||||
"shortcuts/task/",
|
||||
"shortcuts/vc/",
|
||||
"shortcuts/whiteboard/",
|
||||
"shortcuts/wiki/",
|
||||
"shortcuts/im/",
|
||||
}
|
||||
|
||||
|
||||
@@ -960,6 +960,7 @@ func TestCheckNoLegacyCommonHelperCall_RejectsLegacyHelpersOnMigratedPath(t *tes
|
||||
"shortcuts/slides/slides_create.go",
|
||||
"shortcuts/task/task_update.go",
|
||||
"shortcuts/whiteboard/whiteboard_query.go",
|
||||
"shortcuts/wiki/wiki_node_get.go",
|
||||
}
|
||||
for _, path := range paths {
|
||||
for _, helper := range helpers {
|
||||
@@ -1076,6 +1077,23 @@ func boom() {
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheckNoLegacyCommonHelperCall_CoversWikiPathWithAliasAndFunctionValue(t *testing.T) {
|
||||
src := `package migrated
|
||||
|
||||
import c "github.com/larksuite/cli/shortcuts/common"
|
||||
|
||||
func boom() {
|
||||
f := c.FlagErrorf
|
||||
_ = f
|
||||
c.WrapInputStatError(nil)
|
||||
}
|
||||
`
|
||||
v := CheckNoLegacyCommonHelperCall("shortcuts/wiki/wiki_node_get.go", src)
|
||||
if len(v) != 2 {
|
||||
t.Fatalf("expected 2 violations for aliased/function-value legacy helpers on wiki path, got %d: %+v", len(v), v)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheckNoLegacyCommonHelperCall_AllowsNonMigratedPath(t *testing.T) {
|
||||
src := `package contact
|
||||
|
||||
|
||||
@@ -11,6 +11,7 @@ import (
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
@@ -61,7 +62,15 @@ var AppsGitCredentialInit = common.Shortcut{
|
||||
return common.NewDryRunAPI().
|
||||
GET(gitCredentialIssuePath).
|
||||
Desc("Issue a Miaoda Git repository PAT").
|
||||
Set("mode", "api-plus-local-setup").
|
||||
Set("action", "initialize_local_git_credential").
|
||||
Set("app_id", appID).
|
||||
Set("metadata_file", appKeyPath(appID, gitcred.MetadataFilename)).
|
||||
Set("local_effects", []string{
|
||||
"save the issued PAT in the local system credential store",
|
||||
"write app-scoped git credential metadata",
|
||||
"configure a URL-scoped Git credential helper in global git config when possible",
|
||||
}).
|
||||
Params(gitCredentialIssueParams(appID))
|
||||
},
|
||||
Execute: func(ctx context.Context, rctx *common.RuntimeContext) error {
|
||||
@@ -124,6 +133,21 @@ var AppsGitCredentialRemove = common.Shortcut{
|
||||
}
|
||||
return validate.ResourceName(strings.TrimSpace(rctx.Str("app-id")), "--app-id")
|
||||
},
|
||||
DryRun: func(ctx context.Context, rctx *common.RuntimeContext) *common.DryRunAPI {
|
||||
appID := strings.TrimSpace(rctx.Str("app-id"))
|
||||
return common.NewDryRunAPI().
|
||||
Desc("Preview local Git credential cleanup (no API call; would clean up local-only state).").
|
||||
Set("mode", "local-cleanup-only").
|
||||
Set("action", "remove_local_git_credential").
|
||||
Set("app_id", appID).
|
||||
Set("metadata_file", appKeyPath(appID, gitcred.MetadataFilename)).
|
||||
Set("effects", []string{
|
||||
"read app-scoped git credential metadata",
|
||||
"remove the saved PAT from the local system credential store",
|
||||
"remove the app-scoped Git helper from global git config when present",
|
||||
"delete the local metadata record after cleanup succeeds",
|
||||
})
|
||||
},
|
||||
Execute: func(ctx context.Context, rctx *common.RuntimeContext) error {
|
||||
appID := strings.TrimSpace(rctx.Str("app-id"))
|
||||
manager := newGitCredentialManager(appID, rctx.Factory.Keychain, nil)
|
||||
@@ -171,6 +195,17 @@ var AppsGitCredentialList = common.Shortcut{
|
||||
Scopes: []string{},
|
||||
AuthTypes: []string{"user"},
|
||||
HasFormat: true,
|
||||
DryRun: func(ctx context.Context, rctx *common.RuntimeContext) *common.DryRunAPI {
|
||||
return common.NewDryRunAPI().
|
||||
Desc("Preview local Git credential listing (no API call, read-only local state).").
|
||||
Set("mode", "local-read-only").
|
||||
Set("action", "list_local_git_credentials").
|
||||
Set("storage_root", filepath.Join(core.GetConfigDir(), storageRoot)).
|
||||
Set("reads", []string{
|
||||
"scan app-scoped git credential metadata under the CLI config directory",
|
||||
"derive per-app repository URLs and local credential status from local metadata",
|
||||
})
|
||||
},
|
||||
Execute: func(ctx context.Context, rctx *common.RuntimeContext) error {
|
||||
records, err := listGitCredentialRecords(rctx.Factory.Keychain, time.Now)
|
||||
if err != nil {
|
||||
|
||||
@@ -45,6 +45,11 @@ func TestAppsGitCredentialInitDryRunRequestShape(t *testing.T) {
|
||||
Params map[string]interface{} `json:"params"`
|
||||
Body interface{} `json:"body"`
|
||||
} `json:"api"`
|
||||
Mode string `json:"mode"`
|
||||
Action string `json:"action"`
|
||||
AppID string `json:"app_id"`
|
||||
MetadataFile string `json:"metadata_file"`
|
||||
LocalEffects []string `json:"local_effects"`
|
||||
}
|
||||
if err := json.Unmarshal([]byte(stdout.String()), &payload); err != nil {
|
||||
t.Fatalf("decode dry-run output: %v\n%s", err, stdout.String())
|
||||
@@ -65,6 +70,107 @@ func TestAppsGitCredentialInitDryRunRequestShape(t *testing.T) {
|
||||
if call.Body != nil {
|
||||
t.Fatalf("body = %#v, want nil", call.Body)
|
||||
}
|
||||
if payload.Mode != "api-plus-local-setup" {
|
||||
t.Fatalf("mode = %q", payload.Mode)
|
||||
}
|
||||
if payload.Action != "initialize_local_git_credential" {
|
||||
t.Fatalf("action = %q", payload.Action)
|
||||
}
|
||||
if payload.AppID != "app_xxx" {
|
||||
t.Fatalf("app_id = %q", payload.AppID)
|
||||
}
|
||||
if !strings.HasSuffix(payload.MetadataFile, filepath.Join("spark", "app_xxx", "git.json")) {
|
||||
t.Fatalf("metadata_file = %q", payload.MetadataFile)
|
||||
}
|
||||
assertStringSliceEqual(t, payload.LocalEffects, []string{
|
||||
"save the issued PAT in the local system credential store",
|
||||
"write app-scoped git credential metadata",
|
||||
"configure a URL-scoped Git credential helper in global git config when possible",
|
||||
})
|
||||
}
|
||||
|
||||
func TestAppsGitCredentialListDryRunDescribesLocalReads(t *testing.T) {
|
||||
factory, stdout, _ := newAppsExecuteFactory(t)
|
||||
if err := runAppsShortcut(t, AppsGitCredentialList,
|
||||
[]string{"+git-credential-list", "--dry-run", "--as", "user"},
|
||||
factory, stdout); err != nil {
|
||||
t.Fatalf("dry-run err=%v", err)
|
||||
}
|
||||
var payload struct {
|
||||
Description string `json:"description"`
|
||||
API []interface{} `json:"api"`
|
||||
Mode string `json:"mode"`
|
||||
Action string `json:"action"`
|
||||
StorageRoot string `json:"storage_root"`
|
||||
Reads []string `json:"reads"`
|
||||
}
|
||||
if err := json.Unmarshal([]byte(stdout.String()), &payload); err != nil {
|
||||
t.Fatalf("decode dry-run output: %v\n%s", err, stdout.String())
|
||||
}
|
||||
if payload.Description != "Preview local Git credential listing (no API call, read-only local state)." {
|
||||
t.Fatalf("description = %q", payload.Description)
|
||||
}
|
||||
if len(payload.API) != 0 {
|
||||
t.Fatalf("api len = %d, want 0", len(payload.API))
|
||||
}
|
||||
if payload.Mode != "local-read-only" {
|
||||
t.Fatalf("mode = %q", payload.Mode)
|
||||
}
|
||||
if payload.Action != "list_local_git_credentials" {
|
||||
t.Fatalf("action = %q", payload.Action)
|
||||
}
|
||||
if !strings.HasSuffix(payload.StorageRoot, filepath.Join("spark")) {
|
||||
t.Fatalf("storage_root = %q", payload.StorageRoot)
|
||||
}
|
||||
assertStringSliceEqual(t, payload.Reads, []string{
|
||||
"scan app-scoped git credential metadata under the CLI config directory",
|
||||
"derive per-app repository URLs and local credential status from local metadata",
|
||||
})
|
||||
}
|
||||
|
||||
func TestAppsGitCredentialRemoveDryRunDescribesLocalCleanup(t *testing.T) {
|
||||
factory, stdout, _ := newAppsExecuteFactory(t)
|
||||
if err := runAppsShortcut(t, AppsGitCredentialRemove,
|
||||
[]string{"+git-credential-remove", "--app-id", "app_xxx", "--dry-run", "--as", "user"},
|
||||
factory, stdout); err != nil {
|
||||
t.Fatalf("dry-run err=%v", err)
|
||||
}
|
||||
var payload struct {
|
||||
Description string `json:"description"`
|
||||
API []interface{} `json:"api"`
|
||||
Mode string `json:"mode"`
|
||||
Action string `json:"action"`
|
||||
AppID string `json:"app_id"`
|
||||
MetadataFile string `json:"metadata_file"`
|
||||
Effects []string `json:"effects"`
|
||||
}
|
||||
if err := json.Unmarshal([]byte(stdout.String()), &payload); err != nil {
|
||||
t.Fatalf("decode dry-run output: %v\n%s", err, stdout.String())
|
||||
}
|
||||
if payload.Description != "Preview local Git credential cleanup (no API call; would clean up local-only state)." {
|
||||
t.Fatalf("description = %q", payload.Description)
|
||||
}
|
||||
if len(payload.API) != 0 {
|
||||
t.Fatalf("api len = %d, want 0", len(payload.API))
|
||||
}
|
||||
if payload.Mode != "local-cleanup-only" {
|
||||
t.Fatalf("mode = %q", payload.Mode)
|
||||
}
|
||||
if payload.Action != "remove_local_git_credential" {
|
||||
t.Fatalf("action = %q", payload.Action)
|
||||
}
|
||||
if payload.AppID != "app_xxx" {
|
||||
t.Fatalf("app_id = %q", payload.AppID)
|
||||
}
|
||||
if !strings.HasSuffix(payload.MetadataFile, filepath.Join("spark", "app_xxx", "git.json")) {
|
||||
t.Fatalf("metadata_file = %q", payload.MetadataFile)
|
||||
}
|
||||
assertStringSliceEqual(t, payload.Effects, []string{
|
||||
"read app-scoped git credential metadata",
|
||||
"remove the saved PAT from the local system credential store",
|
||||
"remove the app-scoped Git helper from global git config when present",
|
||||
"delete the local metadata record after cleanup succeeds",
|
||||
})
|
||||
}
|
||||
|
||||
func TestAppsGitCredentialInitRequiresAppID(t *testing.T) {
|
||||
@@ -579,6 +685,18 @@ func TestAppsGitCredentialRemoveReturnsStoreError(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func assertStringSliceEqual(t *testing.T, got, want []string) {
|
||||
t.Helper()
|
||||
if len(got) != len(want) {
|
||||
t.Fatalf("slice len = %d, want %d; got %#v", len(got), len(want), got)
|
||||
}
|
||||
for i := range want {
|
||||
if got[i] != want[i] {
|
||||
t.Fatalf("slice[%d] = %q, want %q; got %#v", i, got[i], want[i], got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestGitCredentialLocalErrorWrapsOnlyPlainErrors(t *testing.T) {
|
||||
plain := errors.New("git config failed")
|
||||
wrapped := gitCredentialLocalError("List local Miaoda Git credentials", plain)
|
||||
|
||||
@@ -56,6 +56,15 @@ func walkHTMLPublishCandidates(fio fileio.FileIO, rootPath string) ([]htmlPublis
|
||||
if walkErr != nil {
|
||||
return walkErr
|
||||
}
|
||||
// Skip a stray git repo: a directory named .git skips the whole subtree,
|
||||
// and a .git file (the gitdir pointer used by submodules/worktrees) is
|
||||
// skipped too.
|
||||
if d.Name() == ".git" {
|
||||
if d.IsDir() {
|
||||
return filepath.SkipDir
|
||||
}
|
||||
return nil
|
||||
}
|
||||
if d.IsDir() {
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -113,6 +113,102 @@ func TestIsUnsafeRelPath(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestWalkHTMLPublishCandidates_ExcludesGitDir(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
files := map[string]string{
|
||||
"index.html": "<html></html>",
|
||||
".git/config": "[core]\n",
|
||||
".git/HEAD": "ref: refs/heads/main\n",
|
||||
".git/objects/ab/cdef123": "binary",
|
||||
".github/workflows/ci.yml": "on: push\n",
|
||||
".gitignore": "node_modules\n",
|
||||
}
|
||||
for rel, content := range files {
|
||||
full := filepath.Join(dir, rel)
|
||||
if err := os.MkdirAll(filepath.Dir(full), 0o755); err != nil {
|
||||
t.Fatalf("mkdir: %v", err)
|
||||
}
|
||||
if err := os.WriteFile(full, []byte(content), 0o644); err != nil {
|
||||
t.Fatalf("write: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
got, err := walkHTMLPublishCandidates(newTestFIO(), dir)
|
||||
if err != nil {
|
||||
t.Fatalf("err=%v", err)
|
||||
}
|
||||
rels := make(map[string]bool)
|
||||
for _, c := range got {
|
||||
rels[c.RelPath] = true
|
||||
}
|
||||
// .git 子树整体排除
|
||||
for _, banned := range []string{".git/config", ".git/HEAD", ".git/objects/ab/cdef123"} {
|
||||
if rels[banned] {
|
||||
t.Errorf("%q should be excluded from candidates", banned)
|
||||
}
|
||||
}
|
||||
// 相邻名不受影响
|
||||
for _, kept := range []string{"index.html", ".github/workflows/ci.yml", ".gitignore"} {
|
||||
if !rels[kept] {
|
||||
t.Errorf("%q should be kept in candidates, got %+v", kept, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestWalkHTMLPublishCandidates_ExcludesNestedGitDir(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
files := map[string]string{
|
||||
"index.html": "<html></html>",
|
||||
"sub/.git/config": "[core]\n",
|
||||
"sub/page.html": "<html></html>",
|
||||
}
|
||||
for rel, content := range files {
|
||||
full := filepath.Join(dir, rel)
|
||||
if err := os.MkdirAll(filepath.Dir(full), 0o755); err != nil {
|
||||
t.Fatalf("mkdir: %v", err)
|
||||
}
|
||||
if err := os.WriteFile(full, []byte(content), 0o644); err != nil {
|
||||
t.Fatalf("write: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
got, err := walkHTMLPublishCandidates(newTestFIO(), dir)
|
||||
if err != nil {
|
||||
t.Fatalf("err=%v", err)
|
||||
}
|
||||
rels := make(map[string]bool)
|
||||
for _, c := range got {
|
||||
rels[c.RelPath] = true
|
||||
}
|
||||
if rels["sub/.git/config"] {
|
||||
t.Errorf("nested sub/.git/config should be excluded, got %+v", got)
|
||||
}
|
||||
if !rels["sub/page.html"] || !rels["index.html"] {
|
||||
t.Errorf("non-git files should be kept, got %+v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWalkHTMLPublishCandidates_ExcludesGitFile(t *testing.T) {
|
||||
// submodule / worktree 场景:.git 是个 gitdir 指针文件,不是目录。
|
||||
dir := t.TempDir()
|
||||
if err := os.WriteFile(filepath.Join(dir, "index.html"), []byte("<html></html>"), 0o644); err != nil {
|
||||
t.Fatalf("write: %v", err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(dir, ".git"), []byte("gitdir: /elsewhere\n"), 0o644); err != nil {
|
||||
t.Fatalf("write: %v", err)
|
||||
}
|
||||
|
||||
got, err := walkHTMLPublishCandidates(newTestFIO(), dir)
|
||||
if err != nil {
|
||||
t.Fatalf("err=%v", err)
|
||||
}
|
||||
for _, c := range got {
|
||||
if c.RelPath == ".git" {
|
||||
t.Errorf(".git pointer file should be excluded, got %+v", got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestWalkHTMLPublishCandidates_SymlinkSkipped(t *testing.T) {
|
||||
// Walker 只接受 regular file —— symlink 跳过(避免 loop + out-of-root 引用,
|
||||
// 且 fio.Open 对 symlink 行为不一致)。real.html 仍然被收,link.html 不在结果里。
|
||||
|
||||
@@ -317,6 +317,17 @@ func TestShortcutValidateBranches(t *testing.T) {
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("ImChatSearch invalid chat-modes value", func(t *testing.T) {
|
||||
runtime := newTestRuntimeContext(t, map[string]string{
|
||||
"query": "ok",
|
||||
"chat-modes": "group,bogus",
|
||||
}, nil)
|
||||
err := ImChatSearch.Validate(context.Background(), runtime)
|
||||
if err == nil || !strings.Contains(err.Error(), "invalid --chat-modes value") {
|
||||
t.Fatalf("ImChatSearch.Validate() error = %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("ImChatUpdate requires fields", func(t *testing.T) {
|
||||
runtime := newTestRuntimeContext(t, map[string]string{
|
||||
"chat-id": "oc_123",
|
||||
@@ -693,6 +704,39 @@ func TestShortcutDryRunShapes(t *testing.T) {
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("ImChatSearch dry run maps chat-modes to wire values", func(t *testing.T) {
|
||||
runtime := newTestRuntimeContext(t, map[string]string{
|
||||
"query": "team-alpha",
|
||||
"chat-modes": "group,topic",
|
||||
}, nil)
|
||||
got := mustMarshalDryRun(t, ImChatSearch.DryRun(context.Background(), runtime))
|
||||
if !strings.Contains(got, `"chat_modes":["default","thread"]`) {
|
||||
t.Fatalf("ImChatSearch.DryRun() chat_modes mapping = %s", got)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("ImChatSearch dry run maps single chat-mode topic", func(t *testing.T) {
|
||||
runtime := newTestRuntimeContext(t, map[string]string{
|
||||
"query": "team-alpha",
|
||||
"chat-modes": "topic",
|
||||
}, nil)
|
||||
got := mustMarshalDryRun(t, ImChatSearch.DryRun(context.Background(), runtime))
|
||||
if !strings.Contains(got, `"chat_modes":["thread"]`) {
|
||||
t.Fatalf("ImChatSearch.DryRun() chat_modes mapping = %s", got)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("ImChatSearch dry run dedupes chat-modes", func(t *testing.T) {
|
||||
runtime := newTestRuntimeContext(t, map[string]string{
|
||||
"query": "team-alpha",
|
||||
"chat-modes": "group, group",
|
||||
}, nil)
|
||||
got := mustMarshalDryRun(t, ImChatSearch.DryRun(context.Background(), runtime))
|
||||
if !strings.Contains(got, `"chat_modes":["default"]`) {
|
||||
t.Fatalf("ImChatSearch.DryRun() chat_modes dedupe = %s", got)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("ImMessagesSearch dry run uses messages search endpoint", func(t *testing.T) {
|
||||
runtime := newMessagesSearchTestRuntimeContext(t, map[string]string{
|
||||
"query": "incident",
|
||||
|
||||
@@ -1411,7 +1411,7 @@ const (
|
||||
)
|
||||
|
||||
const (
|
||||
feedShortcutBatchLimit = 10
|
||||
feedShortcutBatchLimit = 30
|
||||
feedShortcutWriteScope = "im:feed.shortcut:write"
|
||||
feedShortcutReadScope = "im:feed.shortcut:read"
|
||||
)
|
||||
@@ -1423,7 +1423,8 @@ type shortcutItem struct {
|
||||
}
|
||||
|
||||
// collectChatIDs reads --chat-id values (repeatable + comma-split) and
|
||||
// returns deduped, validated oc_ IDs. The server batch limit is 10.
|
||||
// returns deduped, validated oc_ IDs. This CLI enforces a local batch limit
|
||||
// of 30 even though the upstream API currently documents a higher ceiling.
|
||||
func collectChatIDs(rt *common.RuntimeContext) ([]string, error) {
|
||||
raw := rt.StrSlice("chat-id")
|
||||
if len(raw) == 0 {
|
||||
|
||||
@@ -31,6 +31,7 @@ var ImChatSearch = common.Shortcut{
|
||||
Flags: []common.Flag{
|
||||
{Name: "query", Desc: "search keyword (max 64 chars)"},
|
||||
{Name: "search-types", Desc: "chat types, comma-separated (private, external, public_joined, public_not_joined)"},
|
||||
{Name: "chat-modes", Desc: "filter by chat mode, comma-separated (group, topic)"},
|
||||
{Name: "member-ids", Desc: "filter by member open_ids, comma-separated"},
|
||||
{Name: "is-manager", Type: "bool", Desc: "only show chats you created or manage"},
|
||||
{Name: "disable-search-by-user", Type: "bool", Desc: "disable search-by-member-name (default: search by member name first, then group name)"},
|
||||
@@ -72,6 +73,13 @@ var ImChatSearch = common.Shortcut{
|
||||
}
|
||||
}
|
||||
}
|
||||
if cm := runtime.Str("chat-modes"); cm != "" {
|
||||
for _, mode := range common.SplitCSV(cm) {
|
||||
if mode != "group" && mode != "topic" {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "invalid --chat-modes value %q: expected one of group, topic", mode).WithParam("--chat-modes")
|
||||
}
|
||||
}
|
||||
}
|
||||
if mi := runtime.Str("member-ids"); mi != "" {
|
||||
ids := common.SplitCSV(mi)
|
||||
if len(ids) > 50 {
|
||||
@@ -217,6 +225,24 @@ func buildSearchChatBody(runtime *common.RuntimeContext) map[string]interface{}
|
||||
if st := runtime.Str("search-types"); st != "" {
|
||||
filter["search_types"] = common.SplitCSV(st)
|
||||
}
|
||||
// chat_modes is a server-side filter. The CLI exposes group/topic; the wire
|
||||
// expects default/thread. Map and dedupe (the API caps the list at 2, and
|
||||
// there are only 2 distinct modes) while preserving the user's order.
|
||||
if cm := runtime.Str("chat-modes"); cm != "" {
|
||||
seen := map[string]bool{}
|
||||
var modes []string
|
||||
for _, mode := range common.SplitCSV(cm) {
|
||||
wire := map[string]string{"group": "default", "topic": "thread"}[mode]
|
||||
if wire == "" || seen[wire] {
|
||||
continue
|
||||
}
|
||||
seen[wire] = true
|
||||
modes = append(modes, wire)
|
||||
}
|
||||
if len(modes) > 0 {
|
||||
filter["chat_modes"] = modes
|
||||
}
|
||||
}
|
||||
if mi := runtime.Str("member-ids"); mi != "" {
|
||||
filter["member_ids"] = common.SplitCSV(mi)
|
||||
}
|
||||
|
||||
@@ -17,7 +17,7 @@ import (
|
||||
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",
|
||||
Description: "Add chats to the user's feed shortcuts; user-only; CHAT only; CLI enforces up to 30 chat IDs per call; --head/--tail controls insertion order",
|
||||
Risk: "write",
|
||||
UserScopes: []string{feedShortcutWriteScope},
|
||||
AuthTypes: []string{"user"},
|
||||
@@ -28,7 +28,7 @@ var ImFeedShortcutCreate = common.Shortcut{
|
||||
// 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"},
|
||||
Desc: "open_chat_id to add as a feed shortcut (oc_xxx); required; repeat the flag or pass comma-separated; CLI max 30 per call"},
|
||||
{Name: "head", Type: "bool",
|
||||
Desc: "insert at the top of the shortcut list (default); mutually exclusive with --tail"},
|
||||
{Name: "tail", Type: "bool",
|
||||
|
||||
@@ -5,75 +5,31 @@ 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.
|
||||
// the current user's feed shortcuts. The latest OAPI contract returns the
|
||||
// full list directly, so the shortcut intentionally exposes no pagination or
|
||||
// detail-enrichment behavior.
|
||||
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)"},
|
||||
},
|
||||
Service: "im",
|
||||
Command: "+feed-shortcut-list",
|
||||
Description: "List the current user's feed shortcuts; user-only; returns the full CHAT shortcut list directly with no pagination or detail lookup",
|
||||
Risk: "read",
|
||||
UserScopes: []string{feedShortcutReadScope},
|
||||
AuthTypes: []string{"user"},
|
||||
HasFormat: true,
|
||||
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
|
||||
return common.NewDryRunAPI().GET("/open-apis/im/v2/feed_shortcuts")
|
||||
},
|
||||
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)
|
||||
data, err := runtime.DoAPIJSONTyped("GET", "/open-apis/im/v2/feed_shortcuts", nil, 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}}
|
||||
}
|
||||
|
||||
@@ -15,7 +15,7 @@ import (
|
||||
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",
|
||||
Description: "Remove chats from the user's feed shortcuts; user-only; CHAT only; CLI enforces up to 30 chat IDs per call; per-item failures return ok:false with failed_shortcuts",
|
||||
Risk: "write",
|
||||
UserScopes: []string{feedShortcutWriteScope},
|
||||
AuthTypes: []string{"user"},
|
||||
@@ -26,7 +26,7 @@ var ImFeedShortcutRemove = common.Shortcut{
|
||||
// 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"},
|
||||
Desc: "open_chat_id to remove from feed shortcuts (oc_xxx); required; repeat the flag or pass comma-separated; CLI max 30 per call"},
|
||||
},
|
||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
_, err := collectChatIDs(runtime)
|
||||
|
||||
@@ -6,7 +6,6 @@ package im
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
@@ -49,11 +48,6 @@ func newFeedShortcutRemoveCmd(t *testing.T) *cobra.Command {
|
||||
func newFeedShortcutListCmd(t *testing.T) *cobra.Command {
|
||||
t.Helper()
|
||||
cmd := &cobra.Command{Use: "test"}
|
||||
cmd.Flags().String("page-token", "", "")
|
||||
// Default true (skip enrichment) in tests so non-enrichment-focused tests
|
||||
// don't trigger the batch_query path; tests that exercise detail
|
||||
// enrichment flip this off.
|
||||
cmd.Flags().Bool("no-detail", true, "")
|
||||
if err := cmd.ParseFlags(nil); err != nil {
|
||||
t.Fatalf("ParseFlags() error = %v", err)
|
||||
}
|
||||
@@ -76,11 +70,26 @@ func TestCollectChatIDs(t *testing.T) {
|
||||
{name: "dedupes", input: []string{"oc_abc", "oc_abc", "oc_def"}, want: []string{"oc_abc", "oc_def"}},
|
||||
{name: "rejects empty list", input: nil, wantErr: true, errSubstr: "--chat-id is required"},
|
||||
{name: "rejects bad prefix", input: []string{"om_abc"}, wantErr: true, errSubstr: "must be an open_chat_id"},
|
||||
{
|
||||
name: "accepts limit boundary",
|
||||
input: []string{
|
||||
"oc_1", "oc_2", "oc_3", "oc_4", "oc_5", "oc_6", "oc_7", "oc_8", "oc_9", "oc_10",
|
||||
"oc_11", "oc_12", "oc_13", "oc_14", "oc_15", "oc_16", "oc_17", "oc_18", "oc_19", "oc_20",
|
||||
"oc_21", "oc_22", "oc_23", "oc_24", "oc_25", "oc_26", "oc_27", "oc_28", "oc_29", "oc_30",
|
||||
},
|
||||
want: []string{
|
||||
"oc_1", "oc_2", "oc_3", "oc_4", "oc_5", "oc_6", "oc_7", "oc_8", "oc_9", "oc_10",
|
||||
"oc_11", "oc_12", "oc_13", "oc_14", "oc_15", "oc_16", "oc_17", "oc_18", "oc_19", "oc_20",
|
||||
"oc_21", "oc_22", "oc_23", "oc_24", "oc_25", "oc_26", "oc_27", "oc_28", "oc_29", "oc_30",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "rejects over limit",
|
||||
input: []string{
|
||||
"oc_1", "oc_2", "oc_3", "oc_4", "oc_5",
|
||||
"oc_6", "oc_7", "oc_8", "oc_9", "oc_10", "oc_11",
|
||||
"oc_1", "oc_2", "oc_3", "oc_4", "oc_5", "oc_6", "oc_7", "oc_8", "oc_9", "oc_10",
|
||||
"oc_11", "oc_12", "oc_13", "oc_14", "oc_15", "oc_16", "oc_17", "oc_18", "oc_19", "oc_20",
|
||||
"oc_21", "oc_22", "oc_23", "oc_24", "oc_25", "oc_26", "oc_27", "oc_28", "oc_29", "oc_30",
|
||||
"oc_31",
|
||||
},
|
||||
wantErr: true,
|
||||
errSubstr: "too many --chat-id",
|
||||
@@ -549,24 +558,15 @@ func TestImFeedShortcutListDryRunRendersGet(t *testing.T) {
|
||||
t.Fatalf("DryRun output = %s, want %q", got, want)
|
||||
}
|
||||
}
|
||||
if strings.Contains(got, "page_token") {
|
||||
t.Fatalf("DryRun output = %s, should omit page_token on first-page request", got)
|
||||
}
|
||||
|
||||
func TestImFeedShortcutListHasNoCustomFlags(t *testing.T) {
|
||||
if len(ImFeedShortcutList.Flags) != 0 {
|
||||
t.Fatalf("ImFeedShortcutList.Flags = %v, want no shortcut-specific flags", ImFeedShortcutList.Flags)
|
||||
}
|
||||
}
|
||||
|
||||
func TestImFeedShortcutListDryRunIncludesNonEmptyPageToken(t *testing.T) {
|
||||
cmd := newFeedShortcutListCmd(t)
|
||||
if err := cmd.Flags().Set("page-token", "tok1"); err != nil {
|
||||
t.Fatalf("Set page-token error = %v", err)
|
||||
}
|
||||
rt := &common.RuntimeContext{Cmd: cmd}
|
||||
got := ImFeedShortcutList.DryRun(context.Background(), rt).Format()
|
||||
if !strings.Contains(got, "page_token=tok1") {
|
||||
t.Fatalf("DryRun output = %s, want page_token=tok1", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestImFeedShortcutListHelpDoesNotTreatDetailAsArgName(t *testing.T) {
|
||||
func TestImFeedShortcutListHelpShowsNoLegacyFlags(t *testing.T) {
|
||||
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
|
||||
AppID: "app", AppSecret: "secret", Brand: core.BrandFeishu,
|
||||
})
|
||||
@@ -584,71 +584,13 @@ func TestImFeedShortcutListHelpDoesNotTreatDetailAsArgName(t *testing.T) {
|
||||
t.Fatalf("Help() error = %v", err)
|
||||
}
|
||||
got := out.String()
|
||||
if strings.Contains(got, "--no-detail detail") {
|
||||
t.Fatalf("help output treats `detail` as a flag arg name:\n%s", got)
|
||||
}
|
||||
if !strings.Contains(got, "--no-detail") {
|
||||
t.Fatalf("help output missing --no-detail:\n%s", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestImFeedShortcutListDryRunMentionsDetailScope(t *testing.T) {
|
||||
cmd := newFeedShortcutListCmd(t)
|
||||
if err := cmd.Flags().Set("no-detail", "false"); err != nil {
|
||||
t.Fatalf("Set no-detail error = %v", err)
|
||||
}
|
||||
rt := &common.RuntimeContext{Cmd: cmd}
|
||||
got := ImFeedShortcutList.DryRun(context.Background(), rt).Format()
|
||||
for _, want := range []string{
|
||||
"im:chat:read",
|
||||
"--no-detail",
|
||||
"batch_query",
|
||||
} {
|
||||
if !strings.Contains(got, want) {
|
||||
t.Fatalf("DryRun output = %s, want %q", got, want)
|
||||
for _, banned := range []string{"--no-detail", "--page-token"} {
|
||||
if strings.Contains(got, banned) {
|
||||
t.Fatalf("help output should not mention legacy flag %s:\n%s", banned, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestImFeedShortcutListDoesNotExposeAutoPaginationFlags(t *testing.T) {
|
||||
// Locks in the design decision: this shortcut is a one-page wrapper.
|
||||
// If any of these reappear, callers/AI agents will assume auto-walking
|
||||
// is supported and write code that silently double-fetches.
|
||||
banned := map[string]bool{"page-all": true, "page-limit": true, "page-size": true}
|
||||
for _, fl := range ImFeedShortcutList.Flags {
|
||||
if banned[fl.Name] {
|
||||
t.Fatalf("ImFeedShortcutList must not expose --%s", fl.Name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestImFeedShortcutListPageTokenIsOptional(t *testing.T) {
|
||||
// --page-token must NOT be Required: omitting it is the natural first-page
|
||||
// signal (the server treats "missing" and "" the same). Forcing an empty
|
||||
// string would just be noise.
|
||||
for _, fl := range ImFeedShortcutList.Flags {
|
||||
if fl.Name == "page-token" && fl.Required {
|
||||
t.Fatalf("--page-token must be optional; omitting it should mean first page")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestImFeedShortcutListDetailOnByDefault(t *testing.T) {
|
||||
// The real flag definition must keep detail enrichment on by default:
|
||||
// --no-detail is an opt-out bool with a false zero-value default. The
|
||||
// test-helper command flips it for isolation, so this definition-level
|
||||
// check is what actually locks the shipped default against a flip.
|
||||
for _, fl := range ImFeedShortcutList.Flags {
|
||||
if fl.Name == "no-detail" {
|
||||
if fl.Default != "" && fl.Default != "false" {
|
||||
t.Fatalf("--no-detail default = %q, want unset/false (enrichment on by default)", fl.Default)
|
||||
}
|
||||
return
|
||||
}
|
||||
}
|
||||
t.Fatalf("--no-detail flag not found on ImFeedShortcutList")
|
||||
}
|
||||
|
||||
func TestFeedShortcutChatIDNotCobraRequired(t *testing.T) {
|
||||
// --chat-id is mandatory, but must NOT be cobra-Required: cobra would
|
||||
// intercept a missing flag before Validate runs and emit a plain-text
|
||||
@@ -663,430 +605,46 @@ func TestFeedShortcutChatIDNotCobraRequired(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestFeedShortcutListQueryOmitsEmptyToken(t *testing.T) {
|
||||
q := feedShortcutListQuery("")
|
||||
if _, ok := q["page_token"]; ok {
|
||||
t.Fatalf("feedShortcutListQuery(\"\") = %v, want no page_token key", q)
|
||||
}
|
||||
q = feedShortcutListQuery("next")
|
||||
if v := q["page_token"]; len(v) != 1 || v[0] != "next" {
|
||||
t.Fatalf("feedShortcutListQuery(\"next\") page_token = %v, want [next]", v)
|
||||
}
|
||||
}
|
||||
|
||||
func TestImFeedShortcutListExecuteForwardsToken(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
token string
|
||||
wantSent string // value the server should see in ?page_token=
|
||||
wantKey bool // whether ?page_token should appear at all
|
||||
}{
|
||||
{name: "first page omits param", token: "", wantSent: "", wantKey: false},
|
||||
{name: "explicit token is forwarded", token: "tok1", wantSent: "tok1", wantKey: true},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
var calls int
|
||||
var sawKey bool
|
||||
var gotToken string
|
||||
rt := newUserShortcutRuntime(t, shortcutRoundTripFunc(func(req *http.Request) (*http.Response, error) {
|
||||
if !strings.Contains(req.URL.Path, "/open-apis/im/v2/feed_shortcuts") {
|
||||
return nil, fmt.Errorf("unexpected request: %s", req.URL.Path)
|
||||
}
|
||||
calls++
|
||||
_, sawKey = req.URL.Query()["page_token"]
|
||||
gotToken = req.URL.Query().Get("page_token")
|
||||
return shortcutJSONResponse(200, map[string]any{
|
||||
"code": 0,
|
||||
"data": map[string]any{
|
||||
"shortcuts": []any{map[string]any{"feed_card_id": "oc_a", "type": float64(1)}},
|
||||
"has_more": false,
|
||||
"page_token": "end",
|
||||
},
|
||||
}), nil
|
||||
}))
|
||||
|
||||
cmd := newFeedShortcutListCmd(t)
|
||||
if err := cmd.Flags().Set("page-token", tt.token); err != nil {
|
||||
t.Fatalf("Set page-token error = %v", err)
|
||||
}
|
||||
setRuntimeField(t, rt, "Cmd", cmd)
|
||||
|
||||
if err := ImFeedShortcutList.Execute(context.Background(), rt); err != nil {
|
||||
t.Fatalf("Execute() error = %v", err)
|
||||
}
|
||||
if calls != 1 {
|
||||
t.Fatalf("expected 1 API call, got %d", calls)
|
||||
}
|
||||
if sawKey != tt.wantKey {
|
||||
t.Fatalf("page_token query key present = %v, want %v", sawKey, tt.wantKey)
|
||||
}
|
||||
if gotToken != tt.wantSent {
|
||||
t.Fatalf("page_token sent = %q, want %q", gotToken, tt.wantSent)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestShortcutTypeFromValue(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
v any
|
||||
want ShortcutType
|
||||
}{
|
||||
{name: "float64 1 → chat", v: float64(1), want: ShortcutTypeChat},
|
||||
{name: "int 1 → chat", v: 1, want: ShortcutTypeChat},
|
||||
{name: "float64 0 → unknown", v: float64(0), want: ShortcutTypeUnknown},
|
||||
{name: "unknown numeric → unknown ShortcutType(99)", v: float64(99), want: ShortcutType(99)},
|
||||
{name: "string defaults to unknown", v: "1", want: ShortcutTypeUnknown},
|
||||
{name: "nil defaults to unknown", v: nil, want: ShortcutTypeUnknown},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
if got := shortcutTypeFromValue(tt.v); got != tt.want {
|
||||
t.Fatalf("shortcutTypeFromValue(%v) = %v, want %v", tt.v, got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveChatDetailBatchesAt50(t *testing.T) {
|
||||
func TestImFeedShortcutListExecuteRequestsFullList(t *testing.T) {
|
||||
var calls int
|
||||
var rawQuery string
|
||||
rt := newUserShortcutRuntime(t, shortcutRoundTripFunc(func(req *http.Request) (*http.Response, error) {
|
||||
if !strings.Contains(req.URL.Path, "/open-apis/im/v1/chats/batch_query") {
|
||||
if !strings.Contains(req.URL.Path, "/open-apis/im/v2/feed_shortcuts") {
|
||||
return nil, fmt.Errorf("unexpected request: %s", req.URL.Path)
|
||||
}
|
||||
calls++
|
||||
// Echo each requested chat_id back with a synthetic name so we can
|
||||
// confirm both that batching happened and that the response was
|
||||
// parsed correctly.
|
||||
body, _ := io.ReadAll(req.Body)
|
||||
var parsed struct {
|
||||
ChatIDs []string `json:"chat_ids"`
|
||||
}
|
||||
_ = json.Unmarshal(body, &parsed)
|
||||
items := make([]any, 0, len(parsed.ChatIDs))
|
||||
for _, id := range parsed.ChatIDs {
|
||||
items = append(items, map[string]any{"chat_id": id, "name": "group-" + id})
|
||||
}
|
||||
return shortcutJSONResponse(200, map[string]any{
|
||||
"code": 0,
|
||||
"data": map[string]any{"items": items},
|
||||
}), nil
|
||||
}))
|
||||
setRuntimeScopes(t, rt, chatBatchQueryScope)
|
||||
|
||||
ids := make([]string, 120) // 50 + 50 + 20 → 3 batches
|
||||
for i := range ids {
|
||||
ids[i] = fmt.Sprintf("oc_%d", i)
|
||||
}
|
||||
got, err := resolveChatDetail(rt, ids)
|
||||
if err != nil {
|
||||
t.Fatalf("resolveChatDetail() error = %v", err)
|
||||
}
|
||||
if calls != 3 {
|
||||
t.Fatalf("calls = %d, want 3 (120 ids / 50 batch size)", calls)
|
||||
}
|
||||
if len(got) != 120 {
|
||||
t.Fatalf("resolved size = %d, want 120", len(got))
|
||||
}
|
||||
first := got["oc_0"]
|
||||
last := got["oc_119"]
|
||||
if first == nil || last == nil {
|
||||
t.Fatalf("Items missing boundary entries: first=%v last=%v", first, last)
|
||||
}
|
||||
if first["name"] != "group-oc_0" || last["name"] != "group-oc_119" {
|
||||
t.Fatalf("expected name passthrough; got first=%v last=%v", first["name"], last["name"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveChatDetailIncludesP2PChats(t *testing.T) {
|
||||
// Unlike the old title-only resolver, the detail resolver keeps p2p chats
|
||||
// in the result map (their full object carries chat_mode/p2p_target_id);
|
||||
// only `name` is empty. Locks in that the empty-name skip was removed
|
||||
// when we switched from `title` (string) to `detail` (full object).
|
||||
rt := newUserShortcutRuntime(t, shortcutRoundTripFunc(func(req *http.Request) (*http.Response, error) {
|
||||
rawQuery = req.URL.RawQuery
|
||||
return shortcutJSONResponse(200, map[string]any{
|
||||
"code": 0,
|
||||
"data": map[string]any{
|
||||
"items": []any{
|
||||
map[string]any{"chat_id": "oc_group", "name": "Engineering", "chat_mode": "group"},
|
||||
map[string]any{"chat_id": "oc_p2p", "name": "", "chat_mode": "p2p", "p2p_target_id": "ou_x"},
|
||||
"shortcuts": []any{
|
||||
map[string]any{"feed_card_id": "oc_a", "type": float64(1)},
|
||||
},
|
||||
},
|
||||
}), nil
|
||||
}))
|
||||
setRuntimeScopes(t, rt, chatBatchQueryScope)
|
||||
|
||||
got, err := resolveChatDetail(rt, []string{"oc_group", "oc_p2p"})
|
||||
if err != nil {
|
||||
t.Fatalf("resolveChatDetail() error = %v", err)
|
||||
}
|
||||
if got["oc_group"]["name"] != "Engineering" {
|
||||
t.Fatalf("oc_group name = %v, want Engineering", got["oc_group"]["name"])
|
||||
}
|
||||
p2p, ok := got["oc_p2p"]
|
||||
if !ok {
|
||||
t.Fatalf("oc_p2p must be in Items even though name is empty (caller decides what to show)")
|
||||
}
|
||||
if p2p["chat_mode"] != "p2p" || p2p["p2p_target_id"] != "ou_x" {
|
||||
t.Fatalf("p2p detail = %v, want chat_mode=p2p with p2p_target_id passthrough", p2p)
|
||||
}
|
||||
}
|
||||
cmd := newFeedShortcutListCmd(t)
|
||||
setRuntimeField(t, rt, "Cmd", cmd)
|
||||
|
||||
func TestResolveChatDetailDropsItemsWithoutChatID(t *testing.T) {
|
||||
// Defensive: the server should always echo chat_id back, but if it ever
|
||||
// returns an item missing chat_id we must not write a "" → object entry
|
||||
// into the map and end up attaching nonsense to entries.
|
||||
rt := newUserShortcutRuntime(t, shortcutRoundTripFunc(func(req *http.Request) (*http.Response, error) {
|
||||
return shortcutJSONResponse(200, map[string]any{
|
||||
"code": 0,
|
||||
"data": map[string]any{
|
||||
"items": []any{
|
||||
map[string]any{"chat_id": "oc_ok", "name": "ok"},
|
||||
map[string]any{"name": "no chat_id"},
|
||||
},
|
||||
},
|
||||
}), nil
|
||||
}))
|
||||
setRuntimeScopes(t, rt, chatBatchQueryScope)
|
||||
|
||||
got, err := resolveChatDetail(rt, []string{"oc_ok"})
|
||||
if err != nil {
|
||||
t.Fatalf("resolveChatDetail() error = %v", err)
|
||||
}
|
||||
if len(got) != 1 {
|
||||
t.Fatalf("resolved size = %d, want 1 (entry without chat_id must be dropped)", len(got))
|
||||
}
|
||||
if _, ok := got[""]; ok {
|
||||
t.Fatalf("got[\"\"] must not exist; got %v", got[""])
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveChatDetailPropagatesScopeError(t *testing.T) {
|
||||
rt := newUserShortcutRuntime(t, shortcutRoundTripFunc(func(req *http.Request) (*http.Response, error) {
|
||||
t.Fatalf("resolver should fail scope pre-flight before calling API: %s", req.URL.Path)
|
||||
return nil, nil
|
||||
}))
|
||||
// Token resolves with a known-but-wrong scope set so the missing-scope
|
||||
// branch (not the unknown-metadata warning branch) fires.
|
||||
setRuntimeScopes(t, rt, "search:message")
|
||||
|
||||
_, err := resolveChatDetail(rt, []string{"oc_abc"})
|
||||
if err == nil {
|
||||
t.Fatalf("resolveChatDetail() expected scope error, got nil")
|
||||
}
|
||||
if !strings.Contains(err.Error(), chatBatchQueryScope) {
|
||||
t.Fatalf("resolveChatDetail() error = %v, want mention of %s", err, chatBatchQueryScope)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnrichFeedShortcutDetailAttachesAndDedupes(t *testing.T) {
|
||||
var calls int
|
||||
var capturedIDs []string
|
||||
rt := newUserShortcutRuntime(t, shortcutRoundTripFunc(func(req *http.Request) (*http.Response, error) {
|
||||
if !strings.Contains(req.URL.Path, "/open-apis/im/v1/chats/batch_query") {
|
||||
return nil, fmt.Errorf("unexpected request: %s", req.URL.Path)
|
||||
}
|
||||
calls++
|
||||
body, _ := io.ReadAll(req.Body)
|
||||
var parsed struct {
|
||||
ChatIDs []string `json:"chat_ids"`
|
||||
}
|
||||
_ = json.Unmarshal(body, &parsed)
|
||||
capturedIDs = append(capturedIDs, parsed.ChatIDs...)
|
||||
items := make([]any, 0, len(parsed.ChatIDs))
|
||||
for _, id := range parsed.ChatIDs {
|
||||
items = append(items, map[string]any{
|
||||
"chat_id": id,
|
||||
"name": "name-of-" + id,
|
||||
"chat_mode": "group",
|
||||
})
|
||||
}
|
||||
return shortcutJSONResponse(200, map[string]any{
|
||||
"code": 0,
|
||||
"data": map[string]any{"items": items},
|
||||
}), nil
|
||||
}))
|
||||
setRuntimeScopes(t, rt, chatBatchQueryScope)
|
||||
|
||||
data := map[string]any{
|
||||
"shortcuts": []any{
|
||||
map[string]any{"feed_card_id": "oc_a", "type": float64(1)},
|
||||
map[string]any{"feed_card_id": "oc_b", "type": float64(1)},
|
||||
map[string]any{"feed_card_id": "oc_a", "type": float64(1)}, // duplicate
|
||||
// Unknown type — must be skipped without aborting the whole call.
|
||||
map[string]any{"feed_card_id": "doc_xxx", "type": float64(3)},
|
||||
},
|
||||
}
|
||||
if err := enrichFeedShortcutDetail(rt, data); err != nil {
|
||||
t.Fatalf("enrichFeedShortcutDetail() error = %v", err)
|
||||
if err := ImFeedShortcutList.Execute(context.Background(), rt); err != nil {
|
||||
t.Fatalf("Execute() error = %v", err)
|
||||
}
|
||||
if calls != 1 {
|
||||
t.Fatalf("calls = %d, want 1 (single batch covers all CHAT ids)", calls)
|
||||
t.Fatalf("expected 1 API call, got %d", calls)
|
||||
}
|
||||
if len(capturedIDs) != 2 {
|
||||
t.Fatalf("server saw chat_ids = %v, want 2 dedup'd ids", capturedIDs)
|
||||
if rawQuery != "" {
|
||||
t.Fatalf("request query = %q, want empty query string", rawQuery)
|
||||
}
|
||||
|
||||
items := data["shortcuts"].([]any)
|
||||
for _, ix := range []int{0, 1, 2} { // 2 is the duplicate of 0
|
||||
detail, ok := items[ix].(map[string]any)["detail"].(map[string]any)
|
||||
if !ok {
|
||||
t.Fatalf("item[%d] missing detail field; got %v", ix, items[ix])
|
||||
}
|
||||
// The full chat object is passed through verbatim — not just a name.
|
||||
if detail["chat_mode"] != "group" {
|
||||
t.Fatalf("item[%d] detail.chat_mode = %v, want group (full object passthrough)", ix, detail["chat_mode"])
|
||||
}
|
||||
wantName := "name-of-" + items[ix].(map[string]any)["feed_card_id"].(string)
|
||||
if detail["name"] != wantName {
|
||||
t.Fatalf("item[%d] detail.name = %v, want %q", ix, detail["name"], wantName)
|
||||
}
|
||||
}
|
||||
if _, ok := items[3].(map[string]any)["detail"]; ok {
|
||||
t.Fatalf("item[3] (unknown type) should not have detail set")
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnrichFeedShortcutDetailNoOpWhenEmpty(t *testing.T) {
|
||||
rt := newUserShortcutRuntime(t, shortcutRoundTripFunc(func(req *http.Request) (*http.Response, error) {
|
||||
t.Fatalf("must not call API for empty list: %s", req.URL.Path)
|
||||
return nil, nil
|
||||
}))
|
||||
if err := enrichFeedShortcutDetail(rt, map[string]any{}); err != nil {
|
||||
t.Fatalf("enrichFeedShortcutDetail(empty data) error = %v", err)
|
||||
}
|
||||
if err := enrichFeedShortcutDetail(rt, map[string]any{"shortcuts": []any{}}); err != nil {
|
||||
t.Fatalf("enrichFeedShortcutDetail(empty shortcuts) error = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnrichFeedShortcutDetailSkipsWhenNoSupportedType(t *testing.T) {
|
||||
rt := newUserShortcutRuntime(t, shortcutRoundTripFunc(func(req *http.Request) (*http.Response, error) {
|
||||
t.Fatalf("must not call batch_query when no resolvable types: %s", req.URL.Path)
|
||||
return nil, nil
|
||||
}))
|
||||
data := map[string]any{
|
||||
"shortcuts": []any{
|
||||
map[string]any{"feed_card_id": "doc_1", "type": float64(3)}, // DOC, not exposed
|
||||
map[string]any{"feed_card_id": "app_1", "type": float64(4)}, // OPENAPP, not exposed
|
||||
map[string]any{"feed_card_id": "biz_1", "type": float64(13)}, // APP_FEED, not exposed
|
||||
},
|
||||
}
|
||||
if err := enrichFeedShortcutDetail(rt, data); err != nil {
|
||||
t.Fatalf("enrichFeedShortcutDetail() error = %v", err)
|
||||
}
|
||||
for i, it := range data["shortcuts"].([]any) {
|
||||
if _, ok := it.(map[string]any)["detail"]; ok {
|
||||
t.Fatalf("item[%d] should not have a detail (unknown type)", i)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestImFeedShortcutListExecuteEnrichesDetailByDefault(t *testing.T) {
|
||||
rt := newUserShortcutRuntime(t, shortcutRoundTripFunc(func(req *http.Request) (*http.Response, error) {
|
||||
switch {
|
||||
case strings.Contains(req.URL.Path, "/open-apis/im/v2/feed_shortcuts"):
|
||||
return shortcutJSONResponse(200, map[string]any{
|
||||
"code": 0,
|
||||
"data": map[string]any{
|
||||
"shortcuts": []any{
|
||||
map[string]any{"feed_card_id": "oc_a", "type": float64(1)},
|
||||
},
|
||||
"has_more": false,
|
||||
"page_token": "",
|
||||
},
|
||||
}), nil
|
||||
case strings.Contains(req.URL.Path, "/open-apis/im/v1/chats/batch_query"):
|
||||
return shortcutJSONResponse(200, map[string]any{
|
||||
"code": 0,
|
||||
"data": map[string]any{
|
||||
"items": []any{
|
||||
map[string]any{
|
||||
"chat_id": "oc_a",
|
||||
"name": "Team Alpha",
|
||||
"chat_mode": "group",
|
||||
},
|
||||
},
|
||||
},
|
||||
}), nil
|
||||
}
|
||||
return nil, fmt.Errorf("unexpected request: %s", req.URL.Path)
|
||||
}))
|
||||
setRuntimeScopes(t, rt, feedShortcutReadScope+" "+chatBatchQueryScope)
|
||||
|
||||
cmd := newFeedShortcutListCmd(t)
|
||||
if err := cmd.Flags().Set("no-detail", "false"); err != nil {
|
||||
t.Fatalf("Set no-detail error = %v", err)
|
||||
}
|
||||
setRuntimeField(t, rt, "Cmd", cmd)
|
||||
|
||||
if err := ImFeedShortcutList.Execute(context.Background(), rt); err != nil {
|
||||
t.Fatalf("Execute() error = %v", err)
|
||||
}
|
||||
out := rt.Factory.IOStreams.Out.(interface{ String() string }).String()
|
||||
// Verify both the attach-field name and the full-object passthrough,
|
||||
// so future regressions that drop fields (e.g. only keeping `name`)
|
||||
// fail loudly here.
|
||||
for _, want := range []string{
|
||||
`"detail":`,
|
||||
`"chat_mode": "group"`,
|
||||
`"name": "Team Alpha"`,
|
||||
} {
|
||||
if !strings.Contains(out, want) {
|
||||
t.Fatalf("stdout missing %q, got:\n%s", want, out)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestImFeedShortcutListExecuteWarnsOnEnrichFailure(t *testing.T) {
|
||||
rt := newUserShortcutRuntime(t, shortcutRoundTripFunc(func(req *http.Request) (*http.Response, error) {
|
||||
switch {
|
||||
case strings.Contains(req.URL.Path, "/open-apis/im/v2/feed_shortcuts"):
|
||||
return shortcutJSONResponse(200, map[string]any{
|
||||
"code": 0,
|
||||
"data": map[string]any{
|
||||
"shortcuts": []any{
|
||||
map[string]any{"feed_card_id": "oc_a", "type": float64(1)},
|
||||
},
|
||||
"has_more": false,
|
||||
"page_token": "",
|
||||
},
|
||||
}), nil
|
||||
case strings.Contains(req.URL.Path, "/open-apis/im/v1/chats/batch_query"):
|
||||
return nil, fmt.Errorf("batch_query network failure")
|
||||
}
|
||||
return nil, fmt.Errorf("unexpected request: %s", req.URL.Path)
|
||||
}))
|
||||
setRuntimeScopes(t, rt, feedShortcutReadScope+" "+chatBatchQueryScope)
|
||||
|
||||
cmd := newFeedShortcutListCmd(t)
|
||||
if err := cmd.Flags().Set("no-detail", "false"); err != nil {
|
||||
t.Fatalf("Set no-detail error = %v", err)
|
||||
}
|
||||
setRuntimeField(t, rt, "Cmd", cmd)
|
||||
|
||||
// Listing should still succeed even when enrichment can't reach the API —
|
||||
// failure becomes a stderr warning, not a hard exit.
|
||||
if err := ImFeedShortcutList.Execute(context.Background(), rt); err != nil {
|
||||
t.Fatalf("Execute() error = %v", err)
|
||||
}
|
||||
stderr := rt.Factory.IOStreams.ErrOut.(interface{ String() string }).String()
|
||||
if !strings.Contains(stderr, "detail enrichment failed") {
|
||||
t.Fatalf("stderr = %q, want enrichment warning", stderr)
|
||||
}
|
||||
// And the shortcut itself still appears, just without `detail`.
|
||||
stdout := rt.Factory.IOStreams.Out.(interface{ String() string }).String()
|
||||
if !strings.Contains(stdout, `"feed_card_id": "oc_a"`) {
|
||||
t.Fatalf("stdout should still contain the bare shortcut entry; got:\n%s", stdout)
|
||||
for _, want := range []string{`"feed_card_id": "oc_a"`, `"type": 1`} {
|
||||
if !strings.Contains(stdout, want) {
|
||||
t.Fatalf("stdout = %s, want %q", stdout, want)
|
||||
}
|
||||
}
|
||||
if strings.Contains(stdout, `"detail"`) {
|
||||
t.Fatalf("stdout should NOT contain detail when enrichment failed; got:\n%s", stdout)
|
||||
}
|
||||
// The degradation is mirrored as a machine-readable data field so
|
||||
// stdout-only consumers can tell "skipped" from "nothing to enrich".
|
||||
if !strings.Contains(stdout, `"_notice": "detail enrichment skipped`) {
|
||||
t.Fatalf("stdout should carry the _notice degradation marker; got:\n%s", stdout)
|
||||
for _, banned := range []string{`"detail"`, `"_notice"`, `"page_token"`, `"has_more"`} {
|
||||
if strings.Contains(stdout, banned) {
|
||||
t.Fatalf("stdout should not contain legacy field %s; got:\n%s", banned, stdout)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -184,10 +184,7 @@ func fetchWhiteboardNodes(runtime *common.RuntimeContext, wbToken string) (*wbNo
|
||||
return nil, err
|
||||
}
|
||||
var nodes wbNodesResp
|
||||
rawNodes, ok := data["nodes"]
|
||||
if !ok {
|
||||
return nil, wbInvalidResponse("get whiteboard nodes failed: missing data.nodes")
|
||||
}
|
||||
rawNodes, _ := data["nodes"]
|
||||
if rawNodes != nil {
|
||||
var ok bool
|
||||
nodes.Data.Nodes, ok = rawNodes.([]interface{})
|
||||
|
||||
@@ -848,11 +848,6 @@ func TestFetchWhiteboardNodes_InvalidResponseTypedError(t *testing.T) {
|
||||
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",
|
||||
@@ -880,6 +875,32 @@ func TestFetchWhiteboardNodes_InvalidResponseTypedError(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestFetchWhiteboardNodes_MissingNodesIsEmpty verifies that a response with
|
||||
// missing nodes field is treated as an empty whiteboard (success), not an error.
|
||||
// This matches the behavior introduced in commit 4b39b037.
|
||||
func TestFetchWhiteboardNodes_MissingNodesIsEmpty(t *testing.T) {
|
||||
factory, stdout, reg := newExecuteFactory(t)
|
||||
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/board/v1/whiteboards/test-token-missing-nodes/nodes",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"msg": "success",
|
||||
"data": map[string]interface{}{},
|
||||
},
|
||||
})
|
||||
|
||||
args := []string{"+query", "--whiteboard-token", "test-token-missing-nodes", "--output_as", "raw"}
|
||||
if err := runShortcut(t, WhiteboardQuery, args, factory, stdout); err != nil {
|
||||
t.Fatalf("expected success for missing nodes (empty whiteboard), got err=%v", err)
|
||||
}
|
||||
|
||||
if !strings.Contains(stdout.String(), "whiteboard is empty") {
|
||||
t.Fatalf("stdout missing empty whiteboard message: %s", stdout.String())
|
||||
}
|
||||
}
|
||||
|
||||
func assertInvalidResponse(t *testing.T, err error) {
|
||||
t.Helper()
|
||||
if err == nil {
|
||||
|
||||
@@ -5,12 +5,11 @@ package wiki
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
@@ -95,7 +94,7 @@ func (s wikiAsyncTaskStatus) StatusLabel() string {
|
||||
}
|
||||
|
||||
// wikiAsyncTaskFetcher returns the latest status for taskID. Implementations
|
||||
// translate from runtime.CallAPI responses or test fakes.
|
||||
// translate from runtime.CallAPITyped responses or test fakes.
|
||||
type wikiAsyncTaskFetcher func(ctx context.Context, taskID string) (wikiAsyncTaskStatus, error)
|
||||
|
||||
// parseWikiAsyncTaskStatus normalizes an /wiki/v2/tasks/{task_id} payload.
|
||||
@@ -103,7 +102,7 @@ type wikiAsyncTaskFetcher func(ctx context.Context, taskID string) (wikiAsyncTas
|
||||
// "simple_task_result" for delete-node).
|
||||
func parseWikiAsyncTaskStatus(taskID string, task map[string]interface{}, resultKey string) (wikiAsyncTaskStatus, error) {
|
||||
if task == nil {
|
||||
return wikiAsyncTaskStatus{}, output.Errorf(output.ExitAPI, "api_error", "wiki task response missing task")
|
||||
return wikiAsyncTaskStatus{}, errs.NewInternalError(errs.SubtypeInvalidResponse, "wiki task response missing task")
|
||||
}
|
||||
|
||||
result := common.GetMap(task, resultKey)
|
||||
@@ -167,7 +166,7 @@ func pollWikiAsyncTask(
|
||||
return status, true, nil
|
||||
}
|
||||
if status.Failed() {
|
||||
return status, false, output.Errorf(output.ExitAPI, "api_error", "wiki %s task %s failed: %s", label, taskID, status.StatusLabel())
|
||||
return status, false, errs.NewAPIError(errs.SubtypeServerError, "wiki %s task %s failed: %s", label, taskID, status.StatusLabel())
|
||||
}
|
||||
|
||||
fmt.Fprintf(runtime.IO().ErrOut, "Wiki %s status %d/%d: %s\n", label, attempt, attempts, status.StatusLabel())
|
||||
@@ -178,29 +177,18 @@ func pollWikiAsyncTask(
|
||||
"the wiki %s task was created but every status poll failed (task_id=%s)\nretry status lookup with: %s",
|
||||
label, taskID, nextCommand,
|
||||
)
|
||||
var exitErr *output.ExitError
|
||||
if errors.As(lastErr, &exitErr) && exitErr.Detail != nil {
|
||||
if strings.TrimSpace(exitErr.Detail.Hint) != "" {
|
||||
hint = exitErr.Detail.Hint + "\n" + hint
|
||||
}
|
||||
// ErrWithHint rebuilds the error and drops the upstream Lark
|
||||
// Detail.Code / ConsoleURL / Risk / nested Detail. Build the
|
||||
// ExitError by hand so the original API code survives a fully
|
||||
// failed poll, matching wrapWikiNodeDeleteAPIError.
|
||||
return lastStatus, false, &output.ExitError{
|
||||
Code: exitErr.Code,
|
||||
Detail: &output.ErrDetail{
|
||||
Type: exitErr.Detail.Type,
|
||||
Code: exitErr.Detail.Code,
|
||||
Message: exitErr.Detail.Message,
|
||||
Hint: hint,
|
||||
ConsoleURL: exitErr.Detail.ConsoleURL,
|
||||
Risk: exitErr.Detail.Risk,
|
||||
Detail: exitErr.Detail.Detail,
|
||||
},
|
||||
// The poll error comes from a typed CallAPITyped path; append the resume
|
||||
// hint in place so the original category / subtype / code / log_id
|
||||
// survives a fully failed poll (per ERROR_CONTRACT.md "propagate typed
|
||||
// errors unchanged"), matching wrapWikiNodeDeleteAPIError.
|
||||
if p, ok := errs.ProblemOf(lastErr); ok {
|
||||
if strings.TrimSpace(p.Hint) != "" {
|
||||
hint = p.Hint + "\n" + hint
|
||||
}
|
||||
p.Hint = hint
|
||||
return lastStatus, false, lastErr
|
||||
}
|
||||
return lastStatus, false, output.ErrWithHint(output.ExitAPI, "api_error", lastErr.Error(), hint)
|
||||
return lastStatus, false, errs.NewInternalError(errs.SubtypeUnknown, "%s", lastErr.Error()).WithHint("%s", hint).WithCause(lastErr)
|
||||
}
|
||||
|
||||
return lastStatus, false, nil
|
||||
|
||||
@@ -10,8 +10,8 @@ import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
)
|
||||
|
||||
// pollWikiAsyncTask is shared infrastructure for every wiki delete shortcut,
|
||||
@@ -88,45 +88,54 @@ func TestPollWikiAsyncTaskAllPollsFailWrapsWithResumeHint(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
runtime, stderr := newWikiNodeDeleteRuntime(t, core.AsUser)
|
||||
transportErr := errors.New("transport boom")
|
||||
_, ready, err := pollWikiAsyncTask(
|
||||
context.Background(), runtime, "task_lost", "delete-node", 2, 0,
|
||||
func(context.Context, string) (wikiAsyncTaskStatus, error) {
|
||||
return wikiAsyncTaskStatus{}, errors.New("transport boom")
|
||||
return wikiAsyncTaskStatus{}, transportErr
|
||||
},
|
||||
"lark-cli drive +task_result --task-id task_lost",
|
||||
)
|
||||
if ready {
|
||||
t.Fatalf("ready = true, want false when every poll failed")
|
||||
}
|
||||
var exitErr *output.ExitError
|
||||
if !errors.As(err, &exitErr) || exitErr.Detail == nil {
|
||||
t.Fatalf("err = %T %v, want *output.ExitError with detail", err, err)
|
||||
p, ok := errs.ProblemOf(err)
|
||||
if !ok {
|
||||
t.Fatalf("err = %T %v, want a typed errs.* error", err, err)
|
||||
}
|
||||
if exitErr.Code != output.ExitAPI {
|
||||
t.Fatalf("exit code = %d, want ExitAPI", exitErr.Code)
|
||||
if p.Subtype != errs.SubtypeUnknown {
|
||||
t.Fatalf("subtype = %q, want unknown for an untyped poll failure", p.Subtype)
|
||||
}
|
||||
if !strings.Contains(exitErr.Detail.Hint, "every status poll failed (task_id=task_lost)") ||
|
||||
!strings.Contains(exitErr.Detail.Hint, "lark-cli drive +task_result --task-id task_lost") {
|
||||
t.Fatalf("hint = %q, want resume guidance naming the task", exitErr.Detail.Hint)
|
||||
if !errors.Is(err, transportErr) {
|
||||
t.Fatalf("err does not preserve the transport cause: %v", err)
|
||||
}
|
||||
if !strings.Contains(p.Hint, "every status poll failed (task_id=task_lost)") ||
|
||||
!strings.Contains(p.Hint, "lark-cli drive +task_result --task-id task_lost") {
|
||||
t.Fatalf("hint = %q, want resume guidance naming the task", p.Hint)
|
||||
}
|
||||
if !strings.Contains(stderr.String(), "attempt 2/2 failed") {
|
||||
t.Fatalf("stderr = %q, want per-attempt progress", stderr.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseWikiAsyncTaskStatusRejectsNilTask(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
_, err := parseWikiAsyncTaskStatus("task_x", nil, "delete_space_result")
|
||||
p, ok := errs.ProblemOf(err)
|
||||
if !ok || p.Category != errs.CategoryInternal || p.Subtype != errs.SubtypeInvalidResponse {
|
||||
t.Fatalf("expected internal/invalid_response, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPollWikiAsyncTaskPrependsUpstreamExitHint(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
runtime, _ := newWikiNodeDeleteRuntime(t, core.AsUser)
|
||||
upstream := &output.ExitError{
|
||||
Code: output.ExitAPI,
|
||||
Detail: &output.ErrDetail{
|
||||
Type: "permission",
|
||||
Code: 99991663,
|
||||
Message: "permission denied",
|
||||
Hint: "grant the wiki:node:retrieve scope",
|
||||
},
|
||||
}
|
||||
// The upstream poll error is a typed error carrying its own hint, mirroring
|
||||
// what runtime.CallAPITyped produces for a permission failure.
|
||||
upstream := errs.NewPermissionError(errs.SubtypePermissionDenied, "permission denied").
|
||||
WithHint("grant the wiki:node:retrieve scope")
|
||||
_, _, err := pollWikiAsyncTask(
|
||||
context.Background(), runtime, "task_perm", "delete-node", 1, 0,
|
||||
func(context.Context, string) (wikiAsyncTaskStatus, error) {
|
||||
@@ -134,23 +143,23 @@ func TestPollWikiAsyncTaskPrependsUpstreamExitHint(t *testing.T) {
|
||||
},
|
||||
"resume-cmd",
|
||||
)
|
||||
var exitErr *output.ExitError
|
||||
if !errors.As(err, &exitErr) || exitErr.Detail == nil {
|
||||
t.Fatalf("err = %T %v, want *output.ExitError", err, err)
|
||||
p, ok := errs.ProblemOf(err)
|
||||
if !ok {
|
||||
t.Fatalf("err = %T %v, want a typed errs.* error", err, err)
|
||||
}
|
||||
// The upstream hint must lead so the actionable cause is read first, with
|
||||
// the resume guidance appended. Type and exit code propagate from upstream.
|
||||
if !strings.HasPrefix(exitErr.Detail.Hint, "grant the wiki:node:retrieve scope\n") {
|
||||
t.Fatalf("hint = %q, want upstream hint prepended", exitErr.Detail.Hint)
|
||||
// the resume guidance appended. The original typed error propagates in place.
|
||||
if !strings.HasPrefix(p.Hint, "grant the wiki:node:retrieve scope\n") {
|
||||
t.Fatalf("hint = %q, want upstream hint prepended", p.Hint)
|
||||
}
|
||||
if !strings.Contains(exitErr.Detail.Hint, "resume-cmd") {
|
||||
t.Fatalf("hint = %q, want resume command appended", exitErr.Detail.Hint)
|
||||
if !strings.Contains(p.Hint, "resume-cmd") {
|
||||
t.Fatalf("hint = %q, want resume command appended", p.Hint)
|
||||
}
|
||||
if exitErr.Detail.Type != "permission" || exitErr.Code != output.ExitAPI {
|
||||
t.Fatalf("exitErr = %+v, want permission/ExitAPI propagated", exitErr)
|
||||
if p.Subtype != errs.SubtypePermissionDenied {
|
||||
t.Fatalf("subtype = %q, want permission_denied propagated", p.Subtype)
|
||||
}
|
||||
if exitErr.Detail.Message != "permission denied" {
|
||||
t.Fatalf("message = %q, want upstream message preserved", exitErr.Detail.Message)
|
||||
if p.Message != "permission denied" {
|
||||
t.Fatalf("message = %q, want upstream message preserved", p.Message)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -9,8 +9,8 @@ import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"github.com/larksuite/cli/internal/validate"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
@@ -89,7 +89,7 @@ type wikiDeleteSpaceAPI struct {
|
||||
}
|
||||
|
||||
func (api wikiDeleteSpaceAPI) DeleteSpace(ctx context.Context, spaceID string) (*wikiDeleteSpaceResponse, error) {
|
||||
data, err := api.runtime.CallAPI(
|
||||
data, err := api.runtime.CallAPITyped(
|
||||
"DELETE",
|
||||
fmt.Sprintf("/open-apis/wiki/v2/spaces/%s", validate.EncodePathSegment(spaceID)),
|
||||
nil,
|
||||
@@ -104,7 +104,7 @@ func (api wikiDeleteSpaceAPI) DeleteSpace(ctx context.Context, spaceID string) (
|
||||
}
|
||||
|
||||
func (api wikiDeleteSpaceAPI) GetDeleteSpaceTask(ctx context.Context, taskID string) (wikiDeleteSpaceTaskStatus, error) {
|
||||
data, err := api.runtime.CallAPI(
|
||||
data, err := api.runtime.CallAPITyped(
|
||||
"GET",
|
||||
fmt.Sprintf("/open-apis/wiki/v2/tasks/%s", validate.EncodePathSegment(taskID)),
|
||||
map[string]interface{}{"task_type": "delete_space"},
|
||||
@@ -124,7 +124,7 @@ func readWikiDeleteSpaceSpec(runtime *common.RuntimeContext) wikiDeleteSpaceSpec
|
||||
|
||||
func validateWikiDeleteSpaceSpec(spec wikiDeleteSpaceSpec) error {
|
||||
if spec.SpaceID == "" {
|
||||
return output.ErrValidation("--space-id is required")
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--space-id is required").WithParam("--space-id")
|
||||
}
|
||||
return validateOptionalResourceName(spec.SpaceID, "--space-id")
|
||||
}
|
||||
|
||||
@@ -6,7 +6,6 @@ package wiki
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"errors"
|
||||
"reflect"
|
||||
"strings"
|
||||
"sync"
|
||||
@@ -14,11 +13,12 @@ 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/credential"
|
||||
"github.com/larksuite/cli/internal/errclass"
|
||||
"github.com/larksuite/cli/internal/httpmock"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
@@ -266,19 +266,18 @@ func TestPollWikiDeleteSpaceTaskWrapsPollFailuresWithHint(t *testing.T) {
|
||||
withSingleWikiDeleteSpacePoll(t)
|
||||
|
||||
runtime, stderr := newWikiDeleteSpaceRuntimeWithScopes(t, core.AsUser, "")
|
||||
// Seed an error that carries an upstream Lark Detail.Code so the test
|
||||
// Seed a typed error that carries an upstream Lark code and hint so the test
|
||||
// pins that structured fields survive a fully failed poll (not just the
|
||||
// hint). ErrWithHint drops Detail.Code, which is exactly what we fixed.
|
||||
// hint): the poll-exhaustion path must propagate the typed error in place.
|
||||
seeded := errclass.BuildAPIError(
|
||||
map[string]any{"code": float64(131006), "msg": "poll failed"},
|
||||
errclass.ClassifyContext{},
|
||||
)
|
||||
if p, ok := errs.ProblemOf(seeded); ok {
|
||||
p.Hint = "retry original"
|
||||
}
|
||||
client := &fakeWikiDeleteSpaceClient{
|
||||
taskErrs: []error{&output.ExitError{
|
||||
Code: output.ExitAPI,
|
||||
Detail: &output.ErrDetail{
|
||||
Type: "api_error",
|
||||
Code: 131006,
|
||||
Message: "poll failed",
|
||||
Hint: "retry original",
|
||||
},
|
||||
}},
|
||||
taskErrs: []error{seeded},
|
||||
}
|
||||
|
||||
status, ready, err := pollWikiDeleteSpaceTask(context.Background(), client, runtime, "task_123")
|
||||
@@ -291,15 +290,15 @@ func TestPollWikiDeleteSpaceTaskWrapsPollFailuresWithHint(t *testing.T) {
|
||||
if status.TaskID != "task_123" {
|
||||
t.Fatalf("status.TaskID = %q, want %q", status.TaskID, "task_123")
|
||||
}
|
||||
var exitErr *output.ExitError
|
||||
if !errors.As(err, &exitErr) || exitErr.Detail == nil {
|
||||
t.Fatalf("expected structured exit error, got %T %v", err, err)
|
||||
p, ok := errs.ProblemOf(err)
|
||||
if !ok {
|
||||
t.Fatalf("expected a typed errs.* error, got %T %v", err, err)
|
||||
}
|
||||
if !strings.Contains(exitErr.Detail.Hint, "retry original") || !strings.Contains(exitErr.Detail.Hint, wikiDeleteSpaceTaskResultCommand("task_123", core.AsUser)) {
|
||||
t.Fatalf("hint = %q, want original hint and resume command", exitErr.Detail.Hint)
|
||||
if !strings.Contains(p.Hint, "retry original") || !strings.Contains(p.Hint, wikiDeleteSpaceTaskResultCommand("task_123", core.AsUser)) {
|
||||
t.Fatalf("hint = %q, want original hint and resume command", p.Hint)
|
||||
}
|
||||
if exitErr.Detail.Code != 131006 {
|
||||
t.Fatalf("Detail.Code = %d, want 131006 preserved through poll exhaustion", exitErr.Detail.Code)
|
||||
if p.Code != 131006 {
|
||||
t.Fatalf("Code = %d, want 131006 preserved through poll exhaustion", p.Code)
|
||||
}
|
||||
if !strings.Contains(stderr.String(), "Wiki delete-space status attempt 1/1 failed") {
|
||||
t.Fatalf("stderr = %q, want poll failure log", stderr.String())
|
||||
|
||||
@@ -351,6 +351,7 @@ func TestWikiNodeCopyRequiresTargetSpaceOrParent(t *testing.T) {
|
||||
if err == nil || !strings.Contains(err.Error(), "--target-space-id or --target-parent-node-token") {
|
||||
t.Fatalf("expected target validation error, got %v", err)
|
||||
}
|
||||
requireWikiValidationParams(t, err, "--target-space-id", "--target-parent-node-token")
|
||||
}
|
||||
|
||||
func TestWikiNodeCopyRejectsBothTargetFlags(t *testing.T) {
|
||||
@@ -365,6 +366,7 @@ func TestWikiNodeCopyRejectsBothTargetFlags(t *testing.T) {
|
||||
if err == nil || !strings.Contains(err.Error(), "mutually exclusive") {
|
||||
t.Fatalf("expected mutually exclusive error, got %v", err)
|
||||
}
|
||||
requireWikiValidationParams(t, err, "--target-space-id", "--target-parent-node-token")
|
||||
}
|
||||
|
||||
// TestWikiNodeCopyDeclaredHighRiskWrite pins down the high-risk-write
|
||||
|
||||
@@ -8,7 +8,7 @@ import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/validate"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
@@ -65,7 +65,7 @@ var WikiMemberAdd = common.Shortcut{
|
||||
common.MaskToken(spec.MemberID), spec.MemberType, spec.MemberRole, common.MaskToken(spaceID))
|
||||
|
||||
path := fmt.Sprintf("/open-apis/wiki/v2/spaces/%s/members", validate.EncodePathSegment(spaceID))
|
||||
data, err := runtime.CallAPI("POST", path, spec.QueryParams(), spec.RequestBody())
|
||||
data, err := runtime.CallAPITyped("POST", path, spec.QueryParams(), spec.RequestBody())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -131,16 +131,16 @@ func readWikiMemberAddSpec(runtime *common.RuntimeContext) (wikiMemberAddSpec, e
|
||||
return wikiMemberAddSpec{}, err
|
||||
}
|
||||
if spec.MemberID == "" {
|
||||
return wikiMemberAddSpec{}, output.ErrValidation("--member-id is required and cannot be blank")
|
||||
return wikiMemberAddSpec{}, errs.NewValidationError(errs.SubtypeInvalidArgument, "--member-id is required and cannot be blank").WithParam("--member-id")
|
||||
}
|
||||
// The space-member API rejects opendepartmentid grants under a
|
||||
// tenant_access_token; surface that as a CLI validation error so callers do
|
||||
// not waste a network round-trip on a server-side 403. The escape hatch is
|
||||
// --as user, which is the only identity the API accepts for departments.
|
||||
if runtime.As().IsBot() && spec.MemberType == "opendepartmentid" {
|
||||
return wikiMemberAddSpec{}, output.ErrValidation(
|
||||
return wikiMemberAddSpec{}, errs.NewValidationError(errs.SubtypeInvalidArgument,
|
||||
"--as bot does not support --member-type opendepartmentid; rerun with --as user",
|
||||
)
|
||||
).WithParam("--member-type")
|
||||
}
|
||||
// --member-type / --member-role enum membership is enforced by the
|
||||
// framework's validateEnumFlags (runner.go) before Validate runs, so no
|
||||
|
||||
@@ -6,7 +6,7 @@ package wiki
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
@@ -27,10 +27,10 @@ var wikiMemberRoles = []string{"admin", "member"}
|
||||
// tenant_access_token; same contract as +node-list / +node-create)
|
||||
func validateWikiMemberSpaceID(runtime *common.RuntimeContext, spaceID string) error {
|
||||
if spaceID == "" {
|
||||
return output.ErrValidation("--space-id is required and cannot be blank")
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--space-id is required and cannot be blank").WithParam("--space-id")
|
||||
}
|
||||
if runtime.As().IsBot() && spaceID == wikiMyLibrarySpaceID {
|
||||
return output.ErrValidation("bot identity does not support --space-id my_library; use an explicit --space-id")
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "bot identity does not support --space-id my_library; use an explicit --space-id").WithParam("--space-id")
|
||||
}
|
||||
return validateOptionalResourceName(spaceID, "--space-id")
|
||||
}
|
||||
|
||||
@@ -122,7 +122,7 @@ func fetchWikiMembers(runtime *common.RuntimeContext, spaceID string) ([]map[str
|
||||
if pageToken != "" {
|
||||
params["page_token"] = pageToken
|
||||
}
|
||||
data, err := runtime.CallAPI("GET", apiPath, params, nil)
|
||||
data, err := runtime.CallAPITyped("GET", apiPath, params, nil)
|
||||
if err != nil {
|
||||
return nil, false, "", err
|
||||
}
|
||||
|
||||
@@ -8,7 +8,7 @@ import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/validate"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
@@ -68,7 +68,7 @@ var WikiMemberRemove = common.Shortcut{
|
||||
validate.EncodePathSegment(spaceID),
|
||||
validate.EncodePathSegment(spec.MemberID),
|
||||
)
|
||||
data, err := runtime.CallAPI("DELETE", path, nil, spec.RequestBody())
|
||||
data, err := runtime.CallAPITyped("DELETE", path, nil, spec.RequestBody())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -122,7 +122,7 @@ func readWikiMemberRemoveSpec(runtime *common.RuntimeContext) (wikiMemberRemoveS
|
||||
return wikiMemberRemoveSpec{}, err
|
||||
}
|
||||
if spec.MemberID == "" {
|
||||
return wikiMemberRemoveSpec{}, output.ErrValidation("--member-id is required and cannot be blank")
|
||||
return wikiMemberRemoveSpec{}, errs.NewValidationError(errs.SubtypeInvalidArgument, "--member-id is required and cannot be blank").WithParam("--member-id")
|
||||
}
|
||||
// Enum membership for --member-type / --member-role is enforced by the
|
||||
// framework's validateEnumFlags (runner.go) before Validate runs.
|
||||
|
||||
@@ -5,13 +5,12 @@ package wiki
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"github.com/larksuite/cli/internal/validate"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
@@ -65,7 +64,7 @@ var WikiMove = common.Shortcut{
|
||||
// for a tenant_access_token (--as bot), so reject early with a clear
|
||||
// hint instead of letting the API return a confusing error.
|
||||
if runtime.As().IsBot() && spec.TargetSpaceID == wikiMyLibrarySpaceID {
|
||||
return output.ErrValidation("--target-space-id my_library is a per-user personal library alias and cannot be used with --as bot; resolve it to a real space_id first via `lark-cli wiki spaces get --params '{\"space_id\":\"my_library\"}' --as user`")
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--target-space-id my_library is a per-user personal library alias and cannot be used with --as bot; resolve it to a real space_id first via `lark-cli wiki spaces get --params '{\"space_id\":\"my_library\"}' --as user`").WithParam("--target-space-id")
|
||||
}
|
||||
return validateWikiMoveSpec(spec)
|
||||
},
|
||||
@@ -230,7 +229,7 @@ type wikiMoveAPI struct {
|
||||
}
|
||||
|
||||
func (api wikiMoveAPI) GetNode(ctx context.Context, token string) (*wikiNodeRecord, error) {
|
||||
data, err := api.runtime.CallAPI(
|
||||
data, err := api.runtime.CallAPITyped(
|
||||
"GET",
|
||||
"/open-apis/wiki/v2/spaces/get_node",
|
||||
map[string]interface{}{"token": token},
|
||||
@@ -243,7 +242,7 @@ func (api wikiMoveAPI) GetNode(ctx context.Context, token string) (*wikiNodeReco
|
||||
}
|
||||
|
||||
func (api wikiMoveAPI) MoveNode(ctx context.Context, sourceSpaceID string, spec wikiMoveSpec) (*wikiNodeRecord, error) {
|
||||
data, err := api.runtime.CallAPI(
|
||||
data, err := api.runtime.CallAPITyped(
|
||||
"POST",
|
||||
fmt.Sprintf(
|
||||
"/open-apis/wiki/v2/spaces/%s/nodes/%s/move",
|
||||
@@ -260,7 +259,7 @@ func (api wikiMoveAPI) MoveNode(ctx context.Context, sourceSpaceID string, spec
|
||||
}
|
||||
|
||||
func (api wikiMoveAPI) MoveDocsToWiki(ctx context.Context, targetSpaceID string, spec wikiMoveSpec) (*wikiMoveDocsResponse, error) {
|
||||
data, err := api.runtime.CallAPI(
|
||||
data, err := api.runtime.CallAPITyped(
|
||||
"POST",
|
||||
fmt.Sprintf(
|
||||
"/open-apis/wiki/v2/spaces/%s/nodes/move_docs_to_wiki",
|
||||
@@ -281,7 +280,7 @@ func (api wikiMoveAPI) MoveDocsToWiki(ctx context.Context, targetSpaceID string,
|
||||
}
|
||||
|
||||
func (api wikiMoveAPI) GetMoveTask(ctx context.Context, taskID string) (wikiMoveTaskStatus, error) {
|
||||
data, err := api.runtime.CallAPI(
|
||||
data, err := api.runtime.CallAPITyped(
|
||||
"GET",
|
||||
fmt.Sprintf("/open-apis/wiki/v2/tasks/%s", validate.EncodePathSegment(taskID)),
|
||||
map[string]interface{}{"task_type": "move"},
|
||||
@@ -324,28 +323,42 @@ func validateWikiMoveSpec(spec wikiMoveSpec) error {
|
||||
|
||||
if spec.NodeToken != "" {
|
||||
if spec.ObjType != "" || spec.ObjToken != "" || spec.Apply {
|
||||
return output.ErrValidation("--node-token cannot be combined with --obj-type, --obj-token, or --apply")
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--node-token cannot be combined with --obj-type, --obj-token, or --apply").
|
||||
WithParams(
|
||||
errs.InvalidParam{Name: "--obj-type", Reason: "cannot be combined with --node-token"},
|
||||
errs.InvalidParam{Name: "--obj-token", Reason: "cannot be combined with --node-token"},
|
||||
errs.InvalidParam{Name: "--apply", Reason: "cannot be combined with --node-token"},
|
||||
)
|
||||
}
|
||||
if spec.TargetParentToken == "" && spec.TargetSpaceID == "" {
|
||||
return output.ErrValidation("--target-parent-token and --target-space-id cannot both be empty for wiki node move")
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--target-parent-token and --target-space-id cannot both be empty for wiki node move").
|
||||
WithParams(
|
||||
errs.InvalidParam{Name: "--target-parent-token", Reason: "provide --target-parent-token or --target-space-id"},
|
||||
errs.InvalidParam{Name: "--target-space-id", Reason: "provide --target-parent-token or --target-space-id"},
|
||||
)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
if spec.SourceSpaceID != "" {
|
||||
return output.ErrValidation("--source-space-id can only be used with --node-token")
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--source-space-id can only be used with --node-token").WithParam("--source-space-id")
|
||||
}
|
||||
if spec.ObjType == "" && spec.ObjToken == "" && !spec.Apply {
|
||||
return output.ErrValidation("provide --node-token for wiki node move, or provide --obj-type and --obj-token for docs-to-wiki move")
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "provide --node-token for wiki node move, or provide --obj-type and --obj-token for docs-to-wiki move").
|
||||
WithParams(
|
||||
errs.InvalidParam{Name: "--node-token", Reason: "provide --node-token, or --obj-type and --obj-token"},
|
||||
errs.InvalidParam{Name: "--obj-type", Reason: "provide --node-token, or --obj-type and --obj-token"},
|
||||
errs.InvalidParam{Name: "--obj-token", Reason: "provide --node-token, or --obj-type and --obj-token"},
|
||||
)
|
||||
}
|
||||
if spec.ObjType == "" {
|
||||
return output.ErrValidation("--obj-type is required for docs-to-wiki move")
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--obj-type is required for docs-to-wiki move").WithParam("--obj-type")
|
||||
}
|
||||
if spec.ObjToken == "" {
|
||||
return output.ErrValidation("--obj-token is required for docs-to-wiki move")
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--obj-token is required for docs-to-wiki move").WithParam("--obj-token")
|
||||
}
|
||||
if spec.TargetSpaceID == "" {
|
||||
return output.ErrValidation("--target-space-id is required for docs-to-wiki move")
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--target-space-id is required for docs-to-wiki move").WithParam("--target-space-id")
|
||||
}
|
||||
|
||||
return nil
|
||||
@@ -426,7 +439,7 @@ func runWikiMove(ctx context.Context, client wikiMoveClient, runtime *common.Run
|
||||
case wikiMoveModeDocsToWiki:
|
||||
return runWikiDocsToWikiMove(ctx, client, runtime, spec)
|
||||
default:
|
||||
return nil, output.ErrValidation("unknown wiki move mode")
|
||||
return nil, errs.NewInternalError(errs.SubtypeUnknown, "unknown wiki move mode")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -479,11 +492,11 @@ func resolveWikiNodeMoveSpaces(ctx context.Context, client wikiMoveClient, spec
|
||||
if targetSpaceID == "" {
|
||||
targetSpaceID = parentSpaceID
|
||||
} else if targetSpaceID != parentSpaceID {
|
||||
return "", "", output.ErrValidation(
|
||||
return "", "", errs.NewValidationError(errs.SubtypeInvalidArgument,
|
||||
"--target-space-id %q does not match target parent node space %q",
|
||||
spec.TargetSpaceID,
|
||||
parentSpaceID,
|
||||
)
|
||||
).WithParam("--target-space-id")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -549,7 +562,7 @@ func runWikiDocsToWikiMove(ctx context.Context, client wikiMoveClient, runtime *
|
||||
}
|
||||
return out, nil
|
||||
default:
|
||||
return nil, output.Errorf(output.ExitAPI, "api_error", "move_docs_to_wiki returned neither wiki_token, task_id, nor applied result")
|
||||
return nil, errs.NewInternalError(errs.SubtypeInvalidResponse, "move_docs_to_wiki returned neither wiki_token, task_id, nor applied result")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -592,7 +605,7 @@ func pollWikiMoveTask(ctx context.Context, client wikiMoveClient, runtime *commo
|
||||
return status, true, nil
|
||||
}
|
||||
if status.Failed() {
|
||||
return status, false, output.Errorf(output.ExitAPI, "api_error", "wiki move task failed: %s", status.PrimaryStatusLabel())
|
||||
return status, false, errs.NewAPIError(errs.SubtypeServerError, "wiki move task failed: %s", status.PrimaryStatusLabel())
|
||||
}
|
||||
|
||||
fmt.Fprintf(runtime.IO().ErrOut, "Wiki move status %d/%d: %s\n", attempt, wikiMovePollAttempts, status.PrimaryStatusLabel())
|
||||
@@ -605,14 +618,18 @@ func pollWikiMoveTask(ctx context.Context, client wikiMoveClient, runtime *commo
|
||||
taskID,
|
||||
nextCommand,
|
||||
)
|
||||
var exitErr *output.ExitError
|
||||
if errors.As(lastErr, &exitErr) && exitErr.Detail != nil {
|
||||
if strings.TrimSpace(exitErr.Detail.Hint) != "" {
|
||||
hint = exitErr.Detail.Hint + "\n" + hint
|
||||
// The poll error comes from a typed CallAPITyped path; append the resume
|
||||
// hint in place so the original category / subtype / code / log_id
|
||||
// survives a fully failed poll (per ERROR_CONTRACT.md "propagate typed
|
||||
// errors unchanged").
|
||||
if p, ok := errs.ProblemOf(lastErr); ok {
|
||||
if strings.TrimSpace(p.Hint) != "" {
|
||||
hint = p.Hint + "\n" + hint
|
||||
}
|
||||
return lastStatus, false, output.ErrWithHint(exitErr.Code, exitErr.Detail.Type, exitErr.Detail.Message, hint)
|
||||
p.Hint = hint
|
||||
return lastStatus, false, lastErr
|
||||
}
|
||||
return lastStatus, false, output.ErrWithHint(output.ExitAPI, "api_error", lastErr.Error(), hint)
|
||||
return lastStatus, false, errs.NewInternalError(errs.SubtypeSDKError, "%s", lastErr.Error()).WithHint("%s", hint).WithCause(lastErr)
|
||||
}
|
||||
|
||||
return lastStatus, false, nil
|
||||
@@ -620,7 +637,7 @@ func pollWikiMoveTask(ctx context.Context, client wikiMoveClient, runtime *commo
|
||||
|
||||
func parseWikiMoveTaskStatus(taskID string, task map[string]interface{}) (wikiMoveTaskStatus, error) {
|
||||
if task == nil {
|
||||
return wikiMoveTaskStatus{}, output.Errorf(output.ExitAPI, "api_error", "wiki task response missing task")
|
||||
return wikiMoveTaskStatus{}, errs.NewInternalError(errs.SubtypeInvalidResponse, "wiki task response missing task")
|
||||
}
|
||||
|
||||
status := wikiMoveTaskStatus{
|
||||
|
||||
@@ -7,7 +7,6 @@ import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"reflect"
|
||||
"strings"
|
||||
"sync"
|
||||
@@ -15,11 +14,11 @@ 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/credential"
|
||||
"github.com/larksuite/cli/internal/httpmock"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
@@ -181,39 +180,52 @@ func TestValidateWikiMoveSpecRejectsInvalidCombinations(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
spec wikiMoveSpec
|
||||
wantErr string
|
||||
name string
|
||||
spec wikiMoveSpec
|
||||
wantErr string
|
||||
wantParams []string
|
||||
}{
|
||||
{
|
||||
name: "node move rejects docs flags",
|
||||
spec: wikiMoveSpec{NodeToken: "wik_node", ObjType: "sheet", TargetSpaceID: "space_dst"},
|
||||
wantErr: "cannot be combined",
|
||||
name: "node move rejects docs flags",
|
||||
spec: wikiMoveSpec{NodeToken: "wik_node", ObjType: "sheet", TargetSpaceID: "space_dst"},
|
||||
wantErr: "cannot be combined",
|
||||
wantParams: []string{"--obj-type", "--obj-token", "--apply"},
|
||||
},
|
||||
{
|
||||
name: "node move requires target",
|
||||
spec: wikiMoveSpec{NodeToken: "wik_node"},
|
||||
wantErr: "cannot both be empty",
|
||||
name: "node move requires target",
|
||||
spec: wikiMoveSpec{NodeToken: "wik_node"},
|
||||
wantErr: "cannot both be empty",
|
||||
wantParams: []string{"--target-parent-token", "--target-space-id"},
|
||||
},
|
||||
{
|
||||
name: "source space requires node token",
|
||||
spec: wikiMoveSpec{SourceSpaceID: "space_src", ObjType: "sheet", ObjToken: "sheet_token", TargetSpaceID: "space_dst"},
|
||||
wantErr: "can only be used with --node-token",
|
||||
name: "requires a move mode",
|
||||
spec: wikiMoveSpec{TargetSpaceID: "space_dst"},
|
||||
wantErr: "provide --node-token for wiki node move",
|
||||
wantParams: []string{"--node-token", "--obj-type", "--obj-token"},
|
||||
},
|
||||
{
|
||||
name: "docs to wiki requires obj type",
|
||||
spec: wikiMoveSpec{ObjToken: "sheet_token", TargetSpaceID: "space_dst"},
|
||||
wantErr: "--obj-type is required",
|
||||
name: "source space requires node token",
|
||||
spec: wikiMoveSpec{SourceSpaceID: "space_src", ObjType: "sheet", ObjToken: "sheet_token", TargetSpaceID: "space_dst"},
|
||||
wantErr: "can only be used with --node-token",
|
||||
wantParams: []string{"--source-space-id"},
|
||||
},
|
||||
{
|
||||
name: "docs to wiki requires obj token",
|
||||
spec: wikiMoveSpec{ObjType: "sheet", TargetSpaceID: "space_dst"},
|
||||
wantErr: "--obj-token is required",
|
||||
name: "docs to wiki requires obj type",
|
||||
spec: wikiMoveSpec{ObjToken: "sheet_token", TargetSpaceID: "space_dst"},
|
||||
wantErr: "--obj-type is required",
|
||||
wantParams: []string{"--obj-type"},
|
||||
},
|
||||
{
|
||||
name: "docs to wiki requires target space",
|
||||
spec: wikiMoveSpec{ObjType: "sheet", ObjToken: "sheet_token"},
|
||||
wantErr: "--target-space-id is required",
|
||||
name: "docs to wiki requires obj token",
|
||||
spec: wikiMoveSpec{ObjType: "sheet", TargetSpaceID: "space_dst"},
|
||||
wantErr: "--obj-token is required",
|
||||
wantParams: []string{"--obj-token"},
|
||||
},
|
||||
{
|
||||
name: "docs to wiki requires target space",
|
||||
spec: wikiMoveSpec{ObjType: "sheet", ObjToken: "sheet_token"},
|
||||
wantErr: "--target-space-id is required",
|
||||
wantParams: []string{"--target-space-id"},
|
||||
},
|
||||
}
|
||||
|
||||
@@ -225,6 +237,9 @@ func TestValidateWikiMoveSpecRejectsInvalidCombinations(t *testing.T) {
|
||||
if err == nil || !strings.Contains(err.Error(), tt.wantErr) {
|
||||
t.Fatalf("expected error containing %q, got %v", tt.wantErr, err)
|
||||
}
|
||||
if len(tt.wantParams) > 0 {
|
||||
requireWikiValidationParams(t, err, tt.wantParams...)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -837,7 +852,7 @@ func TestPollWikiMoveTaskWrapsRepeatedPollFailuresWithHint(t *testing.T) {
|
||||
|
||||
runtime, stderr := newWikiMoveRuntimeWithScopes(t, core.AsUser, "")
|
||||
client := &fakeWikiMoveClient{
|
||||
taskErrs: []error{output.ErrWithHint(output.ExitAPI, "api_error", "poll failed", "retry original")},
|
||||
taskErrs: []error{errs.NewAPIError(errs.SubtypeServerError, "poll failed").WithHint("retry original")},
|
||||
}
|
||||
|
||||
status, ready, err := pollWikiMoveTask(context.Background(), client, runtime, "task_123")
|
||||
@@ -850,12 +865,12 @@ func TestPollWikiMoveTaskWrapsRepeatedPollFailuresWithHint(t *testing.T) {
|
||||
if status.TaskID != "task_123" {
|
||||
t.Fatalf("status.TaskID = %q, want %q", status.TaskID, "task_123")
|
||||
}
|
||||
var exitErr *output.ExitError
|
||||
if !errors.As(err, &exitErr) || exitErr.Detail == nil {
|
||||
t.Fatalf("expected structured exit error, got %T %v", err, err)
|
||||
p, ok := errs.ProblemOf(err)
|
||||
if !ok {
|
||||
t.Fatalf("expected a typed errs.* error, got %T %v", err, err)
|
||||
}
|
||||
if !strings.Contains(exitErr.Detail.Hint, "retry original") || !strings.Contains(exitErr.Detail.Hint, wikiMoveTaskResultCommand("task_123", core.AsUser)) {
|
||||
t.Fatalf("hint = %q, want original hint and resume command", exitErr.Detail.Hint)
|
||||
if !strings.Contains(p.Hint, "retry original") || !strings.Contains(p.Hint, wikiMoveTaskResultCommand("task_123", core.AsUser)) {
|
||||
t.Fatalf("hint = %q, want original hint and resume command", p.Hint)
|
||||
}
|
||||
if !strings.Contains(stderr.String(), "Wiki move status attempt 1/1 failed") {
|
||||
t.Fatalf("stderr = %q, want poll failure log", stderr.String())
|
||||
|
||||
@@ -9,7 +9,7 @@ import (
|
||||
"io"
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/validate"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
@@ -44,10 +44,18 @@ var WikiNodeCopy = common.Shortcut{
|
||||
targetSpaceID := strings.TrimSpace(runtime.Str("target-space-id"))
|
||||
targetParent := strings.TrimSpace(runtime.Str("target-parent-node-token"))
|
||||
if targetSpaceID == "" && targetParent == "" {
|
||||
return output.ErrValidation("at least one of --target-space-id or --target-parent-node-token is required")
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "at least one of --target-space-id or --target-parent-node-token is required").
|
||||
WithParams(
|
||||
errs.InvalidParam{Name: "--target-space-id", Reason: "provide --target-space-id or --target-parent-node-token"},
|
||||
errs.InvalidParam{Name: "--target-parent-node-token", Reason: "provide --target-space-id or --target-parent-node-token"},
|
||||
)
|
||||
}
|
||||
if targetSpaceID != "" && targetParent != "" {
|
||||
return output.ErrValidation("--target-space-id and --target-parent-node-token are mutually exclusive; provide only one")
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--target-space-id and --target-parent-node-token are mutually exclusive; provide only one").
|
||||
WithParams(
|
||||
errs.InvalidParam{Name: "--target-space-id", Reason: "mutually exclusive with --target-parent-node-token"},
|
||||
errs.InvalidParam{Name: "--target-parent-node-token", Reason: "mutually exclusive with --target-space-id"},
|
||||
)
|
||||
}
|
||||
if err := validateOptionalResourceName(targetSpaceID, "--target-space-id"); err != nil {
|
||||
return err
|
||||
@@ -72,7 +80,7 @@ var WikiNodeCopy = common.Shortcut{
|
||||
fmt.Fprintf(runtime.IO().ErrOut, "Copying wiki node %s from space %s\n",
|
||||
common.MaskToken(nodeToken), common.MaskToken(spaceID))
|
||||
|
||||
data, err := runtime.CallAPI("POST",
|
||||
data, err := runtime.CallAPITyped("POST",
|
||||
fmt.Sprintf("/open-apis/wiki/v2/spaces/%s/nodes/%s/copy",
|
||||
validate.EncodePathSegment(spaceID),
|
||||
validate.EncodePathSegment(nodeToken)),
|
||||
|
||||
@@ -5,12 +5,12 @@ package wiki
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"github.com/larksuite/cli/internal/validate"
|
||||
@@ -170,7 +170,7 @@ type wikiNodeCreateAPI struct {
|
||||
}
|
||||
|
||||
func (api wikiNodeCreateAPI) GetNode(ctx context.Context, token string) (*wikiNodeRecord, error) {
|
||||
data, err := api.runtime.CallAPI(
|
||||
data, err := api.runtime.CallAPITyped(
|
||||
"GET",
|
||||
"/open-apis/wiki/v2/spaces/get_node",
|
||||
map[string]interface{}{"token": token},
|
||||
@@ -183,7 +183,7 @@ func (api wikiNodeCreateAPI) GetNode(ctx context.Context, token string) (*wikiNo
|
||||
}
|
||||
|
||||
func (api wikiNodeCreateAPI) GetSpace(ctx context.Context, spaceID string) (*wikiSpaceRecord, error) {
|
||||
data, err := api.runtime.CallAPI(
|
||||
data, err := api.runtime.CallAPITyped(
|
||||
"GET",
|
||||
fmt.Sprintf("/open-apis/wiki/v2/spaces/%s", validate.EncodePathSegment(spaceID)),
|
||||
nil,
|
||||
@@ -196,7 +196,7 @@ func (api wikiNodeCreateAPI) GetSpace(ctx context.Context, spaceID string) (*wik
|
||||
}
|
||||
|
||||
func (api wikiNodeCreateAPI) CreateNode(ctx context.Context, spaceID string, spec wikiNodeCreateSpec) (*wikiNodeRecord, error) {
|
||||
data, err := api.runtime.CallAPI(
|
||||
data, err := api.runtime.CallAPITyped(
|
||||
"POST",
|
||||
fmt.Sprintf("/open-apis/wiki/v2/spaces/%s/nodes", validate.EncodePathSegment(spaceID)),
|
||||
nil,
|
||||
@@ -231,22 +231,26 @@ func validateWikiNodeCreateSpec(spec wikiNodeCreateSpec, identity core.Identity)
|
||||
}
|
||||
|
||||
if spec.NodeType == wikiNodeTypeShortcut && spec.OriginNodeToken == "" {
|
||||
return output.ErrValidation("--origin-node-token is required when --node-type=shortcut")
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--origin-node-token is required when --node-type=shortcut").WithParam("--origin-node-token")
|
||||
}
|
||||
if spec.NodeType != wikiNodeTypeShortcut && spec.OriginNodeToken != "" {
|
||||
return output.ErrValidation("--origin-node-token can only be used when --node-type=shortcut")
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--origin-node-token can only be used when --node-type=shortcut").WithParam("--origin-node-token")
|
||||
}
|
||||
|
||||
// Bot identity has no meaningful "personal document library" target, so
|
||||
// my_library must be rejected explicitly instead of deferring to API-time
|
||||
// resolution errors.
|
||||
if identity.IsBot() && spec.SpaceID == wikiMyLibrarySpaceID {
|
||||
return output.ErrValidation("bot identity does not support --space-id my_library; use an explicit --space-id or --parent-node-token")
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "bot identity does not support --space-id my_library; use an explicit --space-id or --parent-node-token").WithParam("--space-id")
|
||||
}
|
||||
// Bot identity also cannot fall back implicitly, so it requires an explicit
|
||||
// target or a parent it can resolve from.
|
||||
if identity.IsBot() && spec.SpaceID == "" && spec.ParentNodeToken == "" {
|
||||
return output.ErrValidation("bot identity requires --space-id or --parent-node-token")
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "bot identity requires --space-id or --parent-node-token").
|
||||
WithParams(
|
||||
errs.InvalidParam{Name: "--space-id", Reason: "provide --space-id or --parent-node-token for bot identity"},
|
||||
errs.InvalidParam{Name: "--parent-node-token", Reason: "provide --space-id or --parent-node-token for bot identity"},
|
||||
)
|
||||
}
|
||||
|
||||
return nil
|
||||
@@ -334,7 +338,7 @@ func runWikiNodeCreate(ctx context.Context, client wikiNodeCreateClient, identit
|
||||
return nil, wrapWikiNodeCreateRetryError(lastErr)
|
||||
}
|
||||
if node == nil {
|
||||
return nil, output.Errorf(output.ExitAPI, "api_error", "wiki node create returned no node")
|
||||
return nil, errs.NewInternalError(errs.SubtypeInvalidResponse, "wiki node create returned no node")
|
||||
}
|
||||
|
||||
return &wikiNodeCreateExecution{
|
||||
@@ -346,45 +350,32 @@ func runWikiNodeCreate(ctx context.Context, client wikiNodeCreateClient, identit
|
||||
// isWikiNodeLockContention returns true if the error is a Lark API error with
|
||||
// code 131009 (wiki node lock contention), which is retryable with backoff.
|
||||
func isWikiNodeLockContention(err error) bool {
|
||||
var exitErr *output.ExitError
|
||||
if !errors.As(err, &exitErr) || exitErr.Detail == nil {
|
||||
p, ok := errs.ProblemOf(err)
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
return exitErr.Detail.Code == output.LarkErrWikiLockContention
|
||||
return p.Code == output.LarkErrWikiLockContention
|
||||
}
|
||||
|
||||
// wrapWikiNodeCreateRetryError appends a retry-exhaustion hint to the original
|
||||
// API error. It builds the ExitError by hand (instead of using ErrWithHint) so
|
||||
// the original Lark error code survives in the envelope.
|
||||
// API error in place, preserving its typed category / subtype / code / log_id.
|
||||
func wrapWikiNodeCreateRetryError(err error) error {
|
||||
if err == nil {
|
||||
return nil
|
||||
}
|
||||
var exitErr *output.ExitError
|
||||
if !errors.As(err, &exitErr) || exitErr.Detail == nil {
|
||||
p, ok := errs.ProblemOf(err)
|
||||
if !ok {
|
||||
return err
|
||||
}
|
||||
hint := fmt.Sprintf(
|
||||
"wiki node create failed after %d retries due to lock contention; try again later or reduce concurrent node creations under the same parent",
|
||||
wikiNodeCreateMaxRetries,
|
||||
)
|
||||
if existing := strings.TrimSpace(exitErr.Detail.Hint); existing != "" {
|
||||
if existing := strings.TrimSpace(p.Hint); existing != "" {
|
||||
hint = existing + "\n" + hint
|
||||
}
|
||||
return &output.ExitError{
|
||||
Code: exitErr.Code,
|
||||
Detail: &output.ErrDetail{
|
||||
Type: exitErr.Detail.Type,
|
||||
Code: exitErr.Detail.Code,
|
||||
Message: exitErr.Detail.Message,
|
||||
Hint: hint,
|
||||
ConsoleURL: exitErr.Detail.ConsoleURL,
|
||||
Risk: exitErr.Detail.Risk,
|
||||
Detail: exitErr.Detail.Detail,
|
||||
},
|
||||
Err: exitErr.Err,
|
||||
Raw: exitErr.Raw,
|
||||
}
|
||||
p.Hint = hint
|
||||
return err
|
||||
}
|
||||
|
||||
// resolveWikiNodeCreateSpace applies the shortcut's precedence rules:
|
||||
@@ -397,7 +388,11 @@ func resolveWikiNodeCreateSpace(ctx context.Context, client wikiNodeCreateClient
|
||||
return resolveWikiNodeCreateSpaceFromParentNode(ctx, client, spec.ParentNodeToken)
|
||||
}
|
||||
if identity.IsBot() {
|
||||
return wikiResolvedSpace{}, output.ErrValidation("bot identity requires --space-id or --parent-node-token")
|
||||
return wikiResolvedSpace{}, errs.NewValidationError(errs.SubtypeInvalidArgument, "bot identity requires --space-id or --parent-node-token").
|
||||
WithParams(
|
||||
errs.InvalidParam{Name: "--space-id", Reason: "provide --space-id or --parent-node-token for bot identity"},
|
||||
errs.InvalidParam{Name: "--parent-node-token", Reason: "provide --space-id or --parent-node-token for bot identity"},
|
||||
)
|
||||
}
|
||||
return resolveWikiNodeCreateSpaceFromMyLibrary(ctx, client)
|
||||
}
|
||||
@@ -434,12 +429,12 @@ func resolveWikiNodeCreateSpaceFromExplicitSpace(ctx context.Context, client wik
|
||||
return wikiResolvedSpace{}, err
|
||||
}
|
||||
if parentSpaceID != resolved.SpaceID {
|
||||
return wikiResolvedSpace{}, output.ErrValidation(
|
||||
return wikiResolvedSpace{}, errs.NewValidationError(errs.SubtypeInvalidArgument,
|
||||
"--space-id %q does not match parent node space %q (resolved space: %q)",
|
||||
spec.SpaceID,
|
||||
parentSpaceID,
|
||||
resolved.SpaceID,
|
||||
)
|
||||
).WithParam("--space-id")
|
||||
}
|
||||
|
||||
resolved.ParentNode = parent
|
||||
@@ -483,21 +478,22 @@ func requireWikiNodeSpaceID(node *wikiNodeRecord) (string, error) {
|
||||
if node != nil && node.SpaceID != "" {
|
||||
return node.SpaceID, nil
|
||||
}
|
||||
return "", output.Errorf(output.ExitAPI, "api_error", "wiki node lookup returned no space_id")
|
||||
return "", errs.NewInternalError(errs.SubtypeInvalidResponse, "wiki node lookup returned no space_id")
|
||||
}
|
||||
|
||||
func requireWikiSpaceID(space *wikiSpaceRecord) (string, error) {
|
||||
if space != nil && space.SpaceID != "" {
|
||||
return space.SpaceID, nil
|
||||
}
|
||||
return "", output.ErrValidation("personal document library was not found, please specify --space-id")
|
||||
return "", errs.NewInternalError(errs.SubtypeInvalidResponse, "personal document library lookup returned no space_id").
|
||||
WithHint("specify --space-id explicitly to target a space directly")
|
||||
}
|
||||
|
||||
// resolveMyLibrarySpaceID calls GET /wiki/v2/spaces/my_library and returns
|
||||
// the per-user real space_id. Shared by shortcuts that accept the my_library
|
||||
// alias (e.g. +node-create, +node-list) so the behavior stays consistent.
|
||||
func resolveMyLibrarySpaceID(runtime *common.RuntimeContext) (string, error) {
|
||||
data, err := runtime.CallAPI(
|
||||
data, err := runtime.CallAPITyped(
|
||||
"GET",
|
||||
fmt.Sprintf("/open-apis/wiki/v2/spaces/%s", validate.EncodePathSegment(wikiMyLibrarySpaceID)),
|
||||
nil, nil,
|
||||
@@ -517,14 +513,14 @@ func validateOptionalResourceName(value, flagName string) error {
|
||||
return nil
|
||||
}
|
||||
if err := validate.ResourceName(value, flagName); err != nil {
|
||||
return output.ErrValidation("%s", err)
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "%s", err).WithParam(flagName).WithCause(err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func parseWikiNodeRecord(node map[string]interface{}) (*wikiNodeRecord, error) {
|
||||
if node == nil {
|
||||
return nil, output.Errorf(output.ExitAPI, "api_error", "wiki node response missing node")
|
||||
return nil, errs.NewInternalError(errs.SubtypeInvalidResponse, "wiki node response missing node")
|
||||
}
|
||||
return &wikiNodeRecord{
|
||||
SpaceID: common.GetString(node, "space_id"),
|
||||
@@ -542,7 +538,7 @@ func parseWikiNodeRecord(node map[string]interface{}) (*wikiNodeRecord, error) {
|
||||
|
||||
func parseWikiSpaceRecord(space map[string]interface{}) (*wikiSpaceRecord, error) {
|
||||
if space == nil {
|
||||
return nil, output.Errorf(output.ExitAPI, "api_error", "wiki space response missing space")
|
||||
return nil, errs.NewInternalError(errs.SubtypeInvalidResponse, "wiki space response missing space")
|
||||
}
|
||||
return &wikiSpaceRecord{
|
||||
SpaceID: common.GetString(space, "space_id"),
|
||||
|
||||
@@ -16,13 +16,26 @@ 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/errclass"
|
||||
"github.com/larksuite/cli/internal/httpmock"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
// wikiTestLockContentionErr builds the typed API error that runtime.CallAPITyped
|
||||
// produces for a wiki write-lock-contention response (code 131009), so the
|
||||
// retry path's errs.ProblemOf(...).Code check sees the same shape in tests as
|
||||
// in production.
|
||||
func wikiTestLockContentionErr() error {
|
||||
return errclass.BuildAPIError(
|
||||
map[string]any{"code": float64(output.LarkErrWikiLockContention), "msg": "lock contention"},
|
||||
errclass.ClassifyContext{},
|
||||
)
|
||||
}
|
||||
|
||||
type fakeWikiNodeCreateCall struct {
|
||||
SpaceID string
|
||||
Spec wikiNodeCreateSpec
|
||||
@@ -116,6 +129,44 @@ func mountAndRunWiki(t *testing.T, shortcut common.Shortcut, args []string, fact
|
||||
return parent.Execute()
|
||||
}
|
||||
|
||||
// requireWikiValidationParams asserts err carries a *errs.ValidationError of
|
||||
// category validation / subtype invalid_argument and that its named flags
|
||||
// (single Param or structured Params) cover every wanted flag, locking the
|
||||
// typed contract and input-recovery fields so callers and agents can re-fill
|
||||
// the right flags without parsing the prose message.
|
||||
func requireWikiValidationParams(t *testing.T, err error, wantNames ...string) {
|
||||
t.Helper()
|
||||
var ve *errs.ValidationError
|
||||
if !errors.As(err, &ve) {
|
||||
t.Fatalf("expected *errs.ValidationError, got %T (%v)", err, err)
|
||||
}
|
||||
if ve.Category != errs.CategoryValidation || ve.Subtype != errs.SubtypeInvalidArgument {
|
||||
t.Fatalf("expected validation/invalid_argument, got %s/%s", ve.Category, ve.Subtype)
|
||||
}
|
||||
have := make(map[string]bool, len(ve.Params)+1)
|
||||
if ve.Param != "" {
|
||||
have[ve.Param] = true
|
||||
}
|
||||
for _, p := range ve.Params {
|
||||
have[p.Name] = true
|
||||
}
|
||||
for _, name := range wantNames {
|
||||
if !have[name] {
|
||||
t.Fatalf("flag %q not found; Param=%q Params=%+v", name, ve.Param, ve.Params)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestRequireWikiSpaceIDTreatsEmptyAsInvalidResponse(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
_, err := requireWikiSpaceID(&wikiSpaceRecord{})
|
||||
p, ok := errs.ProblemOf(err)
|
||||
if !ok || p.Category != errs.CategoryInternal || p.Subtype != errs.SubtypeInvalidResponse {
|
||||
t.Fatalf("expected internal/invalid_response, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateWikiNodeCreateSpecRejectsShortcutWithoutOriginNodeToken(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
@@ -151,6 +202,7 @@ func TestValidateWikiNodeCreateSpecRejectsBotWithoutLocation(t *testing.T) {
|
||||
if err == nil || !strings.Contains(err.Error(), "bot identity requires --space-id or --parent-node-token") {
|
||||
t.Fatalf("expected bot location validation error, got %v", err)
|
||||
}
|
||||
requireWikiValidationParams(t, err, "--space-id", "--parent-node-token")
|
||||
}
|
||||
|
||||
func TestValidateWikiNodeCreateSpecRejectsBotMyLibrarySpaceID(t *testing.T) {
|
||||
@@ -236,6 +288,19 @@ func TestResolveWikiNodeCreateSpaceUsesMyLibraryFallback(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveWikiNodeCreateSpaceRejectsBotWithoutLocation(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
_, err := resolveWikiNodeCreateSpace(context.Background(), &fakeWikiNodeCreateClient{}, core.AsBot, wikiNodeCreateSpec{
|
||||
NodeType: wikiNodeTypeOrigin,
|
||||
ObjType: "docx",
|
||||
})
|
||||
if err == nil || !strings.Contains(err.Error(), "bot identity requires --space-id or --parent-node-token") {
|
||||
t.Fatalf("expected bot location validation error, got %v", err)
|
||||
}
|
||||
requireWikiValidationParams(t, err, "--space-id", "--parent-node-token")
|
||||
}
|
||||
|
||||
func TestRunWikiNodeCreateCreatesNodeInResolvedSpace(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
@@ -785,7 +850,7 @@ func TestWikiNodeURL(t *testing.T) {
|
||||
func TestRunWikiNodeCreateRetriesOnLockContention(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
lockErr := output.ErrAPI(output.LarkErrWikiLockContention, "lock contention", nil)
|
||||
lockErr := wikiTestLockContentionErr()
|
||||
|
||||
client := &fakeWikiNodeCreateClient{
|
||||
spaces: map[string]*wikiSpaceRecord{
|
||||
@@ -831,7 +896,7 @@ func TestRunWikiNodeCreateRetriesOnLockContention(t *testing.T) {
|
||||
func TestRunWikiNodeCreateRetriesExhausted(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
lockErr := output.ErrAPI(output.LarkErrWikiLockContention, "lock contention", nil)
|
||||
lockErr := wikiTestLockContentionErr()
|
||||
|
||||
client := &fakeWikiNodeCreateClient{
|
||||
spaces: map[string]*wikiSpaceRecord{
|
||||
@@ -853,25 +918,30 @@ func TestRunWikiNodeCreateRetriesExhausted(t *testing.T) {
|
||||
if len(client.createInvoked) != 3 {
|
||||
t.Fatalf("create invoked %d times, want 3", len(client.createInvoked))
|
||||
}
|
||||
var exitErr *output.ExitError
|
||||
if !errors.As(err, &exitErr) || exitErr.Detail == nil {
|
||||
t.Fatalf("expected ExitError, got %T: %v", err, err)
|
||||
p, ok := errs.ProblemOf(err)
|
||||
if !ok {
|
||||
t.Fatalf("expected a typed errs.* error, got %T: %v", err, err)
|
||||
}
|
||||
if exitErr.Detail.Code != output.LarkErrWikiLockContention {
|
||||
t.Fatalf("error code = %d, want %d", exitErr.Detail.Code, output.LarkErrWikiLockContention)
|
||||
if p.Code != output.LarkErrWikiLockContention {
|
||||
t.Fatalf("error code = %d, want %d", p.Code, output.LarkErrWikiLockContention)
|
||||
}
|
||||
if !strings.Contains(exitErr.Detail.Hint, "failed after 2 retries") {
|
||||
t.Fatalf("hint = %q, want retry exhaustion message", exitErr.Detail.Hint)
|
||||
if !strings.Contains(p.Hint, "failed after 2 retries") {
|
||||
t.Fatalf("hint = %q, want retry exhaustion message", p.Hint)
|
||||
}
|
||||
if !strings.Contains(exitErr.Detail.Hint, "lock contention") {
|
||||
t.Fatalf("hint = %q, want original classification hint preserved", exitErr.Detail.Hint)
|
||||
if !strings.Contains(p.Hint, "lock contention") {
|
||||
t.Fatalf("hint = %q, want original classification hint preserved", p.Hint)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunWikiNodeCreateNoRetryOnNonContentionError(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
otherErr := output.ErrAPI(output.LarkErrRateLimit, "rate limit", nil) // rate limit, not lock contention
|
||||
// A typed API error for a different code (rate limit, not lock contention),
|
||||
// mirroring what runtime.CallAPITyped produces.
|
||||
otherErr := errclass.BuildAPIError(
|
||||
map[string]any{"code": float64(output.LarkErrRateLimit), "msg": "rate limit"},
|
||||
errclass.ClassifyContext{},
|
||||
)
|
||||
|
||||
client := &fakeWikiNodeCreateClient{
|
||||
spaces: map[string]*wikiSpaceRecord{
|
||||
@@ -901,7 +971,7 @@ func TestRunWikiNodeCreateNoRetryOnNonContentionError(t *testing.T) {
|
||||
func TestRunWikiNodeCreateRetriesOnFirstLockThenSucceeds(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
lockErr := output.ErrAPI(output.LarkErrWikiLockContention, "lock contention", nil)
|
||||
lockErr := wikiTestLockContentionErr()
|
||||
|
||||
client := &fakeWikiNodeCreateClient{
|
||||
spaces: map[string]*wikiSpaceRecord{
|
||||
@@ -944,7 +1014,7 @@ func TestRunWikiNodeCreateRetriesOnFirstLockThenSucceeds(t *testing.T) {
|
||||
func TestRunWikiNodeCreateRetryContextCancelled(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
lockErr := output.ErrAPI(output.LarkErrWikiLockContention, "lock contention", nil)
|
||||
lockErr := wikiTestLockContentionErr()
|
||||
|
||||
client := &fakeWikiNodeCreateClient{
|
||||
spaces: map[string]*wikiSpaceRecord{
|
||||
|
||||
@@ -5,14 +5,13 @@ package wiki
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"github.com/larksuite/cli/internal/validate"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
@@ -135,7 +134,7 @@ func (api wikiNodeDeleteAPI) ResolveNode(ctx context.Context, token, objType str
|
||||
if objType != "" && objType != "wiki" {
|
||||
params["obj_type"] = objType
|
||||
}
|
||||
data, err := api.runtime.CallAPI("GET", "/open-apis/wiki/v2/spaces/get_node", params, nil)
|
||||
data, err := api.runtime.CallAPITyped("GET", "/open-apis/wiki/v2/spaces/get_node", params, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -143,7 +142,7 @@ func (api wikiNodeDeleteAPI) ResolveNode(ctx context.Context, token, objType str
|
||||
}
|
||||
|
||||
func (api wikiNodeDeleteAPI) DeleteNode(ctx context.Context, spaceID string, spec wikiNodeDeleteSpec) (string, error) {
|
||||
data, err := api.runtime.CallAPI(
|
||||
data, err := api.runtime.CallAPITyped(
|
||||
"DELETE",
|
||||
fmt.Sprintf(
|
||||
"/open-apis/wiki/v2/spaces/%s/nodes/%s",
|
||||
@@ -160,7 +159,7 @@ func (api wikiNodeDeleteAPI) DeleteNode(ctx context.Context, spaceID string, spe
|
||||
}
|
||||
|
||||
func (api wikiNodeDeleteAPI) GetDeleteNodeTask(ctx context.Context, taskID string) (wikiAsyncTaskStatus, error) {
|
||||
data, err := api.runtime.CallAPI(
|
||||
data, err := api.runtime.CallAPITyped(
|
||||
"GET",
|
||||
fmt.Sprintf("/open-apis/wiki/v2/tasks/%s", validate.EncodePathSegment(taskID)),
|
||||
map[string]interface{}{"task_type": wikiAsyncTaskTypeDeleteNode},
|
||||
@@ -188,7 +187,7 @@ func readWikiNodeDeleteSpec(runtime *common.RuntimeContext) (wikiNodeDeleteSpec,
|
||||
func parseWikiNodeDeleteSpec(rawToken, rawObjType, rawSpaceID string, includeChildren bool) (wikiNodeDeleteSpec, error) {
|
||||
tokenInput := strings.TrimSpace(rawToken)
|
||||
if tokenInput == "" {
|
||||
return wikiNodeDeleteSpec{}, output.ErrValidation("--node-token is required")
|
||||
return wikiNodeDeleteSpec{}, errs.NewValidationError(errs.SubtypeInvalidArgument, "--node-token is required").WithParam("--node-token")
|
||||
}
|
||||
|
||||
spec := wikiNodeDeleteSpec{
|
||||
@@ -200,14 +199,14 @@ func parseWikiNodeDeleteSpec(rawToken, rawObjType, rawSpaceID string, includeChi
|
||||
if strings.Contains(tokenInput, "://") {
|
||||
u, err := url.Parse(tokenInput)
|
||||
if err != nil || u.Path == "" {
|
||||
return wikiNodeDeleteSpec{}, output.ErrValidation("--node-token URL is malformed: %q", tokenInput)
|
||||
return wikiNodeDeleteSpec{}, errs.NewValidationError(errs.SubtypeInvalidArgument, "--node-token URL is malformed: %q", tokenInput).WithParam("--node-token")
|
||||
}
|
||||
token, urlObjType, ok := tokenAndObjTypeFromWikiURL(u.Path)
|
||||
if !ok {
|
||||
return wikiNodeDeleteSpec{}, output.ErrValidation(
|
||||
return wikiNodeDeleteSpec{}, errs.NewValidationError(errs.SubtypeInvalidArgument,
|
||||
"unsupported --node-token URL path %q: expected /wiki/, /docx/, /doc/, /sheets/, /base/, /mindnote/, /slides/, or /file/ followed by a token",
|
||||
u.Path,
|
||||
)
|
||||
).WithParam("--node-token")
|
||||
}
|
||||
spec.NodeToken = token
|
||||
spec.SourceKind = "url"
|
||||
@@ -222,32 +221,32 @@ func parseWikiNodeDeleteSpec(rawToken, rawObjType, rawSpaceID string, includeChi
|
||||
case spec.ObjType == "":
|
||||
spec.ObjType = inferred
|
||||
case spec.ObjType != inferred:
|
||||
return wikiNodeDeleteSpec{}, output.ErrValidation(
|
||||
return wikiNodeDeleteSpec{}, errs.NewValidationError(errs.SubtypeInvalidArgument,
|
||||
"--obj-type %q does not match the obj_type %q implied by the URL path; pass only one",
|
||||
spec.ObjType, inferred,
|
||||
)
|
||||
).WithParam("--obj-type")
|
||||
}
|
||||
} else if strings.ContainsAny(tokenInput, "/?#") {
|
||||
return wikiNodeDeleteSpec{}, output.ErrValidation(
|
||||
return wikiNodeDeleteSpec{}, errs.NewValidationError(errs.SubtypeInvalidArgument,
|
||||
"--node-token must be a raw token or a full URL; partial paths are not accepted: %q",
|
||||
tokenInput,
|
||||
)
|
||||
).WithParam("--node-token")
|
||||
} else {
|
||||
spec.NodeToken = tokenInput
|
||||
spec.SourceKind = "raw"
|
||||
}
|
||||
|
||||
if spec.ObjType == "" {
|
||||
return wikiNodeDeleteSpec{}, output.ErrValidation(
|
||||
return wikiNodeDeleteSpec{}, errs.NewValidationError(errs.SubtypeInvalidArgument,
|
||||
"--obj-type is required (one of: %s)",
|
||||
strings.Join(wikiNodeDeleteObjTypes, ", "),
|
||||
)
|
||||
).WithParam("--obj-type")
|
||||
}
|
||||
if !isValidWikiDeleteObjType(spec.ObjType) {
|
||||
return wikiNodeDeleteSpec{}, output.ErrValidation(
|
||||
return wikiNodeDeleteSpec{}, errs.NewValidationError(errs.SubtypeInvalidArgument,
|
||||
"--obj-type %q is not valid; pick one of: %s",
|
||||
spec.ObjType, strings.Join(wikiNodeDeleteObjTypes, ", "),
|
||||
)
|
||||
).WithParam("--obj-type")
|
||||
}
|
||||
if err := validateOptionalResourceName(spec.NodeToken, "--node-token"); err != nil {
|
||||
return wikiNodeDeleteSpec{}, err
|
||||
@@ -405,12 +404,12 @@ func wrapWikiNodeDeleteAPIError(err error) error {
|
||||
if err == nil {
|
||||
return nil
|
||||
}
|
||||
var exitErr *output.ExitError
|
||||
if !errors.As(err, &exitErr) || exitErr.Detail == nil {
|
||||
p, ok := errs.ProblemOf(err)
|
||||
if !ok {
|
||||
return err
|
||||
}
|
||||
var hint string
|
||||
switch exitErr.Detail.Code {
|
||||
switch p.Code {
|
||||
case wikiDeleteNodeErrCodeApprovalRequired:
|
||||
hint = "this wiki node has delete-approval enabled; ask the user to apply via the Wiki UI (CLI cannot bypass approval)"
|
||||
case wikiDeleteNodeErrCodeSubtreeTooLarge:
|
||||
@@ -419,22 +418,11 @@ func wrapWikiNodeDeleteAPIError(err error) error {
|
||||
if hint == "" {
|
||||
return err
|
||||
}
|
||||
if existing := strings.TrimSpace(exitErr.Detail.Hint); existing != "" {
|
||||
// Append the hint in place so the typed error keeps its category / subtype /
|
||||
// code / log_id (per ERROR_CONTRACT.md "propagate typed errors unchanged").
|
||||
if existing := strings.TrimSpace(p.Hint); existing != "" {
|
||||
hint = existing + "\n" + hint
|
||||
}
|
||||
// ErrWithHint drops the upstream Detail.Code / Detail / Risk fields; build
|
||||
// the ExitError by hand so the Lark error code stays available to logs and
|
||||
// downstream pivots.
|
||||
return &output.ExitError{
|
||||
Code: exitErr.Code,
|
||||
Detail: &output.ErrDetail{
|
||||
Type: exitErr.Detail.Type,
|
||||
Code: exitErr.Detail.Code,
|
||||
Message: exitErr.Detail.Message,
|
||||
Hint: hint,
|
||||
ConsoleURL: exitErr.Detail.ConsoleURL,
|
||||
Risk: exitErr.Detail.Risk,
|
||||
Detail: exitErr.Detail.Detail,
|
||||
},
|
||||
}
|
||||
p.Hint = hint
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -9,17 +9,17 @@ import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
"reflect"
|
||||
"strings"
|
||||
"sync"
|
||||
"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"
|
||||
)
|
||||
|
||||
@@ -357,63 +357,55 @@ func TestRunWikiNodeDeleteAsyncFailureSurfacesReason(t *testing.T) {
|
||||
func TestWrapWikiNodeDeleteAPIErrorAddsApprovalHint(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
in := &output.ExitError{
|
||||
Code: output.ExitAPI,
|
||||
Detail: &output.ErrDetail{
|
||||
Type: "api_error",
|
||||
Code: wikiDeleteNodeErrCodeApprovalRequired,
|
||||
Message: "node requires delete approval",
|
||||
},
|
||||
}
|
||||
in := errclass.BuildAPIError(
|
||||
map[string]any{"code": float64(wikiDeleteNodeErrCodeApprovalRequired), "msg": "node requires delete approval"},
|
||||
errclass.ClassifyContext{},
|
||||
)
|
||||
got := wrapWikiNodeDeleteAPIError(in)
|
||||
var exitErr *output.ExitError
|
||||
if !errors.As(got, &exitErr) || exitErr.Detail == nil {
|
||||
t.Fatalf("expected ExitError, got %T %v", got, got)
|
||||
p, ok := errs.ProblemOf(got)
|
||||
if !ok {
|
||||
t.Fatalf("expected a typed errs.* error, got %T %v", got, got)
|
||||
}
|
||||
if !strings.Contains(exitErr.Detail.Hint, "delete-approval enabled") || !strings.Contains(exitErr.Detail.Hint, "Wiki UI") {
|
||||
t.Fatalf("hint = %q, want approval guidance", exitErr.Detail.Hint)
|
||||
if !strings.Contains(p.Hint, "delete-approval enabled") || !strings.Contains(p.Hint, "Wiki UI") {
|
||||
t.Fatalf("hint = %q, want approval guidance", p.Hint)
|
||||
}
|
||||
// Original code/message must be preserved so logs and dashboards still
|
||||
// pivot on the upstream error code.
|
||||
if exitErr.Detail.Code != wikiDeleteNodeErrCodeApprovalRequired {
|
||||
t.Fatalf("hint wrapper lost the original code: %d", exitErr.Detail.Code)
|
||||
if p.Code != wikiDeleteNodeErrCodeApprovalRequired {
|
||||
t.Fatalf("hint wrapper lost the original code: %d", p.Code)
|
||||
}
|
||||
if exitErr.Detail.Message != "node requires delete approval" {
|
||||
t.Fatalf("message changed unexpectedly: %q", exitErr.Detail.Message)
|
||||
if p.Message != "node requires delete approval" {
|
||||
t.Fatalf("message changed unexpectedly: %q", p.Message)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWrapWikiNodeDeleteAPIErrorAddsSubtreeHint(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
in := &output.ExitError{
|
||||
Code: output.ExitAPI,
|
||||
Detail: &output.ErrDetail{
|
||||
Type: "api_error",
|
||||
Code: wikiDeleteNodeErrCodeSubtreeTooLarge,
|
||||
Message: "subtree too large",
|
||||
},
|
||||
}
|
||||
in := errclass.BuildAPIError(
|
||||
map[string]any{"code": float64(wikiDeleteNodeErrCodeSubtreeTooLarge), "msg": "subtree too large"},
|
||||
errclass.ClassifyContext{},
|
||||
)
|
||||
got := wrapWikiNodeDeleteAPIError(in)
|
||||
var exitErr *output.ExitError
|
||||
if !errors.As(got, &exitErr) {
|
||||
t.Fatalf("expected ExitError, got %T %v", got, got)
|
||||
p, ok := errs.ProblemOf(got)
|
||||
if !ok {
|
||||
t.Fatalf("expected a typed errs.* error, got %T %v", got, got)
|
||||
}
|
||||
if !strings.Contains(exitErr.Detail.Hint, "--include-children=false") {
|
||||
t.Fatalf("hint = %q, want subtree-too-large guidance", exitErr.Detail.Hint)
|
||||
if !strings.Contains(p.Hint, "--include-children=false") {
|
||||
t.Fatalf("hint = %q, want subtree-too-large guidance", p.Hint)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWrapWikiNodeDeleteAPIErrorPassesThroughUnknownCodes(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
in := &output.ExitError{
|
||||
Code: output.ExitAPI,
|
||||
Detail: &output.ErrDetail{Type: "api_error", Code: 131005, Message: "node not found"},
|
||||
}
|
||||
in := errclass.BuildAPIError(
|
||||
map[string]any{"code": float64(131005), "msg": "node not found"},
|
||||
errclass.ClassifyContext{},
|
||||
)
|
||||
got := wrapWikiNodeDeleteAPIError(in)
|
||||
if !reflect.DeepEqual(got, in) {
|
||||
t.Fatalf("unknown code should pass through; got %#v", got)
|
||||
if got != in {
|
||||
t.Fatalf("unknown code should pass through unchanged; got %#v", got)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -12,7 +12,7 @@ import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
@@ -98,7 +98,7 @@ var WikiNodeGet = common.Shortcut{
|
||||
|
||||
fmt.Fprintf(runtime.IO().ErrOut, "Fetching wiki node %s...\n", common.MaskToken(spec.Token))
|
||||
|
||||
data, err := runtime.CallAPI("GET", "/open-apis/wiki/v2/spaces/get_node", spec.RequestParams(), nil)
|
||||
data, err := runtime.CallAPITyped("GET", "/open-apis/wiki/v2/spaces/get_node", spec.RequestParams(), nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -109,10 +109,10 @@ var WikiNodeGet = common.Shortcut{
|
||||
}
|
||||
|
||||
if spec.SpaceID != "" && node.SpaceID != "" && spec.SpaceID != node.SpaceID {
|
||||
return output.ErrValidation(
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument,
|
||||
"--space-id %q does not match the resolved node space %q (node_token=%s)",
|
||||
spec.SpaceID, node.SpaceID, node.NodeToken,
|
||||
)
|
||||
).WithParam("--space-id")
|
||||
}
|
||||
if spec.SpaceID != "" && node.SpaceID == "" {
|
||||
// The cross-check was requested but get_node returned no space_id,
|
||||
@@ -178,8 +178,8 @@ func resolveWikiNodeGetRawToken(nodeToken, legacyToken string) (string, error) {
|
||||
legacy := strings.TrimSpace(legacyToken)
|
||||
switch {
|
||||
case canonical != "" && legacy != "" && canonical != legacy:
|
||||
return "", output.ErrValidation(
|
||||
"--node-token and --token are both set with different values; pass --node-token only (--token is deprecated)")
|
||||
return "", errs.NewValidationError(errs.SubtypeInvalidArgument,
|
||||
"--node-token and --token are both set with different values; pass --node-token only (--token is deprecated)").WithParam("--token")
|
||||
case canonical != "":
|
||||
return nodeToken, nil
|
||||
default:
|
||||
@@ -193,7 +193,7 @@ func resolveWikiNodeGetRawToken(nodeToken, legacyToken string) (string, error) {
|
||||
func parseWikiNodeGetSpec(rawToken, rawObjType, rawSpaceID string) (wikiNodeGetSpec, error) {
|
||||
tokenInput := strings.TrimSpace(rawToken)
|
||||
if tokenInput == "" {
|
||||
return wikiNodeGetSpec{}, output.ErrValidation("--node-token is required")
|
||||
return wikiNodeGetSpec{}, errs.NewValidationError(errs.SubtypeInvalidArgument, "--node-token is required").WithParam("--node-token")
|
||||
}
|
||||
|
||||
spec := wikiNodeGetSpec{
|
||||
@@ -204,14 +204,14 @@ func parseWikiNodeGetSpec(rawToken, rawObjType, rawSpaceID string) (wikiNodeGetS
|
||||
if strings.Contains(tokenInput, "://") {
|
||||
u, err := url.Parse(tokenInput)
|
||||
if err != nil || u.Path == "" {
|
||||
return wikiNodeGetSpec{}, output.ErrValidation("--node-token URL is malformed: %q", tokenInput)
|
||||
return wikiNodeGetSpec{}, errs.NewValidationError(errs.SubtypeInvalidArgument, "--node-token URL is malformed: %q", tokenInput).WithParam("--node-token")
|
||||
}
|
||||
token, urlObjType, ok := tokenAndObjTypeFromWikiURL(u.Path)
|
||||
if !ok {
|
||||
return wikiNodeGetSpec{}, output.ErrValidation(
|
||||
return wikiNodeGetSpec{}, errs.NewValidationError(errs.SubtypeInvalidArgument,
|
||||
"unsupported --node-token URL path %q: expected /wiki/, /docx/, /doc/, /sheets/, /base/, /mindnote/, /slides/, or /file/ followed by a token",
|
||||
u.Path,
|
||||
)
|
||||
).WithParam("--node-token")
|
||||
}
|
||||
spec.Token = token
|
||||
if urlObjType == "" {
|
||||
@@ -223,16 +223,16 @@ func parseWikiNodeGetSpec(rawToken, rawObjType, rawSpaceID string) (wikiNodeGetS
|
||||
case spec.ObjType == "" && urlObjType != "":
|
||||
spec.ObjType = urlObjType
|
||||
case spec.ObjType != "" && urlObjType != "" && spec.ObjType != urlObjType:
|
||||
return wikiNodeGetSpec{}, output.ErrValidation(
|
||||
return wikiNodeGetSpec{}, errs.NewValidationError(errs.SubtypeInvalidArgument,
|
||||
"--obj-type %q does not match the obj_type %q implied by the URL path; pass only one",
|
||||
spec.ObjType, urlObjType,
|
||||
)
|
||||
).WithParam("--obj-type")
|
||||
}
|
||||
} else if strings.ContainsAny(tokenInput, "/?#") {
|
||||
return wikiNodeGetSpec{}, output.ErrValidation(
|
||||
return wikiNodeGetSpec{}, errs.NewValidationError(errs.SubtypeInvalidArgument,
|
||||
"--node-token must be a raw token or a full URL; partial paths are not accepted: %q",
|
||||
tokenInput,
|
||||
)
|
||||
).WithParam("--node-token")
|
||||
} else {
|
||||
spec.Token = tokenInput
|
||||
if looksLikeWikiNodeToken(spec.Token) {
|
||||
@@ -241,10 +241,10 @@ func parseWikiNodeGetSpec(rawToken, rawObjType, rawSpaceID string) (wikiNodeGetS
|
||||
// than silently passing it (the API would just ignore it, but the
|
||||
// mismatch signals caller confusion).
|
||||
if spec.ObjType != "" {
|
||||
return wikiNodeGetSpec{}, output.ErrValidation(
|
||||
return wikiNodeGetSpec{}, errs.NewValidationError(errs.SubtypeInvalidArgument,
|
||||
"--obj-type is only valid for obj_tokens; %q looks like a node_token",
|
||||
spec.Token,
|
||||
)
|
||||
).WithParam("--obj-type")
|
||||
}
|
||||
} else {
|
||||
spec.SourceKind = "raw-obj"
|
||||
@@ -253,10 +253,10 @@ func parseWikiNodeGetSpec(rawToken, rawObjType, rawSpaceID string) (wikiNodeGetS
|
||||
// sheet / bitable / ... Fail fast with the same upfront contract
|
||||
// as +node-delete instead of deferring to an opaque API error.
|
||||
if spec.ObjType == "" {
|
||||
return wikiNodeGetSpec{}, output.ErrValidation(
|
||||
return wikiNodeGetSpec{}, errs.NewValidationError(errs.SubtypeInvalidArgument,
|
||||
"--obj-type is required for a raw obj_token %q (one of: %s); or pass a typed Lark URL (e.g. /docx/<token>) so it can be inferred",
|
||||
spec.Token, strings.Join(wikiNodeGetObjTypeEnum, ", "),
|
||||
)
|
||||
).WithParam("--obj-type")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@ import (
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"github.com/larksuite/cli/internal/validate"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
@@ -53,7 +54,7 @@ var WikiNodeList = common.Shortcut{
|
||||
// hint instead of deferring to API-time errors. Matches the contract
|
||||
// used by +node-create and +move.
|
||||
if runtime.As().IsBot() && spaceID == wikiMyLibrarySpaceID {
|
||||
return output.ErrValidation("bot identity does not support --space-id my_library; use an explicit --space-id")
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "bot identity does not support --space-id my_library; use an explicit --space-id").WithParam("--space-id")
|
||||
}
|
||||
if err := validateOptionalResourceName(spaceID, "--space-id"); err != nil {
|
||||
return err
|
||||
@@ -150,7 +151,7 @@ func fetchWikiNodes(runtime *common.RuntimeContext, spaceID string) ([]map[strin
|
||||
if pageToken != "" {
|
||||
params["page_token"] = pageToken
|
||||
}
|
||||
data, err := runtime.CallAPI("GET", apiPath, params, nil)
|
||||
data, err := runtime.CallAPITyped("GET", apiPath, params, nil)
|
||||
if err != nil {
|
||||
return nil, false, "", err
|
||||
}
|
||||
|
||||
@@ -8,7 +8,7 @@ import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
@@ -60,14 +60,14 @@ var WikiSpaceCreate = common.Shortcut{
|
||||
|
||||
fmt.Fprintf(runtime.IO().ErrOut, "Creating wiki space %q...\n", spec.Name)
|
||||
|
||||
data, err := runtime.CallAPI("POST", wikiSpacesAPIPath, nil, spec.RequestBody())
|
||||
data, err := runtime.CallAPITyped("POST", wikiSpacesAPIPath, nil, spec.RequestBody())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
raw := common.GetMap(data, "space")
|
||||
if raw == nil {
|
||||
return output.Errorf(output.ExitAPI, "api_error", "wiki space create returned no space")
|
||||
return errs.NewInternalError(errs.SubtypeInvalidResponse, "wiki space create returned no space")
|
||||
}
|
||||
|
||||
out := wikiSpaceCreateOutput(raw)
|
||||
@@ -100,7 +100,7 @@ func readWikiSpaceCreateSpec(runtime *common.RuntimeContext) (wikiSpaceCreateSpe
|
||||
Description: strings.TrimSpace(runtime.Str("description")),
|
||||
}
|
||||
if spec.Name == "" {
|
||||
return wikiSpaceCreateSpec{}, output.ErrValidation("--name is required and cannot be blank")
|
||||
return wikiSpaceCreateSpec{}, errs.NewValidationError(errs.SubtypeInvalidArgument, "--name is required and cannot be blank").WithParam("--name")
|
||||
}
|
||||
return spec, nil
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@ import (
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
@@ -103,7 +104,7 @@ func fetchWikiSpaces(runtime *common.RuntimeContext) ([]map[string]interface{},
|
||||
if pageToken != "" {
|
||||
params["page_token"] = pageToken
|
||||
}
|
||||
data, err := runtime.CallAPI("GET", wikiSpacesAPIPath, params, nil)
|
||||
data, err := runtime.CallAPITyped("GET", wikiSpacesAPIPath, params, nil)
|
||||
if err != nil {
|
||||
return nil, false, "", err
|
||||
}
|
||||
@@ -181,10 +182,10 @@ func valueOrDash(v interface{}) string {
|
||||
// +space-list and +node-list.
|
||||
func validateWikiListPagination(runtime *common.RuntimeContext, maxPageSize int) error {
|
||||
if n := runtime.Int("page-size"); n < 1 || n > maxPageSize {
|
||||
return common.FlagErrorf("--page-size must be between 1 and %d", maxPageSize)
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--page-size must be between 1 and %d", maxPageSize).WithParam("--page-size")
|
||||
}
|
||||
if n := runtime.Int("page-limit"); n < 0 {
|
||||
return common.FlagErrorf("--page-limit must be a non-negative integer")
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--page-limit must be a non-negative integer").WithParam("--page-limit")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -38,7 +38,7 @@ lark-cli apps +html-publish --app-id app_xxx --path ./index.html --dry-run
|
||||
- 用户只说“用 HTML 写个 PPT/页面给我看看”时,先生成本地文件或目录,返回路径并问是否发布到妙搭分享;不要默认创建应用或部署。
|
||||
- 用户明确说“部署出去/发链接/可分享”时,才创建 `html` 应用并用 `+html-publish`。
|
||||
- 用户要发布但没有 app_id 时,先 `+create --app-type html` 创建应用;应用名可从页面/站点主题生成,不要让用户手动提供 app_id。
|
||||
- 若产物首页不是 `index.html`,发布前改名或复制为 `index.html`;目录发布时只传干净产物目录,例如 `./dist`,不要把 `.git`、`node_modules`、源码缓存一起带上。
|
||||
- 若产物首页不是 `index.html`,发布前改名或复制为 `index.html`;目录发布时只传干净产物目录,例如 `./dist`。`.git` 目录会被自动排除,不会进入压缩包;`node_modules`、源码缓存等仍建议手动精简以控制包体。
|
||||
- 重新部署同一个 HTML 应用时复用原 `app_id`,只重新执行 `+html-publish --app-id <id> --path <dir-or-index.html>`。
|
||||
|
||||
## 安全规则
|
||||
|
||||
@@ -78,6 +78,12 @@ p, h1-h9, ul, ol, li, table, thead, tbody, tr, th, td, blockquote, pre, code, hr
|
||||
```
|
||||
|
||||
|
||||
## 用户名写入规则
|
||||
|
||||
- 当从 IM 消息、日历、审批、任务等来源获取到用户的 `open_id` 时,写入文档**必须**使用 `<cite type="user" user-id="open_id">` 标签,而非纯文本名字。这样文档中会渲染为可点击的 @人。
|
||||
- 典型场景:IM 消息的 `sender`、`mentions`、reactions 的 `operator`、卡片消息中引用的用户、系统消息中的用户名、合并转发中的用户名。
|
||||
- 当只有纯文本名字而没有 `open_id` 时(如系统消息、合并转发内容),先通过 `lark-cli contact +search-user --query "名字" --as user` 反查 `open_id`,再写入 cite 标签。
|
||||
|
||||
## 表格扩展
|
||||
标准 HTML table 结构不变,扩展点:
|
||||
- `<colgroup>` / `<col>` 定义列宽,紧跟 `<table>` 之后:`<col span="2" width="100"/>`
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
1. **结构优于文字**:能用结构化 block 表达的信息,不用纯文本段落
|
||||
2. **Front-load 结论**:文档以 `<callout>` 开头概括核心结论;每章节首段点明要旨
|
||||
3. **视觉节奏**:连续纯文本不超过 3 段;不同主题章节间用 `<hr/>` 分隔
|
||||
4. **最少惊讶**:同类信息使用同类元素,全篇风格统一
|
||||
4. **风格一致**:同类信息使用同类元素,全篇风格统一
|
||||
5. **重要信息画板化**:核心流程、架构、对比、风险、路线图、指标趋势等重要信息优先使用画板表达
|
||||
|
||||
## 二、元素选择指南
|
||||
@@ -24,7 +24,7 @@
|
||||
| 代码片段 | `<pre lang="x" caption="说明">` |
|
||||
| 引用 / 公式 | `<blockquote>` / `<latex>` |
|
||||
| 操作入口 / 跳转链接 | `<button>` / `<a type="url-preview">` |
|
||||
| 流程图 / 时间线 / 示意图 / 自定义图形 / 架构图 / 数据图 /思维导图等 | 画板图表 |
|
||||
| 流程图 / 时间线 / 示意图 / 自定义图形 / 架构图 / 数据图 / 思维导图等 | 画板图表 |
|
||||
|
||||
|
||||
### 画板意图识别
|
||||
@@ -69,7 +69,7 @@
|
||||
## 四、排版规范
|
||||
|
||||
- 标题层级 ≤ 4 层,段落单段 ≤ 5 行,列表嵌套 ≤ 2 层,Grid ≤ 3 列
|
||||
- 文档开头用 `<callout>` front-load 结论;
|
||||
- 文档开头用 `<callout>` front-load 结论。
|
||||
|
||||
## 五、丰富度自检
|
||||
|
||||
|
||||
@@ -134,6 +134,21 @@ lark-cli im <resource> <method> [flags] # 调用 API
|
||||
- `delete` — 将用户或机器人移出群聊。Identity: supports `user` and `bot`; only group owner, admin, or creator bot can remove others; max 50 users or 5 bots per request.
|
||||
- `get` — 获取群成员列表。Identity: supports `user` and `bot`; the caller must be in the target chat and must belong to the same tenant for internal chats.
|
||||
|
||||
### chat.user_setting
|
||||
|
||||
- `batch_query` — 批量查询当前用户在群内的个人偏好设置 (e.g. `is_muted` mutes normal messages, `is_mute_at_all` mutes @all messages); up to 10 chats per request. Identity: `user` only (`user_access_token`); the caller must be in each target chat.
|
||||
- `batch_update` — 批量更新当前用户在群内的个人偏好设置 (e.g. `is_muted` mutes normal messages, `is_mute_at_all` mutes @all messages); up to 10 chats per request. Identity: `user` only (`user_access_token`); the caller must be in each target chat.
|
||||
|
||||
### chat.managers
|
||||
|
||||
- `add_managers` — 指定群管理员。Identity: supports `user` and `bot`; only the group owner can add managers; max 10 managers per chat (20 for super-large chats), and at most 5 bots per request.
|
||||
- `delete_managers` — 删除群管理员。Identity: supports `user` and `bot`; only the group owner can remove managers; max 50 users or 5 bots per request.
|
||||
|
||||
### chat.moderation
|
||||
|
||||
- `get` — 获取群成员发言权限。Identity: supports `user` and `bot`; the caller must be in the target chat and belong to the same tenant.
|
||||
- `update` — 更新群发言权限。Identity: supports `user` and `bot`; only the group owner (or creator bot with `im:chat:operate_as_owner`) can update; the caller must be in the chat.
|
||||
|
||||
### messages
|
||||
|
||||
- `delete` — 撤回消息。Identity: supports `user` and `bot`; for `bot` calls, the bot must be in the chat to revoke group messages; to revoke another user's group message, the bot must be the owner, an admin, or the creator; for user P2P recalls, the target user must be within the bot's availability.
|
||||
@@ -186,6 +201,12 @@ lark-cli im <resource> <method> [flags] # 调用 API
|
||||
| `chat.members.create` | `im:chat.members:write_only` |
|
||||
| `chat.members.delete` | `im:chat.members:write_only` |
|
||||
| `chat.members.get` | `im:chat.members:read` |
|
||||
| `chat.user_setting.batch_query` | `im:chat.user_setting:read` |
|
||||
| `chat.user_setting.batch_update` | `im:chat.user_setting:write` |
|
||||
| `chat.managers.add_managers` | `im:chat.managers:write_only` |
|
||||
| `chat.managers.delete_managers` | `im:chat.managers:write_only` |
|
||||
| `chat.moderation.get` | `im:chat.moderation:read` |
|
||||
| `chat.moderation.update` | `im:chat:moderation:write_only` |
|
||||
| `messages.delete` | `im:message:recall` |
|
||||
| `messages.forward` | `im:message` |
|
||||
| `messages.merge_forward` | `im:message` |
|
||||
|
||||
@@ -15,6 +15,9 @@ lark-cli im +chat-search --query "project"
|
||||
# Restrict by search types
|
||||
lark-cli im +chat-search --query "project" --search-types "private,public_joined"
|
||||
|
||||
# Filter by chat mode (group = regular group, topic = topic/thread group)
|
||||
lark-cli im +chat-search --query "project" --chat-modes "topic"
|
||||
|
||||
# Filter by member open_ids (with keyword)
|
||||
lark-cli im +chat-search --query "project" --member-ids "ou_xxx,ou_yyy"
|
||||
|
||||
@@ -43,6 +46,7 @@ lark-cli im +chat-search --query "project" --dry-run
|
||||
|------|------|------|------|
|
||||
| `--query <keyword>` | No (at least one of `--query` / `--member-ids` required) | Max 64 characters | Search keyword. Supports matching localized chat names, member names, multilingual search, pinyin, and prefix fuzzy search. If the query contains `-`, it is automatically wrapped in quotes |
|
||||
| `--search-types <types>` | No | Comma-separated: `private`, `external`, `public_joined`, `public_not_joined` | Restrict the visible chat types returned by search |
|
||||
| `--chat-modes <modes>` | No | Comma-separated: `group`, `topic` | Filter by chat mode (server-side): `group` = regular group, `topic` = topic/thread group |
|
||||
| `--member-ids <ids>` | No (at least one of `--query` / `--member-ids` required) | Up to 50, format `ou_xxx` | Filter by member open_ids; can be used alone or combined with `--query` |
|
||||
| `--is-manager` | No | - | Only show chats you created or manage |
|
||||
| `--disable-search-by-user` | No | - | Disable member-name-based matching and search by group name only |
|
||||
|
||||
@@ -9,7 +9,7 @@ This skill maps to shortcut: `lark-cli im +feed-shortcut-create`. Underlying API
|
||||
Adds one or more chats to the **current user's** feed shortcuts — equivalent to right-clicking a chat in the Feishu client and pinning it to the feed sidebar.
|
||||
|
||||
- Only **CHAT-type** shortcuts are exposed by the OpenAPI gateway right now (`feed_card_id` must be an `oc_xxx` open_chat_id).
|
||||
- Batch up to **10 chat IDs per call**; pass more by issuing multiple calls.
|
||||
- The upstream OAPI currently documents up to 50 items per write call, but this CLI intentionally enforces a stricter **30 chat IDs per call** local limit; pass more by issuing multiple calls.
|
||||
- Currently only supports **user identity** (`--as user`); bot identity is not allowed by the server.
|
||||
- If you only know a group name, resolve its `oc_xxx` first with `im +chat-search` or `im +chat-list`.
|
||||
|
||||
@@ -34,7 +34,7 @@ lark-cli im +feed-shortcut-create --as user --chat-id oc_xxx --dry-run
|
||||
|
||||
| Parameter | Default | Description |
|
||||
|------|------|------|
|
||||
| `--chat-id <oc_xxx>` | required | open_chat_id to add as a feed shortcut; repeatable or comma-separated; **max 10 per call** |
|
||||
| `--chat-id <oc_xxx>` | required | open_chat_id to add as a feed shortcut; repeatable or comma-separated; **CLI max 30 per call** |
|
||||
| `--head` | true (implied) | Insert at the top of the shortcut list; mutually exclusive with `--tail` |
|
||||
| `--tail` | false | Append at the bottom of the shortcut list |
|
||||
| `--as user` | required | Server only accepts user_access_token for this API |
|
||||
|
||||
@@ -6,45 +6,32 @@ This skill maps to shortcut: `lark-cli im +feed-shortcut-list`. Underlying API:
|
||||
|
||||
## What it does
|
||||
|
||||
Lists **one page** of the **current user's** feed shortcuts.
|
||||
Lists the **current user's full** feed shortcut list.
|
||||
|
||||
- Only **CHAT-type** shortcuts are exposed via OpenAPI today (others in the IDL are not yet whitelisted).
|
||||
- The shortcut is a **thin one-page wrapper** — there is no built-in auto-pagination. Callers drive their own loop when they actually need to paginate.
|
||||
- Server-side page size is controlled by the service; in normal use one page usually covers the list.
|
||||
- Pagination tokens are opaque. If a token is rejected because the shortcut list changed, restart by omitting `--page-token`.
|
||||
- The latest OAPI contract returns the whole list directly, so this shortcut exposes **no pagination flags**.
|
||||
- The shortcut also does **not** perform any follow-up `im.chats.batch_query` detail enrichment.
|
||||
|
||||
## Commands
|
||||
|
||||
```bash
|
||||
# First page (the only call most users ever need — --page-token omitted)
|
||||
# List the current user's full shortcut list
|
||||
lark-cli im +feed-shortcut-list --as user
|
||||
|
||||
# Continue from the previous response's page_token
|
||||
lark-cli im +feed-shortcut-list --as user --page-token <token-from-previous-response>
|
||||
|
||||
# Skip detail enrichment when only IDs are needed; avoids the extra im:chat:read lookup
|
||||
lark-cli im +feed-shortcut-list --as user --no-detail -q '.data.shortcuts[].feed_card_id'
|
||||
```
|
||||
|
||||
> If you need to walk every page, write the loop yourself: read `data.page_token` from each response and pass it back in until `has_more=false`. The shortcut intentionally does not auto-walk because page-token errors require the caller to decide whether to restart from the first page.
|
||||
|
||||
## Parameters
|
||||
|
||||
| Parameter | Required | Description |
|
||||
|------|------|------|
|
||||
| `--page-token <token>` | no | Opaque pagination token from the previous response. **Omit it for the first page.** |
|
||||
| `--no-detail` | no (default `false`) | Skip fetching each entry's full info object. By default enrichment is enabled: CHAT-type entries call `im.chats.batch_query`, need `im:chat:read`, and attach the object under the `detail` field. Pass `--no-detail` to skip the extra call and scope. |
|
||||
| `--as user` | yes | Server only accepts user_access_token for this API |
|
||||
|
||||
## Response Structure
|
||||
|
||||
| Field | Type | Description |
|
||||
|------|------|------|
|
||||
| `shortcuts` | array | Feed shortcut entries; each has `feed_card_id` (oc_xxx) and `type` (1=CHAT). By default (without `--no-detail`), each entry also has a `detail` field with the full per-type info object. |
|
||||
| `has_more` | boolean | Whether more pages exist |
|
||||
| `page_token` | string | Opaque token to pass to the next call when continuing pagination |
|
||||
| `shortcuts` | array | Feed shortcut entries; each has `feed_card_id` (oc_xxx) and `type` (1=CHAT). |
|
||||
|
||||
Example (with detail enrichment, CHAT type):
|
||||
Example:
|
||||
|
||||
```json
|
||||
{
|
||||
@@ -52,52 +39,18 @@ Example (with detail enrichment, CHAT type):
|
||||
"shortcuts": [
|
||||
{
|
||||
"feed_card_id": "oc_092f0100fe59c35995727db1039777a8",
|
||||
"type": 1,
|
||||
"detail": {
|
||||
"chat_id": "oc_092f0100fe59c35995727db1039777a8",
|
||||
"chat_mode": "group",
|
||||
"name": "Engineering",
|
||||
"avatar": "https://...",
|
||||
"description": "",
|
||||
"external": false,
|
||||
"owner_id": "ou_xxx",
|
||||
"owner_id_type": "open_id",
|
||||
"tenant_key": "..."
|
||||
}
|
||||
"type": 1
|
||||
},
|
||||
{
|
||||
"feed_card_id": "oc_c82061d126a06635aa3569587b134bb1",
|
||||
"type": 1,
|
||||
"detail": {
|
||||
"chat_id": "oc_c82061d126a06635aa3569587b134bb1",
|
||||
"chat_mode": "p2p",
|
||||
"name": "",
|
||||
"p2p_target_id": "ou_xxx",
|
||||
"p2p_target_type": "user",
|
||||
"avatar": "",
|
||||
"description": "",
|
||||
"external": false,
|
||||
"tenant_key": "..."
|
||||
}
|
||||
"type": 1
|
||||
}
|
||||
],
|
||||
"has_more": false,
|
||||
"page_token": "v1.example-opaque-token"
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Detail Enrichment
|
||||
|
||||
The `detail` payload is dispatched **per `type`**. Today only CHAT is wired in; future shortcut types can attach different object shapes. Callers should `switch` on `type` before parsing `detail`. For CHAT (`type=1`):
|
||||
|
||||
- **Source**: `POST /open-apis/im/v1/chats/batch_query` (50 ids per call, server limit).
|
||||
- **Payload**: the **full chat object** is passed through verbatim — `chat_id`, `chat_mode` (`group` / `p2p` / `topic`), `name`, `avatar`, `description`, `external`, `tenant_key`, plus type-specific fields (`owner_id*` for groups, `p2p_target_*` for p2p).
|
||||
- **P2P chats** return an empty `name` because the Feishu client renders the partner's display name there. The rest of the object (especially `p2p_target_id`) still flows through, so callers can resolve the partner via `+contact-search` if a display title is needed.
|
||||
- **Lookup failure** (missing scope, network error) → the list still returns successfully; a warning is printed to stderr, the data payload carries a `_notice` field (`"detail enrichment skipped: ..."`), and affected entries simply lack the `detail` field. Check `_notice` to tell "enrichment skipped" from "nothing to enrich".
|
||||
|
||||
## Permissions
|
||||
|
||||
- Required scope: `im:feed.shortcut:read`
|
||||
- Conditional scope (default detail path only): `im:chat:read`; pass `--no-detail` to avoid this extra scope and lookup.
|
||||
- Only available with user identity (`--as user`).
|
||||
|
||||
@@ -9,7 +9,7 @@ This skill maps to shortcut: `lark-cli im +feed-shortcut-remove`. Underlying API
|
||||
Removes one or more chats from the **current user's** feed shortcuts.
|
||||
|
||||
- Only **CHAT-type** shortcuts are supported (`feed_card_id` must be an `oc_xxx`).
|
||||
- Batch up to **10 chat IDs per call**.
|
||||
- The upstream OAPI currently documents up to 50 items per write call, but this CLI intentionally enforces a stricter **30 chat IDs per call** local limit.
|
||||
- Currently only supports **user identity** (`--as user`).
|
||||
- Removing a chat that is not currently in the shortcut list is idempotent success: the call returns `ok:true`, `failure_count=0`, and no `failed_shortcuts` entry for that chat.
|
||||
|
||||
@@ -31,7 +31,7 @@ lark-cli im +feed-shortcut-remove --as user --chat-id oc_xxx --dry-run
|
||||
|
||||
| Parameter | Required | Description |
|
||||
|------|------|------|
|
||||
| `--chat-id <oc_xxx>` | yes | open_chat_id to remove from feed shortcuts; repeatable or comma-separated; max 10 per call |
|
||||
| `--chat-id <oc_xxx>` | yes | open_chat_id to remove from feed shortcuts; repeatable or comma-separated; CLI max 30 per call |
|
||||
| `--as user` | yes | Server only accepts user_access_token for this API |
|
||||
|
||||
## Response
|
||||
@@ -45,4 +45,4 @@ The response uses the same batch ledger as [`+feed-shortcut-create`](lark-im-fee
|
||||
|
||||
## Note
|
||||
|
||||
- To see what is currently in the shortcut list before removing, run [`+feed-shortcut-list`](lark-im-feed-shortcut-list.md). Use `--no-detail` when you only need the `feed_card_id` values.
|
||||
- To see what is currently in the shortcut list before removing, run [`+feed-shortcut-list`](lark-im-feed-shortcut-list.md).
|
||||
|
||||
@@ -5,6 +5,8 @@ package apps
|
||||
|
||||
import (
|
||||
"context"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
@@ -26,7 +28,8 @@ func TestAppsGitCredentialInitDryRun(t *testing.T) {
|
||||
"--app-id", "app_xxx",
|
||||
"--dry-run",
|
||||
},
|
||||
DefaultAs: "user",
|
||||
BinaryPath: "../../../lark-cli",
|
||||
DefaultAs: "user",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
result.AssertExitCode(t, 0)
|
||||
@@ -35,4 +38,56 @@ func TestAppsGitCredentialInitDryRun(t *testing.T) {
|
||||
assert.Equal(t, "/open-apis/spark/v1/apps/app_xxx/git_info", gjson.Get(result.Stdout, "api.0.url").String())
|
||||
assert.Equal(t, "app_xxx", gjson.Get(result.Stdout, "api.0.params.app_id").String())
|
||||
assert.False(t, gjson.Get(result.Stdout, "api.0.body").Exists())
|
||||
assert.Equal(t, "api-plus-local-setup", gjson.Get(result.Stdout, "mode").String())
|
||||
assert.Equal(t, "initialize_local_git_credential", gjson.Get(result.Stdout, "action").String())
|
||||
assert.True(t, strings.HasSuffix(gjson.Get(result.Stdout, "metadata_file").String(), filepath.Join("spark", "app_xxx", "git.json")))
|
||||
assert.Equal(t, int64(3), gjson.Get(result.Stdout, "local_effects.#").Int())
|
||||
assert.Equal(t, "save the issued PAT in the local system credential store", gjson.Get(result.Stdout, "local_effects.0").String())
|
||||
assert.Equal(t, "write app-scoped git credential metadata", gjson.Get(result.Stdout, "local_effects.1").String())
|
||||
assert.Equal(t, "configure a URL-scoped Git credential helper in global git config when possible", gjson.Get(result.Stdout, "local_effects.2").String())
|
||||
}
|
||||
|
||||
func TestAppsGitCredentialListDryRun(t *testing.T) {
|
||||
setAppsDryRunEnv(t)
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
t.Cleanup(cancel)
|
||||
|
||||
result, err := clie2e.RunCmd(ctx, clie2e.Request{
|
||||
Args: []string{"apps", "+git-credential-list", "--dry-run"},
|
||||
BinaryPath: "../../../lark-cli",
|
||||
DefaultAs: "user",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
result.AssertExitCode(t, 0)
|
||||
|
||||
assert.Equal(t, "Preview local Git credential listing (no API call, read-only local state).", gjson.Get(result.Stdout, "description").String())
|
||||
assert.Equal(t, "local-read-only", gjson.Get(result.Stdout, "mode").String())
|
||||
assert.Equal(t, "list_local_git_credentials", gjson.Get(result.Stdout, "action").String())
|
||||
assert.Equal(t, int64(0), gjson.Get(result.Stdout, "api.#").Int())
|
||||
assert.Contains(t, gjson.Get(result.Stdout, "storage_root").String(), filepath.Join("", "spark"))
|
||||
assert.Equal(t, "scan app-scoped git credential metadata under the CLI config directory", gjson.Get(result.Stdout, "reads.0").String())
|
||||
}
|
||||
|
||||
func TestAppsGitCredentialRemoveDryRun(t *testing.T) {
|
||||
setAppsDryRunEnv(t)
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
t.Cleanup(cancel)
|
||||
|
||||
result, err := clie2e.RunCmd(ctx, clie2e.Request{
|
||||
Args: []string{"apps", "+git-credential-remove", "--app-id", "app_xxx", "--dry-run"},
|
||||
BinaryPath: "../../../lark-cli",
|
||||
DefaultAs: "user",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
result.AssertExitCode(t, 0)
|
||||
|
||||
assert.Equal(t, "Preview local Git credential cleanup (no API call; would clean up local-only state).", gjson.Get(result.Stdout, "description").String())
|
||||
assert.Equal(t, "local-cleanup-only", gjson.Get(result.Stdout, "mode").String())
|
||||
assert.Equal(t, "remove_local_git_credential", gjson.Get(result.Stdout, "action").String())
|
||||
assert.Equal(t, "app_xxx", gjson.Get(result.Stdout, "app_id").String())
|
||||
assert.Equal(t, int64(0), gjson.Get(result.Stdout, "api.#").Int())
|
||||
assert.True(t, strings.HasSuffix(gjson.Get(result.Stdout, "metadata_file").String(), filepath.Join("spark", "app_xxx", "git.json")))
|
||||
assert.Equal(t, "read app-scoped git credential metadata", gjson.Get(result.Stdout, "effects.0").String())
|
||||
}
|
||||
|
||||
@@ -87,9 +87,11 @@ func TestAppsHTMLPublishDryRun(t *testing.T) {
|
||||
assert.Equal(t, "page.html", gjson.Get(result.Stdout, "files.0").String())
|
||||
})
|
||||
|
||||
t.Run("HiddenFilesIncluded", func(t *testing.T) {
|
||||
// Walker MUST NOT silently filter .git / .DS_Store — that's an explicit
|
||||
// design decision so users pass clean ./dist trees, not source repos.
|
||||
t.Run("HiddenFilesIncludedExceptGit", func(t *testing.T) {
|
||||
// The walker filters the .git directory (and a .git gitdir pointer file)
|
||||
// so a stray repo under --path doesn't ship its history / remote URL to a
|
||||
// public share URL. Generic hidden files like .DS_Store are NOT filtered —
|
||||
// only .git is — so users still see everything else they pointed --path at.
|
||||
dir := t.TempDir()
|
||||
require.NoError(t, os.MkdirAll(filepath.Join(dir, "dist"), 0o755))
|
||||
require.NoError(t, os.WriteFile(filepath.Join(dir, "dist", "index.html"), []byte("<html/>"), 0o644))
|
||||
@@ -112,8 +114,17 @@ func TestAppsHTMLPublishDryRun(t *testing.T) {
|
||||
})
|
||||
require.NoError(t, err)
|
||||
result.AssertExitCode(t, 0)
|
||||
assert.Equal(t, int64(3), gjson.Get(result.Stdout, "file_count").Int(),
|
||||
"walker must include hidden files; got: %s", result.Stdout)
|
||||
// index.html + .DS_Store kept; .git/HEAD filtered out → 2 files.
|
||||
assert.Equal(t, int64(2), gjson.Get(result.Stdout, "file_count").Int(),
|
||||
"walker must keep non-.git hidden files but drop .git; got: %s", result.Stdout)
|
||||
names := gjson.Get(result.Stdout, "files").Array()
|
||||
var got []string
|
||||
for _, n := range names {
|
||||
got = append(got, n.String())
|
||||
}
|
||||
assert.Contains(t, got, "index.html")
|
||||
assert.Contains(t, got, ".DS_Store")
|
||||
assert.NotContains(t, got, ".git/HEAD", "walker must exclude .git contents")
|
||||
})
|
||||
|
||||
t.Run("EmptyDir_ManifestEmpty", func(t *testing.T) {
|
||||
|
||||
@@ -55,7 +55,7 @@ func TestIM_FeedShortcutWorkflowAsUser(t *testing.T) {
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("list feed shortcuts as user with detail enrichment", func(t *testing.T) {
|
||||
t.Run("list feed shortcuts as user", func(t *testing.T) {
|
||||
result, err := clie2e.RunCmdWithRetry(ctx, clie2e.Request{
|
||||
Args: []string{
|
||||
"im", "+feed-shortcut-list",
|
||||
@@ -85,43 +85,13 @@ func TestIM_FeedShortcutWorkflowAsUser(t *testing.T) {
|
||||
}
|
||||
found = true
|
||||
require.Equal(t, int64(1), item.Get("type").Int(), "type should be 1 (CHAT)")
|
||||
// detail enrichment is on by default — the chat we just created
|
||||
// must come back with the chat info object attached.
|
||||
require.True(t, item.Get("detail").Exists(),
|
||||
"detail field should be attached when enrichment is enabled")
|
||||
require.Equal(t, chatID, item.Get("detail.chat_id").String(),
|
||||
"detail.chat_id should echo feed_card_id")
|
||||
require.Equal(t, chatName, item.Get("detail.name").String(),
|
||||
"detail.name should carry the chat's group name")
|
||||
require.False(t, item.Get("detail").Exists(),
|
||||
"detail field should not exist in the direct list contract")
|
||||
break
|
||||
}
|
||||
require.True(t, found, "expected chat %s in feed shortcut list", chatID)
|
||||
})
|
||||
|
||||
t.Run("list feed shortcuts with --no-detail skips lookup", func(t *testing.T) {
|
||||
result, err := clie2e.RunCmd(ctx, clie2e.Request{
|
||||
Args: []string{
|
||||
"im", "+feed-shortcut-list",
|
||||
"--no-detail",
|
||||
},
|
||||
DefaultAs: "user",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
result.AssertExitCode(t, 0)
|
||||
result.AssertStdoutStatus(t, true)
|
||||
|
||||
var foundEntry gjson.Result
|
||||
for _, item := range gjson.Get(result.Stdout, "data.shortcuts").Array() {
|
||||
if item.Get("feed_card_id").String() == chatID {
|
||||
foundEntry = item
|
||||
break
|
||||
}
|
||||
}
|
||||
require.True(t, foundEntry.Exists(), "expected our chat in the bare list")
|
||||
require.False(t, foundEntry.Get("detail").Exists(),
|
||||
"detail field should NOT be present with --no-detail")
|
||||
})
|
||||
|
||||
t.Run("unpin chat from feed as user", func(t *testing.T) {
|
||||
result, err := clie2e.RunCmd(ctx, clie2e.Request{
|
||||
Args: []string{
|
||||
@@ -143,7 +113,6 @@ func TestIM_FeedShortcutWorkflowAsUser(t *testing.T) {
|
||||
result, err := clie2e.RunCmdWithRetry(ctx, clie2e.Request{
|
||||
Args: []string{
|
||||
"im", "+feed-shortcut-list",
|
||||
"--no-detail",
|
||||
},
|
||||
DefaultAs: "user",
|
||||
}, clie2e.RetryOptions{
|
||||
@@ -277,7 +246,7 @@ func cleanupFeedShortcuts(parentT *testing.T, defaultAs string, chatIDs ...strin
|
||||
cleanupCtx, cancel := clie2e.CleanupContext()
|
||||
defer cancel()
|
||||
listResult, listErr := clie2e.RunCmd(cleanupCtx, clie2e.Request{
|
||||
Args: []string{"im", "+feed-shortcut-list", "--no-detail"},
|
||||
Args: []string{"im", "+feed-shortcut-list"},
|
||||
DefaultAs: defaultAs,
|
||||
})
|
||||
clie2e.ReportCleanupFailure(parentT, "cleanup feed shortcuts list", listResult, listErr)
|
||||
@@ -410,7 +379,7 @@ func TestIM_FeedShortcutDryRun(t *testing.T) {
|
||||
require.NotContains(t, result.Stdout, "is_header", "remove must not send is_header")
|
||||
})
|
||||
|
||||
t.Run("list dry-run mentions detail enrichment path", func(t *testing.T) {
|
||||
t.Run("list dry-run hits feed_shortcuts endpoint directly", func(t *testing.T) {
|
||||
result, err := clie2e.RunCmd(ctx, clie2e.Request{
|
||||
Args: []string{
|
||||
"im", "+feed-shortcut-list",
|
||||
@@ -422,24 +391,7 @@ func TestIM_FeedShortcutDryRun(t *testing.T) {
|
||||
result.AssertExitCode(t, 0)
|
||||
require.Contains(t, result.Stdout, "GET")
|
||||
require.Contains(t, result.Stdout, "/open-apis/im/v2/feed_shortcuts")
|
||||
// Enrichment is on by default → DryRun adds a desc about the extra
|
||||
// chats.batch_query call and the conditional scope.
|
||||
require.Contains(t, result.Stdout, "im:chat:read")
|
||||
require.Contains(t, result.Stdout, "batch_query")
|
||||
})
|
||||
|
||||
t.Run("list dry-run with --no-detail omits the extra-scope note", func(t *testing.T) {
|
||||
result, err := clie2e.RunCmd(ctx, clie2e.Request{
|
||||
Args: []string{
|
||||
"im", "+feed-shortcut-list",
|
||||
"--no-detail",
|
||||
"--dry-run",
|
||||
},
|
||||
DefaultAs: "user",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
result.AssertExitCode(t, 0)
|
||||
require.NotContains(t, result.Stdout, "im:chat:read",
|
||||
"with --no-detail, dry-run must not mention im:chat:read")
|
||||
require.NotContains(t, result.Stdout, "im:chat:read")
|
||||
require.NotContains(t, result.Stdout, "batch_query")
|
||||
})
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user