889 lines
29 KiB
Go
889 lines
29 KiB
Go
package service
|
|
|
|
import (
|
|
"context"
|
|
"path/filepath"
|
|
"strings"
|
|
"sync"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/jmoiron/sqlx"
|
|
"github.com/kandev/kandev/internal/common/logger"
|
|
"github.com/kandev/kandev/internal/db"
|
|
"github.com/kandev/kandev/internal/events/bus"
|
|
officesqlite "github.com/kandev/kandev/internal/office/repository/sqlite"
|
|
"github.com/kandev/kandev/internal/task/models"
|
|
"github.com/kandev/kandev/internal/task/repository"
|
|
sqliterepo "github.com/kandev/kandev/internal/task/repository/sqlite"
|
|
"github.com/kandev/kandev/internal/worktree"
|
|
v1 "github.com/kandev/kandev/pkg/api/v1"
|
|
)
|
|
|
|
// MockEventBus implements bus.EventBus for testing
|
|
type MockEventBus struct {
|
|
mu sync.Mutex
|
|
publishedEvents []*bus.Event
|
|
closed bool
|
|
}
|
|
|
|
func NewMockEventBus() *MockEventBus {
|
|
return &MockEventBus{
|
|
publishedEvents: make([]*bus.Event, 0),
|
|
}
|
|
}
|
|
|
|
func (m *MockEventBus) Publish(ctx context.Context, subject string, event *bus.Event) error {
|
|
m.mu.Lock()
|
|
defer m.mu.Unlock()
|
|
m.publishedEvents = append(m.publishedEvents, event)
|
|
return nil
|
|
}
|
|
|
|
func (m *MockEventBus) Subscribe(subject string, handler bus.EventHandler) (bus.Subscription, error) {
|
|
return nil, nil
|
|
}
|
|
|
|
func (m *MockEventBus) QueueSubscribe(subject, queue string, handler bus.EventHandler) (bus.Subscription, error) {
|
|
return nil, nil
|
|
}
|
|
|
|
func (m *MockEventBus) Request(ctx context.Context, subject string, event *bus.Event, timeout time.Duration) (*bus.Event, error) {
|
|
return nil, nil
|
|
}
|
|
|
|
func (m *MockEventBus) Close() {
|
|
m.mu.Lock()
|
|
defer m.mu.Unlock()
|
|
m.closed = true
|
|
}
|
|
|
|
func (m *MockEventBus) IsConnected() bool {
|
|
return !m.closed
|
|
}
|
|
|
|
func (m *MockEventBus) GetPublishedEvents() []*bus.Event {
|
|
m.mu.Lock()
|
|
defer m.mu.Unlock()
|
|
return m.publishedEvents
|
|
}
|
|
|
|
func (m *MockEventBus) ClearEvents() {
|
|
m.mu.Lock()
|
|
defer m.mu.Unlock()
|
|
m.publishedEvents = make([]*bus.Event, 0)
|
|
}
|
|
|
|
func createTestService(t *testing.T) (*Service, *MockEventBus, *sqliterepo.Repository) {
|
|
t.Helper()
|
|
tmpDir := t.TempDir()
|
|
dbConn, err := db.OpenSQLite(filepath.Join(tmpDir, "test.db"))
|
|
if err != nil {
|
|
t.Fatalf("failed to open test database: %v", err)
|
|
}
|
|
sqlxDB := sqlx.NewDb(dbConn, "sqlite3")
|
|
repo, cleanup, err := repository.Provide(sqlxDB, sqlxDB, nil)
|
|
if err != nil {
|
|
t.Fatalf("failed to create test repository: %v", err)
|
|
}
|
|
if _, err := worktree.NewSQLiteStore(sqlxDB, sqlxDB); err != nil {
|
|
t.Fatalf("failed to init worktree store: %v", err)
|
|
}
|
|
// Apply office migrations on top of the task schema. The office
|
|
// package adds CHECK constraints (notably tasks.priority) and
|
|
// enables foreign_keys=ON. Running both migrations here mirrors
|
|
// production startup so service-layer tests catch cross-package
|
|
// constraint regressions automatically.
|
|
if _, err := officesqlite.NewWithDB(sqlxDB, sqlxDB, nil); err != nil {
|
|
t.Fatalf("failed to apply office migrations: %v", err)
|
|
}
|
|
t.Cleanup(func() {
|
|
if err := sqlxDB.Close(); err != nil {
|
|
t.Errorf("failed to close sqlite db: %v", err)
|
|
}
|
|
if err := cleanup(); err != nil {
|
|
t.Errorf("failed to close repo: %v", err)
|
|
}
|
|
})
|
|
eventBus := NewMockEventBus()
|
|
log, _ := logger.NewLogger(logger.LoggingConfig{Level: "error", Format: "json", OutputPath: "stdout"})
|
|
svc := NewService(Repos{
|
|
Workspaces: repo,
|
|
Tasks: repo,
|
|
TaskRepos: repo,
|
|
Workflows: repo,
|
|
Messages: repo,
|
|
Turns: repo,
|
|
Sessions: repo,
|
|
GitSnapshots: repo,
|
|
RepoEntities: repo,
|
|
Executors: repo,
|
|
Environments: repo,
|
|
TaskEnvironments: repo,
|
|
Reviews: repo,
|
|
}, eventBus, log, RepositoryDiscoveryConfig{})
|
|
return svc, eventBus, repo
|
|
}
|
|
|
|
// Task tests
|
|
|
|
func TestService_CreateTask(t *testing.T) {
|
|
svc, eventBus, repo := createTestService(t)
|
|
ctx := context.Background()
|
|
|
|
// Create workflow first
|
|
_ = repo.CreateWorkspace(ctx, &models.Workspace{ID: "ws-1", Name: "Workspace"})
|
|
workflow := &models.Workflow{ID: "wf-123", WorkspaceID: "ws-1", Name: "Test Workflow"}
|
|
_ = repo.CreateWorkflow(ctx, workflow)
|
|
repository := &models.Repository{ID: "repo-123", WorkspaceID: "ws-1", Name: "Test Repo"}
|
|
_ = repo.CreateRepository(ctx, repository)
|
|
|
|
req := &CreateTaskRequest{
|
|
WorkspaceID: "ws-1",
|
|
WorkflowID: "wf-123",
|
|
WorkflowStepID: "step-123",
|
|
Title: "Test Task",
|
|
Description: "A test task",
|
|
Priority: "high",
|
|
Repositories: []TaskRepositoryInput{
|
|
{
|
|
RepositoryID: "repo-123",
|
|
BaseBranch: "main",
|
|
},
|
|
},
|
|
}
|
|
|
|
task, err := svc.CreateTask(ctx, req)
|
|
if err != nil {
|
|
t.Fatalf("failed to create task: %v", err)
|
|
}
|
|
|
|
if task.ID == "" {
|
|
t.Error("expected task ID to be set")
|
|
}
|
|
if task.Title != "Test Task" {
|
|
t.Errorf("expected title 'Test Task', got %s", task.Title)
|
|
}
|
|
if task.State != v1.TaskStateCreated {
|
|
t.Errorf("expected state CREATED, got %s", task.State)
|
|
}
|
|
|
|
// Check event was published
|
|
events := eventBus.GetPublishedEvents()
|
|
if len(events) != 1 {
|
|
t.Errorf("expected 1 event, got %d", len(events))
|
|
}
|
|
if events[0].Type != "task.created" {
|
|
t.Errorf("expected event type 'task.created', got %s", events[0].Type)
|
|
}
|
|
// workspace_id MUST be in the event payload so the office WS handler
|
|
// can workspace-scope the dashboard refetch.
|
|
data, ok := events[0].Data.(map[string]interface{})
|
|
if !ok {
|
|
t.Fatalf("event data is not map[string]interface{}, got %T", events[0].Data)
|
|
}
|
|
if got := data["workspace_id"]; got != "ws-1" {
|
|
t.Errorf("expected workspace_id 'ws-1' in event payload, got %v", got)
|
|
}
|
|
}
|
|
|
|
// TestService_CreateTask_DefaultsPriorityWhenEmpty pins the regression
|
|
// from the office priority migration. The migration added a CHECK
|
|
// constraint that the priority column must be one of {critical, high,
|
|
// medium, low}; callers that don't set Priority on the request (e.g.
|
|
// the onboarding adapter creating the CEO's first task) must still
|
|
// produce a row that satisfies the constraint. buildTask defaults the
|
|
// priority to "medium" exactly for this case.
|
|
func TestService_CreateTask_DefaultsPriorityWhenEmpty(t *testing.T) {
|
|
svc, _, repo := createTestService(t)
|
|
ctx := context.Background()
|
|
|
|
_ = repo.CreateWorkspace(ctx, &models.Workspace{ID: "ws-1", Name: "Workspace"})
|
|
_ = repo.CreateWorkflow(ctx, &models.Workflow{ID: "wf-1", WorkspaceID: "ws-1", Name: "WF"})
|
|
|
|
req := &CreateTaskRequest{
|
|
WorkspaceID: "ws-1",
|
|
WorkflowID: "wf-1",
|
|
WorkflowStepID: "step-1",
|
|
Title: "Onboarding-style task with no priority",
|
|
// Priority intentionally omitted — caller didn't set it.
|
|
}
|
|
task, err := svc.CreateTask(ctx, req)
|
|
if err != nil {
|
|
t.Fatalf("CreateTask with empty priority should succeed, got %v", err)
|
|
}
|
|
if task.Priority != "medium" {
|
|
t.Errorf("expected default priority 'medium', got %q", task.Priority)
|
|
}
|
|
}
|
|
|
|
func TestService_CreateTask_RejectsDuplicateRepositories(t *testing.T) {
|
|
svc, _, repo := createTestService(t)
|
|
ctx := context.Background()
|
|
|
|
_ = repo.CreateWorkspace(ctx, &models.Workspace{ID: "ws-1", Name: "Workspace"})
|
|
_ = repo.CreateWorkflow(ctx, &models.Workflow{ID: "wf-1", WorkspaceID: "ws-1", Name: "WF"})
|
|
_ = repo.CreateRepository(ctx, &models.Repository{ID: "repo-dup", WorkspaceID: "ws-1", Name: "Dup"})
|
|
|
|
req := &CreateTaskRequest{
|
|
WorkspaceID: "ws-1",
|
|
WorkflowID: "wf-1",
|
|
WorkflowStepID: "step-1",
|
|
Title: "dup repos",
|
|
Repositories: []TaskRepositoryInput{
|
|
{RepositoryID: "repo-dup", BaseBranch: "main"},
|
|
{RepositoryID: "repo-dup", BaseBranch: "main"},
|
|
},
|
|
}
|
|
_, err := svc.CreateTask(ctx, req)
|
|
if err == nil {
|
|
t.Fatal("expected duplicate-repository error, got nil")
|
|
}
|
|
// The error must name the repository (its display name) rather than leak
|
|
// the raw repository UUID, which the create-task dialog shows verbatim.
|
|
if !strings.Contains(err.Error(), "Dup") {
|
|
t.Errorf("expected error to name the repo (%q), got: %v", "Dup", err)
|
|
}
|
|
if strings.Contains(err.Error(), "repo-dup") {
|
|
t.Errorf("expected error not to leak repository UUID, got: %v", err)
|
|
}
|
|
}
|
|
|
|
// Regression: two PR URLs of the same GitHub repo (e.g. /pull/1116 and
|
|
// /pull/1117) parse to the same owner/repo and collapse to one repositoryID,
|
|
// tripping the duplicate guard. The error must name owner/repo, not the UUID.
|
|
func TestService_CreateTask_RejectsDuplicateGitHubURLs(t *testing.T) {
|
|
svc, _, repo := createTestService(t)
|
|
ctx := context.Background()
|
|
|
|
_ = repo.CreateWorkspace(ctx, &models.Workspace{ID: "ws-1", Name: "Workspace"})
|
|
_ = repo.CreateWorkflow(ctx, &models.Workflow{ID: "wf-1", WorkspaceID: "ws-1", Name: "WF"})
|
|
|
|
req := &CreateTaskRequest{
|
|
WorkspaceID: "ws-1",
|
|
WorkflowID: "wf-1",
|
|
WorkflowStepID: "step-1",
|
|
Title: "dup PR urls",
|
|
Repositories: []TaskRepositoryInput{
|
|
{GitHubURL: "https://github.com/kdlbs/kandev/pull/1116"},
|
|
{GitHubURL: "https://github.com/kdlbs/kandev/pull/1117"},
|
|
},
|
|
}
|
|
_, err := svc.CreateTask(ctx, req)
|
|
if err == nil {
|
|
t.Fatal("expected duplicate-repository error, got nil")
|
|
}
|
|
if !strings.Contains(err.Error(), "kdlbs/kandev") {
|
|
t.Errorf("expected error to name owner/repo (kdlbs/kandev), got: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestService_CreateTask_AcceptsMultipleDistinctRepositories(t *testing.T) {
|
|
svc, _, repo := createTestService(t)
|
|
ctx := context.Background()
|
|
|
|
_ = repo.CreateWorkspace(ctx, &models.Workspace{ID: "ws-1", Name: "Workspace"})
|
|
_ = repo.CreateWorkflow(ctx, &models.Workflow{ID: "wf-1", WorkspaceID: "ws-1", Name: "WF"})
|
|
_ = repo.CreateRepository(ctx, &models.Repository{ID: "repo-front", WorkspaceID: "ws-1", Name: "front"})
|
|
_ = repo.CreateRepository(ctx, &models.Repository{ID: "repo-back", WorkspaceID: "ws-1", Name: "back"})
|
|
|
|
req := &CreateTaskRequest{
|
|
WorkspaceID: "ws-1",
|
|
WorkflowID: "wf-1",
|
|
WorkflowStepID: "step-1",
|
|
Title: "multi",
|
|
Repositories: []TaskRepositoryInput{
|
|
{RepositoryID: "repo-front", BaseBranch: "main"},
|
|
{RepositoryID: "repo-back", BaseBranch: "main"},
|
|
},
|
|
}
|
|
task, err := svc.CreateTask(ctx, req)
|
|
if err != nil {
|
|
t.Fatalf("CreateTask: %v", err)
|
|
}
|
|
if len(task.Repositories) != 2 {
|
|
t.Errorf("expected 2 task repositories, got %d", len(task.Repositories))
|
|
}
|
|
}
|
|
|
|
func TestService_CreateTask_RejectsRepositoryFromOtherWorkspace(t *testing.T) {
|
|
svc, _, repo := createTestService(t)
|
|
ctx := context.Background()
|
|
|
|
_ = repo.CreateWorkspace(ctx, &models.Workspace{ID: "ws-a", Name: "A"})
|
|
_ = repo.CreateWorkspace(ctx, &models.Workspace{ID: "ws-b", Name: "B"})
|
|
_ = repo.CreateWorkflow(ctx, &models.Workflow{ID: "wf-a", WorkspaceID: "ws-a", Name: "WF"})
|
|
_ = repo.CreateRepository(ctx, &models.Repository{ID: "repo-b", WorkspaceID: "ws-b", Name: "B repo"})
|
|
|
|
req := &CreateTaskRequest{
|
|
WorkspaceID: "ws-a",
|
|
WorkflowID: "wf-a",
|
|
WorkflowStepID: "step-1",
|
|
Title: "cross-ws",
|
|
Repositories: []TaskRepositoryInput{
|
|
{RepositoryID: "repo-b", BaseBranch: "main"},
|
|
},
|
|
}
|
|
_, err := svc.CreateTask(ctx, req)
|
|
if err == nil {
|
|
t.Fatal("expected cross-workspace repository error, got nil")
|
|
}
|
|
if !strings.Contains(err.Error(), "does not belong to workspace") {
|
|
t.Errorf("expected workspace-ownership error, got: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestService_CreateRepository_DefaultWorktreeBranchPrefix(t *testing.T) {
|
|
svc, _, repo := createTestService(t)
|
|
ctx := context.Background()
|
|
|
|
_ = repo.CreateWorkspace(ctx, &models.Workspace{ID: "ws-1", Name: "Workspace"})
|
|
|
|
created, err := svc.CreateRepository(ctx, &CreateRepositoryRequest{
|
|
WorkspaceID: "ws-1",
|
|
Name: "Test Repo",
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("CreateRepository failed: %v", err)
|
|
}
|
|
if created.WorktreeBranchPrefix != worktree.DefaultBranchPrefix {
|
|
t.Fatalf("expected default prefix %q, got %q", worktree.DefaultBranchPrefix, created.WorktreeBranchPrefix)
|
|
}
|
|
if !created.PullBeforeWorktree {
|
|
t.Fatalf("expected pull_before_worktree to default to true")
|
|
}
|
|
|
|
stored, err := repo.GetRepository(ctx, created.ID)
|
|
if err != nil {
|
|
t.Fatalf("GetRepository failed: %v", err)
|
|
}
|
|
if stored.WorktreeBranchPrefix != worktree.DefaultBranchPrefix {
|
|
t.Fatalf("expected stored prefix %q, got %q", worktree.DefaultBranchPrefix, stored.WorktreeBranchPrefix)
|
|
}
|
|
if !stored.PullBeforeWorktree {
|
|
t.Fatalf("expected stored pull_before_worktree to default to true")
|
|
}
|
|
}
|
|
|
|
func TestService_CreateRepository_PullBeforeWorktreeFalse(t *testing.T) {
|
|
svc, _, repo := createTestService(t)
|
|
ctx := context.Background()
|
|
|
|
_ = repo.CreateWorkspace(ctx, &models.Workspace{ID: "ws-1", Name: "Workspace"})
|
|
|
|
pullFalse := false
|
|
created, err := svc.CreateRepository(ctx, &CreateRepositoryRequest{
|
|
WorkspaceID: "ws-1",
|
|
Name: "Test Repo",
|
|
PullBeforeWorktree: &pullFalse,
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("CreateRepository failed: %v", err)
|
|
}
|
|
if created.PullBeforeWorktree {
|
|
t.Fatalf("expected pull_before_worktree to be false when explicitly set")
|
|
}
|
|
|
|
stored, err := repo.GetRepository(ctx, created.ID)
|
|
if err != nil {
|
|
t.Fatalf("GetRepository failed: %v", err)
|
|
}
|
|
if stored.PullBeforeWorktree {
|
|
t.Fatalf("expected stored pull_before_worktree to be false")
|
|
}
|
|
}
|
|
|
|
func TestService_GetTask(t *testing.T) {
|
|
svc, _, repo := createTestService(t)
|
|
ctx := context.Background()
|
|
|
|
// Create required entities
|
|
setupTestTask(t, repo)
|
|
|
|
retrieved, err := svc.GetTask(ctx, "task-123")
|
|
if err != nil {
|
|
t.Fatalf("failed to get task: %v", err)
|
|
}
|
|
if retrieved.Title != "Test Task" {
|
|
t.Errorf("expected title 'Test Task', got %s", retrieved.Title)
|
|
}
|
|
}
|
|
|
|
func TestService_GetTaskNotFound(t *testing.T) {
|
|
svc, _, _ := createTestService(t)
|
|
ctx := context.Background()
|
|
|
|
_, err := svc.GetTask(ctx, "nonexistent")
|
|
if err == nil {
|
|
t.Error("expected error for nonexistent task")
|
|
}
|
|
}
|
|
|
|
func TestService_UpdateTask(t *testing.T) {
|
|
svc, eventBus, repo := createTestService(t)
|
|
ctx := context.Background()
|
|
|
|
_ = repo.CreateWorkspace(ctx, &models.Workspace{ID: "ws-1", Name: "Workspace"})
|
|
_ = repo.CreateWorkflow(ctx, &models.Workflow{ID: "wf-123", WorkspaceID: "ws-1", Name: "Workflow"})
|
|
_ = repo.CreateTask(ctx, &models.Task{ID: "task-123", WorkspaceID: "ws-1", WorkflowID: "wf-123", WorkflowStepID: "step-123", Title: "Original", Priority: "medium"})
|
|
eventBus.ClearEvents()
|
|
|
|
newTitle := "Updated Title"
|
|
req := &UpdateTaskRequest{Title: &newTitle}
|
|
|
|
updated, err := svc.UpdateTask(ctx, "task-123", req)
|
|
if err != nil {
|
|
t.Fatalf("failed to update task: %v", err)
|
|
}
|
|
if updated.Title != "Updated Title" {
|
|
t.Errorf("expected title 'Updated Title', got %s", updated.Title)
|
|
}
|
|
|
|
// Check event was published
|
|
events := eventBus.GetPublishedEvents()
|
|
if len(events) != 1 {
|
|
t.Errorf("expected 1 event, got %d", len(events))
|
|
}
|
|
if events[0].Type != "task.updated" {
|
|
t.Errorf("expected event type 'task.updated', got %s", events[0].Type)
|
|
}
|
|
}
|
|
|
|
func TestService_DeleteTask(t *testing.T) {
|
|
svc, eventBus, repo := createTestService(t)
|
|
ctx := context.Background()
|
|
|
|
_ = repo.CreateWorkspace(ctx, &models.Workspace{ID: "ws-1", Name: "Workspace"})
|
|
_ = repo.CreateWorkflow(ctx, &models.Workflow{ID: "wf-123", WorkspaceID: "ws-1", Name: "Workflow"})
|
|
_ = repo.CreateTask(ctx, &models.Task{ID: "task-123", WorkspaceID: "ws-1", WorkflowID: "wf-123", WorkflowStepID: "step-123", Title: "Test", Priority: "medium"})
|
|
eventBus.ClearEvents()
|
|
|
|
err := svc.DeleteTask(ctx, "task-123")
|
|
if err != nil {
|
|
t.Fatalf("failed to delete task: %v", err)
|
|
}
|
|
|
|
// Verify task is deleted
|
|
_, err = svc.GetTask(ctx, "task-123")
|
|
if err == nil {
|
|
t.Error("expected task to be deleted")
|
|
}
|
|
|
|
// Check event was published
|
|
events := eventBus.GetPublishedEvents()
|
|
if len(events) != 1 {
|
|
t.Errorf("expected 1 event, got %d", len(events))
|
|
}
|
|
}
|
|
|
|
func TestService_ListTasks(t *testing.T) {
|
|
svc, _, repo := createTestService(t)
|
|
ctx := context.Background()
|
|
|
|
_ = repo.CreateWorkspace(ctx, &models.Workspace{ID: "ws-1", Name: "Workspace"})
|
|
_ = repo.CreateWorkflow(ctx, &models.Workflow{ID: "wf-123", WorkspaceID: "ws-1", Name: "Workflow"})
|
|
_ = repo.CreateTask(ctx, &models.Task{ID: "task-1", WorkspaceID: "ws-1", WorkflowID: "wf-123", WorkflowStepID: "step-123", Title: "Task 1", Priority: "medium"})
|
|
_ = repo.CreateTask(ctx, &models.Task{ID: "task-2", WorkspaceID: "ws-1", WorkflowID: "wf-123", WorkflowStepID: "step-123", Title: "Task 2", Priority: "medium"})
|
|
|
|
tasks, err := svc.ListTasks(ctx, "wf-123")
|
|
if err != nil {
|
|
t.Fatalf("failed to list tasks: %v", err)
|
|
}
|
|
if len(tasks) != 2 {
|
|
t.Errorf("expected 2 tasks, got %d", len(tasks))
|
|
}
|
|
}
|
|
|
|
func TestService_UpdateTaskState(t *testing.T) {
|
|
svc, eventBus, repo := createTestService(t)
|
|
ctx := context.Background()
|
|
|
|
_ = repo.CreateWorkspace(ctx, &models.Workspace{ID: "ws-1", Name: "Workspace"})
|
|
_ = repo.CreateWorkflow(ctx, &models.Workflow{ID: "wf-123", WorkspaceID: "ws-1", Name: "Workflow"})
|
|
task := &models.Task{ID: "task-123", WorkspaceID: "ws-1", WorkflowID: "wf-123", WorkflowStepID: "step-123", Title: "Test", State: v1.TaskStateTODO, Priority: "medium"}
|
|
_ = repo.CreateTask(ctx, task)
|
|
eventBus.ClearEvents()
|
|
|
|
updated, err := svc.UpdateTaskState(ctx, "task-123", v1.TaskStateInProgress)
|
|
if err != nil {
|
|
t.Fatalf("failed to update task state: %v", err)
|
|
}
|
|
if updated.State != v1.TaskStateInProgress {
|
|
t.Errorf("expected state IN_PROGRESS, got %s", updated.State)
|
|
}
|
|
|
|
// Check event was published
|
|
events := eventBus.GetPublishedEvents()
|
|
if len(events) != 1 {
|
|
t.Errorf("expected 1 event, got %d", len(events))
|
|
}
|
|
if events[0].Type != "task.state_changed" {
|
|
t.Errorf("expected event type 'task.state_changed', got %s", events[0].Type)
|
|
}
|
|
}
|
|
|
|
func TestService_MoveTask(t *testing.T) {
|
|
svc, eventBus, repo := createTestService(t)
|
|
ctx := context.Background()
|
|
|
|
_ = repo.CreateWorkspace(ctx, &models.Workspace{ID: "ws-1", Name: "Workspace"})
|
|
_ = repo.CreateWorkflow(ctx, &models.Workflow{ID: "wf-123", WorkspaceID: "ws-1", Name: "Workflow"})
|
|
|
|
task := &models.Task{ID: "task-123", WorkspaceID: "ws-1", WorkflowID: "wf-123", WorkflowStepID: "step-todo", Title: "Test", State: v1.TaskStateTODO, Priority: "medium"}
|
|
_ = repo.CreateTask(ctx, task)
|
|
eventBus.ClearEvents()
|
|
|
|
moved, err := svc.MoveTask(ctx, "task-123", "wf-123", "step-done", 0)
|
|
if err != nil {
|
|
t.Fatalf("failed to move task: %v", err)
|
|
}
|
|
if moved.Task.WorkflowStepID != "step-done" {
|
|
t.Errorf("expected workflow step 'step-done', got %s", moved.Task.WorkflowStepID)
|
|
}
|
|
}
|
|
|
|
// Workflow tests
|
|
|
|
func TestService_CreateWorkflow(t *testing.T) {
|
|
svc, _, repo := createTestService(t)
|
|
ctx := context.Background()
|
|
|
|
_ = repo.CreateWorkspace(ctx, &models.Workspace{ID: "ws-1", Name: "Workspace"})
|
|
req := &CreateWorkflowRequest{
|
|
WorkspaceID: "ws-1",
|
|
Name: "Test Workflow",
|
|
Description: "A test workflow",
|
|
}
|
|
|
|
workflow, err := svc.CreateWorkflow(ctx, req)
|
|
if err != nil {
|
|
t.Fatalf("failed to create workflow: %v", err)
|
|
}
|
|
if workflow.ID == "" {
|
|
t.Error("expected workflow ID to be set")
|
|
}
|
|
if workflow.Name != "Test Workflow" {
|
|
t.Errorf("expected name 'Test Workflow', got %s", workflow.Name)
|
|
}
|
|
}
|
|
|
|
func TestService_GetWorkflow(t *testing.T) {
|
|
svc, _, repo := createTestService(t)
|
|
ctx := context.Background()
|
|
|
|
_ = repo.CreateWorkspace(ctx, &models.Workspace{ID: "ws-1", Name: "Workspace"})
|
|
workflow := &models.Workflow{ID: "wf-123", WorkspaceID: "ws-1", Name: "Test Workflow"}
|
|
_ = repo.CreateWorkflow(ctx, workflow)
|
|
|
|
retrieved, err := svc.GetWorkflow(ctx, "wf-123")
|
|
if err != nil {
|
|
t.Fatalf("failed to get workflow: %v", err)
|
|
}
|
|
if retrieved.Name != "Test Workflow" {
|
|
t.Errorf("expected name 'Test Workflow', got %s", retrieved.Name)
|
|
}
|
|
}
|
|
|
|
func TestService_UpdateWorkflow(t *testing.T) {
|
|
svc, _, repo := createTestService(t)
|
|
ctx := context.Background()
|
|
|
|
_ = repo.CreateWorkspace(ctx, &models.Workspace{ID: "ws-1", Name: "Workspace"})
|
|
workflow := &models.Workflow{ID: "wf-123", WorkspaceID: "ws-1", Name: "Original"}
|
|
_ = repo.CreateWorkflow(ctx, workflow)
|
|
|
|
newName := "Updated"
|
|
req := &UpdateWorkflowRequest{Name: &newName}
|
|
|
|
updated, err := svc.UpdateWorkflow(ctx, "wf-123", req)
|
|
if err != nil {
|
|
t.Fatalf("failed to update workflow: %v", err)
|
|
}
|
|
if updated.Name != "Updated" {
|
|
t.Errorf("expected name 'Updated', got %s", updated.Name)
|
|
}
|
|
}
|
|
func TestService_DeleteWorkflow(t *testing.T) {
|
|
svc, _, repo := createTestService(t)
|
|
ctx := context.Background()
|
|
|
|
_ = repo.CreateWorkspace(ctx, &models.Workspace{ID: "ws-1", Name: "Workspace"})
|
|
workflow := &models.Workflow{ID: "wf-123", WorkspaceID: "ws-1", Name: "Test Workflow"}
|
|
_ = repo.CreateWorkflow(ctx, workflow)
|
|
|
|
err := svc.DeleteWorkflow(ctx, "wf-123")
|
|
if err != nil {
|
|
t.Fatalf("failed to delete workflow: %v", err)
|
|
}
|
|
|
|
_, err = svc.GetWorkflow(ctx, "wf-123")
|
|
if err == nil {
|
|
t.Error("expected workflow to be deleted")
|
|
}
|
|
}
|
|
|
|
func TestService_ListWorkflows(t *testing.T) {
|
|
svc, _, repo := createTestService(t)
|
|
ctx := context.Background()
|
|
|
|
_ = repo.CreateWorkspace(ctx, &models.Workspace{ID: "ws-1", Name: "Workspace"})
|
|
_ = repo.CreateWorkflow(ctx, &models.Workflow{ID: "wf-1", WorkspaceID: "ws-1", Name: "Workflow 1"})
|
|
_ = repo.CreateWorkflow(ctx, &models.Workflow{ID: "wf-2", WorkspaceID: "ws-1", Name: "Workflow 2"})
|
|
|
|
workflows, err := svc.ListWorkflows(ctx, "ws-1", false)
|
|
if err != nil {
|
|
t.Fatalf("failed to list workflows: %v", err)
|
|
}
|
|
if len(workflows) != 2 {
|
|
t.Errorf("expected 2 workflows, got %d", len(workflows))
|
|
}
|
|
}
|
|
|
|
// Message tests
|
|
|
|
func setupTestTask(t *testing.T, repo *sqliterepo.Repository) {
|
|
t.Helper()
|
|
ctx := context.Background()
|
|
_ = repo.CreateWorkspace(ctx, &models.Workspace{ID: "ws-1", Name: "Workspace"})
|
|
_ = repo.CreateWorkflow(ctx, &models.Workflow{ID: "wf-123", WorkspaceID: "ws-1", Name: "Workflow"})
|
|
_ = repo.CreateTask(ctx, &models.Task{ID: "task-123", WorkspaceID: "ws-1", WorkflowID: "wf-123", WorkflowStepID: "step-123", Title: "Test Task", Priority: "medium"})
|
|
}
|
|
|
|
func setupTestSession(t *testing.T, repo *sqliterepo.Repository) string {
|
|
t.Helper()
|
|
ctx := context.Background()
|
|
session := &models.TaskSession{
|
|
ID: "session-123",
|
|
TaskID: "task-123",
|
|
AgentProfileID: "profile-123",
|
|
State: models.TaskSessionStateStarting,
|
|
}
|
|
_ = repo.CreateTaskSession(ctx, session)
|
|
return session.ID
|
|
}
|
|
|
|
func setupTestTurn(t *testing.T, repo *sqliterepo.Repository, sessionID, taskID, turnID string) string {
|
|
t.Helper()
|
|
ctx := context.Background()
|
|
now := time.Now()
|
|
turn := &models.Turn{
|
|
ID: turnID,
|
|
TaskSessionID: sessionID,
|
|
TaskID: taskID,
|
|
StartedAt: now,
|
|
CreatedAt: now,
|
|
UpdatedAt: now,
|
|
}
|
|
if err := repo.CreateTurn(ctx, turn); err != nil {
|
|
t.Fatalf("failed to create test turn: %v", err)
|
|
}
|
|
return turn.ID
|
|
}
|
|
|
|
func TestService_CreateMessage(t *testing.T) {
|
|
svc, eventBus, repo := createTestService(t)
|
|
ctx := context.Background()
|
|
|
|
// Create a task first
|
|
setupTestTask(t, repo)
|
|
sessionID := setupTestSession(t, repo)
|
|
turnID := setupTestTurn(t, repo, sessionID, "task-123", "turn-123")
|
|
eventBus.ClearEvents()
|
|
|
|
req := &CreateMessageRequest{
|
|
TaskSessionID: sessionID,
|
|
TurnID: turnID,
|
|
Content: "This is a test comment",
|
|
AuthorType: "user",
|
|
AuthorID: "user-123",
|
|
}
|
|
|
|
comment, err := svc.CreateMessage(ctx, req)
|
|
if err != nil {
|
|
t.Fatalf("failed to create comment: %v", err)
|
|
}
|
|
|
|
if comment.ID == "" {
|
|
t.Error("expected comment ID to be set")
|
|
}
|
|
if comment.Content != "This is a test comment" {
|
|
t.Errorf("expected content 'This is a test comment', got %s", comment.Content)
|
|
}
|
|
if comment.AuthorType != models.MessageAuthorUser {
|
|
t.Errorf("expected author type 'user', got %s", comment.AuthorType)
|
|
}
|
|
|
|
// Check event was published
|
|
events := eventBus.GetPublishedEvents()
|
|
if len(events) != 1 {
|
|
t.Errorf("expected 1 event, got %d", len(events))
|
|
}
|
|
if events[0].Type != "message.added" {
|
|
t.Errorf("expected event type 'message.added', got %s", events[0].Type)
|
|
}
|
|
}
|
|
|
|
func TestService_CreateAgentMessage(t *testing.T) {
|
|
svc, _, repo := createTestService(t)
|
|
ctx := context.Background()
|
|
|
|
setupTestTask(t, repo)
|
|
sessionID := setupTestSession(t, repo)
|
|
turnID := setupTestTurn(t, repo, sessionID, "task-123", "turn-123")
|
|
|
|
req := &CreateMessageRequest{
|
|
TaskSessionID: sessionID,
|
|
TurnID: turnID,
|
|
Content: "What should I do next?",
|
|
AuthorType: "agent",
|
|
AuthorID: "agent-123",
|
|
RequestsInput: true,
|
|
}
|
|
|
|
comment, err := svc.CreateMessage(ctx, req)
|
|
if err != nil {
|
|
t.Fatalf("failed to create comment: %v", err)
|
|
}
|
|
|
|
if comment.AuthorType != models.MessageAuthorAgent {
|
|
t.Errorf("expected author type 'agent', got %s", comment.AuthorType)
|
|
}
|
|
if !comment.RequestsInput {
|
|
t.Error("expected RequestsInput to be true")
|
|
}
|
|
}
|
|
|
|
func TestService_CreateMessageSessionNotFound(t *testing.T) {
|
|
svc, _, _ := createTestService(t)
|
|
ctx := context.Background()
|
|
|
|
req := &CreateMessageRequest{
|
|
TaskSessionID: "nonexistent",
|
|
Content: "Test comment",
|
|
}
|
|
|
|
_, err := svc.CreateMessage(ctx, req)
|
|
if err == nil {
|
|
t.Error("expected error for nonexistent task")
|
|
}
|
|
}
|
|
|
|
func TestService_GetMessage(t *testing.T) {
|
|
svc, _, repo := createTestService(t)
|
|
ctx := context.Background()
|
|
|
|
setupTestTask(t, repo)
|
|
sessionID := setupTestSession(t, repo)
|
|
turnID := setupTestTurn(t, repo, sessionID, "task-123", "turn-123")
|
|
|
|
comment := &models.Message{ID: "comment-123", TaskSessionID: sessionID, TaskID: "task-123", TurnID: turnID, AuthorType: models.MessageAuthorUser, Content: "Test"}
|
|
_ = repo.CreateMessage(ctx, comment)
|
|
|
|
retrieved, err := svc.GetMessage(ctx, "comment-123")
|
|
if err != nil {
|
|
t.Fatalf("failed to get comment: %v", err)
|
|
}
|
|
if retrieved.Content != "Test" {
|
|
t.Errorf("expected content 'Test', got %s", retrieved.Content)
|
|
}
|
|
}
|
|
|
|
func TestService_ListMessages(t *testing.T) {
|
|
svc, _, repo := createTestService(t)
|
|
ctx := context.Background()
|
|
|
|
setupTestTask(t, repo)
|
|
sessionID := setupTestSession(t, repo)
|
|
turnID := setupTestTurn(t, repo, sessionID, "task-123", "turn-123")
|
|
|
|
_ = repo.CreateMessage(ctx, &models.Message{ID: "comment-1", TaskSessionID: sessionID, TaskID: "task-123", TurnID: turnID, AuthorType: models.MessageAuthorUser, Content: "Comment 1"})
|
|
_ = repo.CreateMessage(ctx, &models.Message{ID: "comment-2", TaskSessionID: sessionID, TaskID: "task-123", TurnID: turnID, AuthorType: models.MessageAuthorAgent, Content: "Comment 2"})
|
|
|
|
comments, err := svc.ListMessages(ctx, sessionID)
|
|
if err != nil {
|
|
t.Fatalf("failed to list comments: %v", err)
|
|
}
|
|
if len(comments) != 2 {
|
|
t.Errorf("expected 2 comments, got %d", len(comments))
|
|
}
|
|
}
|
|
|
|
func TestService_DeleteMessage(t *testing.T) {
|
|
svc, _, repo := createTestService(t)
|
|
ctx := context.Background()
|
|
|
|
setupTestTask(t, repo)
|
|
sessionID := setupTestSession(t, repo)
|
|
turnID := setupTestTurn(t, repo, sessionID, "task-123", "turn-123")
|
|
|
|
comment := &models.Message{ID: "comment-123", TaskSessionID: sessionID, TaskID: "task-123", TurnID: turnID, AuthorType: models.MessageAuthorUser, Content: "Test"}
|
|
_ = repo.CreateMessage(ctx, comment)
|
|
|
|
err := svc.DeleteMessage(ctx, "comment-123")
|
|
if err != nil {
|
|
t.Fatalf("failed to delete comment: %v", err)
|
|
}
|
|
|
|
_, err = svc.GetMessage(ctx, "comment-123")
|
|
if err == nil {
|
|
t.Error("expected comment to be deleted")
|
|
}
|
|
}
|
|
|
|
// TestPublishTaskUpdated_FallbackRepositoryID exercises the DB fallback in
|
|
// primaryRepositoryID: orchestrator-originated events load the task via the
|
|
// raw repo.GetTask, which does not populate Repositories. The publisher must
|
|
// still emit repository_id so the frontend doesn't lose the repo link on
|
|
// workflow transitions or state changes.
|
|
func TestPublishTaskUpdated_FallbackRepositoryID(t *testing.T) {
|
|
svc, eventBus, repo := createTestService(t)
|
|
ctx := context.Background()
|
|
|
|
// Seed workspace + workflow + repo + task with an association.
|
|
if err := repo.CreateWorkspace(ctx, &models.Workspace{ID: "ws-1", Name: "Workspace"}); err != nil {
|
|
t.Fatalf("CreateWorkspace: %v", err)
|
|
}
|
|
if err := repo.CreateWorkflow(ctx, &models.Workflow{ID: "wf-1", WorkspaceID: "ws-1", Name: "WF"}); err != nil {
|
|
t.Fatalf("CreateWorkflow: %v", err)
|
|
}
|
|
if err := repo.CreateRepository(ctx, &models.Repository{ID: "repo-x", WorkspaceID: "ws-1", Name: "Repo"}); err != nil {
|
|
t.Fatalf("CreateRepository: %v", err)
|
|
}
|
|
if err := repo.CreateTask(ctx, &models.Task{
|
|
ID: "task-1", WorkspaceID: "ws-1", WorkflowID: "wf-1", WorkflowStepID: "step-1", Title: "T", Priority: "medium",
|
|
}); err != nil {
|
|
t.Fatalf("CreateTask: %v", err)
|
|
}
|
|
if err := repo.CreateTaskRepository(ctx, &models.TaskRepository{
|
|
TaskID: "task-1", RepositoryID: "repo-x", BaseBranch: "main",
|
|
}); err != nil {
|
|
t.Fatalf("CreateTaskRepository: %v", err)
|
|
}
|
|
eventBus.ClearEvents()
|
|
|
|
// Mimic the orchestrator path: pass a task with Repositories nil.
|
|
task := &models.Task{
|
|
ID: "task-1", WorkspaceID: "ws-1", WorkflowID: "wf-1", WorkflowStepID: "step-1",
|
|
}
|
|
if len(task.Repositories) != 0 {
|
|
t.Fatal("pre-condition: task.Repositories must be nil for this test")
|
|
}
|
|
svc.PublishTaskUpdated(ctx, task)
|
|
|
|
events := eventBus.GetPublishedEvents()
|
|
if len(events) != 1 {
|
|
t.Fatalf("expected 1 published event, got %d", len(events))
|
|
}
|
|
data, ok := events[0].Data.(map[string]interface{})
|
|
if !ok {
|
|
t.Fatalf("event Data wrong type: %T", events[0].Data)
|
|
}
|
|
got, ok := data["repository_id"].(string)
|
|
if !ok {
|
|
t.Fatalf("repository_id missing from payload or wrong type: %#v", data["repository_id"])
|
|
}
|
|
if got != "repo-x" {
|
|
t.Fatalf("expected repository_id=repo-x via DB fallback, got %q", got)
|
|
}
|
|
}
|