diff --git a/apps/backend/internal/task/repository/sqlite/attachment_lifecycle_coverage_test.go b/apps/backend/internal/task/repository/sqlite/attachment_lifecycle_coverage_test.go new file mode 100644 index 0000000000..bf5c7ca429 --- /dev/null +++ b/apps/backend/internal/task/repository/sqlite/attachment_lifecycle_coverage_test.go @@ -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) + } +} diff --git a/apps/backend/internal/task/repository/sqlite/entity_crud_coverage_test.go b/apps/backend/internal/task/repository/sqlite/entity_crud_coverage_test.go new file mode 100644 index 0000000000..20ccd2b471 --- /dev/null +++ b/apps/backend/internal/task/repository/sqlite/entity_crud_coverage_test.go @@ -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 +} diff --git a/apps/backend/internal/task/repository/sqlite/message_crud_coverage_test.go b/apps/backend/internal/task/repository/sqlite/message_crud_coverage_test.go new file mode 100644 index 0000000000..f0ae65530e --- /dev/null +++ b/apps/backend/internal/task/repository/sqlite/message_crud_coverage_test.go @@ -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 +} diff --git a/apps/backend/internal/task/repository/sqlite/relationship_crud_coverage_test.go b/apps/backend/internal/task/repository/sqlite/relationship_crud_coverage_test.go new file mode 100644 index 0000000000..19ac4e8798 --- /dev/null +++ b/apps/backend/internal/task/repository/sqlite/relationship_crud_coverage_test.go @@ -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 +} diff --git a/apps/backend/internal/task/repository/sqlite/repository_entity_test.go b/apps/backend/internal/task/repository/sqlite/repository_entity_test.go index 153c5e999e..2ca03b3e98 100644 --- a/apps/backend/internal/task/repository/sqlite/repository_entity_test.go +++ b/apps/backend/internal/task/repository/sqlite/repository_entity_test.go @@ -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 { diff --git a/apps/backend/internal/task/repository/sqlite/resource_cleanup_test.go b/apps/backend/internal/task/repository/sqlite/resource_cleanup_test.go index 6000b7b9fc..6baf4c314f 100644 --- a/apps/backend/internal/task/repository/sqlite/resource_cleanup_test.go +++ b/apps/backend/internal/task/repository/sqlite/resource_cleanup_test.go @@ -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") + } +} diff --git a/apps/backend/internal/task/repository/sqlite/runtime_query_coverage_test.go b/apps/backend/internal/task/repository/sqlite/runtime_query_coverage_test.go new file mode 100644 index 0000000000..de611c04ef --- /dev/null +++ b/apps/backend/internal/task/repository/sqlite/runtime_query_coverage_test.go @@ -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) + } +} diff --git a/apps/backend/internal/task/repository/sqlite/task_environment_test.go b/apps/backend/internal/task/repository/sqlite/task_environment_test.go index 84a12f95c4..da7cc85a5d 100644 --- a/apps/backend/internal/task/repository/sqlite/task_environment_test.go +++ b/apps/backend/internal/task/repository/sqlite/task_environment_test.go @@ -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) diff --git a/apps/backend/internal/task/repository/sqlite/task_selection_coverage_test.go b/apps/backend/internal/task/repository/sqlite/task_selection_coverage_test.go new file mode 100644 index 0000000000..1b64968e3c --- /dev/null +++ b/apps/backend/internal/task/repository/sqlite/task_selection_coverage_test.go @@ -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) + } +} diff --git a/apps/backend/internal/task/repository/sqlite/task_status_summary_test.go b/apps/backend/internal/task/repository/sqlite/task_status_summary_test.go index 9093c1175a..ed21735028 100644 --- a/apps/backend/internal/task/repository/sqlite/task_status_summary_test.go +++ b/apps/backend/internal/task/repository/sqlite/task_status_summary_test.go @@ -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) { diff --git a/apps/backend/internal/task/repository/sqlite/workspace_crud_coverage_test.go b/apps/backend/internal/task/repository/sqlite/workspace_crud_coverage_test.go new file mode 100644 index 0000000000..2d417dd1a3 --- /dev/null +++ b/apps/backend/internal/task/repository/sqlite/workspace_crud_coverage_test.go @@ -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) + } +}