Files
kandev/apps/backend/internal/plugins/host_data_test.go
T

1114 lines
44 KiB
Go

package plugins
import (
"context"
"errors"
"fmt"
"testing"
"time"
agentsettingsdto "github.com/kandev/kandev/internal/agent/settings/dto"
analyticsmodels "github.com/kandev/kandev/internal/analytics/models"
"github.com/kandev/kandev/internal/plugins/manifest"
taskmodels "github.com/kandev/kandev/internal/task/models"
"github.com/kandev/kandev/internal/task/repository/repoerrors"
wfmodels "github.com/kandev/kandev/internal/workflow/models"
v1 "github.com/kandev/kandev/pkg/api/v1"
"github.com/kandev/kandev/pkg/pluginsdk"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/status"
)
func int64Ptr(v int64) *int64 { return &v }
// ── fakes for the narrow Host data API interfaces ───────────────────────
type fakeTaskDataSource struct {
workspaces []*taskmodels.Workspace
tasksByWorkspace map[string][]*taskmodels.Task
tasksByID map[string]*taskmodels.Task
repositories map[string][]*taskmodels.Repository
sessionsByTask map[string][]*taskmodels.TaskSession
executorRunning map[string]*taskmodels.ExecutorRunning
executorProfiles []*taskmodels.ExecutorProfile
executors map[string]*taskmodels.Executor
executorErrors map[string]error
executorCalls int
// gotIncludeArchived records the includeArchived flag of every
// ListTasksByWorkspace call, in call order.
gotIncludeArchived []bool
// executorRunningCalls counts GetExecutorRunningBySessionID calls, so
// tests can prove Sessions().List only resolves ACPSessionID for the
// page it actually returns, not every session it fetched.
executorRunningCalls int
// listTasksByWorkspaceCalls counts ListTasksByWorkspace calls, so tests
// can prove a workspace with more tasks than a single page issues
// multiple calls instead of returning a truncated first page.
listTasksByWorkspaceCalls int
}
func (f *fakeTaskDataSource) ListWorkspaces(context.Context) ([]*taskmodels.Workspace, error) {
return f.workspaces, nil
}
// ListTasksByWorkspace mirrors the real repository's page/pageSize semantics
// (1-based page, offset = (page-1)*pageSize, total = the full unpaginated
// count) so tests can prove callers that need every task actually loop
// pagination to completion instead of assuming a single page has it all.
func (f *fakeTaskDataSource) ListTasksByWorkspace(
_ context.Context, workspaceID, _, _, _ string, page, pageSize int, _ string, includeArchived, _, _, _ bool,
) ([]*taskmodels.Task, int, error) {
f.gotIncludeArchived = append(f.gotIncludeArchived, includeArchived)
f.listTasksByWorkspaceCalls++
all := f.tasksByWorkspace[workspaceID]
total := len(all)
offset := (page - 1) * pageSize
if offset < 0 || offset >= total {
return []*taskmodels.Task{}, total, nil
}
end := offset + pageSize
if end > total {
end = total
}
return all[offset:end], total, nil
}
func (f *fakeTaskDataSource) GetTask(_ context.Context, id string) (*taskmodels.Task, error) {
task, ok := f.tasksByID[id]
if !ok {
return nil, repoerrors.ErrTaskNotFound
}
return task, nil
}
func (f *fakeTaskDataSource) ListRepositories(_ context.Context, workspaceID string) ([]*taskmodels.Repository, error) {
return f.repositories[workspaceID], nil
}
func (f *fakeTaskDataSource) ListTaskSessions(_ context.Context, taskID string) ([]*taskmodels.TaskSession, error) {
return f.sessionsByTask[taskID], nil
}
func (f *fakeTaskDataSource) GetExecutorRunningBySessionID(_ context.Context, sessionID string) (*taskmodels.ExecutorRunning, error) {
f.executorRunningCalls++
running, ok := f.executorRunning[sessionID]
if !ok {
return nil, taskmodels.ErrExecutorRunningNotFound
}
return running, nil
}
func (f *fakeTaskDataSource) ListAllExecutorProfiles(context.Context) ([]*taskmodels.ExecutorProfile, error) {
return f.executorProfiles, nil
}
func (f *fakeTaskDataSource) GetExecutor(_ context.Context, id string) (*taskmodels.Executor, error) {
f.executorCalls++
if err := f.executorErrors[id]; err != nil {
return nil, err
}
return f.executors[id], nil
}
type fakeWorkflowLister struct {
workflows map[string][]*taskmodels.Workflow
}
func (f *fakeWorkflowLister) ListWorkflows(_ context.Context, workspaceID string, _ bool) ([]*taskmodels.Workflow, error) {
return f.workflows[workspaceID], nil
}
type fakeWorkflowStepLister struct {
steps map[string][]*wfmodels.WorkflowStep
}
func (f *fakeWorkflowStepLister) ListStepsByWorkflow(_ context.Context, workflowID string) ([]*wfmodels.WorkflowStep, error) {
return f.steps[workflowID], nil
}
type fakeAgentProfileDataSource struct {
resp *agentsettingsdto.ListAgentsResponse
}
func (f *fakeAgentProfileDataSource) ListAgents(context.Context) (*agentsettingsdto.ListAgentsResponse, error) {
return f.resp, nil
}
type fakeSessionCodeStatsSource struct {
calls int
lastFilter analyticsmodels.SessionCodeStatsFilter
stats []*analyticsmodels.SessionCodeStats
err error
}
func (f *fakeSessionCodeStatsSource) ListSessionCodeStats(
_ context.Context, filter analyticsmodels.SessionCodeStatsFilter,
) ([]*analyticsmodels.SessionCodeStats, error) {
f.calls++
f.lastFilter = filter
if f.err != nil {
return nil, f.err
}
return f.stats, nil
}
type fakeMessageDataSource struct {
calls int
lastFilter taskmodels.PluginMessageFilter
messages []*taskmodels.Message
err error
}
func (f *fakeMessageDataSource) ListMessagesForPlugin(
_ context.Context, filter taskmodels.PluginMessageFilter,
) ([]*taskmodels.Message, error) {
f.calls++
f.lastFilter = filter
if f.err != nil {
return nil, f.err
}
return f.messages, nil
}
// fakeTaskWriter records CreateTask/UpdateTask inputs and returns canned
// tasks, standing in for the backendapp adapter over internal/task/service.
type fakeTaskWriter struct {
lastCreate TaskCreateInput
lastUpdate TaskUpdateInput
createCalls int
updateCalls int
created *taskmodels.Task
updated *taskmodels.Task
createErr error
updateErr error
deletedIDs []string
deleteErr error
}
func (f *fakeTaskWriter) CreateTask(_ context.Context, in TaskCreateInput) (*taskmodels.Task, error) {
f.createCalls++
f.lastCreate = in
if f.createErr != nil {
return nil, f.createErr
}
if f.created != nil {
return f.created, nil
}
return &taskmodels.Task{ID: "task-created", WorkspaceID: in.WorkspaceID, WorkflowID: in.WorkflowID, Title: in.Title}, nil
}
func (f *fakeTaskWriter) UpdateTask(_ context.Context, in TaskUpdateInput) (*taskmodels.Task, error) {
f.updateCalls++
f.lastUpdate = in
if f.updateErr != nil {
return nil, f.updateErr
}
if f.updated != nil {
return f.updated, nil
}
return &taskmodels.Task{ID: in.ID, Title: "updated"}, nil
}
func (f *fakeTaskWriter) DeleteTask(_ context.Context, id string) error {
if f.deleteErr != nil {
return f.deleteErr
}
f.deletedIDs = append(f.deletedIDs, id)
return nil
}
// fakeMessenger records SendMessage calls, standing in for the backendapp
// adapter over the task service + orchestrator delivery path.
type fakeMessenger struct {
calls int
lastTaskID string
lastSession string
lastText string
lastSource string
result PluginMessageResult
err error
}
func (f *fakeMessenger) SendMessage(_ context.Context, taskID, sessionID, text, source string) (PluginMessageResult, error) {
f.calls++
f.lastTaskID, f.lastSession, f.lastText, f.lastSource = taskID, sessionID, text, source
if f.err != nil {
return PluginMessageResult{}, f.err
}
if f.result == (PluginMessageResult{}) {
return PluginMessageResult{SessionID: "session-x", Status: "sent"}, nil
}
return f.result, nil
}
// fakeTaskStarter records StartTask calls behind CreateTask's start_agent.
type fakeTaskStarter struct {
calls int
lastID string
lastLaunch TaskLaunchInput
err error
}
func (f *fakeTaskStarter) StartTask(_ context.Context, taskID string, launch TaskLaunchInput) error {
f.calls++
f.lastID = taskID
f.lastLaunch = launch
return f.err
}
// testDataHost bundles a pluginHost with every fake it was wired from, so
// tests can both drive Host calls and assert against the fakes' recorded
// state.
type testDataHost struct {
host *pluginHost
tasks *fakeTaskDataSource
workflows *fakeWorkflowLister
steps *fakeWorkflowStepLister
profiles *fakeAgentProfileDataSource
codeStats *fakeSessionCodeStatsSource
messages *fakeMessageDataSource
utilAgents *fakeUtilityAgentSource
utilRun *fakeUtilityRunner
taskWriter *fakeTaskWriter
messenger *fakeMessenger
starter *fakeTaskStarter
}
// newTestDataHost builds a fully-wired pluginHost (every Host data API
// dependency set, even if a given test's capabilities don't grant every
// resource) so each test only needs to vary caps.
func newTestDataHost(caps manifest.Capabilities) *testDataHost {
d := &testDataHost{
tasks: &fakeTaskDataSource{},
workflows: &fakeWorkflowLister{},
steps: &fakeWorkflowStepLister{},
profiles: &fakeAgentProfileDataSource{resp: &agentsettingsdto.ListAgentsResponse{}},
codeStats: &fakeSessionCodeStatsSource{},
messages: &fakeMessageDataSource{},
utilAgents: &fakeUtilityAgentSource{},
utilRun: &fakeUtilityRunner{text: "ok"},
taskWriter: &fakeTaskWriter{},
messenger: &fakeMessenger{},
starter: &fakeTaskStarter{},
}
d.host = &pluginHost{
pluginID: "p1",
capabilities: caps,
taskData: d.tasks,
workflows: d.workflows,
workflowSteps: d.steps,
agentProfiles: d.profiles,
sessionCodeStats: d.codeStats,
messageData: d.messages,
taskWriter: d.taskWriter,
configs: &fakeConfigReader{configs: map[string]any{utilityAgentConfigKey: "utility-agent-42"}},
utilityDeps: func() (utilityAgentSource, utilityRunner) {
return d.utilAgents, d.utilRun
},
writeDeps: func() (taskMessenger, taskStarter) {
return d.messenger, d.starter
},
}
return d
}
// ── capability gating: denied without api_read:<resource> ──────────────
func TestPluginHost_Tasks_DeniedWithoutCapability(t *testing.T) {
d := newTestDataHost(manifest.Capabilities{})
_, _, err := d.host.Tasks().List(context.Background(), pluginsdk.TaskFilter{}, pluginsdk.Page{})
assertPermissionDenied(t, err, "api_read:tasks")
_, err = d.host.Tasks().Get(context.Background(), "task-1")
assertPermissionDenied(t, err, "api_read:tasks")
}
func TestPluginHost_Sessions_DeniedWithoutCapability(t *testing.T) {
d := newTestDataHost(manifest.Capabilities{})
_, _, err := d.host.Sessions().List(context.Background(), pluginsdk.SessionFilter{}, pluginsdk.Page{})
assertPermissionDenied(t, err, "api_read:sessions")
_, _, err = d.host.Sessions().CodeStats(context.Background(), pluginsdk.SessionFilter{}, pluginsdk.Page{})
assertPermissionDenied(t, err, "api_read:sessions")
}
func TestPluginHost_Workspaces_DeniedWithoutCapability(t *testing.T) {
d := newTestDataHost(manifest.Capabilities{})
_, _, err := d.host.Workspaces().List(context.Background(), pluginsdk.Page{})
assertPermissionDenied(t, err, "api_read:workspaces")
}
func TestPluginHost_Workflows_DeniedWithoutCapability(t *testing.T) {
d := newTestDataHost(manifest.Capabilities{})
_, _, err := d.host.Workflows().List(context.Background(), "ws-1", pluginsdk.Page{})
assertPermissionDenied(t, err, "api_read:workflows")
_, err = d.host.Workflows().ListSteps(context.Background(), "wf-1")
assertPermissionDenied(t, err, "api_read:workflows")
}
func TestPluginHost_AgentProfiles_DeniedWithoutCapability(t *testing.T) {
d := newTestDataHost(manifest.Capabilities{})
_, _, err := d.host.AgentProfiles().List(context.Background(), pluginsdk.Page{})
assertPermissionDenied(t, err, "api_read:agent_profiles")
}
func TestPluginHost_ExecutorProfiles_DeniedWithoutCapability(t *testing.T) {
d := newTestDataHost(manifest.Capabilities{})
profiles, ok := pluginsdk.ExecutorProfiles(d.host)
if !ok {
t.Fatal("plugin host must expose executor-profile reader")
}
_, _, err := profiles.List(context.Background(), pluginsdk.Page{})
assertPermissionDenied(t, err, "api_read:executor_profiles")
}
func TestPluginHost_ExecutorProfiles_ListsProfilesWithExecutorType(t *testing.T) {
d := newTestDataHost(manifest.Capabilities{APIRead: []string{"executor_profiles"}})
d.tasks.executorProfiles = []*taskmodels.ExecutorProfile{{ID: "profile-1", ExecutorID: "executor-1", Name: "Remote default"}}
d.tasks.executors = map[string]*taskmodels.Executor{
"executor-1": {ID: "executor-1", Type: taskmodels.ExecutorTypeRemoteDocker},
}
profiles, ok := pluginsdk.ExecutorProfiles(d.host)
if !ok {
t.Fatal("plugin host must expose executor-profile reader")
}
got, info, err := profiles.List(context.Background(), pluginsdk.Page{})
if err != nil {
t.Fatalf("List() unexpected error: %v", err)
}
if len(got) != 1 || got[0].ID != "profile-1" || got[0].DisplayName != "Remote default" || got[0].ExecutorType != "remote_docker" {
t.Fatalf("List() = %+v, want mapped executor profile", got)
}
if info == nil || info.HasMore {
t.Fatalf("PageInfo = %+v, want final page", info)
}
}
func TestPluginHost_ExecutorProfiles_KeepsProfileWhenExecutorWasDeleted(t *testing.T) {
d := newTestDataHost(manifest.Capabilities{APIRead: []string{"executor_profiles"}})
d.tasks.executorProfiles = []*taskmodels.ExecutorProfile{{ID: "profile-1", ExecutorID: "deleted", Name: "Deleted executor"}}
d.tasks.executorErrors = map[string]error{
"deleted": fmt.Errorf("lookup: %w", taskmodels.ErrExecutorNotFound),
}
profiles, _ := pluginsdk.ExecutorProfiles(d.host)
got, _, err := profiles.List(context.Background(), pluginsdk.Page{})
if err != nil {
t.Fatalf("List() unexpected error: %v", err)
}
if len(got) != 1 || got[0].ID != "profile-1" || got[0].ExecutorType != "" {
t.Fatalf("List() = %+v, want profile with an empty executor type", got)
}
}
func TestPluginHost_ExecutorProfiles_EnrichesOnlyRequestedPage(t *testing.T) {
d := newTestDataHost(manifest.Capabilities{APIRead: []string{"executor_profiles"}})
d.tasks.executorProfiles = []*taskmodels.ExecutorProfile{
{ID: "profile-1", ExecutorID: "executor-1"},
{ID: "profile-2", ExecutorID: "executor-2"},
{ID: "profile-3", ExecutorID: "executor-3"},
}
d.tasks.executors = map[string]*taskmodels.Executor{
"executor-1": {ID: "executor-1", Type: taskmodels.ExecutorTypeLocalDocker},
"executor-2": {ID: "executor-2", Type: taskmodels.ExecutorTypeRemoteDocker},
"executor-3": {ID: "executor-3", Type: taskmodels.ExecutorTypeSSH},
}
profiles, _ := pluginsdk.ExecutorProfiles(d.host)
got, info, err := profiles.List(context.Background(), pluginsdk.Page{Limit: 1})
if err != nil {
t.Fatalf("List() unexpected error: %v", err)
}
if len(got) != 1 || got[0].ID != "profile-1" || info == nil || !info.HasMore {
t.Fatalf("List() = %+v, info = %+v, want first of three profiles", got, info)
}
if d.tasks.executorCalls != 1 {
t.Fatalf("GetExecutor() calls = %d, want 1 for the returned page", d.tasks.executorCalls)
}
}
func TestPluginHost_Repositories_DeniedWithoutCapability(t *testing.T) {
d := newTestDataHost(manifest.Capabilities{})
_, _, err := d.host.Repositories().List(context.Background(), "ws-1", pluginsdk.Page{})
assertPermissionDenied(t, err, "api_read:repositories")
}
func TestPluginHost_Messages_DeniedWithoutCapability(t *testing.T) {
d := newTestDataHost(manifest.Capabilities{})
_, _, err := d.host.Messages().List(context.Background(), pluginsdk.MessageFilter{}, pluginsdk.Page{})
assertPermissionDenied(t, err, "api_read:messages")
}
// ── capability gating: succeeds with api_read:<resource> ───────────────
func TestPluginHost_Tasks_SucceedsWithCapability(t *testing.T) {
d := newTestDataHost(manifest.Capabilities{APIRead: []string{"tasks"}})
d.tasks.workspaces = []*taskmodels.Workspace{{ID: "ws-1"}}
d.tasks.tasksByWorkspace = map[string][]*taskmodels.Task{
"ws-1": {{ID: "task-1", WorkspaceID: "ws-1", WorkflowID: "wf-1", Title: "Task 1", State: v1.TaskStateTODO}},
}
d.tasks.tasksByID = map[string]*taskmodels.Task{"task-1": d.tasks.tasksByWorkspace["ws-1"][0]}
tasks, info, err := d.host.Tasks().List(context.Background(), pluginsdk.TaskFilter{}, pluginsdk.Page{})
if err != nil {
t.Fatalf("List() unexpected error: %v", err)
}
if len(tasks) != 1 || tasks[0].ID != "task-1" {
t.Fatalf("List() = %+v, want one task-1", tasks)
}
if info == nil || info.HasMore {
t.Fatalf("PageInfo = %+v, want HasMore=false", info)
}
got, err := d.host.Tasks().Get(context.Background(), "task-1")
if err != nil {
t.Fatalf("Get() unexpected error: %v", err)
}
if got == nil || got.Title != "Task 1" {
t.Fatalf("Get() = %+v, want Task 1", got)
}
// A missing task returns a gRPC NotFound error directly, matching what a
// real plugin observes over the wire (grpcHostServer.GetTask forwards
// this error as-is) rather than a (nil, nil) success that only the
// in-process caller would ever see.
got, err = d.host.Tasks().Get(context.Background(), "no-such-task")
if got != nil {
t.Fatalf("Get() for missing task = (%+v, %v), want (nil, NotFound)", got, err)
}
if status.Code(err) != codes.NotFound {
t.Fatalf("Get() for missing task error = %v, want codes.NotFound", err)
}
}
func TestPluginHost_Workspaces_SucceedsWithCapability(t *testing.T) {
d := newTestDataHost(manifest.Capabilities{APIRead: []string{"workspaces"}})
d.tasks.workspaces = []*taskmodels.Workspace{{ID: "ws-1", Name: "Workspace 1", OwnerID: "user-1"}}
workspaces, _, err := d.host.Workspaces().List(context.Background(), pluginsdk.Page{})
if err != nil {
t.Fatalf("List() unexpected error: %v", err)
}
if len(workspaces) != 1 || workspaces[0].Name != "Workspace 1" {
t.Fatalf("List() = %+v, want one Workspace 1", workspaces)
}
}
func TestPluginHost_Workflows_SucceedsWithCapability(t *testing.T) {
d := newTestDataHost(manifest.Capabilities{APIRead: []string{"workflows"}})
d.workflows.workflows = map[string][]*taskmodels.Workflow{
"ws-1": {{ID: "wf-1", WorkspaceID: "ws-1", Name: "Default"}},
}
d.steps.steps = map[string][]*wfmodels.WorkflowStep{
"wf-1": {{ID: "step-1", WorkflowID: "wf-1", Name: "Todo", Position: 0, StageType: wfmodels.StageType("work")}},
}
workflows, _, err := d.host.Workflows().List(context.Background(), "ws-1", pluginsdk.Page{})
if err != nil {
t.Fatalf("List() unexpected error: %v", err)
}
if len(workflows) != 1 || workflows[0].ID != "wf-1" {
t.Fatalf("List() = %+v, want one wf-1", workflows)
}
steps, err := d.host.Workflows().ListSteps(context.Background(), "wf-1")
if err != nil {
t.Fatalf("ListSteps() unexpected error: %v", err)
}
if len(steps) != 1 || steps[0].StageType != "work" {
t.Fatalf("ListSteps() = %+v, want one step with StageType=work", steps)
}
}
func TestPluginHost_AgentProfiles_SucceedsWithCapability(t *testing.T) {
d := newTestDataHost(manifest.Capabilities{APIRead: []string{"agent_profiles"}})
d.profiles.resp = &agentsettingsdto.ListAgentsResponse{
Agents: []agentsettingsdto.AgentDTO{
{
ID: "agent-1",
Profiles: []agentsettingsdto.AgentProfileDTO{
{ID: "profile-1", AgentID: "agent-1", Name: "Default", AgentDisplayName: "Claude", Model: "claude-x", Mode: "code"},
},
},
},
}
profiles, _, err := d.host.AgentProfiles().List(context.Background(), pluginsdk.Page{})
if err != nil {
t.Fatalf("List() unexpected error: %v", err)
}
if len(profiles) != 1 || profiles[0].DisplayName != "Claude" || profiles[0].Model != "claude-x" {
t.Fatalf("List() = %+v, want one Claude/claude-x profile", profiles)
}
}
func TestPluginHost_Repositories_SucceedsWithCapability(t *testing.T) {
d := newTestDataHost(manifest.Capabilities{APIRead: []string{"repositories"}})
branch := "main"
d.tasks.repositories = map[string][]*taskmodels.Repository{
"ws-1": {{
ID: "repo-1", WorkspaceID: "ws-1", Name: "team/kandev", DefaultBranch: branch,
SourceType: "provider", Provider: "example-vcs", ProviderRepoID: "repo-42", ProviderHost: "code.example.test",
ProviderOwner: "team", ProviderName: "kandev", RemoteURL: "https://code.example.test/scm/team/kandev.git",
LocalPath: "/private/checkout", SetupScript: "secret setup", CleanupScript: "secret cleanup", DevScript: "secret dev", CopyFiles: ".env",
}},
}
repos, _, err := d.host.Repositories().List(context.Background(), "ws-1", pluginsdk.Page{})
if err != nil {
t.Fatalf("List() unexpected error: %v", err)
}
if len(repos) != 1 || repos[0].Name != "team/kandev" || repos[0].DefaultBranch == nil || *repos[0].DefaultBranch != "main" {
t.Fatalf("List() = %+v, want one kandev repo on main", repos)
}
if repos[0].SourceType != "provider" || repos[0].ProviderID != "example-vcs" || repos[0].ProviderRepositoryID != "repo-42" || repos[0].ProviderHost != "code.example.test" || repos[0].OwnerOrProject != "team" || repos[0].ProviderName != "kandev" || repos[0].RemoteURL != "https://code.example.test/scm/team/kandev.git" {
t.Fatalf("List() = %+v, want provider origin identity", repos[0])
}
}
func TestRepositoryModelToDTO_StripsRemoteURLCredentials(t *testing.T) {
dto := repositoryModelToDTO(&taskmodels.Repository{
RemoteURL: "https://user:secret@code.example.test/team/repo.git",
})
if dto.RemoteURL != "https://code.example.test/team/repo.git" {
t.Fatalf("RemoteURL = %q, want credential-free URL", dto.RemoteURL)
}
}
// TestFetchTasksForWorkspaces_DoesNotTruncateBeyondPageSize proves a
// workspace with more tasks than a single taskFetchPageSize page is fully
// enumerated, not silently cut off at the first page.
func TestFetchTasksForWorkspaces_DoesNotTruncateBeyondPageSize(t *testing.T) {
d := newTestDataHost(manifest.Capabilities{APIRead: []string{"tasks"}})
const total = taskFetchPageSize + 1
tasks := make([]*taskmodels.Task, total)
for i := 0; i < total; i++ {
tasks[i] = &taskmodels.Task{ID: fmt.Sprintf("task-%04d", i), WorkspaceID: "ws-1"}
}
d.tasks.tasksByWorkspace = map[string][]*taskmodels.Task{"ws-1": tasks}
got, err := d.host.fetchTasksForWorkspaces(context.Background(), []string{"ws-1"}, false, false)
if err != nil {
t.Fatalf("fetchTasksForWorkspaces() unexpected error: %v", err)
}
if len(got) != total {
t.Fatalf("fetchTasksForWorkspaces() returned %d tasks, want %d (must not silently truncate at taskFetchPageSize)", len(got), total)
}
if d.tasks.listTasksByWorkspaceCalls < 2 {
t.Fatalf("ListTasksByWorkspace called %d times, want >= 2 (a single %d-task page can't hold %d tasks)",
d.tasks.listTasksByWorkspaceCalls, taskFetchPageSize, total)
}
}
// TestPluginHost_Tasks_List_DoesNotTruncateBeyondPageSize is the
// end-to-end version of the above: Tasks().List's HasMore/pagination must
// stay accurate even when the underlying per-workspace fetch spans more than
// one taskFetchPageSize page.
func TestPluginHost_Tasks_List_DoesNotTruncateBeyondPageSize(t *testing.T) {
d := newTestDataHost(manifest.Capabilities{APIRead: []string{"tasks"}})
const total = taskFetchPageSize + 1
tasks := make([]*taskmodels.Task, total)
for i := 0; i < total; i++ {
tasks[i] = &taskmodels.Task{ID: fmt.Sprintf("task-%04d", i), WorkspaceID: "ws-1"}
}
d.tasks.tasksByWorkspace = map[string][]*taskmodels.Task{"ws-1": tasks}
// maxPageLimit caps a single RPC page at 200 items, so walk every page
// via cursor and count the total returned across the whole read.
seen := 0
cursor := ""
for {
page, info, err := d.host.Tasks().List(context.Background(), pluginsdk.TaskFilter{WorkspaceIDs: []string{"ws-1"}}, pluginsdk.Page{Limit: 200, Cursor: cursor})
if err != nil {
t.Fatalf("List() unexpected error: %v", err)
}
seen += len(page)
if info == nil || !info.HasMore {
break
}
cursor = info.NextCursor
}
if seen != total {
t.Fatalf("Tasks().List() across all pages returned %d tasks, want %d (must not silently truncate at taskFetchPageSize)", seen, total)
}
}
// ── Session DTO mapping: acp_session_id sourcing ────────────────────────
func TestPluginHost_Sessions_ACPSessionIDFromMetadata(t *testing.T) {
d := newTestDataHost(manifest.Capabilities{APIRead: []string{"sessions"}})
d.tasks.workspaces = []*taskmodels.Workspace{{ID: "ws-1"}}
d.tasks.tasksByWorkspace = map[string][]*taskmodels.Task{"ws-1": {{ID: "task-1", WorkspaceID: "ws-1"}}}
d.tasks.sessionsByTask = map[string][]*taskmodels.TaskSession{
"task-1": {{
ID: "session-1",
TaskID: "task-1",
State: taskmodels.TaskSessionStateRunning,
StartedAt: time.Now(),
Metadata: map[string]any{"acp": map[string]any{"session_id": "acp-from-metadata"}},
}},
}
// Also seed an executors_running row so the test proves metadata wins
// over the fallback when both are present.
d.tasks.executorRunning = map[string]*taskmodels.ExecutorRunning{
"session-1": {SessionID: "session-1", ResumeToken: "acp-from-fallback"},
}
sessions, _, err := d.host.Sessions().List(context.Background(), pluginsdk.SessionFilter{}, pluginsdk.Page{})
if err != nil {
t.Fatalf("List() unexpected error: %v", err)
}
if len(sessions) != 1 || sessions[0].ACPSessionID != "acp-from-metadata" {
t.Fatalf("List() = %+v, want ACPSessionID=acp-from-metadata", sessions)
}
}
func TestPluginHost_Sessions_ACPSessionIDFallsBackToExecutorRunning(t *testing.T) {
d := newTestDataHost(manifest.Capabilities{APIRead: []string{"sessions"}})
d.tasks.workspaces = []*taskmodels.Workspace{{ID: "ws-1"}}
d.tasks.tasksByWorkspace = map[string][]*taskmodels.Task{"ws-1": {{ID: "task-1", WorkspaceID: "ws-1"}}}
d.tasks.sessionsByTask = map[string][]*taskmodels.TaskSession{
"task-1": {{ID: "session-1", TaskID: "task-1", State: taskmodels.TaskSessionStateRunning, StartedAt: time.Now()}},
}
d.tasks.executorRunning = map[string]*taskmodels.ExecutorRunning{
"session-1": {SessionID: "session-1", ResumeToken: "acp-from-fallback"},
}
sessions, _, err := d.host.Sessions().List(context.Background(), pluginsdk.SessionFilter{}, pluginsdk.Page{})
if err != nil {
t.Fatalf("List() unexpected error: %v", err)
}
if len(sessions) != 1 || sessions[0].ACPSessionID != "acp-from-fallback" {
t.Fatalf("List() = %+v, want ACPSessionID=acp-from-fallback", sessions)
}
}
func TestPluginHost_Sessions_ACPSessionIDEmptyWhenNeitherSourceHasIt(t *testing.T) {
d := newTestDataHost(manifest.Capabilities{APIRead: []string{"sessions"}})
d.tasks.workspaces = []*taskmodels.Workspace{{ID: "ws-1"}}
d.tasks.tasksByWorkspace = map[string][]*taskmodels.Task{"ws-1": {{ID: "task-1", WorkspaceID: "ws-1"}}}
d.tasks.sessionsByTask = map[string][]*taskmodels.TaskSession{
"task-1": {{ID: "session-1", TaskID: "task-1", State: taskmodels.TaskSessionStateRunning, StartedAt: time.Now()}},
}
// No executorRunning entry for session-1: GetExecutorRunningBySessionID
// returns ErrExecutorRunningNotFound, which must be swallowed.
sessions, _, err := d.host.Sessions().List(context.Background(), pluginsdk.SessionFilter{}, pluginsdk.Page{})
if err != nil {
t.Fatalf("List() unexpected error: %v", err)
}
if len(sessions) != 1 || sessions[0].ACPSessionID != "" {
t.Fatalf("List() = %+v, want empty ACPSessionID", sessions)
}
}
// TestPluginHost_Sessions_PaginatesBeforeResolvingACPSessionID proves
// Sessions().List resolves ACPSessionID (a per-item DB call via
// resolveACPSessionID's GetExecutorRunningBySessionID fallback) only for the
// page it returns, not for every session it fetched — pagination must happen
// on the raw, already-sorted sessions BEFORE the per-item DTO conversion, or
// a Page{Limit: 2} read over 5 sessions would issue 5 lookups instead of 2.
func TestPluginHost_Sessions_PaginatesBeforeResolvingACPSessionID(t *testing.T) {
d := newTestDataHost(manifest.Capabilities{APIRead: []string{"sessions"}})
d.tasks.workspaces = []*taskmodels.Workspace{{ID: "ws-1"}}
d.tasks.tasksByWorkspace = map[string][]*taskmodels.Task{"ws-1": {{ID: "task-1", WorkspaceID: "ws-1"}}}
const total = 5
sessions := make([]*taskmodels.TaskSession, total)
base := time.Date(2026, 1, 10, 0, 0, 0, 0, time.UTC)
for i := 0; i < total; i++ {
// No Metadata: resolveACPSessionID always falls through to
// GetExecutorRunningBySessionID for every one of these sessions.
sessions[i] = &taskmodels.TaskSession{
ID: fmt.Sprintf("session-%d", i),
TaskID: "task-1",
State: taskmodels.TaskSessionStateRunning,
StartedAt: base.Add(time.Duration(total-i) * time.Hour), // session-0 newest
}
}
d.tasks.sessionsByTask = map[string][]*taskmodels.TaskSession{"task-1": sessions}
const limit = 2
page, info, err := d.host.Sessions().List(context.Background(), pluginsdk.SessionFilter{}, pluginsdk.Page{Limit: limit})
if err != nil {
t.Fatalf("List() unexpected error: %v", err)
}
if len(page) != limit {
t.Fatalf("List() returned %d sessions, want %d", len(page), limit)
}
if info == nil || !info.HasMore {
t.Fatalf("PageInfo = %+v, want HasMore=true", info)
}
if d.tasks.executorRunningCalls != limit {
t.Fatalf("GetExecutorRunningBySessionID called %d times, want %d (only the returned page, not all %d fetched sessions)",
d.tasks.executorRunningCalls, limit, total)
}
}
// TestPluginHost_SessionsCodeStats_DelegatesToAnalyticsService proves
// Sessions().CodeStats reaches the injected analytics service with the
// filter translated 1:1, and maps its results back unchanged (ADR 0043(b):
// SessionCodeStats is a stable, computed shape returned as-is).
func TestPluginHost_SessionsCodeStats_DelegatesToAnalyticsService(t *testing.T) {
d := newTestDataHost(manifest.Capabilities{APIRead: []string{"sessions"}})
d.codeStats.stats = []*analyticsmodels.SessionCodeStats{
{SessionID: "session-1", LinesAddedCommitted: int64Ptr(10), LinesDeletedCommitted: int64Ptr(2), LinesAddedPeakPending: 5, LinesDeletedPeakPending: 1},
}
filter := pluginsdk.SessionFilter{TaskIDs: []string{"task-1"}, WorkspaceIDs: []string{"ws-1"}, States: []string{"RUNNING"}}
stats, info, err := d.host.Sessions().CodeStats(context.Background(), filter, pluginsdk.Page{Limit: 10})
if err != nil {
t.Fatalf("CodeStats() unexpected error: %v", err)
}
if d.codeStats.calls != 1 {
t.Fatalf("analytics service called %d times, want 1", d.codeStats.calls)
}
if len(d.codeStats.lastFilter.TaskIDs) != 1 || d.codeStats.lastFilter.TaskIDs[0] != "task-1" {
t.Errorf("filter.TaskIDs = %v, want [task-1]", d.codeStats.lastFilter.TaskIDs)
}
if len(d.codeStats.lastFilter.WorkspaceIDs) != 1 || d.codeStats.lastFilter.WorkspaceIDs[0] != "ws-1" {
t.Errorf("filter.WorkspaceIDs = %v, want [ws-1]", d.codeStats.lastFilter.WorkspaceIDs)
}
if len(d.codeStats.lastFilter.States) != 1 || d.codeStats.lastFilter.States[0] != "RUNNING" {
t.Errorf("filter.States = %v, want [RUNNING]", d.codeStats.lastFilter.States)
}
// Limit is requested as limit+1 (the HasMore probe row) — see
// sessionReader.CodeStats' doc comment.
if d.codeStats.lastFilter.Limit != 11 {
t.Errorf("filter.Limit = %d, want 11 (requested 10 + 1 probe row)", d.codeStats.lastFilter.Limit)
}
if len(stats) != 1 || stats[0].SessionID != "session-1" ||
!stats[0].CommittedLinesAvailable || stats[0].LinesAddedCommitted != 10 {
t.Fatalf("CodeStats() = %+v, want session-1 passed through unchanged", stats)
}
if info == nil || info.HasMore {
t.Fatalf("PageInfo = %+v, want HasMore=false", info)
}
}
// TestPluginHost_SessionsCodeStats_HasMoreWhenExtraProbeRowReturned proves
// the limit+1 probe-row trick correctly reports HasMore and trims the extra
// row before returning results.
func TestPluginHost_SessionsCodeStats_HasMoreWhenExtraProbeRowReturned(t *testing.T) {
d := newTestDataHost(manifest.Capabilities{APIRead: []string{"sessions"}})
d.codeStats.stats = []*analyticsmodels.SessionCodeStats{
{SessionID: "session-1"}, {SessionID: "session-2"},
}
stats, info, err := d.host.Sessions().CodeStats(context.Background(), pluginsdk.SessionFilter{}, pluginsdk.Page{Limit: 1})
if err != nil {
t.Fatalf("CodeStats() unexpected error: %v", err)
}
if len(stats) != 1 || stats[0].SessionID != "session-1" {
t.Fatalf("CodeStats() = %+v, want exactly one trimmed result", stats)
}
if info == nil || !info.HasMore || info.NextCursor != "1" {
t.Fatalf("PageInfo = %+v, want HasMore=true NextCursor=1", info)
}
}
func TestPluginHost_SessionsCodeStats_PropagatesAnalyticsError(t *testing.T) {
d := newTestDataHost(manifest.Capabilities{APIRead: []string{"sessions"}})
wantErr := errors.New("boom")
d.codeStats.err = wantErr
_, _, err := d.host.Sessions().CodeStats(context.Background(), pluginsdk.SessionFilter{}, pluginsdk.Page{})
if !errors.Is(err, wantErr) {
t.Fatalf("CodeStats() error = %v, want %v", err, wantErr)
}
}
// TestPluginHost_Sessions_TaskIDsScopedToWorkspaceIDs proves SessionFilter's
// TaskIDs and WorkspaceIDs are ANDed together: a task id that resolves to a
// task outside the requested workspaces must not leak its sessions, even
// though the id itself was explicitly requested.
func TestPluginHost_Sessions_TaskIDsScopedToWorkspaceIDs(t *testing.T) {
d := newTestDataHost(manifest.Capabilities{APIRead: []string{"sessions"}})
task1 := &taskmodels.Task{ID: "task-1", WorkspaceID: "ws-1"}
task2 := &taskmodels.Task{ID: "task-2", WorkspaceID: "ws-2"}
d.tasks.tasksByID = map[string]*taskmodels.Task{"task-1": task1, "task-2": task2}
d.tasks.sessionsByTask = map[string][]*taskmodels.TaskSession{
"task-1": {{ID: "session-1", TaskID: "task-1", State: taskmodels.TaskSessionStateRunning, StartedAt: time.Now()}},
"task-2": {{ID: "session-2", TaskID: "task-2", State: taskmodels.TaskSessionStateRunning, StartedAt: time.Now()}},
}
filter := pluginsdk.SessionFilter{TaskIDs: []string{"task-1", "task-2"}, WorkspaceIDs: []string{"ws-1"}}
sessions, _, err := d.host.Sessions().List(context.Background(), filter, pluginsdk.Page{})
if err != nil {
t.Fatalf("List() unexpected error: %v", err)
}
if len(sessions) != 1 || sessions[0].ID != "session-1" {
t.Fatalf("List() = %+v, want only session-1 (task-2 is outside the requested workspace)", sessions)
}
}
// ── Stable sort: deterministic pagination across equal timestamps ───────
// TestSortTasksNewestFirst_TiesBrokenByID proves sortTasksNewestFirst orders
// equal-CreatedAt tasks deterministically by ID, not by whatever order
// sort.Slice's unstable algorithm happens to leave them in — an offset-based
// paginated read of tasks sharing a CreatedAt second (a plausible seed/batch
// scenario) must return the same order on every call, or successive pages
// can skip or duplicate a task.
func TestSortTasksNewestFirst_TiesBrokenByID(t *testing.T) {
same := time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC)
tasks := []*taskmodels.Task{
{ID: "c", CreatedAt: same},
{ID: "a", CreatedAt: same},
{ID: "b", CreatedAt: same},
}
sortTasksNewestFirst(tasks)
got := []string{tasks[0].ID, tasks[1].ID, tasks[2].ID}
want := []string{"a", "b", "c"}
for i := range want {
if got[i] != want[i] {
t.Fatalf("sortTasksNewestFirst() order = %v, want %v (equal CreatedAt must tie-break by ID)", got, want)
}
}
}
// TestSortSessionsNewestFirst_TiesBrokenByID mirrors
// TestSortTasksNewestFirst_TiesBrokenByID for sessions (StartedAt instead of
// CreatedAt).
func TestSortSessionsNewestFirst_TiesBrokenByID(t *testing.T) {
same := time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC)
sessions := []*taskmodels.TaskSession{
{ID: "c", StartedAt: same},
{ID: "a", StartedAt: same},
{ID: "b", StartedAt: same},
}
sortSessionsNewestFirst(sessions)
got := []string{sessions[0].ID, sessions[1].ID, sessions[2].ID}
want := []string{"a", "b", "c"}
for i := range want {
if got[i] != want[i] {
t.Fatalf("sortSessionsNewestFirst() order = %v, want %v (equal StartedAt must tie-break by ID)", got, want)
}
}
}
// ── DTO mapping ──────────────────────────────────────────────────────────
func TestTaskModelToDTO_MapsFields(t *testing.T) {
created := time.Date(2026, 1, 2, 3, 4, 5, 0, time.UTC)
task := &taskmodels.Task{
ID: "task-1",
WorkspaceID: "ws-1",
WorkflowID: "wf-1",
Title: "Fix bug",
Description: "details",
State: v1.TaskStateInProgress,
Priority: "high",
Origin: "agent_created",
CreatedAt: created,
UpdatedAt: created,
ParentID: "parent-1",
Identifier: "KAN-1",
IsEphemeral: false,
Repositories: []*taskmodels.TaskRepository{
{ID: "tr-1", RepositoryID: "repo-1", BaseBranch: "main", Position: 0, CheckoutBranch: "feature/fix"},
},
Metadata: map[string]any{"k": "v"},
}
dto := taskModelToDTO(task)
if dto.ID != "task-1" || dto.State != "IN_PROGRESS" || dto.CreatedBy != "agent_created" {
t.Fatalf("taskModelToDTO() = %+v, unexpected core fields", dto)
}
if dto.CreatedAt != created.Format(time.RFC3339) {
t.Errorf("CreatedAt = %q, want RFC3339 %q", dto.CreatedAt, created.Format(time.RFC3339))
}
if dto.ParentID == nil || *dto.ParentID != "parent-1" {
t.Errorf("ParentID = %v, want parent-1", dto.ParentID)
}
if len(dto.Repositories) != 1 || dto.Repositories[0].RepositoryID != "repo-1" || dto.Repositories[0].CheckoutBranch != "feature/fix" {
t.Errorf("Repositories = %+v, want one repo-1", dto.Repositories)
}
if dto.Metadata["k"] != "v" {
t.Errorf("Metadata = %+v, want k=v", dto.Metadata)
}
}
func TestTaskModelToDTO_EmptyParentIDIsNil(t *testing.T) {
dto := taskModelToDTO(&taskmodels.Task{ID: "task-1"})
if dto.ParentID != nil {
t.Fatalf("ParentID = %v, want nil for a root task", dto.ParentID)
}
}
// ── Archived-task visibility ────────────────────────────────────────────
// Sessions().List must fetch tasks WITH archived included (an archived
// task's sessions are still real sessions), while Tasks().List keeps
// archived tasks out of plugin-visible work items.
func TestPluginHost_ArchivedTaskFetchFlags(t *testing.T) {
d := newTestDataHost(manifest.Capabilities{APIRead: []string{"sessions", "tasks"}})
d.tasks.workspaces = []*taskmodels.Workspace{{ID: "ws-1"}}
d.tasks.tasksByWorkspace = map[string][]*taskmodels.Task{"ws-1": {{ID: "task-1", WorkspaceID: "ws-1"}}}
if _, _, err := d.host.Sessions().List(context.Background(), pluginsdk.SessionFilter{}, pluginsdk.Page{}); err != nil {
t.Fatalf("Sessions().List unexpected error: %v", err)
}
if _, _, err := d.host.Tasks().List(context.Background(), pluginsdk.TaskFilter{}, pluginsdk.Page{}); err != nil {
t.Fatalf("Tasks().List unexpected error: %v", err)
}
want := []bool{true, false} // sessions read first (archived in), tasks read second (archived out)
if len(d.tasks.gotIncludeArchived) != 2 ||
d.tasks.gotIncludeArchived[0] != want[0] || d.tasks.gotIncludeArchived[1] != want[1] {
t.Fatalf("includeArchived per call = %v, want %v", d.tasks.gotIncludeArchived, want)
}
}
// ── Messages reader ─────────────────────────────────────────────────────
func TestPluginHost_Messages_SucceedsAndStripsSystemContent(t *testing.T) {
d := newTestDataHost(manifest.Capabilities{APIRead: []string{"messages"}})
created := time.Date(2026, 7, 20, 9, 30, 0, 0, time.UTC)
d.messages.messages = []*taskmodels.Message{
{
ID: "m1",
TaskSessionID: "s1",
TaskID: "t1",
TurnID: "turn1",
AuthorType: taskmodels.MessageAuthorUser,
Content: "<kandev-system>secret prompt</kandev-system>Please summarize yesterday.",
Type: taskmodels.MessageTypeMessage,
CreatedAt: created,
},
{
ID: "m2",
TaskSessionID: "s1",
TaskID: "t1",
TurnID: "turn1",
AuthorType: taskmodels.MessageAuthorAgent,
Content: "Here is the summary.",
Type: "", // empty type must default to "message"
CreatedAt: created.Add(time.Minute),
},
}
msgs, info, err := d.host.Messages().List(context.Background(), pluginsdk.MessageFilter{
SessionIDs: []string{"s1"},
}, pluginsdk.Page{Limit: 50})
if err != nil {
t.Fatalf("List() unexpected error: %v", err)
}
if len(msgs) != 2 {
t.Fatalf("List() len = %d, want 2", len(msgs))
}
if msgs[0].Content != "Please summarize yesterday." {
t.Fatalf("content[0] = %q, want system block stripped", msgs[0].Content)
}
if msgs[0].AuthorType != "user" || msgs[1].AuthorType != "agent" {
t.Fatalf("author types = %q/%q, want user/agent", msgs[0].AuthorType, msgs[1].AuthorType)
}
if msgs[1].Type != "message" {
t.Fatalf("type[1] = %q, want defaulted to message", msgs[1].Type)
}
if msgs[0].SessionID != "s1" || msgs[0].TaskID != "t1" || msgs[0].TurnID != "turn1" {
t.Fatalf("ids = %+v, want session/task/turn set", msgs[0])
}
if msgs[0].CreatedAt != "2026-07-20T09:30:00Z" {
t.Fatalf("created_at = %q, want RFC3339", msgs[0].CreatedAt)
}
if info == nil || info.HasMore {
t.Fatalf("PageInfo = %+v, want HasMore=false", info)
}
}
func TestPluginHost_Messages_PassesFilterAndTimeRange(t *testing.T) {
d := newTestDataHost(manifest.Capabilities{APIRead: []string{"messages"}})
since := "2026-07-20T00:00:00Z"
until := "2026-07-21T00:00:00Z"
_, _, err := d.host.Messages().List(context.Background(), pluginsdk.MessageFilter{
SessionIDs: []string{"s1", "s2"},
TaskIDs: []string{"t1"},
Types: []string{"message", "content"},
Since: &since,
Until: &until,
}, pluginsdk.Page{Limit: 10})
if err != nil {
t.Fatalf("List() unexpected error: %v", err)
}
got := d.messages.lastFilter
if len(got.SessionIDs) != 2 || len(got.TaskIDs) != 1 || len(got.Types) != 2 {
t.Fatalf("filter passthrough = %+v", got)
}
if got.Since == nil || !got.Since.Equal(time.Date(2026, 7, 20, 0, 0, 0, 0, time.UTC)) {
t.Fatalf("since = %v, want parsed 2026-07-20", got.Since)
}
if got.Until == nil || !got.Until.Equal(time.Date(2026, 7, 21, 0, 0, 0, 0, time.UTC)) {
t.Fatalf("until = %v, want parsed 2026-07-21", got.Until)
}
// Reader requests one extra row past the page limit to derive HasMore.
if got.Limit != 11 {
t.Fatalf("data-source Limit = %d, want page-limit+1 (11)", got.Limit)
}
}
func TestPluginHost_Messages_PaginatesWithHasMore(t *testing.T) {
d := newTestDataHost(manifest.Capabilities{APIRead: []string{"messages"}})
base := time.Date(2026, 7, 20, 9, 0, 0, 0, time.UTC)
// Data source returns limit+1 rows (3) for a page limit of 2 → HasMore.
for i := 0; i < 3; i++ {
d.messages.messages = append(d.messages.messages, &taskmodels.Message{
ID: fmt.Sprintf("m%d", i),
AuthorType: taskmodels.MessageAuthorUser,
Content: "hi",
CreatedAt: base.Add(time.Duration(i) * time.Minute),
})
}
msgs, info, err := d.host.Messages().List(context.Background(), pluginsdk.MessageFilter{}, pluginsdk.Page{Limit: 2})
if err != nil {
t.Fatalf("List() unexpected error: %v", err)
}
if len(msgs) != 2 {
t.Fatalf("page len = %d, want 2 (trimmed from 3)", len(msgs))
}
if info == nil || !info.HasMore || info.NextCursor != "2" {
t.Fatalf("PageInfo = %+v, want HasMore=true NextCursor=2", info)
}
}
func TestPluginHost_Messages_TooManyFilterValuesIsInvalidArgument(t *testing.T) {
d := newTestDataHost(manifest.Capabilities{APIRead: []string{"messages"}})
sessionIDs := make([]string, maxMessageFilterValues+1)
for i := range sessionIDs {
sessionIDs[i] = fmt.Sprintf("s%d", i)
}
_, _, err := d.host.Messages().List(context.Background(), pluginsdk.MessageFilter{SessionIDs: sessionIDs}, pluginsdk.Page{})
st, ok := status.FromError(err)
if !ok || st.Code() != codes.InvalidArgument {
t.Fatalf("err = %v, want InvalidArgument", err)
}
if d.messages.calls != 0 {
t.Fatalf("data source called %d times, want 0 (rejected before query)", d.messages.calls)
}
}
func TestPluginHost_Messages_InvalidTimeIsInvalidArgument(t *testing.T) {
d := newTestDataHost(manifest.Capabilities{APIRead: []string{"messages"}})
bad := "not-a-timestamp"
_, _, err := d.host.Messages().List(context.Background(), pluginsdk.MessageFilter{Since: &bad}, pluginsdk.Page{})
st, ok := status.FromError(err)
if !ok || st.Code() != codes.InvalidArgument {
t.Fatalf("err = %v, want InvalidArgument", err)
}
if d.messages.calls != 0 {
t.Fatalf("data source called %d times, want 0 (rejected before query)", d.messages.calls)
}
}