test(backend): increase task sqlite coverage (#2547)

Co-authored-by: Kandev Agent <agent@kandev.dev>
This commit is contained in:
João Salavisa
2026-08-12 02:22:37 +02:00
committed by GitHub
parent 45f1bdc8d2
commit b170d205fa
11 changed files with 1282 additions and 0 deletions
@@ -0,0 +1,94 @@
package sqlite
import (
"context"
"errors"
"testing"
"time"
"github.com/kandev/kandev/internal/task/models"
)
func TestMessageAttachmentDefaultsListExpireAndOwnerScopedDelete(t *testing.T) {
repo := newRepoForEntityTests(t)
ctx := context.Background()
seedWorkspace(t, repo, "workspace-a")
now := time.Date(2026, time.March, 4, 5, 6, 7, 0, time.UTC)
attachments := []*models.TaskMessageAttachment{
{ID: "attachment-expired", OwnerID: "owner-a", WorkspaceID: "workspace-a", Name: "old.txt", SizeBytes: 10, ExpiresAt: now.Add(-time.Minute)},
{ID: "attachment-future", OwnerID: "owner-a", WorkspaceID: "workspace-a", Name: "future.txt", SizeBytes: 20, ExpiresAt: now.Add(time.Minute)},
}
for _, attachment := range attachments {
if err := repo.CreateMessageAttachment(ctx, attachment); err != nil {
t.Fatalf("CreateMessageAttachment(%s): %v", attachment.ID, err)
}
if attachment.StorageKey != attachment.ID || attachment.State != models.AttachmentStateStaged || attachment.CreatedAt.IsZero() || attachment.UpdatedAt.IsZero() {
t.Fatalf("attachment defaults not applied: %+v", attachment)
}
}
listed, err := repo.ListMessageAttachments(ctx, []string{"attachment-future", "attachment-expired", "missing"})
if err != nil || len(listed) != 2 {
t.Fatalf("ListMessageAttachments = %+v, %v", listed, err)
}
empty, err := repo.ListMessageAttachments(ctx, nil)
if err != nil || empty != nil {
t.Fatalf("ListMessageAttachments(nil) = %+v, %v", empty, err)
}
expired, err := repo.MarkExpiredMessageAttachments(ctx, now)
if err != nil || len(expired) != 1 || expired[0].ID != "attachment-expired" || expired[0].State != models.AttachmentStateExpired {
t.Fatalf("MarkExpiredMessageAttachments = %+v, %v", expired, err)
}
got, err := repo.GetMessageAttachment(ctx, "attachment-expired")
if err != nil || got.State != models.AttachmentStateExpired {
t.Fatalf("GetMessageAttachment(expired) = %+v, %v", got, err)
}
if err := repo.DeleteMessageAttachment(ctx, "attachment-future", "wrong-owner"); !errors.Is(err, models.ErrAttachmentNotFound) {
t.Fatalf("wrong-owner delete error = %v", err)
}
if err := repo.DeleteMessageAttachment(ctx, "attachment-future", "owner-a"); err != nil {
t.Fatalf("DeleteMessageAttachment: %v", err)
}
if _, err := repo.GetMessageAttachment(ctx, "attachment-future"); !errors.Is(err, models.ErrAttachmentNotFound) {
t.Fatalf("GetMessageAttachment after delete error = %v", err)
}
}
func TestClaimedAttachmentCleanupReturnsOnlyMatchingRows(t *testing.T) {
repo := newRepoForEntityTests(t)
ctx := context.Background()
seedWorkspace(t, repo, "workspace")
expires := time.Now().UTC().Add(time.Hour)
for _, attachment := range []*models.TaskMessageAttachment{
{ID: "attachment-matching", OwnerID: "owner", WorkspaceID: "workspace", Name: "matching", SizeBytes: 1, ExpiresAt: expires},
{ID: "attachment-other", OwnerID: "owner", WorkspaceID: "workspace", Name: "other", SizeBytes: 1, ExpiresAt: expires},
} {
if err := repo.CreateMessageAttachment(ctx, attachment); err != nil {
t.Fatal(err)
}
}
if err := repo.ClaimMessageAttachments(ctx, []string{"attachment-matching"}, "owner", "workspace", "task", "session"); err != nil {
t.Fatalf("ClaimMessageAttachments: %v", err)
}
if err := repo.ClaimMessageAttachments(ctx, []string{"attachment-matching"}, "owner", "workspace", "task", "session"); err != nil {
t.Fatalf("idempotent ClaimMessageAttachments: %v", err)
}
if err := repo.ClaimMessageAttachments(ctx, []string{"attachment-matching"}, "owner", "workspace", "other-task", "session"); !errors.Is(err, models.ErrAttachmentClaimConflict) {
t.Fatalf("conflicting claim error = %v", err)
}
released, err := repo.DeleteClaimedMessageAttachments(ctx, []string{"attachment-matching", "attachment-other"}, "owner", "task", "session")
if err != nil || len(released) != 1 || released[0].ID != "attachment-matching" {
t.Fatalf("DeleteClaimedMessageAttachments = %+v, %v", released, err)
}
if _, err := repo.GetMessageAttachment(ctx, "attachment-matching"); !errors.Is(err, models.ErrAttachmentNotFound) {
t.Fatalf("released attachment still exists: %v", err)
}
if _, err := repo.GetMessageAttachment(ctx, "attachment-other"); err != nil {
t.Fatalf("nonmatching attachment removed: %v", err)
}
if released, err := repo.DeleteClaimedMessageAttachments(ctx, nil, "owner", "task", "session"); err != nil || released != nil {
t.Fatalf("empty claimed cleanup = %+v, %v", released, err)
}
if removed, err := repo.DeleteMessageAttachmentsByTask(ctx, ""); err != nil || removed != nil {
t.Fatalf("empty task cleanup = %+v, %v", removed, err)
}
}
@@ -0,0 +1,156 @@
package sqlite
import (
"context"
"errors"
"reflect"
"strings"
"testing"
"github.com/kandev/kandev/internal/task/models"
)
func TestExecutorCRUDRoundTripAndSoftDelete(t *testing.T) {
repo := newRepoForEntityTests(t)
ctx := context.Background()
executor := &models.Executor{ID: "executor-crud", Name: "Before", Type: models.ExecutorTypeLocalDocker, Status: "online", IsSystem: true, Resumable: true, Config: map[string]string{"image": "example:v1", "cpus": "2"}}
if err := repo.CreateExecutor(ctx, executor); err != nil {
t.Fatalf("CreateExecutor: %v", err)
}
got, err := repo.GetExecutor(ctx, executor.ID)
if err != nil {
t.Fatalf("GetExecutor: %v", err)
}
if got.Name != executor.Name || !got.IsSystem || !got.Resumable || !reflect.DeepEqual(got.Config, executor.Config) {
t.Fatalf("GetExecutor = %+v, want round trip of %+v", got, executor)
}
executor.Name = "After"
executor.Status = "offline"
executor.IsSystem = false
executor.Resumable = false
executor.Config = map[string]string{"image": "example:v2"}
if err := repo.UpdateExecutor(ctx, executor); err != nil {
t.Fatalf("UpdateExecutor: %v", err)
}
listed, err := repo.ListExecutors(ctx)
if err != nil {
t.Fatalf("ListExecutors: %v", err)
}
var found *models.Executor
for _, item := range listed {
if item.ID == executor.ID {
found = item
}
}
if found == nil || found.Name != "After" || found.Status != "offline" || found.IsSystem || found.Resumable {
t.Fatalf("updated executor not found in list: %+v", found)
}
if err := repo.DeleteExecutor(ctx, executor.ID); err != nil {
t.Fatalf("DeleteExecutor: %v", err)
}
if _, err := repo.GetExecutor(ctx, executor.ID); !errors.Is(err, models.ErrExecutorNotFound) {
t.Fatalf("GetExecutor after delete error = %v", err)
}
if err := repo.UpdateExecutor(ctx, executor); err == nil {
t.Fatal("UpdateExecutor accepted a soft-deleted executor")
}
if err := repo.DeleteExecutor(ctx, executor.ID); err == nil {
t.Fatal("DeleteExecutor accepted an already deleted executor")
}
}
func TestRepositoryScriptsOrderUpdateDeleteAndConstraints(t *testing.T) {
repo := newRepoForEntityTests(t)
ctx := context.Background()
seedWorkspace(t, repo, "workspace-scripts")
if err := repo.CreateRepository(ctx, &models.Repository{ID: "repository-scripts", WorkspaceID: "workspace-scripts", Name: "repo"}); err != nil {
t.Fatalf("CreateRepository: %v", err)
}
for _, script := range []*models.RepositoryScript{
{ID: "script-later", RepositoryID: "repository-scripts", Name: "Later", Command: "echo later", Position: 2},
{ID: "script-first", RepositoryID: "repository-scripts", Name: "First", Command: "echo first", Position: 1},
} {
if err := repo.CreateRepositoryScript(ctx, script); err != nil {
t.Fatalf("CreateRepositoryScript(%s): %v", script.ID, err)
}
}
got, err := repo.GetRepositoryScript(ctx, "script-later")
if err != nil || got.Command != "echo later" {
t.Fatalf("GetRepositoryScript = %+v, %v", got, err)
}
got.Name, got.Command, got.Position = "Updated", "echo updated", 0
if err := repo.UpdateRepositoryScript(ctx, got); err != nil {
t.Fatalf("UpdateRepositoryScript: %v", err)
}
listed, err := repo.ListRepositoryScripts(ctx, "repository-scripts")
if err != nil || strings.Join(repositoryScriptIDs(listed), ",") != "script-later,script-first" {
t.Fatalf("ListRepositoryScripts = %v, %v", repositoryScriptIDs(listed), err)
}
grouped, err := repo.ListScriptsByRepositoryIDs(ctx, []string{"repository-scripts", "missing"})
if err != nil || len(grouped["repository-scripts"]) != 2 {
t.Fatalf("ListScriptsByRepositoryIDs = %v, %v", grouped, err)
}
empty, err := repo.ListScriptsByRepositoryIDs(ctx, nil)
if err != nil || len(empty) != 0 {
t.Fatalf("ListScriptsByRepositoryIDs(nil) = %v, %v", empty, err)
}
if err := repo.CreateRepositoryScript(ctx, &models.RepositoryScript{ID: "orphan-script", RepositoryID: "missing", Name: "bad"}); err == nil {
t.Fatal("CreateRepositoryScript accepted a missing repository")
}
if err := repo.DeleteRepositoryScript(ctx, "script-first"); err != nil {
t.Fatalf("DeleteRepositoryScript: %v", err)
}
if err := repo.DeleteRepositoryScript(ctx, "script-first"); err == nil {
t.Fatal("second DeleteRepositoryScript returned nil")
}
if err := repo.UpdateRepositoryScript(ctx, &models.RepositoryScript{ID: "missing"}); err == nil {
t.Fatal("UpdateRepositoryScript accepted a missing script")
}
}
func TestRepositoryLocalPathAndAtomicBindingReplacement(t *testing.T) {
repo := newRepoForEntityTests(t)
ctx := context.Background()
seedWorkspace(t, repo, "workspace-bindings")
repository := &models.Repository{ID: "repository-bindings", WorkspaceID: "workspace-bindings", Name: "Before", LocalPath: "/tmp/repository-bindings"}
bindings := []models.RepositorySecretBinding{{Key: "TOKEN", SecretID: "secret-one"}, {Key: "SSH_KEY", SecretID: "secret-two"}}
if err := repo.CreateRepositoryWithSecretBindings(ctx, repository, bindings); err != nil {
t.Fatalf("CreateRepositoryWithSecretBindings: %v", err)
}
got, err := repo.GetRepositoryByLocalPath(ctx, "workspace-bindings", "/tmp/repository-bindings")
if err != nil || got == nil || got.ID != repository.ID || len(got.SecretBindings) != 2 {
t.Fatalf("GetRepositoryByLocalPath = %+v, %v", got, err)
}
missing, err := repo.GetRepositoryByLocalPath(ctx, "workspace-bindings", "")
if err != nil || missing != nil {
t.Fatalf("GetRepositoryByLocalPath(empty) = %+v, %v", missing, err)
}
repository.Name = "After"
replacement := []models.RepositorySecretBinding{{Key: "TOKEN", SecretID: "secret-three"}}
if err := repo.UpdateRepositoryWithSecretBindings(ctx, repository, replacement); err != nil {
t.Fatalf("UpdateRepositoryWithSecretBindings: %v", err)
}
got, err = repo.GetRepository(ctx, repository.ID)
if err != nil || got.Name != "After" || len(got.SecretBindings) != 1 || got.SecretBindings[0].SecretID != "secret-three" {
t.Fatalf("updated repository = %+v, %v", got, err)
}
duplicateKeys := []models.RepositorySecretBinding{{Key: "DUP", SecretID: "one"}, {Key: "DUP", SecretID: "two"}}
repository.Name = "Must Roll Back"
if err := repo.UpdateRepositoryWithSecretBindings(ctx, repository, duplicateKeys); err == nil {
t.Fatal("duplicate binding keys were accepted")
}
got, err = repo.GetRepository(ctx, repository.ID)
if err != nil || got.Name != "After" || len(got.SecretBindings) != 1 || got.SecretBindings[0].SecretID != "secret-three" {
t.Fatalf("failed binding replacement was not atomic: %+v, %v", got, err)
}
}
func repositoryScriptIDs(scripts []*models.RepositoryScript) []string {
ids := make([]string, 0, len(scripts))
for _, script := range scripts {
ids = append(ids, script.ID)
}
return ids
}
@@ -0,0 +1,129 @@
package sqlite
import (
"context"
"strings"
"testing"
"time"
"github.com/kandev/kandev/internal/task/models"
)
func TestMessageCRUDPaginationSearchAndMetadataLookups(t *testing.T) {
repo := newRepoForSessionTests(t)
ctx := context.Background()
seedForMsgTest(t, repo, "task-message-crud", "session-message-crud", "turn-message-crud")
seedForMsgTest(t, repo, "task-message-other", "session-message-other", "turn-message-other")
base := time.Date(2026, time.January, 2, 3, 4, 5, 0, time.UTC)
messages := []*models.Message{
{ID: "message-a", TaskSessionID: "session-message-crud", TaskID: "task-message-crud", TurnID: "turn-message-crud", Content: "literal 100% ready", CreatedAt: base, Metadata: map[string]any{"pending_id": "pending-shared", "question_id": "question-a", "status": "pending"}, Type: models.MessageTypeClarificationRequest, RequestsInput: true},
{ID: "message-b", TaskSessionID: "session-message-crud", TaskID: "task-message-crud", TurnID: "turn-message-crud", Content: "literal under_score", CreatedAt: base.Add(time.Second), Metadata: map[string]any{"pending_id": "pending-shared", "question_id": "question-b", "tool_call_id": "tool-1", "status": "running"}, Type: models.MessageTypeToolCall},
{ID: "message-c", TaskSessionID: "session-message-crud", TaskID: "task-message-crud", TurnID: "turn-message-crud", Content: "ordinary tail", CreatedAt: base.Add(2 * time.Second), Metadata: map[string]any{"tool_call_id": "tool-1", "status": "pending"}, Type: models.MessageTypePermissionRequest},
}
for _, message := range messages {
if err := repo.CreateMessage(ctx, message); err != nil {
t.Fatalf("CreateMessage(%s): %v", message.ID, err)
}
}
listed, err := repo.ListMessages(ctx, "session-message-crud")
if err != nil {
t.Fatalf("ListMessages: %v", err)
}
if got := messageIDs(listed); strings.Join(got, ",") != "message-a,message-b,message-c" {
t.Fatalf("ListMessages IDs = %v, want stable created_at order", got)
}
page, more, err := repo.ListMessagesPaginated(ctx, "session-message-crud", models.ListMessagesOptions{Limit: 1})
if err != nil {
t.Fatalf("ListMessagesPaginated(first): %v", err)
}
if !more || len(page) != 1 || page[0].ID != "message-a" {
t.Fatalf("first page = %v more=%v, want [message-a] and more", messageIDs(page), more)
}
page, more, err = repo.ListMessagesPaginated(ctx, "session-message-crud", models.ListMessagesOptions{After: "message-a", Limit: 5})
if err != nil {
t.Fatalf("ListMessagesPaginated(after): %v", err)
}
if more || strings.Join(messageIDs(page), ",") != "message-b,message-c" {
t.Fatalf("after page = %v more=%v", messageIDs(page), more)
}
page, _, err = repo.ListMessagesPaginated(ctx, "session-message-crud", models.ListMessagesOptions{Before: "message-c", Sort: "desc", Limit: 5})
if err != nil {
t.Fatalf("ListMessagesPaginated(before desc): %v", err)
}
if strings.Join(messageIDs(page), ",") != "message-b,message-a" {
t.Fatalf("before descending page = %v", messageIDs(page))
}
if _, _, err := repo.ListMessagesPaginated(ctx, "session-message-other", models.ListMessagesOptions{After: "message-a"}); err == nil {
t.Fatal("cross-session cursor was accepted")
}
for query, wantID := range map[string]string{"100%": "message-a", "under_": "message-b"} {
found, err := repo.SearchMessages(ctx, "session-message-crud", models.SearchMessagesOptions{Query: query})
if err != nil {
t.Fatalf("SearchMessages(%q): %v", query, err)
}
if len(found) != 1 || found[0].ID != wantID {
t.Errorf("SearchMessages(%q) = %v, want [%s]", query, messageIDs(found), wantID)
}
}
empty, err := repo.SearchMessages(ctx, "session-message-crud", models.SearchMessagesOptions{Query: " "})
if err != nil || empty != nil {
t.Fatalf("blank SearchMessages = %v, %v; want nil, nil", empty, err)
}
pending, err := repo.FindMessagesByPendingID(ctx, "pending-shared")
if err != nil || strings.Join(messageIDs(pending), ",") != "message-a,message-b" {
t.Fatalf("FindMessagesByPendingID = %v, %v", messageIDs(pending), err)
}
bySessionPending, err := repo.GetMessageByPendingID(ctx, "session-message-crud", "pending-shared")
if err != nil || bySessionPending.ID != "message-a" {
t.Fatalf("GetMessageByPendingID = %+v, %v", bySessionPending, err)
}
latestPending, err := repo.FindMessageByPendingID(ctx, "pending-shared")
if err != nil || latestPending.ID != "message-b" {
t.Fatalf("FindMessageByPendingID = %+v, %v", latestPending, err)
}
pendingClarifications, err := repo.FindPendingClarificationMessagesBySessionID(ctx, "session-message-crud")
if err != nil || strings.Join(messageIDs(pendingClarifications), ",") != "message-a" {
t.Fatalf("FindPendingClarificationMessagesBySessionID = %v, %v", messageIDs(pendingClarifications), err)
}
question, err := repo.FindMessageByPendingIDAndQuestion(ctx, "session-message-crud", "pending-shared", "question-b")
if err != nil || question.ID != "message-b" {
t.Fatalf("FindMessageByPendingIDAndQuestion = %+v, %v", question, err)
}
tool, err := repo.GetMessageByToolCallID(ctx, "session-message-crud", "tool-1")
if err != nil || tool.ID != "message-b" {
t.Fatalf("GetMessageByToolCallID = %+v, %v; permission row must be excluded", tool, err)
}
updated, err := repo.CompletePendingToolCallsForTurn(ctx, "turn-message-crud")
if err != nil || updated != 1 {
t.Fatalf("CompletePendingToolCallsForTurn = %d, %v; want 1", updated, err)
}
tool, err = repo.GetMessage(ctx, "message-b")
if err != nil || tool.Metadata["status"] != "complete" {
t.Fatalf("completed tool metadata = %v, %v", tool, err)
}
permission, err := repo.GetMessage(ctx, "message-c")
if err != nil || permission.Metadata["status"] != "pending" {
t.Fatalf("permission metadata changed = %v, %v", permission, err)
}
if err := repo.DeleteMessage(ctx, "message-c"); err != nil {
t.Fatalf("DeleteMessage: %v", err)
}
if err := repo.DeleteMessage(ctx, "message-c"); err == nil {
t.Fatal("second DeleteMessage returned nil")
}
}
func messageIDs(messages []*models.Message) []string {
ids := make([]string, 0, len(messages))
for _, message := range messages {
ids = append(ids, message.ID)
}
return ids
}
@@ -0,0 +1,176 @@
package sqlite
import (
"context"
"strings"
"testing"
"time"
"github.com/kandev/kandev/internal/task/models"
)
func TestWorkflowCRUDOrderingVisibilityAndTransactionalReorder(t *testing.T) {
repo := newRepoForEntityTests(t)
ctx := context.Background()
seedWorkspace(t, repo, "workspace-workflow-crud")
for _, workflow := range []*models.Workflow{
{ID: "workflow-one", WorkspaceID: "workspace-workflow-crud", Name: "One", Source: "invalid", Style: "invalid"},
{ID: "workflow-two", WorkspaceID: "workspace-workflow-crud", Name: "Two", Source: models.WorkflowSourceGitHub, SourcePath: ".kandev/two.yaml", Style: models.WorkflowStyleCustom},
{ID: "workflow-hidden", WorkspaceID: "workspace-workflow-crud", Name: "Hidden", Hidden: true},
} {
if err := repo.CreateWorkflow(ctx, workflow); err != nil {
t.Fatalf("CreateWorkflow(%s): %v", workflow.ID, err)
}
}
got, err := repo.GetWorkflow(ctx, "workflow-one")
if err != nil {
t.Fatalf("GetWorkflow: %v", err)
}
if got.Source != models.WorkflowSourceManual || got.Style != models.WorkflowStyleKanban || got.SortOrder != 0 {
t.Fatalf("normalized workflow = %+v", got)
}
visible, err := repo.ListWorkflows(ctx, "workspace-workflow-crud", false)
if err != nil || strings.Join(workflowIDs(visible), ",") != "workflow-one,workflow-two" {
t.Fatalf("visible workflows = %v, %v", workflowIDs(visible), err)
}
if err := repo.ReorderWorkflows(ctx, "workspace-workflow-crud", []string{"workflow-two", "workflow-one"}); err != nil {
t.Fatalf("ReorderWorkflows: %v", err)
}
visible, err = repo.ListWorkflows(ctx, "workspace-workflow-crud", false)
if err != nil || strings.Join(workflowIDs(visible), ",") != "workflow-two,workflow-one" {
t.Fatalf("reordered workflows = %v, %v", workflowIDs(visible), err)
}
if err := repo.ReorderWorkflows(ctx, "workspace-workflow-crud", []string{"workflow-one", "missing"}); err == nil {
t.Fatal("ReorderWorkflows accepted a missing workflow")
}
visible, err = repo.ListWorkflows(ctx, "workspace-workflow-crud", false)
if err != nil || strings.Join(workflowIDs(visible), ",") != "workflow-two,workflow-one" {
t.Fatalf("failed reorder was not rolled back: %v, %v", workflowIDs(visible), err)
}
got, err = repo.GetWorkflow(ctx, "workflow-two")
if err != nil {
t.Fatal(err)
}
got.Name, got.Description, got.Prompt, got.Hidden = "Updated", "description", "prompt", true
if err := repo.UpdateWorkflow(ctx, got); err != nil {
t.Fatalf("UpdateWorkflow: %v", err)
}
all, err := repo.ListWorkflows(ctx, "workspace-workflow-crud", true)
if err != nil || len(all) != 3 {
t.Fatalf("ListWorkflows(include hidden) = %v, %v", workflowIDs(all), err)
}
deleted, err := repo.DeleteWorkflowsByWorkspace(ctx, "workspace-workflow-crud", []string{"workflow-one"})
if err != nil || deleted != 2 {
t.Fatalf("DeleteWorkflowsByWorkspace = %d, %v", deleted, err)
}
if err := repo.DeleteWorkflow(ctx, "workflow-one"); err != nil {
t.Fatalf("DeleteWorkflow: %v", err)
}
if err := repo.DeleteWorkflow(ctx, "workflow-one"); err == nil {
t.Fatal("second DeleteWorkflow returned nil")
}
}
func TestTaskRepositoryCRUDOrderingBatchAndCascade(t *testing.T) {
repo := newRepoForEntityTests(t)
ctx := context.Background()
seedWorkspace(t, repo, "workspace-task-repo")
if err := repo.CreateWorkflow(ctx, &models.Workflow{ID: "workflow-task-repo", WorkspaceID: "workspace-task-repo", Name: "WF"}); err != nil {
t.Fatal(err)
}
for _, taskID := range []string{"task-repo-one", "task-repo-two"} {
if err := repo.CreateTask(ctx, &models.Task{ID: taskID, WorkspaceID: "workspace-task-repo", WorkflowID: "workflow-task-repo", Title: taskID}); err != nil {
t.Fatal(err)
}
}
for _, repositoryID := range []string{"repo-one", "repo-two"} {
if err := repo.CreateRepository(ctx, &models.Repository{ID: repositoryID, WorkspaceID: "workspace-task-repo", Name: repositoryID}); err != nil {
t.Fatal(err)
}
}
links := []*models.TaskRepository{
{ID: "link-second", TaskID: "task-repo-one", RepositoryID: "repo-two", BaseBranch: "main", CheckoutBranch: "feature", Position: 2, Metadata: map[string]any{"role": "secondary"}},
{ID: "link-first", TaskID: "task-repo-one", RepositoryID: "repo-one", BaseBranch: "main", Position: 1, Metadata: map[string]any{"role": "primary"}},
{ID: "link-other", TaskID: "task-repo-two", RepositoryID: "repo-one", Position: 0},
}
for _, link := range links {
if err := repo.CreateTaskRepository(ctx, link); err != nil {
t.Fatalf("CreateTaskRepository(%s): %v", link.ID, err)
}
}
got, err := repo.GetTaskRepository(ctx, "link-second")
if err != nil || got.CheckoutBranch != "feature" || got.Metadata["role"] != "secondary" {
t.Fatalf("GetTaskRepository = %+v, %v", got, err)
}
got.Position, got.BaseBranch, got.CheckoutBranch = 0, "develop", "next"
got.Metadata = map[string]any{"updated": true}
if err := repo.UpdateTaskRepository(ctx, got); err != nil {
t.Fatalf("UpdateTaskRepository: %v", err)
}
primary, err := repo.GetPrimaryTaskRepository(ctx, "task-repo-one")
if err != nil || primary.ID != "link-second" {
t.Fatalf("GetPrimaryTaskRepository = %+v, %v", primary, err)
}
grouped, err := repo.ListTaskRepositoriesByTaskIDs(ctx, []string{"task-repo-one", "task-repo-two"})
if err != nil || len(grouped["task-repo-one"]) != 2 || len(grouped["task-repo-two"]) != 1 {
t.Fatalf("ListTaskRepositoriesByTaskIDs = %v, %v", grouped, err)
}
empty, err := repo.ListTaskRepositoriesByTaskIDs(ctx, nil)
if err != nil || len(empty) != 0 {
t.Fatalf("ListTaskRepositoriesByTaskIDs(nil) = %v, %v", empty, err)
}
if err := repo.UpdateTaskRepository(ctx, &models.TaskRepository{ID: "missing"}); err == nil {
t.Fatal("UpdateTaskRepository accepted missing row")
}
if err := repo.DeleteTaskRepository(ctx, "link-first"); err != nil {
t.Fatalf("DeleteTaskRepository: %v", err)
}
if err := repo.DeleteTaskRepository(ctx, "link-first"); err == nil {
t.Fatal("second DeleteTaskRepository returned nil")
}
if err := repo.DeleteTaskRepositoriesByTask(ctx, "task-repo-one"); err != nil {
t.Fatalf("DeleteTaskRepositoriesByTask: %v", err)
}
primary, err = repo.GetPrimaryTaskRepository(ctx, "task-repo-one")
if err != nil || primary != nil {
t.Fatalf("primary after bulk delete = %+v, %v", primary, err)
}
}
func TestSessionFileReviewUpsertOrderingAndDelete(t *testing.T) {
repo := newRepoForSessionTests(t)
ctx := context.Background()
seedForMsgTest(t, repo, "task-file-review", "session-file-review", "turn-file-review")
reviewedAt := time.Date(2026, time.February, 3, 4, 5, 6, 0, time.UTC)
for _, review := range []*models.SessionFileReview{
{ID: "review-z", SessionID: "session-file-review", FilePath: "z.go", DiffHash: "old"},
{ID: "review-a", SessionID: "session-file-review", FilePath: "a.go", Reviewed: true, DiffHash: "a", ReviewedAt: &reviewedAt},
} {
if err := repo.UpsertSessionFileReview(ctx, review); err != nil {
t.Fatalf("UpsertSessionFileReview: %v", err)
}
}
if err := repo.UpsertSessionFileReview(ctx, &models.SessionFileReview{ID: "replacement-id", SessionID: "session-file-review", FilePath: "z.go", Reviewed: true, DiffHash: "new", ReviewedAt: &reviewedAt}); err != nil {
t.Fatalf("UpsertSessionFileReview(update): %v", err)
}
reviews, err := repo.GetSessionFileReviews(ctx, "session-file-review")
if err != nil || len(reviews) != 2 || reviews[0].FilePath != "a.go" || reviews[1].ID != "review-z" || reviews[1].DiffHash != "new" {
t.Fatalf("GetSessionFileReviews = %+v, %v", reviews, err)
}
if err := repo.DeleteSessionFileReviews(ctx, "session-file-review"); err != nil {
t.Fatalf("DeleteSessionFileReviews: %v", err)
}
reviews, err = repo.GetSessionFileReviews(ctx, "session-file-review")
if err != nil || len(reviews) != 0 {
t.Fatalf("reviews after delete = %+v, %v", reviews, err)
}
}
func workflowIDs(workflows []*models.Workflow) []string {
ids := make([]string, 0, len(workflows))
for _, workflow := range workflows {
ids = append(ids, workflow.ID)
}
return ids
}
@@ -31,6 +31,25 @@ func newRepoForEntityTests(t *testing.T) *Repository {
return repo
}
func TestRepositoryCloseHonorsDatabaseOwnership(t *testing.T) {
dbPath := filepath.Join(t.TempDir(), "owned-close.db")
dbConn, err := db.OpenSQLite(dbPath)
if err != nil {
t.Fatal(err)
}
sqlxDB := sqlx.NewDb(dbConn, "sqlite3")
repo, err := newRepository(sqlxDB, sqlxDB, nil, true)
if err != nil {
t.Fatal(err)
}
if err := repo.Close(); err != nil {
t.Fatalf("Close: %v", err)
}
if err := sqlxDB.Ping(); err == nil {
t.Fatal("owned database remains open")
}
}
func seedWorkspace(t *testing.T, repo *Repository, id string) {
t.Helper()
if err := repo.CreateWorkspace(context.Background(), &models.Workspace{ID: id, Name: id}); err != nil {
@@ -238,3 +238,46 @@ func TestTaskResourceCleanupFailedUnclaimedCompletionHasTimestamp(t *testing.T)
t.Fatal("unclaimed failed cleanup has no completion timestamp")
}
}
func TestPreparedCleanupSnapshotStartAndRunningReset(t *testing.T) {
ctx := context.Background()
repo := newRepoForHealTests(t)
job := &models.TaskResourceCleanupJob{
ID: "job-prepared-lifecycle", OperationID: "delete:prepared-lifecycle", TaskID: "task-prepared-lifecycle",
Trigger: models.TaskResourceCleanupTriggerDelete, State: models.TaskResourceCleanupStatePrepared,
}
if err := repo.CreateTaskResourceCleanupJob(ctx, job); err != nil {
t.Fatal(err)
}
if err := repo.UpdateTaskResourceCleanupSnapshot(ctx, job.OperationID, `{"worktrees":["one"]}`); err != nil {
t.Fatalf("UpdateTaskResourceCleanupSnapshot: %v", err)
}
if err := repo.UpdateTaskResourceCleanupSnapshot(ctx, "missing", `{}`); err == nil {
t.Fatal("missing prepared snapshot update returned nil")
}
started, err := repo.StartPreparedTaskResourceCleanupJob(ctx, job.ID)
if err != nil || !started {
t.Fatalf("StartPreparedTaskResourceCleanupJob = %v, %v", started, err)
}
started, err = repo.StartPreparedTaskResourceCleanupJob(ctx, job.ID)
if err != nil || started {
t.Fatalf("second StartPreparedTaskResourceCleanupJob = %v, %v", started, err)
}
claimed, err := repo.MarkTaskResourceCleanupJobRunning(ctx, job.ID)
if err != nil || !claimed {
t.Fatalf("MarkTaskResourceCleanupJobRunning = %v, %v", claimed, err)
}
if err := repo.ResetRunningTaskResourceCleanupJobs(ctx); err != nil {
t.Fatalf("ResetRunningTaskResourceCleanupJobs: %v", err)
}
got, err := repo.GetTaskResourceCleanupJobByOperationID(ctx, job.OperationID)
if err != nil {
t.Fatal(err)
}
if got.State != models.TaskResourceCleanupStateRetryWait || got.NextAttemptAt == nil || got.ResourceSnapshot != `{"worktrees":["one"]}` {
t.Fatalf("reset job = %+v", got)
}
if _, err := repo.GetTaskResourceCleanupJobByOperationID(ctx, "missing"); err == nil {
t.Fatal("missing operation lookup returned nil")
}
}
@@ -0,0 +1,188 @@
package sqlite
import (
"context"
"errors"
"strings"
"testing"
"time"
"github.com/kandev/kandev/internal/task/models"
)
func TestPluginMessageFiltersBoundsTypesAndPagination(t *testing.T) {
repo := newRepoForSessionTests(t)
ctx := context.Background()
seedForMsgTest(t, repo, "task-plugin", "session-plugin", "turn-plugin")
base := time.Date(2026, time.May, 6, 7, 8, 9, 0, time.UTC)
for _, message := range []*models.Message{
{ID: "plugin-a", TaskSessionID: "session-plugin", TaskID: "task-plugin", TurnID: "turn-plugin", Content: "a", Type: models.MessageTypeMessage, CreatedAt: base},
{ID: "plugin-b", TaskSessionID: "session-plugin", TaskID: "task-plugin", TurnID: "turn-plugin", Content: "b", Type: models.MessageTypeToolCall, CreatedAt: base.Add(time.Second)},
{ID: "plugin-c", TaskSessionID: "session-plugin", TaskID: "task-plugin", TurnID: "turn-plugin", Content: "c", Type: models.MessageTypeMessage, CreatedAt: base.Add(2 * time.Second)},
} {
if err := repo.CreateMessage(ctx, message); err != nil {
t.Fatal(err)
}
}
since, until := base, base.Add(3*time.Second)
got, err := repo.ListMessagesForPlugin(ctx, models.PluginMessageFilter{SessionIDs: []string{"session-plugin"}, TaskIDs: []string{"task-plugin"}, Types: []string{string(models.MessageTypeMessage)}, Since: &since, Until: &until, Limit: 1, Offset: 1})
if err != nil || strings.Join(messageIDs(got), ",") != "plugin-c" {
t.Fatalf("ListMessagesForPlugin = %v, %v", messageIDs(got), err)
}
all, err := repo.ListMessagesForPlugin(ctx, models.PluginMessageFilter{})
if err != nil || len(all) < 3 {
t.Fatalf("unfiltered ListMessagesForPlugin = %d rows, %v", len(all), err)
}
}
func TestExecutorRunningCASStatusAndDeleteLifecycle(t *testing.T) {
repo := newRepoForSessionTests(t)
ctx := context.Background()
seedForMsgTest(t, repo, "task-running", "session-running", "turn-running")
running := &models.ExecutorRunning{SessionID: "session-running", TaskID: "task-running", AgentExecutionID: "execution-one", Status: "running", Resumable: true, ResumeToken: "old", Metadata: map[string]any{"key": "value"}}
if err := repo.UpsertExecutorRunning(ctx, running); err != nil {
t.Fatalf("UpsertExecutorRunning: %v", err)
}
if exists, err := repo.HasExecutorRunningRow(ctx, "session-running"); err != nil || !exists {
t.Fatalf("HasExecutorRunningRow = %v, %v", exists, err)
}
got, err := repo.GetExecutorRunningBySessionID(ctx, "session-running")
if err != nil || got.ID != "session-running" || !got.Resumable || got.Metadata["key"] != "value" {
t.Fatalf("GetExecutorRunningBySessionID = %+v, %v", got, err)
}
if err := repo.UpdateResumeToken(ctx, "session-running", "execution-one", "new", "message-uuid"); err != nil {
t.Fatalf("UpdateResumeToken: %v", err)
}
wroteACP, err := repo.SetSessionACPSessionID(ctx, "session-running", "new")
if err != nil || !wroteACP {
t.Fatalf("SetSessionACPSessionID = %v, %v", wroteACP, err)
}
wroteACP, err = repo.SetSessionACPSessionID(ctx, "session-running", "new")
if err != nil || wroteACP {
t.Fatalf("idempotent SetSessionACPSessionID = %v, %v", wroteACP, err)
}
if err := repo.UpdateResumeToken(ctx, "session-running", "rotated", "bad", "bad"); !errors.Is(err, models.ErrExecutionRotated) {
t.Fatalf("rotated UpdateResumeToken error = %v", err)
}
if err := repo.UpdateExecutorRunningStatus(ctx, "session-running", "stopped"); err != nil {
t.Fatalf("UpdateExecutorRunningStatus: %v", err)
}
got, err = repo.GetExecutorRunningBySessionID(ctx, "session-running")
if err != nil || got.Status != "stopped" || got.ResumeToken != "new" || got.LastMessageUUID != "message-uuid" {
t.Fatalf("updated running row = %+v, %v", got, err)
}
if err := repo.DeleteExecutorRunningBySessionID(ctx, "session-running"); err != nil {
t.Fatalf("DeleteExecutorRunningBySessionID: %v", err)
}
if exists, err := repo.HasExecutorRunningRow(ctx, "session-running"); err != nil || exists {
t.Fatalf("HasExecutorRunningRow after delete = %v, %v", exists, err)
}
if err := repo.DeleteExecutorRunningBySessionID(ctx, "session-running"); !errors.Is(err, models.ErrExecutorRunningNotFound) {
t.Fatalf("second delete error = %v", err)
}
if err := repo.UpdateExecutorRunningStatus(ctx, "missing", "stopped"); !errors.Is(err, models.ErrExecutorRunningNotFound) {
t.Fatalf("missing status update error = %v", err)
}
}
func TestSessionStateGuardedMetadataReviewAndBatchLookup(t *testing.T) {
repo := newRepoForSessionTests(t)
ctx := context.Background()
seedForMsgTest(t, repo, "task-session-state", "session-session-state", "turn-session-state")
if err := repo.UpdateSessionReviewStatus(ctx, "session-session-state", "approved"); err != nil {
t.Fatalf("UpdateSessionReviewStatus: %v", err)
}
if err := repo.UpdateSessionReviewStatus(ctx, "missing", "approved"); err == nil {
t.Fatal("UpdateSessionReviewStatus accepted missing session")
}
claimed, err := repo.SetSessionMetadataKeyIfAbsentIfState(ctx, "session-session-state", "claim", map[string]any{"attempt": 1}, models.TaskSessionStateCreated)
if err != nil || !claimed {
t.Fatalf("SetSessionMetadataKeyIfAbsentIfState = %v, %v", claimed, err)
}
claimed, err = repo.SetSessionMetadataKeyIfAbsentIfState(ctx, "session-session-state", "claim", "replacement", models.TaskSessionStateCreated)
if err != nil || claimed {
t.Fatalf("duplicate metadata claim = %v, %v", claimed, err)
}
removed, err := repo.RemoveSessionMetadataKeyIfState(ctx, "session-session-state", "claim", models.TaskSessionStateRunning)
if err != nil || removed {
t.Fatalf("wrong-state metadata removal = %v, %v", removed, err)
}
removed, err = repo.RemoveSessionMetadataKeyIfState(ctx, "session-session-state", "claim", models.TaskSessionStateCreated)
if err != nil || !removed {
t.Fatalf("RemoveSessionMetadataKeyIfState = %v, %v", removed, err)
}
grouped, err := repo.BatchGetSessionsByTaskIDs(ctx, []string{"task-session-state", "missing"})
if err != nil || len(grouped["task-session-state"]) != 1 || grouped["task-session-state"][0].ID != "session-session-state" {
t.Fatalf("BatchGetSessionsByTaskIDs = %+v, %v", grouped, err)
}
empty, err := repo.BatchGetSessionsByTaskIDs(ctx, nil)
if err != nil || len(empty) != 0 {
t.Fatalf("BatchGetSessionsByTaskIDs(nil) = %+v, %v", empty, err)
}
}
func TestHasActiveTaskSessionsByExecutor(t *testing.T) {
repo := newRepoForSessionTests(t)
ctx := context.Background()
seedForMsgTest(t, repo, "task-executor-active", "session-executor-active", "turn-executor-active")
if _, err := repo.db.Exec(`UPDATE task_sessions SET executor_id = ?, state = ? WHERE id = ?`, "executor-active", models.TaskSessionStateRunning, "session-executor-active"); err != nil {
t.Fatal(err)
}
active, err := repo.HasActiveTaskSessionsByExecutor(ctx, "executor-active")
if err != nil || !active {
t.Fatalf("HasActiveTaskSessionsByExecutor = %v, %v", active, err)
}
if _, err := repo.db.Exec(`UPDATE task_sessions SET state = ? WHERE id = ?`, models.TaskSessionStateCompleted, "session-executor-active"); err != nil {
t.Fatal(err)
}
active, err = repo.HasActiveTaskSessionsByExecutor(ctx, "executor-active")
if err != nil || active {
t.Fatalf("HasActiveTaskSessionsByExecutor(completed) = %v, %v", active, err)
}
}
func TestSessionAgentSnapshotNonTerminalFilterAndEphemeralCleanup(t *testing.T) {
repo := newRepoForSessionTests(t)
ctx := context.Background()
for _, fixture := range []struct {
taskID, sessionID string
ephemeral bool
state models.TaskSessionState
}{
{"task-agent-live", "session-agent-live", false, models.TaskSessionStateRunning},
{"task-agent-done", "session-agent-done", false, models.TaskSessionStateCompleted},
{"task-agent-ephemeral", "session-agent-ephemeral", true, models.TaskSessionStateCreated},
} {
seedForMsgTest(t, repo, fixture.taskID, fixture.sessionID, "turn-"+fixture.sessionID)
if _, err := repo.db.Exec(`UPDATE tasks SET is_ephemeral = ? WHERE id = ?`, fixture.ephemeral, fixture.taskID); err != nil {
t.Fatal(err)
}
if _, err := repo.db.Exec(`UPDATE task_sessions SET agent_profile_id = ?, state = ? WHERE id = ?`, "agent-profile", fixture.state, fixture.sessionID); err != nil {
t.Fatal(err)
}
}
if err := repo.UpdateTaskSessionAgentProfileSnapshot(ctx, "session-agent-live", map[string]any{"model": "gpt-test", "nested": map[string]any{"enabled": true}}); err != nil {
t.Fatalf("UpdateTaskSessionAgentProfileSnapshot: %v", err)
}
if err := repo.UpdateTaskSessionAgentProfileSnapshot(ctx, "missing", nil); !errors.Is(err, models.ErrTaskSessionNotFound) {
t.Fatalf("missing profile snapshot error = %v", err)
}
live, err := repo.ListNonTerminalSessionsByAgentInstance(ctx, "agent-profile")
if err != nil || len(live) != 2 {
t.Fatalf("ListNonTerminalSessionsByAgentInstance = %d, %v", len(live), err)
}
empty, err := repo.ListNonTerminalSessionsByAgentInstance(ctx, "")
if err != nil || empty != nil {
t.Fatalf("ListNonTerminalSessionsByAgentInstance(empty) = %+v, %v", empty, err)
}
deleted, err := repo.DeleteEphemeralTasksByAgentProfile(ctx, "agent-profile")
if err != nil || deleted != 1 {
t.Fatalf("DeleteEphemeralTasksByAgentProfile = %d, %v", deleted, err)
}
if _, err := repo.GetTask(ctx, "task-agent-ephemeral"); err == nil {
t.Fatal("ephemeral task remains after profile cleanup")
}
if _, err := repo.GetTask(ctx, "task-agent-live"); err != nil {
t.Fatalf("non-ephemeral task removed: %v", err)
}
}
@@ -4,6 +4,7 @@ import (
"context"
"errors"
"testing"
"time"
"github.com/kandev/kandev/internal/task/models"
)
@@ -18,6 +19,78 @@ func TestDeleteTaskEnvironmentMissingReturnsSentinel(t *testing.T) {
}
}
func TestTaskEnvironmentRepoUpdateDeleteAndBulkCleanup(t *testing.T) {
repo := newRepoForEntityTests(t)
ctx := context.Background()
seedWorkspace(t, repo, "workspace-task-environment-crud")
if err := repo.CreateTask(ctx, &models.Task{ID: "task-environment-crud", WorkspaceID: "workspace-task-environment-crud", Title: "Task"}); err != nil {
t.Fatal(err)
}
for _, repositoryID := range []string{"environment-repo-one", "environment-repo-two"} {
if err := repo.CreateRepository(ctx, &models.Repository{ID: repositoryID, WorkspaceID: "workspace-task-environment-crud", Name: repositoryID}); err != nil {
t.Fatal(err)
}
}
environment := &models.TaskEnvironment{ID: "task-environment-crud", TaskID: "task-environment-crud", ExecutorType: string(models.ExecutorTypeLocal), Status: models.TaskEnvironmentStatusReady}
if err := repo.CreateTaskEnvironment(ctx, environment); err != nil {
t.Fatalf("CreateTaskEnvironment: %v", err)
}
seedForMsgTest(t, repo, "task-environment-crud", "session-environment-crud", "turn-environment-crud")
if _, err := repo.db.Exec(`UPDATE task_sessions SET task_environment_id = ? WHERE id = ?`, environment.ID, "session-environment-crud"); err != nil {
t.Fatal(err)
}
for index, repositoryID := range []string{"environment-repo-one", "environment-repo-two"} {
if err := repo.CreateTaskEnvironmentRepo(ctx, &models.TaskEnvironmentRepo{ID: "env-link-" + repositoryID, TaskEnvironmentID: environment.ID, RepositoryID: repositoryID, Position: index, WorktreeBranch: "main"}); err != nil {
t.Fatalf("CreateTaskEnvironmentRepo: %v", err)
}
}
links, err := repo.ListTaskEnvironmentRepos(ctx, environment.ID)
if err != nil || len(links) != 2 {
t.Fatalf("ListTaskEnvironmentRepos = %+v, %v", links, err)
}
mergedAt := time.Date(2026, time.July, 8, 9, 10, 11, 0, time.UTC)
links[0].BranchSlug = "feature"
links[0].WorktreeBranch = "feature/updated"
links[0].Status = "merged"
links[0].MergedAt = &mergedAt
if err := repo.UpdateTaskEnvironmentRepo(ctx, links[0]); err != nil {
t.Fatalf("UpdateTaskEnvironmentRepo: %v", err)
}
if err := repo.UpdateTaskEnvironmentRepo(ctx, &models.TaskEnvironmentRepo{ID: "missing"}); err == nil {
t.Fatal("UpdateTaskEnvironmentRepo accepted missing row")
}
links, err = repo.ListTaskEnvironmentRepos(ctx, environment.ID)
if err != nil || links[0].WorktreeBranch != "feature/updated" || links[0].MergedAt == nil {
t.Fatalf("updated environment repo = %+v, %v", links, err)
}
if err := repo.UpdateTaskSessionWorktreeBranch(ctx, "session-environment-crud", "feature/all"); err != nil {
t.Fatalf("UpdateTaskSessionWorktreeBranch: %v", err)
}
branches, err := repo.ListSessionsWithBranches(ctx)
if err != nil || len(branches) != 1 || branches[0].SessionID != "session-environment-crud" || branches[0].Branch != "feature/all" {
t.Fatalf("ListSessionsWithBranches = %+v, %v", branches, err)
}
if err := repo.DeleteTaskEnvironmentRepo(ctx, links[0].ID); err != nil {
t.Fatalf("DeleteTaskEnvironmentRepo: %v", err)
}
if err := repo.DeleteTaskEnvironmentRepo(ctx, links[0].ID); err == nil {
t.Fatal("second DeleteTaskEnvironmentRepo returned nil")
}
if err := repo.DeleteTaskEnvironmentReposByEnv(ctx, environment.ID); err != nil {
t.Fatalf("DeleteTaskEnvironmentReposByEnv: %v", err)
}
links, err = repo.ListTaskEnvironmentRepos(ctx, environment.ID)
if err != nil || len(links) != 0 {
t.Fatalf("links after bulk cleanup = %+v, %v", links, err)
}
if err := repo.DeleteTaskEnvironmentsByTask(ctx, environment.TaskID); err != nil {
t.Fatalf("DeleteTaskEnvironmentsByTask: %v", err)
}
if got, err := repo.GetTaskEnvironmentByTaskID(ctx, environment.TaskID); err != nil || got != nil {
t.Fatalf("environment after bulk delete = %+v, %v", got, err)
}
}
func TestGetTaskEnvironmentMissingReturnsSentinel(t *testing.T) {
repo := newRepoForHealTests(t)
@@ -0,0 +1,287 @@
package sqlite
import (
"context"
"errors"
"strings"
"testing"
"time"
"github.com/kandev/kandev/internal/task/models"
wfmodels "github.com/kandev/kandev/internal/workflow/models"
)
func TestTaskCountsPullCandidatesQueueAndWorkflowPlacement(t *testing.T) {
repo := newRepoForEntityTests(t)
ctx := context.Background()
seedWorkspace(t, repo, "workspace-selection")
if err := repo.CreateWorkflow(ctx, &models.Workflow{ID: "workflow-selection", WorkspaceID: "workspace-selection", Name: "Workflow"}); err != nil {
t.Fatal(err)
}
now := time.Date(2026, time.April, 5, 6, 7, 8, 0, time.UTC)
for _, stepID := range []string{"step-feeder", "step-destination"} {
if _, err := repo.db.Exec(repo.db.Rebind(`INSERT INTO workflow_steps
(id, workflow_id, name, position, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?)`), stepID, "workflow-selection", stepID, 0, now, now); err != nil {
t.Fatalf("insert step %s: %v", stepID, err)
}
}
for _, task := range []*models.Task{
{ID: "task-low", WorkspaceID: "workspace-selection", WorkflowID: "workflow-selection", WorkflowStepID: "step-feeder", Title: "Low", Priority: "low", Position: 1},
{ID: "task-high", WorkspaceID: "workspace-selection", WorkflowID: "workflow-selection", WorkflowStepID: "step-feeder", Title: "High", Priority: "high", Position: 1},
{ID: "task-first-position", WorkspaceID: "workspace-selection", WorkflowID: "workflow-selection", WorkflowStepID: "step-feeder", Title: "First", Priority: "none", Position: 0},
} {
if err := repo.CreateTask(ctx, task); err != nil {
t.Fatalf("CreateTask(%s): %v", task.ID, err)
}
}
if count, err := repo.CountTasksByWorkflow(ctx, "workflow-selection"); err != nil || count != 3 {
t.Fatalf("CountTasksByWorkflow = %d, %v", count, err)
}
if count, err := repo.CountTasksByWorkflowStep(ctx, "step-feeder"); err != nil || count != 3 {
t.Fatalf("CountTasksByWorkflowStep = %d, %v", count, err)
}
if count, err := repo.CountTasksByWorkflowStepExcludingTask(ctx, "step-feeder", "task-low"); err != nil || count != 2 {
t.Fatalf("CountTasksByWorkflowStepExcludingTask = %d, %v", count, err)
}
if count, err := repo.CountAdmittedTasksByWorkflowStep(ctx, "step-feeder"); err != nil || count != 3 {
t.Fatalf("CountAdmittedTasksByWorkflowStep = %d, %v", count, err)
}
candidate, err := repo.NextPullCandidate(ctx, "step-feeder", "")
if err != nil || candidate.ID != "task-first-position" {
t.Fatalf("NextPullCandidate = %+v, %v", candidate, err)
}
candidate, err = repo.NextPullCandidateExcluding(ctx, "step-feeder", []string{"", "task-first-position", "task-high"})
if err != nil || candidate.ID != "task-low" {
t.Fatalf("NextPullCandidateExcluding = %+v, %v", candidate, err)
}
queuedAt := now.Add(time.Minute)
if _, err := repo.db.Exec(repo.db.Rebind(`UPDATE tasks SET queued_for_step_id = ?, queued_at = ?, wip_admitted = 0 WHERE id = ?`), "step-destination", queuedAt, "task-first-position"); err != nil {
t.Fatal(err)
}
queued, err := repo.ListQueuedTasks(ctx)
if err != nil || strings.Join(selectionTaskIDs(queued), ",") != "task-first-position" {
t.Fatalf("ListQueuedTasks = %v, %v", selectionTaskIDs(queued), err)
}
if count, err := repo.CountAdmittedTasksByWorkflowStep(ctx, "step-feeder"); err != nil || count != 2 {
t.Fatalf("admitted count after queue = %d, %v", count, err)
}
candidate, err = repo.NextQueuedTaskForStepExcluding(ctx, "step-feeder", "step-destination", nil)
if err != nil || candidate.ID != "task-first-position" {
t.Fatalf("NextQueuedTaskForStepExcluding = %+v, %v", candidate, err)
}
if err := repo.AddTaskToWorkflow(ctx, "task-first-position", "workflow-selection", "step-destination", 4); err != nil {
t.Fatalf("AddTaskToWorkflow: %v", err)
}
placed, err := repo.GetTask(ctx, "task-first-position")
if err != nil || placed.WorkflowStepID != "step-destination" || placed.Position != 4 {
t.Fatalf("placed task = %+v, %v", placed, err)
}
if err := repo.RemoveTaskFromWorkflow(ctx, "task-first-position", "wrong-workflow"); err != nil {
t.Fatalf("RemoveTaskFromWorkflow(wrong): %v", err)
}
stillPlaced, err := repo.GetTask(ctx, "task-first-position")
if err != nil || stillPlaced.WorkflowID != "workflow-selection" {
t.Fatalf("wrong-workflow removal mutated task: %+v, %v", stillPlaced, err)
}
if err := repo.RemoveTaskFromWorkflow(ctx, "task-first-position", "workflow-selection"); err != nil {
t.Fatalf("RemoveTaskFromWorkflow: %v", err)
}
detached, err := repo.GetTask(ctx, "task-first-position")
if err != nil || detached.WorkflowID != "" || detached.WorkflowStepID != "" || detached.Position != 0 {
t.Fatalf("detached task = %+v, %v", detached, err)
}
}
func selectionTaskIDs(tasks []*models.Task) []string {
ids := make([]string, 0, len(tasks))
for _, task := range tasks {
ids = append(ids, task.ID)
}
return ids
}
func TestTaskAdmissionCapacityOverflowFeederAndPromotion(t *testing.T) {
repo := newRepoForEntityTests(t)
ctx := context.Background()
seedWorkspace(t, repo, "workspace-admission")
if err := repo.CreateWorkflow(ctx, &models.Workflow{ID: "workflow-admission", WorkspaceID: "workspace-admission", Name: "Workflow"}); err != nil {
t.Fatal(err)
}
insertSelectionStep(t, repo, "workflow-admission", "step-target", 0)
insertSelectionStep(t, repo, "workflow-admission", "step-feeder-admission", 1)
first := &models.Task{ID: "admission-first", WorkspaceID: "workspace-admission", WorkflowID: "workflow-admission", WorkflowStepID: "step-target", Title: "First"}
if err := repo.CreateTaskIfWorkflowStepHasCapacity(ctx, first, "step-target", 1); err != nil {
t.Fatalf("CreateTaskIfWorkflowStepHasCapacity(first): %v", err)
}
blocked := &models.Task{ID: "admission-blocked", WorkspaceID: "workspace-admission", WorkflowID: "workflow-admission", WorkflowStepID: "step-target", Title: "Blocked"}
if err := repo.CreateTaskIfWorkflowStepHasCapacity(ctx, blocked, "step-target", 1); err == nil {
t.Fatal("capacity-limited create admitted a second occupant")
} else {
var limitErr *wfmodels.WIPLimitError
if !errors.As(err, &limitErr) {
t.Fatalf("capacity error = %T %v", err, err)
}
}
overflow := &models.Task{ID: "admission-overflow", WorkspaceID: "workspace-admission", WorkflowID: "workflow-admission", Title: "Overflow", Metadata: map[string]any{models.MetaKeyDeferredLaunch: true}}
if err := repo.CreateTaskWithWorkflowStepAdmission(ctx, overflow, "step-target", 1, "", 0); err != nil {
t.Fatalf("CreateTaskWithWorkflowStepAdmission(overflow): %v", err)
}
if overflow.WorkflowStepID != "step-target" || overflow.WIPAdmitted || overflow.QueuedForStepID != "step-target" || overflow.QueuedAt == nil {
t.Fatalf("destination overflow placement = %+v", overflow)
}
feeder := &models.Task{ID: "admission-feeder", WorkspaceID: "workspace-admission", WorkflowID: "workflow-admission", Title: "Feeder"}
if err := repo.CreateTaskWithWorkflowStepAdmission(ctx, feeder, "step-target", 1, "step-feeder-admission", 2); err != nil {
t.Fatalf("CreateTaskWithWorkflowStepAdmission(feeder): %v", err)
}
if feeder.WorkflowStepID != "step-feeder-admission" || !feeder.WIPAdmitted || feeder.QueuedForStepID != "step-target" {
t.Fatalf("feeder placement = %+v", feeder)
}
feederBlocked := &models.Task{ID: "admission-feeder-blocked", WorkspaceID: "workspace-admission", WorkflowID: "workflow-admission", Title: "Feeder blocked"}
if err := repo.CreateTaskWithWorkflowStepAdmission(ctx, feederBlocked, "step-target", 1, "step-feeder-admission", 1); err == nil {
t.Fatal("full feeder accepted overflow")
}
sameStep := &models.Task{ID: "admission-same-step", WorkspaceID: "workspace-admission", WorkflowID: "workflow-admission", Title: "Same"}
if err := repo.CreateTaskWithWorkflowStepAdmission(ctx, sameStep, "step-target", 1, "step-target", 1); err == nil {
t.Fatal("same target and feeder accepted overflow")
}
first.Title = "Updated in target"
if err := repo.UpdateTaskIfWorkflowStepHasCapacity(ctx, first, "step-target", first.ID, 1); err != nil {
t.Fatalf("UpdateTaskIfWorkflowStepHasCapacity: %v", err)
}
feeder.WorkflowStepID = "step-target"
feeder.WIPAdmitted = true
feeder.QueuedForStepID = ""
feeder.QueuedAt = nil
promoted, err := repo.PromoteQueuedTaskIfWorkflowStepHasCapacity(ctx, feeder, "step-feeder-admission", "step-target", 1)
if err != nil || promoted {
t.Fatalf("promotion into full target = %v, %v", promoted, err)
}
if err := repo.DeleteTask(ctx, "admission-first"); err != nil {
t.Fatal(err)
}
promoted, err = repo.PromoteQueuedTaskIfWorkflowStepHasCapacity(ctx, feeder, "step-feeder-admission", "step-target", 1)
if err != nil || !promoted {
t.Fatalf("promotion after capacity frees = %v, %v", promoted, err)
}
}
func TestTaskMetadataWatcherFiltersDetachAndQuickChatExpiry(t *testing.T) {
repo := newRepoForEntityTests(t)
ctx := context.Background()
seedWorkspace(t, repo, "workspace-task-extra")
if err := repo.CreateWorkflow(ctx, &models.Workflow{ID: "workflow-task-extra", WorkspaceID: "workspace-task-extra", Name: "Workflow"}); err != nil {
t.Fatal(err)
}
insertSelectionStep(t, repo, "workflow-task-extra", "step-task-extra", 0)
parent := &models.Task{ID: "task-extra-parent", WorkspaceID: "workspace-task-extra", WorkflowID: "workflow-task-extra", WorkflowStepID: "step-task-extra", Title: "Parent", ProjectID: "project-one"}
child := &models.Task{ID: "task-extra-child", WorkspaceID: "workspace-task-extra", WorkflowID: "workflow-task-extra", WorkflowStepID: "step-task-extra", Title: "Child", ParentID: parent.ID, ProjectID: "project-one", AssigneeAgentProfileID: "agent-one", Metadata: map[string]any{"workspace": map[string]any{"mode": "inherit_parent"}, "watch_id": "watch-one"}}
for _, task := range []*models.Task{parent, child} {
if err := repo.CreateTask(ctx, task); err != nil {
t.Fatal(err)
}
}
if err := repo.SetTaskMetadataKey(ctx, child.ID, "extra", map[string]any{"ok": true}); err != nil {
t.Fatalf("SetTaskMetadataKey: %v", err)
}
wrote, err := repo.SetTaskMetadataKeyIfNotArchived(ctx, child.ID, "guarded", "yes")
if err != nil || !wrote {
t.Fatalf("SetTaskMetadataKeyIfNotArchived = %v, %v", wrote, err)
}
removed, err := repo.RemoveTaskMetadataKey(ctx, child.ID, "extra")
if err != nil || !removed {
t.Fatalf("RemoveTaskMetadataKey = %v, %v", removed, err)
}
removed, err = repo.RemoveTaskMetadataKey(ctx, child.ID, "missing")
if err != nil || removed {
t.Fatalf("RemoveTaskMetadataKey(missing) = %v, %v", removed, err)
}
count, err := repo.CountOpenWatcherCreatedTasks(ctx, "watch_id", "watch-one")
if err != nil || count != 1 {
t.Fatalf("CountOpenWatcherCreatedTasks = %d, %v", count, err)
}
if count, err := repo.CountOpenWatcherCreatedTasks(ctx, "watch-id", "watch-one"); err == nil || count != 0 {
t.Fatalf("invalid watcher key = %d, %v", count, err)
}
if count, err := repo.CountOpenWatcherCreatedTasks(ctx, "watch_id", ""); err != nil || count != 0 {
t.Fatalf("empty watcher id = %d, %v", count, err)
}
projectTasks, err := repo.ListTasksByProject(ctx, "project-one")
if err != nil || len(projectTasks) != 2 {
t.Fatalf("ListTasksByProject = %v, %v", selectionTaskIDs(projectTasks), err)
}
assigned, err := repo.ListTasksByAssignee(ctx, "agent-one")
if err != nil || len(assigned) != 1 || assigned[0].ID != child.ID {
t.Fatalf("ListTasksByAssignee = %v, %v", selectionTaskIDs(assigned), err)
}
detached, err := repo.DetachTask(ctx, child.ID)
if err != nil || !detached {
t.Fatalf("DetachTask = %v, %v", detached, err)
}
detached, err = repo.DetachTask(ctx, child.ID)
if err != nil || detached {
t.Fatalf("second DetachTask = %v, %v", detached, err)
}
if _, err := repo.DetachTask(ctx, "missing"); err == nil {
t.Fatal("DetachTask accepted missing task")
}
got, err := repo.GetTask(ctx, child.ID)
if err != nil || got.ParentID != "" {
t.Fatalf("detached persisted task = %+v, %v", got, err)
}
cutoff := time.Date(2026, time.June, 7, 8, 9, 10, 0, time.UTC)
for _, task := range []*models.Task{
{ID: "quick-expired", WorkspaceID: "workspace-task-extra", Title: "Expired", IsEphemeral: true},
{ID: "quick-fresh", WorkspaceID: "workspace-task-extra", Title: "Fresh", IsEphemeral: true},
{ID: "quick-config", WorkspaceID: "workspace-task-extra", Title: "Config", IsEphemeral: true, Metadata: map[string]any{"config_mode": true}},
} {
if err := repo.CreateTask(ctx, task); err != nil {
t.Fatal(err)
}
}
if _, err := repo.db.Exec(`UPDATE tasks SET updated_at = ? WHERE id IN ('quick-expired', 'quick-config')`, cutoff.Add(-time.Hour)); err != nil {
t.Fatal(err)
}
if _, err := repo.db.Exec(`UPDATE tasks SET updated_at = ? WHERE id = 'quick-fresh'`, cutoff.Add(time.Hour)); err != nil {
t.Fatal(err)
}
expired, err := repo.ListExpiredQuickChatTasks(ctx, cutoff)
if err != nil || len(expired) != 1 || expired[0].ID != "quick-expired" {
t.Fatalf("ListExpiredQuickChatTasks = %v, %v", selectionTaskIDs(expired), err)
}
deleted, err := repo.DeleteExpiredQuickChatTask(ctx, "quick-fresh", cutoff)
if err != nil || deleted {
t.Fatalf("DeleteExpiredQuickChatTask(fresh) = %v, %v", deleted, err)
}
deleted, err = repo.DeleteExpiredQuickChatTask(ctx, "quick-expired", cutoff)
if err != nil || !deleted {
t.Fatalf("DeleteExpiredQuickChatTask(expired) = %v, %v", deleted, err)
}
}
func insertSelectionStep(t *testing.T, repo *Repository, workflowID, stepID string, position int) {
t.Helper()
now := time.Now().UTC()
if _, err := repo.db.Exec(repo.db.Rebind(`INSERT INTO workflow_steps
(id, workflow_id, name, position, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?)`), stepID, workflowID, stepID, position, now, now); err != nil {
t.Fatalf("insert step %s: %v", stepID, err)
}
}
func TestTaskSQLProjectionHelpers(t *testing.T) {
for _, alias := range []string{"", "custom"} {
predicate := IsFromOfficePredicate(alias)
if !strings.Contains(predicate, "project_id") || !strings.Contains(predicate, "office_workflow_id") {
t.Fatalf("IsFromOfficePredicate(%q) = %q", alias, predicate)
}
}
if predicate := excludeConfigModePredicate("sqlite3", "metadata"); !strings.Contains(predicate, "config_mode") {
t.Fatalf("excludeConfigModePredicate = %q", predicate)
}
}
@@ -139,6 +139,16 @@ func TestTaskStatusSummaryBatchCompareAndUpdate(t *testing.T) {
if loaded["task-summary-repository"].Revision != 2 || loaded["task-summary-repository"].ForegroundActivity != "generating" {
t.Fatalf("newer summary was not retained: %#v", loaded["task-summary-repository"])
}
if err := repo.DeleteTaskStatusSummary(ctx, "task-summary-repository"); err != nil {
t.Fatalf("DeleteTaskStatusSummary: %v", err)
}
if err := repo.DeleteTaskStatusSummary(ctx, ""); err != nil {
t.Fatalf("DeleteTaskStatusSummary(empty): %v", err)
}
loaded, err = repo.LoadTaskStatusSummaries(ctx, []string{"task-summary-repository"})
if err != nil || len(loaded) != 0 {
t.Fatalf("summaries after delete = %+v, %v", loaded, err)
}
}
func TestTaskStatusSummaryConcurrentUpdatesRemainMonotonic(t *testing.T) {
@@ -0,0 +1,107 @@
package sqlite
import (
"context"
"errors"
"testing"
"github.com/jmoiron/sqlx"
"github.com/kandev/kandev/internal/task/models"
"github.com/kandev/kandev/internal/task/repository/repoerrors"
)
func TestWorkspaceCRUDDefaultsOwnershipAndMissingErrors(t *testing.T) {
repo := newRepoForEntityTests(t)
ctx := context.Background()
workspace := &models.Workspace{Name: "Workspace", Description: "before"}
if err := repo.CreateWorkspace(ctx, workspace); err != nil {
t.Fatalf("CreateWorkspace: %v", err)
}
if workspace.ID == "" || workspace.TaskPrefix != "KAN" || workspace.CreatedAt.IsZero() || !workspace.CreatedAt.Equal(workspace.UpdatedAt) {
t.Fatalf("workspace defaults = %+v", workspace)
}
got, err := repo.GetWorkspace(ctx, workspace.ID)
if err != nil || got.Name != "Workspace" || got.Description != "before" {
t.Fatalf("GetWorkspace = %+v, %v", got, err)
}
got.Name, got.Description = "Updated", "after"
if err := repo.UpdateWorkspace(ctx, got); err != nil {
t.Fatalf("UpdateWorkspace: %v", err)
}
if err := repo.ClaimUnownedWorkspaces(ctx, "owner-one"); err != nil {
t.Fatalf("ClaimUnownedWorkspaces: %v", err)
}
listed, err := repo.ListWorkspaces(ctx)
if err != nil {
t.Fatalf("ListWorkspaces: %v", err)
}
var found *models.Workspace
for _, item := range listed {
if item.ID == workspace.ID {
found = item
}
}
if found == nil || found.Name != "Updated" || found.Description != "after" || found.OwnerID != "owner-one" {
t.Fatalf("updated workspace = %+v", found)
}
if _, err := repo.GetWorkspace(ctx, "missing"); !errors.Is(err, repoerrors.ErrWorkspaceNotFound) {
t.Fatalf("GetWorkspace missing error = %v", err)
}
if err := repo.UpdateWorkspace(ctx, &models.Workspace{ID: "missing"}); !errors.Is(err, repoerrors.ErrWorkspaceNotFound) {
t.Fatalf("UpdateWorkspace missing error = %v", err)
}
if err := repo.DeleteWorkspace(ctx, workspace.ID); err != nil {
t.Fatalf("DeleteWorkspace: %v", err)
}
if err := repo.DeleteWorkspace(ctx, workspace.ID); !errors.Is(err, repoerrors.ErrWorkspaceNotFound) {
t.Fatalf("second DeleteWorkspace error = %v", err)
}
}
func TestWorkspaceCascadeConfirmationAndCleanupAreAtomic(t *testing.T) {
repo := newRepoForEntityTests(t)
ctx := context.Background()
seedWorkspace(t, repo, "workspace-cascade")
if err := repo.UpdateWorkspace(ctx, &models.Workspace{ID: "workspace-cascade", Name: "Confirmed", Description: "kept on rollback"}); err != nil {
t.Fatal(err)
}
if err := repo.CreateWorkflow(ctx, &models.Workflow{ID: "workflow-cascade", WorkspaceID: "workspace-cascade", Name: "Workflow"}); err != nil {
t.Fatal(err)
}
if err := repo.CreateTask(ctx, &models.Task{ID: "task-cascade", WorkspaceID: "workspace-cascade", WorkflowID: "workflow-cascade", Title: "Task"}); err != nil {
t.Fatal(err)
}
if _, _, err := repo.DeleteWorkspaceCascadeWithName(ctx, "workspace-cascade", "Wrong"); !errors.Is(err, repoerrors.ErrWorkspaceNameMismatch) {
t.Fatalf("name mismatch error = %v", err)
}
cleanupErr := errors.New("cleanup failed")
cleanupCalled := false
_, _, err := repo.DeleteWorkspaceCascadeWithNameAndSecretCleanup(ctx, "workspace-cascade", "Confirmed", func(context.Context, *sqlx.Tx) error {
cleanupCalled = true
return cleanupErr
})
if !cleanupCalled || !errors.Is(err, cleanupErr) {
t.Fatalf("cleanup rollback = called %v, error %v", cleanupCalled, err)
}
if _, err := repo.GetWorkspace(ctx, "workspace-cascade"); err != nil {
t.Fatalf("workspace missing after cleanup rollback: %v", err)
}
if _, err := repo.GetTask(ctx, "task-cascade"); err != nil {
t.Fatalf("task missing after cleanup rollback: %v", err)
}
tasks, workflows, err := repo.DeleteWorkspaceCascadeWithNameAndSecretCleanup(ctx, "workspace-cascade", "Confirmed", func(context.Context, *sqlx.Tx) error { return nil })
if err != nil {
t.Fatalf("DeleteWorkspaceCascadeWithNameAndSecretCleanup: %v", err)
}
if len(tasks) != 1 || tasks[0].ID != "task-cascade" || len(workflows) != 1 || workflows[0].ID != "workflow-cascade" {
t.Fatalf("cascade snapshots = tasks %+v, workflows %+v", tasks, workflows)
}
if _, err := repo.GetWorkspace(ctx, "workspace-cascade"); !errors.Is(err, repoerrors.ErrWorkspaceNotFound) {
t.Fatalf("workspace remains after cascade: %v", err)
}
if _, _, err := repo.DeleteWorkspaceCascade(ctx, "workspace-cascade"); !errors.Is(err, repoerrors.ErrWorkspaceNotFound) {
t.Fatalf("second cascade error = %v", err)
}
}