277677f330
Co-authored-by: nova28 <17953305+nova28@users.noreply.github.com> Co-authored-by: Carlos Florencio <carlosmflorencio@gmail.com>
237 lines
11 KiB
Go
237 lines
11 KiB
Go
package plugins
|
|
|
|
import (
|
|
"context"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/kandev/kandev/internal/plugins/manifest"
|
|
taskmodels "github.com/kandev/kandev/internal/task/models"
|
|
taskservice "github.com/kandev/kandev/internal/task/service"
|
|
v1 "github.com/kandev/kandev/pkg/api/v1"
|
|
"github.com/kandev/kandev/pkg/pluginsdk"
|
|
"github.com/stretchr/testify/require"
|
|
"google.golang.org/grpc/codes"
|
|
"google.golang.org/grpc/status"
|
|
)
|
|
|
|
func TestAttachDependencies_CopiesViewOntoMatchingTask(t *testing.T) {
|
|
d := newTestDataHost(manifest.Capabilities{APIRead: []string{"tasks"}})
|
|
d.tasks.dependencyViews = map[string]taskservice.DependencyView{
|
|
"task-1": {
|
|
Blocked: true, BlockedReason: taskservice.BlockedReasonPending,
|
|
DependsOn: []taskservice.DependencyRef{{ID: "task-0", Title: "Predecessor", State: v1.TaskStateInProgress, Status: taskservice.DependencyPending}},
|
|
Blocks: []taskservice.DependencyRef{{ID: "task-9", Title: "Dependent", State: v1.TaskStateTODO, Status: taskservice.DependencyPending}},
|
|
DependsOnTruncated: true,
|
|
},
|
|
}
|
|
model := &taskmodels.Task{ID: "task-1"}
|
|
tasks := []pluginsdk.Task{{ID: "task-1"}}
|
|
|
|
err := d.host.attachDependencies(context.Background(), tasks, []*taskmodels.Task{model}, false, "Test")
|
|
require.NoError(t, err)
|
|
|
|
require.True(t, tasks[0].Blocked)
|
|
require.Equal(t, taskservice.BlockedReasonPending, tasks[0].BlockedReason)
|
|
require.Equal(t, []pluginsdk.TaskDependencyRef{{ID: "task-0", Title: "Predecessor", State: string(v1.TaskStateInProgress), Status: taskservice.DependencyPending}}, tasks[0].DependsOn)
|
|
require.True(t, tasks[0].DependsOnTruncated)
|
|
require.False(t, tasks[0].BlocksTruncated)
|
|
}
|
|
|
|
func TestAttachDependencies_BlocksEntriesNeverCarryStatus(t *testing.T) {
|
|
d := newTestDataHost(manifest.Capabilities{APIRead: []string{"tasks"}})
|
|
d.tasks.dependencyViews = map[string]taskservice.DependencyView{
|
|
"task-1": {
|
|
Blocks: []taskservice.DependencyRef{{ID: "task-9", Title: "Dependent", State: v1.TaskStateTODO, Status: taskservice.DependencyPending}},
|
|
},
|
|
}
|
|
tasks := []pluginsdk.Task{{ID: "task-1"}}
|
|
|
|
err := d.host.attachDependencies(context.Background(), tasks, []*taskmodels.Task{{ID: "task-1"}}, false, "Test")
|
|
require.NoError(t, err)
|
|
|
|
require.Len(t, tasks[0].Blocks, 1)
|
|
require.Empty(t, tasks[0].Blocks[0].Status, "a blocks entry's status describes the dependent's own progress, not readiness, so the wire contract drops it")
|
|
}
|
|
|
|
func TestAttachDependencies_EmptyEdgeListsAreNeverNil(t *testing.T) {
|
|
d := newTestDataHost(manifest.Capabilities{APIRead: []string{"tasks"}})
|
|
tasks := []pluginsdk.Task{{ID: "task-1"}}
|
|
|
|
err := d.host.attachDependencies(context.Background(), tasks, []*taskmodels.Task{{ID: "task-1"}}, false, "Test")
|
|
require.NoError(t, err)
|
|
|
|
require.NotNil(t, tasks[0].DependsOn)
|
|
require.Empty(t, tasks[0].DependsOn)
|
|
require.NotNil(t, tasks[0].Blocks)
|
|
require.Empty(t, tasks[0].Blocks)
|
|
}
|
|
|
|
func TestAttachDependencies_StartWhenUnblockedReflectsStoredIntentWhenNotWithheld(t *testing.T) {
|
|
d := newTestDataHost(manifest.Capabilities{APIRead: []string{"tasks"}})
|
|
d.tasks.dependencyViews = map[string]taskservice.DependencyView{
|
|
"task-1": {Blocked: true, BlockedReason: taskservice.BlockedReasonPending},
|
|
}
|
|
model := &taskmodels.Task{
|
|
ID: "task-1",
|
|
Metadata: map[string]interface{}{
|
|
taskmodels.MetaKeyDeferredLaunch: map[string]interface{}{
|
|
taskmodels.DeferredLaunchStartWhenUnblockedKey: true,
|
|
},
|
|
},
|
|
}
|
|
tasks := []pluginsdk.Task{{ID: "task-1"}}
|
|
|
|
err := d.host.attachDependencies(context.Background(), tasks, []*taskmodels.Task{model}, false, "Test")
|
|
require.NoError(t, err)
|
|
require.True(t, tasks[0].StartWhenUnblocked)
|
|
}
|
|
|
|
func TestAttachDependencies_StartWhenUnblockedForcedFalseUnderWithheldVerdict(t *testing.T) {
|
|
d := newTestDataHost(manifest.Capabilities{APIRead: []string{"tasks"}})
|
|
d.tasks.dependencyViews = map[string]taskservice.DependencyView{
|
|
"task-1": {Blocked: true, BlockedReason: taskservice.BlockedReasonUnknown},
|
|
}
|
|
model := &taskmodels.Task{
|
|
ID: "task-1",
|
|
Metadata: map[string]interface{}{
|
|
taskmodels.MetaKeyDeferredLaunch: map[string]interface{}{
|
|
taskmodels.DeferredLaunchStartWhenUnblockedKey: true,
|
|
},
|
|
},
|
|
}
|
|
tasks := []pluginsdk.Task{{ID: "task-1"}}
|
|
|
|
err := d.host.attachDependencies(context.Background(), tasks, []*taskmodels.Task{model}, false, "Test")
|
|
require.NoError(t, err)
|
|
require.False(t, tasks[0].StartWhenUnblocked, "the withheld verdict forces start_when_unblocked false regardless of the stored intent")
|
|
}
|
|
|
|
func TestAttachDependencies_BoundedFanOutRefusalBecomesResourceExhausted(t *testing.T) {
|
|
d := newTestDataHost(manifest.Capabilities{APIRead: []string{"tasks"}})
|
|
d.tasks.dependencyViewsErr = taskservice.ErrDependencyFanOutExceeded
|
|
tasks := []pluginsdk.Task{{ID: "task-1"}}
|
|
|
|
err := d.host.attachDependencies(context.Background(), tasks, []*taskmodels.Task{{ID: "task-1"}}, true, "Test")
|
|
require.Error(t, err)
|
|
require.Equal(t, codes.ResourceExhausted, status.Code(err))
|
|
}
|
|
|
|
func TestAttachDependencies_UnboundedVariantNeverConsultsBoundedSource(t *testing.T) {
|
|
d := newTestDataHost(manifest.Capabilities{APIRead: []string{"tasks"}})
|
|
tasks := []pluginsdk.Task{{ID: "task-1"}}
|
|
|
|
err := d.host.attachDependencies(context.Background(), tasks, []*taskmodels.Task{{ID: "task-1"}}, false, "Test")
|
|
require.NoError(t, err)
|
|
require.Equal(t, 1, d.tasks.dependencyViewsCalls)
|
|
require.Equal(t, 0, d.tasks.dependencyViewsBoundedCalls)
|
|
}
|
|
|
|
func TestAttachDependencies_NoopOnEmptyTasks(t *testing.T) {
|
|
d := newTestDataHost(manifest.Capabilities{APIRead: []string{"tasks"}})
|
|
err := d.host.attachDependencies(context.Background(), nil, nil, true, "Test")
|
|
require.NoError(t, err)
|
|
require.Equal(t, 0, d.tasks.dependencyViewsBoundedCalls)
|
|
}
|
|
|
|
func TestPluginHost_Tasks_GetAttachesDependencyProjectionUnbounded(t *testing.T) {
|
|
d := newTestDataHost(manifest.Capabilities{APIRead: []string{"tasks"}})
|
|
d.tasks.tasksByID = map[string]*taskmodels.Task{"task-1": {ID: "task-1", Title: "Task 1"}}
|
|
d.tasks.dependencyViews = map[string]taskservice.DependencyView{
|
|
"task-1": {Blocked: true, BlockedReason: taskservice.BlockedReasonUnknown},
|
|
}
|
|
|
|
got, err := d.host.Tasks().Get(context.Background(), "task-1")
|
|
require.NoError(t, err)
|
|
require.True(t, got.Blocked)
|
|
require.Equal(t, taskservice.BlockedReasonUnknown, got.BlockedReason)
|
|
require.Equal(t, 1, d.tasks.dependencyViewsCalls)
|
|
require.Equal(t, 0, d.tasks.dependencyViewsBoundedCalls, "Get is one of the single-task flows exempt from the fan-out cap")
|
|
}
|
|
|
|
func TestPluginHost_Tasks_ListAttachesDependencyProjectionBoundedOverThePageOnly(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", CreatedAt: time.Date(2026, 1, 3, 0, 0, 0, 0, time.UTC)},
|
|
{ID: "task-2", WorkspaceID: "ws-1", CreatedAt: time.Date(2026, 1, 2, 0, 0, 0, 0, time.UTC)},
|
|
{ID: "task-3", WorkspaceID: "ws-1", CreatedAt: time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC)},
|
|
},
|
|
}
|
|
d.tasks.dependencyViews = map[string]taskservice.DependencyView{
|
|
"task-1": {Blocked: true, BlockedReason: taskservice.BlockedReasonPending},
|
|
}
|
|
|
|
tasks, _, err := d.host.Tasks().List(context.Background(), pluginsdk.TaskFilter{}, pluginsdk.Page{Limit: 1})
|
|
require.NoError(t, err)
|
|
require.Len(t, tasks, 1)
|
|
require.True(t, tasks[0].Blocked)
|
|
require.Equal(t, []string{"task-1"}, d.tasks.dependencyViewsTasks, "derivation runs over the returned page only, not the whole workspace")
|
|
require.Equal(t, 1, d.tasks.dependencyViewsBoundedCalls, "ListTasks is bound by the fan-out cap")
|
|
}
|
|
|
|
func TestPluginHost_Tasks_ListTranslatesFanOutRefusalToResourceExhausted(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"}}}
|
|
d.tasks.dependencyViewsErr = taskservice.ErrDependencyFanOutExceeded
|
|
|
|
_, _, err := d.host.Tasks().List(context.Background(), pluginsdk.TaskFilter{}, pluginsdk.Page{})
|
|
require.Error(t, err)
|
|
require.Equal(t, codes.ResourceExhausted, status.Code(err))
|
|
}
|
|
|
|
func TestAttachDependencies_WithholdsWhenTaskDataSourceNil(t *testing.T) {
|
|
host := &pluginHost{capabilities: manifest.Capabilities{APIRead: []string{"tasks"}}}
|
|
tasks := []pluginsdk.Task{{ID: "task-1"}}
|
|
err := host.attachDependencies(context.Background(), tasks, []*taskmodels.Task{{ID: "task-1"}}, true, "Test")
|
|
require.NoError(t, err)
|
|
require.True(t, tasks[0].Blocked, "a nil task-data source must fail closed to the withheld verdict, not report not-blocked")
|
|
require.Equal(t, taskservice.BlockedReasonUnknown, tasks[0].BlockedReason)
|
|
require.Equal(t, []pluginsdk.TaskDependencyRef{}, tasks[0].DependsOn)
|
|
require.Equal(t, []pluginsdk.TaskDependencyRef{}, tasks[0].Blocks)
|
|
}
|
|
|
|
func TestAttachDependencies_WithholdsWhenCallerLacksReadCapability(t *testing.T) {
|
|
d := newTestDataHost(manifest.Capabilities{})
|
|
d.tasks.dependencyViews = map[string]taskservice.DependencyView{
|
|
"task-1": {Blocked: false, BlockedReason: ""},
|
|
}
|
|
model := &taskmodels.Task{
|
|
ID: "task-1",
|
|
Metadata: map[string]interface{}{
|
|
taskmodels.MetaKeyDeferredLaunch: map[string]interface{}{
|
|
taskmodels.DeferredLaunchStartWhenUnblockedKey: true,
|
|
},
|
|
},
|
|
}
|
|
tasks := []pluginsdk.Task{{ID: "task-1"}}
|
|
|
|
err := d.host.attachDependencies(context.Background(), tasks, []*taskmodels.Task{model}, false, "Test")
|
|
require.NoError(t, err)
|
|
|
|
require.True(t, tasks[0].Blocked, "a caller without api_read:tasks gets the fail-closed withheld verdict, not real data")
|
|
require.Equal(t, taskservice.BlockedReasonUnknown, tasks[0].BlockedReason)
|
|
require.Equal(t, []pluginsdk.TaskDependencyRef{}, tasks[0].DependsOn)
|
|
require.Equal(t, []pluginsdk.TaskDependencyRef{}, tasks[0].Blocks)
|
|
require.False(t, tasks[0].DependsOnTruncated)
|
|
require.False(t, tasks[0].BlocksTruncated)
|
|
require.False(t, tasks[0].StartWhenUnblocked)
|
|
require.Equal(t, 0, d.tasks.dependencyViewsCalls, "withholding for lack of capability issues no derivation call")
|
|
require.Equal(t, 0, d.tasks.dependencyViewsBoundedCalls)
|
|
}
|
|
|
|
func TestAttachDependencies_WriteOnlyCapabilityWithholdsEvenWithTaskDataSource(t *testing.T) {
|
|
d := newTestDataHost(manifest.Capabilities{APIWrite: []string{"tasks"}})
|
|
d.tasks.dependencyViews = map[string]taskservice.DependencyView{
|
|
"task-1": {Blocked: true, BlockedReason: taskservice.BlockedReasonPending},
|
|
}
|
|
tasks := []pluginsdk.Task{{ID: "task-1"}}
|
|
|
|
err := d.host.attachDependencies(context.Background(), tasks, []*taskmodels.Task{{ID: "task-1"}}, false, "Test")
|
|
require.NoError(t, err)
|
|
|
|
require.Equal(t, taskservice.BlockedReasonUnknown, tasks[0].BlockedReason, "api_write:tasks alone never unlocks real dependency data")
|
|
}
|