884 lines
31 KiB
Go
884 lines
31 KiB
Go
// Copyright 2026 Alibaba Group
|
|
// Licensed under the Apache License, Version 2.0 (the "License");
|
|
|
|
package app
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"errors"
|
|
"io"
|
|
"os"
|
|
"reflect"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/event/busctl"
|
|
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/event/consume"
|
|
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/event/personal"
|
|
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/event/transport"
|
|
"github.com/spf13/cobra"
|
|
)
|
|
|
|
func TestEventConsumeAcceptsOrderedVariadicEventKeys(t *testing.T) {
|
|
oldRun := eventRunPersonalConsume
|
|
defer func() { eventRunPersonalConsume = oldRun }()
|
|
var got personalConsumeOptions
|
|
eventRunPersonalConsume = func(_ *cobra.Command, opts personalConsumeOptions) error {
|
|
got = opts
|
|
return nil
|
|
}
|
|
|
|
cmd := newEventConsumeCommand()
|
|
cmd.SetOut(io.Discard)
|
|
cmd.SetErr(io.Discard)
|
|
cmd.SetArgs([]string{
|
|
personal.EventMention,
|
|
personal.EventSingleChat,
|
|
personal.EventMention,
|
|
"--user", "test-user-001",
|
|
})
|
|
if err := cmd.Execute(); err != nil {
|
|
t.Fatalf("Execute() error = %v", err)
|
|
}
|
|
want := []string{personal.EventMention, personal.EventSingleChat}
|
|
if !reflect.DeepEqual(got.EventKeys, want) || got.EventKey != personal.EventMention {
|
|
t.Fatalf("event keys = %#v, first = %q", got.EventKeys, got.EventKey)
|
|
}
|
|
}
|
|
|
|
func TestPreparePersonalMultiOptionsCombinationMatrix(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
opts personalConsumeOptions
|
|
wantErr string
|
|
}{
|
|
{
|
|
name: "no target events",
|
|
opts: personalConsumeOptions{EventKeys: []string{personal.EventMention, personal.EventAllSingleChat}},
|
|
},
|
|
{
|
|
name: "user and no target",
|
|
opts: personalConsumeOptions{
|
|
EventKeys: []string{personal.EventSingleChat, personal.EventReadO2O, personal.EventMention},
|
|
UserID: "test-user-001",
|
|
},
|
|
},
|
|
{
|
|
name: "group and no target",
|
|
opts: personalConsumeOptions{
|
|
EventKeys: []string{personal.EventInChat, personal.EventGroupUpdated, personal.EventMention},
|
|
GroupID: "cid-test",
|
|
},
|
|
},
|
|
{
|
|
name: "open dingtalk id",
|
|
opts: personalConsumeOptions{
|
|
EventKeys: []string{personal.EventSingleChat, personal.EventRecallO2O},
|
|
OpenDingTalkID: "open-test-user",
|
|
},
|
|
},
|
|
{
|
|
name: "user and group mixed",
|
|
opts: personalConsumeOptions{
|
|
EventKeys: []string{personal.EventSingleChat, personal.EventInChat},
|
|
UserID: "test-user-001",
|
|
},
|
|
wantErr: "cannot be consumed in one command",
|
|
},
|
|
{
|
|
name: "duplicate keys collapse to one",
|
|
opts: personalConsumeOptions{
|
|
EventKeys: []string{personal.EventMention, personal.EventMention},
|
|
},
|
|
wantErr: "multiple event keys are required",
|
|
},
|
|
{
|
|
name: "unknown event",
|
|
opts: personalConsumeOptions{
|
|
EventKeys: []string{personal.EventMention, "user_im_unknown"},
|
|
},
|
|
wantErr: "unknown personal event key",
|
|
},
|
|
{
|
|
name: "missing user target",
|
|
opts: personalConsumeOptions{EventKeys: []string{personal.EventSingleChat, personal.EventReadO2O}},
|
|
wantErr: "one of --user or --open-dingtalk-id",
|
|
},
|
|
{
|
|
name: "missing group target",
|
|
opts: personalConsumeOptions{
|
|
EventKeys: []string{personal.EventInChat, personal.EventGroupUpdated},
|
|
},
|
|
wantErr: "--group is required",
|
|
},
|
|
{
|
|
name: "user identity flags conflict",
|
|
opts: personalConsumeOptions{
|
|
EventKeys: []string{personal.EventSingleChat, personal.EventReadO2O},
|
|
UserID: "test-user-001",
|
|
OpenDingTalkID: "open-test-user",
|
|
},
|
|
wantErr: "mutually exclusive",
|
|
},
|
|
{
|
|
name: "group target on user events",
|
|
opts: personalConsumeOptions{
|
|
EventKeys: []string{personal.EventSingleChat, personal.EventReadO2O},
|
|
UserID: "test-user-001",
|
|
GroupID: "cid-test",
|
|
},
|
|
wantErr: "--group cannot be used",
|
|
},
|
|
{
|
|
name: "user target on group events",
|
|
opts: personalConsumeOptions{
|
|
EventKeys: []string{personal.EventInChat, personal.EventGroupUpdated},
|
|
UserID: "test-user-001",
|
|
GroupID: "cid-test",
|
|
},
|
|
wantErr: "cannot be used with group-scoped events",
|
|
},
|
|
{
|
|
name: "target on no target events",
|
|
opts: personalConsumeOptions{
|
|
EventKeys: []string{personal.EventMention, personal.EventAllSingleChat},
|
|
UserID: "test-user-001",
|
|
},
|
|
wantErr: "do not use --user",
|
|
},
|
|
{
|
|
name: "filter message events",
|
|
opts: personalConsumeOptions{
|
|
EventKeys: []string{personal.EventMention, personal.EventAllGroupChat},
|
|
QueryCSV: "alarm",
|
|
},
|
|
},
|
|
{
|
|
name: "filter mixed with action",
|
|
opts: personalConsumeOptions{
|
|
EventKeys: []string{personal.EventSingleChat, personal.EventReadO2O},
|
|
UserID: "test-user-001",
|
|
QueryCSV: "alarm",
|
|
},
|
|
wantErr: "require all selected events to be message receive events",
|
|
},
|
|
{
|
|
name: "invalid message filter",
|
|
opts: personalConsumeOptions{
|
|
EventKeys: []string{personal.EventMention, personal.EventAllSingleChat},
|
|
FilterJSON: "{",
|
|
},
|
|
wantErr: "filter",
|
|
},
|
|
}
|
|
|
|
for _, test := range tests {
|
|
t.Run(test.name, func(t *testing.T) {
|
|
plans, err := preparePersonalMultiOptions(test.opts)
|
|
if test.wantErr != "" {
|
|
if err == nil || !strings.Contains(err.Error(), test.wantErr) {
|
|
t.Fatalf("error = %v, want %q", err, test.wantErr)
|
|
}
|
|
return
|
|
}
|
|
if err != nil {
|
|
t.Fatalf("preparePersonalMultiOptions() error = %v", err)
|
|
}
|
|
if len(plans) != len(test.opts.EventKeys) {
|
|
t.Fatalf("plans = %d, want %d", len(plans), len(test.opts.EventKeys))
|
|
}
|
|
for _, plan := range plans {
|
|
def, _ := personal.Lookup(plan.EventKey)
|
|
if def.RuleType == "at" || def.RuleType == "all" {
|
|
if plan.UserID != "" || plan.OpenDingTalkID != "" || plan.GroupID != "" {
|
|
t.Fatalf("no-target plan retained target: %#v", plan)
|
|
}
|
|
}
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestPreparePersonalMultiOptionsRejectsNonPublicEvent(t *testing.T) {
|
|
oldLookup := personalLookupDefinition
|
|
t.Cleanup(func() { personalLookupDefinition = oldLookup })
|
|
personalLookupDefinition = func(eventKey string) (personal.Definition, bool) {
|
|
def, ok := personal.Lookup(eventKey)
|
|
if eventKey == personal.EventAllSingleChat {
|
|
def.Public = false
|
|
}
|
|
return def, ok
|
|
}
|
|
|
|
_, err := preparePersonalMultiOptions(personalConsumeOptions{
|
|
EventKeys: []string{personal.EventMention, personal.EventAllSingleChat},
|
|
})
|
|
if err == nil || !strings.Contains(err.Error(), "not publicly available") {
|
|
t.Fatalf("error = %v", err)
|
|
}
|
|
}
|
|
|
|
func TestDedupePersonalEventKeysSkipsEmptyValues(t *testing.T) {
|
|
got := dedupePersonalEventKeys([]string{"", " event-a ", "event-a", "event-b"})
|
|
if !reflect.DeepEqual(got, []string{"event-a", "event-b"}) {
|
|
t.Fatalf("deduped keys = %#v", got)
|
|
}
|
|
}
|
|
|
|
func TestPreparePersonalMultiOptionsRejectsSingleOnlyFlags(t *testing.T) {
|
|
base := personalConsumeOptions{EventKeys: []string{personal.EventMention, personal.EventAllSingleChat}}
|
|
tests := []struct {
|
|
name string
|
|
set func(*personalConsumeOptions)
|
|
}{
|
|
{name: "subscribe-id", set: func(o *personalConsumeOptions) { o.SubscribeID = "sub" }},
|
|
{name: "rule", set: func(o *personalConsumeOptions) { o.Rule = "all" }},
|
|
{name: "event-types", set: func(o *personalConsumeOptions) { o.Common.EventTypes = []string{"x"} }},
|
|
{name: "filter", set: func(o *personalConsumeOptions) { o.Common.Filter = "x" }},
|
|
{name: "foreground", set: func(o *personalConsumeOptions) { o.Common.Foreground = true }},
|
|
{name: "force", set: func(o *personalConsumeOptions) { o.Common.Force = true }},
|
|
{name: "debug-raw-events", set: func(o *personalConsumeOptions) { o.DebugRawEvents = true }},
|
|
}
|
|
for _, test := range tests {
|
|
t.Run(test.name, func(t *testing.T) {
|
|
opts := base
|
|
test.set(&opts)
|
|
if _, err := preparePersonalMultiOptions(opts); err == nil {
|
|
t.Fatal("option succeeded")
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestCrossPlatformCoverageEventConsumeMultiRejectsExplicitSingleOnlyFlagsEvenWhenEmpty(t *testing.T) {
|
|
oldRun := eventRunPersonalConsume
|
|
defer func() { eventRunPersonalConsume = oldRun }()
|
|
eventRunPersonalConsume = func(*cobra.Command, personalConsumeOptions) error {
|
|
t.Fatal("personal consume ran after explicit multi-event flag")
|
|
return nil
|
|
}
|
|
|
|
flags := []string{
|
|
"--subscribe-id=",
|
|
"--rule=",
|
|
"--event-types=",
|
|
"--filter=",
|
|
"--foreground=false",
|
|
"--force=false",
|
|
"--debug-raw-events=false",
|
|
}
|
|
for _, flag := range flags {
|
|
t.Run(flag, func(t *testing.T) {
|
|
cmd := newEventConsumeCommand()
|
|
cmd.SetOut(io.Discard)
|
|
cmd.SetErr(io.Discard)
|
|
cmd.SetArgs([]string{personal.EventMention, personal.EventAllSingleChat, flag})
|
|
err := cmd.Execute()
|
|
if err == nil || !strings.Contains(err.Error(), "not supported when consuming multiple events") {
|
|
t.Fatalf("Execute() error = %v", err)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestRunPersonalEventConsumeManyCreatesAndCleansAllSubscriptions(t *testing.T) {
|
|
restore := installPersonalManySeams(t)
|
|
defer restore()
|
|
t.Setenv("DWS_CONFIG_DIR", t.TempDir())
|
|
|
|
identity := personal.Identity{AccessToken: "token", CorpID: "corp", UserID: "user", ClientID: "client", SourceID: "open"}
|
|
personalResolveEventIdentity = func(context.Context, string, string) (personal.Identity, error) { return identity, nil }
|
|
createdKeys := make([]string, 0, 2)
|
|
personalEnsureSubscription = func(_ context.Context, _ *personal.Client, _ personal.Identity, opts personalConsumeOptions) (*personal.Subscription, string, string, error) {
|
|
createdKeys = append(createdKeys, opts.EventKey)
|
|
return &personal.Subscription{SubscribeID: "sub-" + opts.EventKey}, opts.EventKey, "all", nil
|
|
}
|
|
var states []personal.RunState
|
|
personalUpsertRunState = func(_ string, state personal.RunState) error {
|
|
states = append(states, state)
|
|
return nil
|
|
}
|
|
var deleted []string
|
|
personalDeleteSubscription = func(_ *personal.Client, _ context.Context, id string) error {
|
|
deleted = append(deleted, id)
|
|
return nil
|
|
}
|
|
var removed []string
|
|
personalRemoveRunStates = func(_ string, ids []string) error {
|
|
removed = append(removed, ids...)
|
|
return nil
|
|
}
|
|
personalValidateConsumeConfig = func(consume.Config) error { return nil }
|
|
personalConsumeRunMany = func(_ context.Context, cfg consume.Config, specs []consume.ConsumerSpec) error {
|
|
if !cfg.Flatten || cfg.Projector == nil || len(specs) != 2 {
|
|
t.Fatalf("consume config/specs = %#v / %#v", cfg, specs)
|
|
}
|
|
for i, spec := range specs {
|
|
if spec.EventKey != createdKeys[i] || spec.SubscribeID != "sub-"+createdKeys[i] || !reflect.DeepEqual(spec.EventTypes, []string{createdKeys[i]}) {
|
|
t.Fatalf("spec[%d] = %#v", i, spec)
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
cmd := newPersonalCoverageCommand()
|
|
err := runPersonalEventConsume(cmd, personalConsumeOptions{
|
|
EventKeys: []string{personal.EventMention, personal.EventAllSingleChat},
|
|
Flatten: true,
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("runPersonalEventConsume() error = %v", err)
|
|
}
|
|
if len(states) != 2 || len(deleted) != 2 || len(removed) != 2 {
|
|
t.Fatalf("states=%#v deleted=%#v removed=%#v", states, deleted, removed)
|
|
}
|
|
}
|
|
|
|
func TestRunPersonalEventConsumeManyRollsBackPartialCreation(t *testing.T) {
|
|
restore := installPersonalManySeams(t)
|
|
defer restore()
|
|
t.Setenv("DWS_CONFIG_DIR", t.TempDir())
|
|
personalResolveEventIdentity = func(context.Context, string, string) (personal.Identity, error) {
|
|
return personal.Identity{AccessToken: "token", ClientID: "client", SourceID: "open", LocalSubject: "subject"}, nil
|
|
}
|
|
wantErr := errors.New("second subscription failed")
|
|
calls := 0
|
|
personalEnsureSubscription = func(_ context.Context, _ *personal.Client, _ personal.Identity, opts personalConsumeOptions) (*personal.Subscription, string, string, error) {
|
|
calls++
|
|
if calls == 2 {
|
|
return nil, "", "", wantErr
|
|
}
|
|
return &personal.Subscription{SubscribeID: "sub-first"}, opts.EventKey, "all", nil
|
|
}
|
|
personalUpsertRunState = func(string, personal.RunState) error { return nil }
|
|
var deleted []string
|
|
personalDeleteSubscription = func(_ *personal.Client, _ context.Context, id string) error {
|
|
deleted = append(deleted, id)
|
|
return nil
|
|
}
|
|
var removed []string
|
|
personalRemoveRunStates = func(_ string, ids []string) error {
|
|
removed = append(removed, ids...)
|
|
return nil
|
|
}
|
|
personalValidateConsumeConfig = func(consume.Config) error { return nil }
|
|
personalConsumeRunMany = func(context.Context, consume.Config, []consume.ConsumerSpec) error {
|
|
t.Fatal("RunMany called after partial creation failure")
|
|
return nil
|
|
}
|
|
|
|
err := runPersonalEventConsume(newPersonalCoverageCommand(), personalConsumeOptions{
|
|
EventKeys: []string{personal.EventMention, personal.EventAllSingleChat},
|
|
})
|
|
if !errors.Is(err, wantErr) {
|
|
t.Fatalf("error = %v", err)
|
|
}
|
|
if !reflect.DeepEqual(deleted, []string{"sub-first"}) || !reflect.DeepEqual(removed, []string{"sub-first"}) {
|
|
t.Fatalf("rollback deleted=%#v removed=%#v", deleted, removed)
|
|
}
|
|
}
|
|
|
|
func TestCrossPlatformCoverageRunPersonalEventConsumeManyPersistsFailureBeforeRollback(t *testing.T) {
|
|
restore := installPersonalManySeams(t)
|
|
defer restore()
|
|
t.Setenv("DWS_CONFIG_DIR", t.TempDir())
|
|
|
|
var order []string
|
|
personalNewSubscriptionAttemptStore = func(string) personalSubscriptionAttemptStore {
|
|
return &personalOrderingAttemptStore{order: &order}
|
|
}
|
|
personalResolveEventIdentity = func(context.Context, string, string) (personal.Identity, error) {
|
|
return personal.Identity{
|
|
AccessToken: "token",
|
|
ClientID: "client",
|
|
SourceID: "open",
|
|
LocalSubject: "subject",
|
|
}, nil
|
|
}
|
|
calls := 0
|
|
personalEnsureSubscription = func(
|
|
_ context.Context,
|
|
_ *personal.Client,
|
|
_ personal.Identity,
|
|
opts personalConsumeOptions,
|
|
) (*personal.Subscription, string, string, error) {
|
|
calls++
|
|
if calls == 2 {
|
|
return nil, "", "", errors.New("second subscription failed")
|
|
}
|
|
return &personal.Subscription{SubscribeID: "sub-first"}, opts.EventKey, "all", nil
|
|
}
|
|
personalUpsertRunState = func(string, personal.RunState) error { return nil }
|
|
personalDeleteSubscription = func(_ *personal.Client, _ context.Context, _ string) error {
|
|
order = append(order, "delete")
|
|
return nil
|
|
}
|
|
personalRemoveRunStates = func(string, []string) error { return nil }
|
|
personalValidateConsumeConfig = func(consume.Config) error { return nil }
|
|
|
|
err := runPersonalEventConsume(newPersonalCoverageCommand(), personalConsumeOptions{
|
|
EventKeys: []string{personal.EventMention, personal.EventAllSingleChat},
|
|
})
|
|
if err == nil {
|
|
t.Fatal("partial creation unexpectedly succeeded")
|
|
}
|
|
if !reflect.DeepEqual(order, []string{"complete_failure", "delete"}) {
|
|
t.Fatalf("failure/rollback order = %#v", order)
|
|
}
|
|
}
|
|
|
|
func TestCrossPlatformCoverageRunPersonalEventConsumeSinglePersistsLocalFailureBeforeRollback(t *testing.T) {
|
|
restore := installPersonalManySeams(t)
|
|
defer restore()
|
|
t.Setenv("DWS_CONFIG_DIR", t.TempDir())
|
|
|
|
var order []string
|
|
personalNewSubscriptionAttemptStore = func(string) personalSubscriptionAttemptStore {
|
|
return &personalOrderingAttemptStore{order: &order}
|
|
}
|
|
personalResolveEventIdentity = func(context.Context, string, string) (personal.Identity, error) {
|
|
return personal.Identity{
|
|
AccessToken: "token",
|
|
ClientID: "client",
|
|
SourceID: "open",
|
|
LocalSubject: "subject",
|
|
}, nil
|
|
}
|
|
personalEnsureSubscription = func(
|
|
_ context.Context,
|
|
_ *personal.Client,
|
|
_ personal.Identity,
|
|
opts personalConsumeOptions,
|
|
) (*personal.Subscription, string, string, error) {
|
|
return &personal.Subscription{SubscribeID: "sub-one"}, opts.EventKey, "all", nil
|
|
}
|
|
personalUpsertRunState = func(string, personal.RunState) error {
|
|
return errors.New("state disk failed")
|
|
}
|
|
personalDeleteSubscription = func(_ *personal.Client, _ context.Context, _ string) error {
|
|
order = append(order, "delete")
|
|
return nil
|
|
}
|
|
personalRemoveRunStates = func(string, []string) error { return nil }
|
|
personalValidateConsumeConfig = func(consume.Config) error { return nil }
|
|
|
|
err := runPersonalEventConsume(newPersonalCoverageCommand(), personalConsumeOptions{
|
|
EventKey: personal.EventMention,
|
|
})
|
|
if err == nil {
|
|
t.Fatal("run-state failure unexpectedly succeeded")
|
|
}
|
|
if !reflect.DeepEqual(order, []string{"complete_failure", "delete"}) {
|
|
t.Fatalf("failure/rollback order = %#v", order)
|
|
}
|
|
}
|
|
|
|
func TestCrossPlatformCoverageRunPersonalEventConsumeManyCancellationReleasesBeforeCanceledCleanup(t *testing.T) {
|
|
restore := installPersonalManySeams(t)
|
|
defer restore()
|
|
t.Setenv("DWS_CONFIG_DIR", t.TempDir())
|
|
|
|
var order []string
|
|
personalNewSubscriptionAttemptStore = func(string) personalSubscriptionAttemptStore {
|
|
return &personalOrderingAttemptStore{order: &order}
|
|
}
|
|
personalResolveEventIdentity = func(context.Context, string, string) (personal.Identity, error) {
|
|
return personal.Identity{
|
|
AccessToken: "token",
|
|
ClientID: "client",
|
|
SourceID: "open",
|
|
LocalSubject: "subject",
|
|
}, nil
|
|
}
|
|
calls := 0
|
|
personalEnsureSubscription = func(
|
|
_ context.Context,
|
|
_ *personal.Client,
|
|
_ personal.Identity,
|
|
opts personalConsumeOptions,
|
|
) (*personal.Subscription, string, string, error) {
|
|
calls++
|
|
if calls == 2 {
|
|
return nil, "", "", context.Canceled
|
|
}
|
|
return &personal.Subscription{SubscribeID: "sub-first"}, opts.EventKey, "all", nil
|
|
}
|
|
personalUpsertRunState = func(string, personal.RunState) error { return nil }
|
|
personalDeleteSubscription = func(_ *personal.Client, cleanupCtx context.Context, _ string) error {
|
|
if cleanupCtx.Err() == nil {
|
|
t.Fatal("cancellation cleanup received a live context")
|
|
}
|
|
order = append(order, "delete")
|
|
return cleanupCtx.Err()
|
|
}
|
|
personalRemoveRunStates = func(string, []string) error { return nil }
|
|
personalValidateConsumeConfig = func(consume.Config) error { return nil }
|
|
|
|
cmd := newPersonalCoverageCommand()
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
cancel()
|
|
cmd.SetContext(ctx)
|
|
err := runPersonalEventConsume(cmd, personalConsumeOptions{
|
|
EventKeys: []string{personal.EventMention, personal.EventAllSingleChat},
|
|
})
|
|
if !errors.Is(err, context.Canceled) {
|
|
t.Fatalf("cancellation error = %v", err)
|
|
}
|
|
if !reflect.DeepEqual(order, []string{"release", "delete"}) {
|
|
t.Fatalf("release/canceled-cleanup order = %#v", order)
|
|
}
|
|
}
|
|
|
|
func TestCrossPlatformCoverageRunPersonalEventConsumeManyRejectsInvalidSubscriptionResults(t *testing.T) {
|
|
for _, test := range []struct {
|
|
name string
|
|
ensure func(int, personalConsumeOptions) *personal.Subscription
|
|
upsertErr error
|
|
wantErr string
|
|
}{
|
|
{
|
|
name: "nil subscription",
|
|
ensure: func(int, personalConsumeOptions) *personal.Subscription { return nil },
|
|
wantErr: "empty subscription",
|
|
},
|
|
{
|
|
name: "empty subscribe id",
|
|
ensure: func(int, personalConsumeOptions) *personal.Subscription { return &personal.Subscription{} },
|
|
wantErr: "empty subscribe_id",
|
|
},
|
|
{
|
|
name: "duplicate subscribe id",
|
|
ensure: func(int, personalConsumeOptions) *personal.Subscription {
|
|
return &personal.Subscription{SubscribeID: "sub-duplicate"}
|
|
},
|
|
wantErr: "duplicate subscribe_id",
|
|
},
|
|
{
|
|
name: "run state write failure",
|
|
ensure: func(_ int, opts personalConsumeOptions) *personal.Subscription {
|
|
return &personal.Subscription{SubscribeID: "sub-" + opts.EventKey}
|
|
},
|
|
upsertErr: errors.New("state write failed"),
|
|
wantErr: "save run state",
|
|
},
|
|
} {
|
|
t.Run(test.name, func(t *testing.T) {
|
|
restore := installPersonalManySeams(t)
|
|
defer restore()
|
|
t.Setenv("DWS_CONFIG_DIR", t.TempDir())
|
|
personalResolveEventIdentity = func(context.Context, string, string) (personal.Identity, error) {
|
|
return personal.Identity{AccessToken: "token", ClientID: "client", SourceID: "open", LocalSubject: "subject"}, nil
|
|
}
|
|
calls := 0
|
|
personalEnsureSubscription = func(_ context.Context, _ *personal.Client, _ personal.Identity, opts personalConsumeOptions) (*personal.Subscription, string, string, error) {
|
|
calls++
|
|
return test.ensure(calls, opts), opts.EventKey, "all", nil
|
|
}
|
|
personalUpsertRunState = func(string, personal.RunState) error { return test.upsertErr }
|
|
personalDeleteSubscription = func(*personal.Client, context.Context, string) error { return nil }
|
|
personalRemoveRunStates = func(string, []string) error { return nil }
|
|
personalValidateConsumeConfig = func(consume.Config) error { return nil }
|
|
personalConsumeRunMany = func(context.Context, consume.Config, []consume.ConsumerSpec) error {
|
|
t.Fatal("RunMany called with invalid subscription result")
|
|
return nil
|
|
}
|
|
|
|
err := runPersonalEventConsume(newPersonalCoverageCommand(), personalConsumeOptions{
|
|
EventKeys: []string{personal.EventMention, personal.EventAllSingleChat},
|
|
})
|
|
if err == nil || !strings.Contains(err.Error(), test.wantErr) {
|
|
t.Fatalf("error = %v, want %q", err, test.wantErr)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestRunPersonalEventConsumeManyDryRunDoesNotCreateSubscriptions(t *testing.T) {
|
|
restore := installPersonalManySeams(t)
|
|
defer restore()
|
|
t.Setenv("DWS_CONFIG_DIR", t.TempDir())
|
|
personalResolveEventIdentity = func(context.Context, string, string) (personal.Identity, error) {
|
|
return personal.Identity{AccessToken: "token", ClientID: "client", SourceID: "open", LocalSubject: "subject"}, nil
|
|
}
|
|
personalEnsureSubscription = func(context.Context, *personal.Client, personal.Identity, personalConsumeOptions) (*personal.Subscription, string, string, error) {
|
|
t.Fatal("dry-run created a subscription")
|
|
return nil, "", "", nil
|
|
}
|
|
personalValidateConsumeConfig = func(consume.Config) error { return nil }
|
|
|
|
cmd := newPersonalCoverageCommand()
|
|
var stderr bytes.Buffer
|
|
cmd.SetErr(&stderr)
|
|
err := runPersonalEventConsume(cmd, personalConsumeOptions{
|
|
EventKeys: []string{personal.EventMention, personal.EventAllSingleChat},
|
|
QueryCSV: "alarm",
|
|
Common: commonConsumeOptions{DryRun: true},
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("dry-run error = %v", err)
|
|
}
|
|
if !strings.Contains(stderr.String(), "subscription[0]") ||
|
|
!strings.Contains(stderr.String(), "subscription[1]") ||
|
|
!strings.Contains(stderr.String(), "filter=") {
|
|
t.Fatalf("dry-run subscriptions missing:\n%s", stderr.String())
|
|
}
|
|
}
|
|
|
|
func TestCrossPlatformCoverageRunPersonalEventConsumeManySetupAndCleanupEdges(t *testing.T) {
|
|
valid := personalConsumeOptions{EventKeys: []string{personal.EventMention, personal.EventAllSingleChat}}
|
|
|
|
t.Run("prepare error", func(t *testing.T) {
|
|
err := runPersonalEventConsumeMany(newPersonalCoverageCommand(), personalConsumeOptions{
|
|
EventKeys: []string{personal.EventMention},
|
|
})
|
|
if err == nil || !strings.Contains(err.Error(), "multiple event keys") {
|
|
t.Fatalf("error = %v", err)
|
|
}
|
|
})
|
|
|
|
t.Run("format warning and identity error", func(t *testing.T) {
|
|
restore := installPersonalManySeams(t)
|
|
defer restore()
|
|
cmd := newPersonalCoverageCommand()
|
|
var stderr bytes.Buffer
|
|
cmd.SetErr(&stderr)
|
|
if err := cmd.Flags().Set("format", "bogus"); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
wantErr := errors.New("identity")
|
|
personalResolveEventIdentity = func(context.Context, string, string) (personal.Identity, error) {
|
|
return personal.Identity{}, wantErr
|
|
}
|
|
opts := valid
|
|
opts.Common.FormatRaw = "bogus"
|
|
if err := runPersonalEventConsumeMany(cmd, opts); !errors.Is(err, wantErr) {
|
|
t.Fatalf("error = %v", err)
|
|
}
|
|
if !strings.Contains(stderr.String(), "using ndjson") {
|
|
t.Fatalf("warning = %q", stderr.String())
|
|
}
|
|
})
|
|
|
|
t.Run("flatten raw conflict", func(t *testing.T) {
|
|
cmd := newPersonalCoverageCommand()
|
|
if err := cmd.Flags().Set("format", "raw"); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
opts := valid
|
|
opts.Flatten = true
|
|
opts.Common.FormatRaw = "raw"
|
|
if err := runPersonalEventConsumeMany(cmd, opts); err == nil || !strings.Contains(err.Error(), "mutually exclusive") {
|
|
t.Fatalf("error = %v", err)
|
|
}
|
|
})
|
|
|
|
t.Run("route validation and output conflict", func(t *testing.T) {
|
|
restore := installPersonalManySeams(t)
|
|
defer restore()
|
|
personalResolveEventIdentity = func(context.Context, string, string) (personal.Identity, error) {
|
|
return personal.Identity{AccessToken: "token", ClientID: "client", SourceID: "open", LocalSubject: "subject"}, nil
|
|
}
|
|
|
|
opts := valid
|
|
opts.Common.RoutesRaw = []string{"bad-route"}
|
|
if err := runPersonalEventConsumeMany(newPersonalCoverageCommand(), opts); err == nil {
|
|
t.Fatal("invalid route succeeded")
|
|
}
|
|
|
|
wantErr := errors.New("validate")
|
|
personalValidateConsumeConfig = func(consume.Config) error { return wantErr }
|
|
if err := runPersonalEventConsumeMany(newPersonalCoverageCommand(), valid); !errors.Is(err, wantErr) {
|
|
t.Fatalf("config validation error = %v", err)
|
|
}
|
|
|
|
personalValidateConsumeConfig = func(consume.Config) error { return nil }
|
|
personalValidateNoOutputConflict = func(consume.Config, string) error { return wantErr }
|
|
cmd := newPersonalCoverageCommand()
|
|
if err := cmd.Flags().Set("output", "events.json"); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := runPersonalEventConsumeMany(cmd, valid); !errors.Is(err, wantErr) {
|
|
t.Fatalf("output conflict error = %v", err)
|
|
}
|
|
})
|
|
|
|
t.Run("runtime error reports cleanup failures", func(t *testing.T) {
|
|
restore := installPersonalManySeams(t)
|
|
defer restore()
|
|
personalResolveEventIdentity = func(context.Context, string, string) (personal.Identity, error) {
|
|
return personal.Identity{AccessToken: "token", ClientID: "client", SourceID: "open", LocalSubject: "subject"}, nil
|
|
}
|
|
personalEnsureSubscription = func(_ context.Context, _ *personal.Client, _ personal.Identity, opts personalConsumeOptions) (*personal.Subscription, string, string, error) {
|
|
return &personal.Subscription{SubscribeID: "sub-" + opts.EventKey}, opts.EventKey, "all", nil
|
|
}
|
|
personalUpsertRunState = func(string, personal.RunState) error { return nil }
|
|
personalValidateConsumeConfig = func(consume.Config) error { return nil }
|
|
cleanupErr := errors.New("cleanup")
|
|
personalDeleteSubscription = func(*personal.Client, context.Context, string) error { return cleanupErr }
|
|
personalRemoveRunStates = func(string, []string) error { return cleanupErr }
|
|
runErr := errors.New("run many")
|
|
personalConsumeRunMany = func(context.Context, consume.Config, []consume.ConsumerSpec) error { return runErr }
|
|
|
|
cmd := newPersonalCoverageCommand()
|
|
var stderr bytes.Buffer
|
|
cmd.SetErr(&stderr)
|
|
if err := runPersonalEventConsumeMany(cmd, valid); !errors.Is(err, runErr) {
|
|
t.Fatalf("runtime error = %v", err)
|
|
}
|
|
if !strings.Contains(stderr.String(), "failed to clean personal subscription") ||
|
|
!strings.Contains(stderr.String(), "failed to clean personal event run state") {
|
|
t.Fatalf("cleanup warnings = %q", stderr.String())
|
|
}
|
|
})
|
|
}
|
|
|
|
func TestStopPersonalConsumersUsesTargetedRPCAndLegacyFallback(t *testing.T) {
|
|
oldStop := personalStopConsumers
|
|
oldQuery := personalQueryStatus
|
|
oldFind := personalFindProcess
|
|
oldSignal := personalSignalProcess
|
|
defer func() {
|
|
personalStopConsumers = oldStop
|
|
personalQueryStatus = oldQuery
|
|
personalFindProcess = oldFind
|
|
personalSignalProcess = oldSignal
|
|
}()
|
|
|
|
personalStopConsumers = func(string, []string) (transport.ConsumerStopResp, error) {
|
|
return transport.ConsumerStopResp{Stopped: []string{"sub-a"}}, nil
|
|
}
|
|
personalQueryStatus = func(string) (*transport.StatusResp, error) {
|
|
t.Fatal("legacy status queried after targeted stop succeeded")
|
|
return nil, nil
|
|
}
|
|
if err := stopPersonalConsumers(io.Discard, "endpoint", []string{"sub-a"}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
personalStopConsumers = func(string, []string) (transport.ConsumerStopResp, error) {
|
|
return transport.ConsumerStopResp{}, busctl.ErrConsumerStopUnsupported
|
|
}
|
|
personalQueryStatus = func(string) (*transport.StatusResp, error) {
|
|
return &transport.StatusResp{Consumers: []transport.StatusConsumer{{PID: 321, SubscribeID: "sub-a"}}}, nil
|
|
}
|
|
proc := &os.Process{}
|
|
personalFindProcess = func(int) (*os.Process, error) { return proc, nil }
|
|
signals := 0
|
|
personalSignalProcess = func(*os.Process, os.Signal) error { signals++; return nil }
|
|
var warning bytes.Buffer
|
|
if err := stopPersonalConsumers(&warning, "endpoint", []string{"sub-a"}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if signals != 1 || !strings.Contains(warning.String(), "falling back to process signal") {
|
|
t.Fatalf("signals=%d warning=%q", signals, warning.String())
|
|
}
|
|
|
|
wantErr := errors.New("targeted stop transport failed")
|
|
personalStopConsumers = func(string, []string) (transport.ConsumerStopResp, error) {
|
|
return transport.ConsumerStopResp{}, wantErr
|
|
}
|
|
personalQueryStatus = func(string) (*transport.StatusResp, error) {
|
|
t.Fatal("legacy fallback ran for a non-compatibility error")
|
|
return nil, nil
|
|
}
|
|
if err := stopPersonalConsumers(io.Discard, "endpoint", []string{"sub-a"}); !errors.Is(err, wantErr) {
|
|
t.Fatalf("transport error = %v", err)
|
|
}
|
|
}
|
|
|
|
func installPersonalManySeams(t *testing.T) func() {
|
|
t.Helper()
|
|
oldIdentity := personalResolveEventIdentity
|
|
oldLookup := personalLookupDefinition
|
|
oldEnsure := personalEnsureSubscription
|
|
oldAttemptStore := personalNewSubscriptionAttemptStore
|
|
oldUpsert := personalUpsertRunState
|
|
oldDelete := personalDeleteSubscription
|
|
oldRemove := personalRemoveRunStates
|
|
oldRunMany := personalConsumeRunMany
|
|
oldValidate := personalValidateConsumeConfig
|
|
oldConflict := personalValidateNoOutputConflict
|
|
personalNewSubscriptionAttemptStore = func(string) personalSubscriptionAttemptStore {
|
|
return personalNoopAttemptStore{}
|
|
}
|
|
return func() {
|
|
personalResolveEventIdentity = oldIdentity
|
|
personalLookupDefinition = oldLookup
|
|
personalEnsureSubscription = oldEnsure
|
|
personalNewSubscriptionAttemptStore = oldAttemptStore
|
|
personalUpsertRunState = oldUpsert
|
|
personalDeleteSubscription = oldDelete
|
|
personalRemoveRunStates = oldRemove
|
|
personalConsumeRunMany = oldRunMany
|
|
personalValidateConsumeConfig = oldValidate
|
|
personalValidateNoOutputConflict = oldConflict
|
|
}
|
|
}
|
|
|
|
type personalNoopAttemptStore struct{}
|
|
|
|
func (personalNoopAttemptStore) Claim(specs []personal.AttemptSpec, _ time.Duration) (*personal.AttemptClaim, error) {
|
|
fingerprints := make([]string, 0, len(specs))
|
|
for _, spec := range specs {
|
|
fingerprints = append(fingerprints, spec.Fingerprint)
|
|
}
|
|
return &personal.AttemptClaim{
|
|
AttemptID: "test-attempt",
|
|
Fingerprints: fingerprints,
|
|
}, nil
|
|
}
|
|
|
|
func (personalNoopAttemptStore) CompleteSuccess(*personal.AttemptClaim) error {
|
|
return nil
|
|
}
|
|
|
|
func (personalNoopAttemptStore) CompleteFailure(
|
|
_ *personal.AttemptClaim,
|
|
_ []string,
|
|
failure personal.AttemptFailure,
|
|
) (personal.AttemptHold, error) {
|
|
return personal.AttemptHold{
|
|
Fingerprint: failure.Fingerprint,
|
|
Retryability: failure.Retryability,
|
|
}, nil
|
|
}
|
|
|
|
func (personalNoopAttemptStore) Release(*personal.AttemptClaim) error {
|
|
return nil
|
|
}
|
|
|
|
type personalOrderingAttemptStore struct {
|
|
order *[]string
|
|
}
|
|
|
|
func (s *personalOrderingAttemptStore) Claim(
|
|
specs []personal.AttemptSpec,
|
|
lease time.Duration,
|
|
) (*personal.AttemptClaim, error) {
|
|
return personalNoopAttemptStore{}.Claim(specs, lease)
|
|
}
|
|
|
|
func (s *personalOrderingAttemptStore) CompleteSuccess(*personal.AttemptClaim) error {
|
|
*s.order = append(*s.order, "complete_success")
|
|
return nil
|
|
}
|
|
|
|
func (s *personalOrderingAttemptStore) CompleteFailure(
|
|
_ *personal.AttemptClaim,
|
|
_ []string,
|
|
failure personal.AttemptFailure,
|
|
) (personal.AttemptHold, error) {
|
|
*s.order = append(*s.order, "complete_failure")
|
|
return personal.AttemptHold{
|
|
Fingerprint: failure.Fingerprint,
|
|
Retryability: failure.Retryability,
|
|
}, nil
|
|
}
|
|
|
|
func (s *personalOrderingAttemptStore) Release(*personal.AttemptClaim) error {
|
|
*s.order = append(*s.order, "release")
|
|
return nil
|
|
}
|