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

255 lines
9.8 KiB
Go

package plugins
import (
"context"
"testing"
"time"
githubsvc "github.com/kandev/kandev/internal/github"
"github.com/kandev/kandev/internal/plugins/manifest"
taskmodels "github.com/kandev/kandev/internal/task/models"
wfmodels "github.com/kandev/kandev/internal/workflow/models"
"github.com/kandev/kandev/pkg/pluginsdk"
"github.com/stretchr/testify/require"
)
// The step a task stands on and whether it is archived were all
// unreadable from a plugin before this. A plugin could see a task's State
// (created / in progress / review / completed) but not WHERE on the board it
// was, so it could not render or group by a workflow at all.
func TestTaskModelToDTOCarriesBoardAndPlanningFields(t *testing.T) {
archived := time.Date(2026, 8, 20, 10, 0, 0, 0, time.UTC)
queued := time.Date(2026, 8, 19, 9, 0, 0, 0, time.UTC)
task := &taskmodels.Task{
ID: "t1",
WorkflowStepID: "step-pr-review",
Position: 3,
AssigneeAgentProfileID: "agent-opus",
Autopilot: true,
WIPAdmitted: true,
QueuedForStepID: "step-build",
QueuedAt: &queued,
ProjectID: "proj-1",
ExternalID: "WO-101",
ArchivedAt: &archived,
}
dto := taskModelToDTO(task)
require.Equal(t, "step-pr-review", dto.WorkflowStepID)
require.Equal(t, int32(3), dto.Position)
require.Equal(t, "agent-opus", dto.AssigneeAgentProfileID)
require.True(t, dto.Autopilot)
require.True(t, dto.WIPAdmitted)
require.Equal(t, "step-build", dto.QueuedForStepID)
require.NotNil(t, dto.QueuedAt)
require.Equal(t, "2026-08-19T09:00:00Z", *dto.QueuedAt)
require.Equal(t, "proj-1", dto.ProjectID)
require.Equal(t, "WO-101", dto.ExternalID)
require.NotNil(t, dto.ArchivedAt)
require.Equal(t, "2026-08-20T10:00:00Z", *dto.ArchivedAt)
}
// A live task must report no archive timestamp at all, not a zero time — the
// difference is exactly how a plugin tells delivered work from in-flight work.
func TestTaskModelToDTOLeavesArchivedAtNilForALiveTask(t *testing.T) {
dto := taskModelToDTO(&taskmodels.Task{ID: "t1"})
require.Nil(t, dto.ArchivedAt)
require.Nil(t, dto.QueuedAt)
}
// The session-ceiling replay payload carries the launch-scoped environment
// map, the composed prompt, its attachments and entity references. A plugin
// or agent-authored canvas is a lower-trust surface than the host itself, so
// it must never see that payload even though it is free to see that a
// deferral is pending, of what kind, and why.
func TestTaskModelToDTORedactsCeilingLaunchPayload(t *testing.T) {
task := &taskmodels.Task{
ID: "t1",
Metadata: map[string]interface{}{
taskmodels.MetaKeyDeferredLaunch: map[string]interface{}{
taskmodels.CeilingLaunchPayloadKey: map[string]interface{}{
"prompt": "do the secret thing",
},
taskmodels.CeilingReasonCodeKey: "ceiling_reached",
},
},
}
dto := taskModelToDTO(task)
deferred, ok := dto.Metadata[taskmodels.MetaKeyDeferredLaunch].(map[string]interface{})
require.True(t, ok, "the discriminators must still be visible")
require.Equal(t, "ceiling_reached", deferred[taskmodels.CeilingReasonCodeKey])
_, leaked := deferred[taskmodels.CeilingLaunchPayloadKey]
require.False(t, leaked, "ceiling_launch_payload must be redacted from the plugin host-data path")
}
// Review and check state ride along because kandev's PR watcher already syncs
// them; a plugin asking "what can merge" would otherwise re-query the forge
// once per pull request.
func TestTaskPRsToDTOsCarriesReadinessNotJustIdentity(t *testing.T) {
merged := time.Date(2026, 8, 21, 12, 0, 0, 0, time.UTC)
draft := true
prs := []*githubsvc.TaskPR{{
PRNumber: 3019, PRURL: "https://example/3019", PRTitle: "expose comments",
State: "open", HeadBranch: "feature/x", BaseBranch: "main",
IsDraft: &draft, AuthorLogin: "nova28",
ReviewState: "changes_requested", ChecksState: "failure", MergeableState: "blocked",
UnresolvedReviewThreads: 3, ChecksTotal: 12, ChecksPassing: 10,
Additions: 400, Deletions: 25, MergedAt: &merged,
}}
out := taskPRsToDTOs(prs)
require.Len(t, out, 1)
require.Equal(t, int64(3019), out[0].Number)
require.Equal(t, "github", out[0].Provider)
require.True(t, out[0].IsDraft)
require.Equal(t, "changes_requested", out[0].ReviewState)
require.Equal(t, "failure", out[0].ChecksState)
require.Equal(t, int32(3), out[0].UnresolvedReviewThreads)
require.Equal(t, int32(10), out[0].ChecksPassing)
require.NotNil(t, out[0].MergedAt)
require.Equal(t, "2026-08-21T12:00:00Z", *out[0].MergedAt)
}
func TestTaskPRsToDTOsReturnsNewestFirst(t *testing.T) {
prs := []*githubsvc.TaskPR{
{PRNumber: 100, PRTitle: "older", CreatedAt: time.Date(2026, 8, 20, 0, 0, 0, 0, time.UTC)},
{PRNumber: 200, PRTitle: "newer", CreatedAt: time.Date(2026, 8, 21, 0, 0, 0, 0, time.UTC)},
}
out := taskPRsToDTOs(prs)
require.Len(t, out, 2)
require.Equal(t, int64(200), out[0].Number)
require.Equal(t, "newer", out[0].Title)
require.Equal(t, int64(100), out[1].Number)
}
// IsDraft is nullable on the row: unknown before the first sync. Nil must read
// as not-draft rather than panicking the whole task list.
func TestTaskPRsToDTOsTreatsAnUnknownDraftFlagAsNotDraft(t *testing.T) {
out := taskPRsToDTOs([]*githubsvc.TaskPR{{PRNumber: 1}, nil})
require.Len(t, out, 1, "a nil row is skipped, not mapped")
require.False(t, out[0].IsDraft)
}
type stubPRSource struct {
byTask map[string][]*githubsvc.TaskPR
err error
calls int
}
func (s *stubPRSource) ListTaskPRsByTaskIDs(_ context.Context, ids []string) (map[string][]*githubsvc.TaskPR, error) {
s.calls++
if s.err != nil {
return nil, s.err
}
return s.byTask, nil
}
func TestAttachPullRequestsFillsOnlyTheTasksThatHaveThem(t *testing.T) {
src := &stubPRSource{byTask: map[string][]*githubsvc.TaskPR{
"t1": {{PRNumber: 2907, State: "open"}},
}}
h := &pluginHost{taskPRs: src}
tasks := []pluginsdk.Task{{ID: "t1"}, {ID: "t2"}}
h.attachPullRequests(context.Background(), tasks)
require.Len(t, tasks[0].PullRequests, 1)
require.Equal(t, int64(2907), tasks[0].PullRequests[0].Number)
require.Empty(t, tasks[1].PullRequests)
require.Equal(t, 1, src.calls, "one batched lookup, not one per task")
}
// Pull requests are supplementary to a task read. Refusing the whole list when
// the lookup fails would take away more than it protects.
func TestAttachPullRequestsIsSilentWhenTheSourceIsMissingOrFailing(t *testing.T) {
tasks := []pluginsdk.Task{{ID: "t1"}}
(&pluginHost{}).attachPullRequests(context.Background(), tasks)
require.Empty(t, tasks[0].PullRequests)
failing := &stubPRSource{err: context.DeadlineExceeded}
(&pluginHost{taskPRs: failing}).attachPullRequests(context.Background(), tasks)
require.Empty(t, tasks[0].PullRequests)
require.Equal(t, 1, failing.calls)
}
func TestTaskReaderGetAttachesPullRequests(t *testing.T) {
d := newTestDataHost(manifest.Capabilities{APIRead: []string{"tasks"}})
task := &taskmodels.Task{ID: "task-1", Title: "with a PR"}
d.tasks.tasksByID = map[string]*taskmodels.Task{"task-1": task}
src := &stubPRSource{byTask: map[string][]*githubsvc.TaskPR{
"task-1": {{PRNumber: 42, PRTitle: "fix"}},
}}
d.host.taskPRs = src
got, err := d.host.Tasks().Get(context.Background(), "task-1")
require.NoError(t, err)
require.Len(t, got.PullRequests, 1)
require.Equal(t, int64(42), got.PullRequests[0].Number)
}
func TestAttachPullRequestsUsesSourceWiredAfterHostCreation(t *testing.T) {
var source taskPRSource
h := &pluginHost{taskPRsDep: func() taskPRSource { return source }}
tasks := []pluginsdk.Task{{ID: "task-1"}}
h.attachPullRequests(context.Background(), tasks)
require.Empty(t, tasks[0].PullRequests)
source = &stubPRSource{byTask: map[string][]*githubsvc.TaskPR{
"task-1": {{PRNumber: 99}},
}}
h.attachPullRequests(context.Background(), tasks)
require.Len(t, tasks[0].PullRequests, 1)
require.Equal(t, int64(99), tasks[0].PullRequests[0].Number)
}
// A board plugin reads the step's own colour rather than inventing a palette,
// so its columns match what the operator already sees in kandev.
func TestWorkflowStepModelToDTOCarriesPresentationAndCapacity(t *testing.T) {
step := wfmodels.WorkflowStep{
ID: "s1", Name: "PR Review", Position: 8, Color: "bg-indigo-500",
IsStartStep: true, WIPLimit: 3, AgentProfileID: "agent-1",
}
dto := workflowStepModelToDTO(&step)
require.Equal(t, "bg-indigo-500", dto.Color)
require.True(t, dto.IsStartStep)
require.Equal(t, int32(3), dto.WIPLimit)
require.Equal(t, "agent-1", dto.AgentProfileID)
}
// Archived tasks were unreachable from a plugin: the fetch hardcoded
// includeArchived=false and TaskFilter had no way to ask. A plugin reporting on
// delivery needs them — an archived task is usually a delivered one, so leaving
// them out makes finished work look like it never happened.
func TestTasksListHonoursIncludeArchivedFromTheFilter(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"}}}
_, _, err := d.host.Tasks().List(context.Background(),
pluginsdk.TaskFilter{IncludeArchived: true}, pluginsdk.Page{})
require.NoError(t, err)
require.Equal(t, []bool{true}, d.tasks.gotIncludeArchived)
}
// Not asking still means live tasks only, so a plugin written before the field
// existed keeps exactly the visibility it had.
func TestTasksListDefaultsToLiveTasksOnly(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"}}}
_, _, err := d.host.Tasks().List(context.Background(), pluginsdk.TaskFilter{}, pluginsdk.Page{})
require.NoError(t, err)
require.Equal(t, []bool{false}, d.tasks.gotIncludeArchived)
}