package http import ( "context" "encoding/json" "errors" "net/http" "net/http/httptest" "strings" "testing" "github.com/go-chi/chi/v5" "github.com/nomadcode/nomadcode-core/internal/db" "github.com/nomadcode/nomadcode-core/internal/projectsync" "github.com/nomadcode/nomadcode-core/internal/storage" "github.com/nomadcode/nomadcode-core/internal/workflow" "github.com/nomadcode/nomadcode-core/internal/workitem" "github.com/nomadcode/nomadcode-core/internal/workitempipeline" ) type fakeWorkItemTaskCreator struct { task storage.Task err error calledWith *workitempipeline.CreateTaskInput } func (f *fakeWorkItemTaskCreator) CreateTaskFromWorkItem(_ context.Context, input workitempipeline.CreateTaskInput) (storage.Task, error) { f.calledWith = &input return f.task, f.err } type handlerOpt func(*Handler) func withProviders(providers map[workitem.ProviderID]WorkItemTaskCreator) handlerOpt { return func(h *Handler) { h.workItemProviders = providers } } func withPlaneCreator(creator WorkItemTaskCreator) handlerOpt { return func(h *Handler) { h.workItemProviders["plane"] = creator } } type fakeWebhookEnqueuer struct { task storage.Task err error enqueued []string } func (f *fakeWebhookEnqueuer) EnqueueTask(_ context.Context, id string) (storage.Task, error) { f.enqueued = append(f.enqueued, id) return f.task, f.err } func withWebhookEnqueuer(enqueuer webhookTaskEnqueuer) handlerOpt { return func(h *Handler) { h.webhookEnqueuer = enqueuer } } func newHandlerForTest(opts ...handlerOpt) *Handler { h := &Handler{ workItemProviders: make(map[workitem.ProviderID]WorkItemTaskCreator), gitoDelivery: newGitoDeliveryStore(), } for _, opt := range opts { opt(h) } return h } func TestNewHandlerRegistersConfiguredWorkItemProviders(t *testing.T) { workflowSvc := workflow.NewService(nil, nil, nil) planeReader := &fakeReaderAdapter{} jiraReader := &fakeReaderAdapter{} h := NewHandler( nil, workflowSvc, nil, nil, WorkItemProvider{ID: "plane", Reader: planeReader}, WorkItemProvider{ID: "jira", Reader: jiraReader}, ) if len(h.workItemProviders) != 2 { t.Fatalf("expected 2 providers in registry, got %d", len(h.workItemProviders)) } if _, ok := h.workItemProviders["plane"]; !ok { t.Error("plane provider missing from registry") } if _, ok := h.workItemProviders["jira"]; !ok { t.Error("jira provider missing from registry") } } func TestNewHandlerSkipsNilProviderReaders(t *testing.T) { workflowSvc := workflow.NewService(nil, nil, nil) jiraReader := &fakeReaderAdapter{} h := NewHandler( nil, workflowSvc, nil, nil, WorkItemProvider{ID: "plane", Reader: nil}, WorkItemProvider{ID: "jira", Reader: jiraReader}, ) if _, ok := h.workItemProviders["plane"]; ok { t.Error("plane should not be registered when nil") } if _, ok := h.workItemProviders["jira"]; !ok { t.Error("jira should still be registered") } } func TestNewHandlerNilWorkflowServiceSkipsAllRegistration(t *testing.T) { planeReader := &fakeReaderAdapter{} jiraReader := &fakeReaderAdapter{} h := NewHandler( nil, nil, nil, nil, WorkItemProvider{ID: "plane", Reader: planeReader}, WorkItemProvider{ID: "jira", Reader: jiraReader}, ) if len(h.workItemProviders) != 0 { t.Fatalf("expected 0 providers with nil workflow service, got %d", len(h.workItemProviders)) } } func TestRegisterWorkItemProviderSkipsNilOrEmpty(t *testing.T) { h := newHandlerForTest() h.registerWorkItemProvider("", &fakeWorkItemTaskCreator{task: storage.Task{ID: "x"}}) h.registerWorkItemProvider("empty", nil) h.registerWorkItemProvider(" ", &fakeWorkItemTaskCreator{task: storage.Task{ID: "y"}}) h.registerWorkItemProvider("jira", &fakeWorkItemTaskCreator{task: storage.Task{ID: "j"}}) if len(h.workItemProviders) != 1 { t.Fatalf("expected 1 provider after skip, got %d: keys=%v", len(h.workItemProviders), getProviderKeys(h.workItemProviders)) } if _, ok := h.workItemProviders["jira"]; !ok { t.Error("valid provider jira should be registered") } if _, ok := h.workItemProviders[""]; ok { t.Error("empty string provider should not be registered") } if _, ok := h.workItemProviders["empty"]; ok { t.Error("nil provider should not be registered") } if _, ok := h.workItemProviders[" "]; ok { t.Error("whitespace-only provider should not be registered") } } func getProviderKeys(m map[workitem.ProviderID]WorkItemTaskCreator) []string { keys := make([]string, 0, len(m)) for k := range m { keys = append(keys, string(k)) } return keys } type fakeReaderAdapter struct{} func (f *fakeReaderAdapter) FetchWorkItem(_ context.Context, ref workitem.Ref) (workitem.WorkItem, error) { return workitem.WorkItem{ Ref: ref, Title: "fetched", }, nil } func TestLegacyWorkItemTaskRequestBuildsProviderNeutralRef(t *testing.T) { req := legacyWorkItemTaskRequest{ WorkspaceSlug: " acme ", ProjectID: " proj-1 ", WorkItemID: " work-1 ", ExternalURL: " https://plane.example/work-1 ", } ref, err := req.workItemRef(workitem.ProviderID("plane")) if err != nil { t.Fatalf("workItemRef returned error: %v", err) } if ref.Provider != workitem.ProviderID("plane") { t.Errorf("provider: got %q", ref.Provider) } if ref.Tenant != "acme" { t.Errorf("tenant: got %q", ref.Tenant) } if ref.Project != "proj-1" { t.Errorf("project: got %q", ref.Project) } if ref.ID != "work-1" { t.Errorf("id: got %q", ref.ID) } if ref.URL != "https://plane.example/work-1" { t.Errorf("url: got %q", ref.URL) } } func TestCreatePlaneTaskCompatibilityRouteUsesRegistry(t *testing.T) { planeCreator := &fakeWorkItemTaskCreator{ task: storage.Task{ID: "planed-task-123", Status: "pending"}, } h := newHandlerForTest( withProviders(map[workitem.ProviderID]WorkItemTaskCreator{ "plane": planeCreator, }), withPlaneCreator(planeCreator), ) body := `{"workspace_slug":"acme","project_id":"proj-1","work_item_id":"work-1","state_id":"state-1","comment":"my note","trigger_state_id":" state-backlog ","agent_assignee_id":" agent-1 ","actor":" actor-user ","self_actor":" self-bot "}` req := httptest.NewRequest(http.MethodPost, "/api/integrations/plane/tasks", strings.NewReader(body)) req.Header.Set("Content-Type", "application/json") rec := httptest.NewRecorder() h.CreatePlaneTask(rec, req) if rec.Code != http.StatusCreated { t.Fatalf("expected %d, got %d: %s", http.StatusCreated, rec.Code, rec.Body.String()) } if planeCreator.calledWith == nil { t.Fatal("registry CreateTaskFromWorkItem was not called") } if planeCreator.calledWith.Ref.Provider != "plane" { t.Errorf("ref.provider: got %q", planeCreator.calledWith.Ref.Provider) } if planeCreator.calledWith.Ref.Tenant != "acme" { t.Errorf("ref.tenant: got %q", planeCreator.calledWith.Ref.Tenant) } if planeCreator.calledWith.Ref.Project != "proj-1" { t.Errorf("ref.project: got %q", planeCreator.calledWith.Ref.Project) } if planeCreator.calledWith.Ref.ID != "work-1" { t.Errorf("ref.id: got %q", planeCreator.calledWith.Ref.ID) } if planeCreator.calledWith.Trigger.RequiredStateID != "state-backlog" { t.Errorf("trigger.RequiredStateID: got %q, want 'state-backlog'", planeCreator.calledWith.Trigger.RequiredStateID) } if planeCreator.calledWith.Trigger.RequiredAssigneeID != "agent-1" { t.Errorf("trigger.RequiredAssigneeID: got %q, want 'agent-1'", planeCreator.calledWith.Trigger.RequiredAssigneeID) } if planeCreator.calledWith.Trigger.Actor != "actor-user" { t.Errorf("trigger.Actor: got %q, want 'actor-user'", planeCreator.calledWith.Trigger.Actor) } if planeCreator.calledWith.Trigger.SelfActor != "self-bot" { t.Errorf("trigger.SelfActor: got %q, want 'self-bot'", planeCreator.calledWith.Trigger.SelfActor) } var resp map[string]string if err := json.NewDecoder(rec.Body).Decode(&resp); err != nil { t.Fatalf("decode response: %v", err) } if resp["id"] != "planed-task-123" || resp["status"] != "pending" { t.Errorf("response: %v", resp) } } func TestCreatePlaneTaskReturns503WhenPipelineNotConfigured(t *testing.T) { h := &Handler{} req := httptest.NewRequest(http.MethodPost, "/api/integrations/plane/tasks", strings.NewReader(`{}`)) rec := httptest.NewRecorder() h.CreatePlaneTask(rec, req) if rec.Code != http.StatusServiceUnavailable { t.Fatalf("expected 503, got %d", rec.Code) } var resp map[string]string if err := json.NewDecoder(rec.Body).Decode(&resp); err != nil { t.Fatalf("decode response: %v", err) } if resp["error"] != "plane client is not configured" { t.Errorf("error message: got %q", resp["error"]) } } func TestCreateWorkItemTaskDelegatesToRegisteredProvider(t *testing.T) { creator := &fakeWorkItemTaskCreator{ task: storage.Task{ID: "jira-task-456", Status: "pending"}, } h := newHandlerForTest( withProviders(map[workitem.ProviderID]WorkItemTaskCreator{ "jira": creator, }), ) body := `{"tenant":"acme","project":"proj-1","id":"PROJ-1","state_id":"state-2","comment":"jira note"}` req := httptest.NewRequest(http.MethodPost, "/integrations/jira/tasks", strings.NewReader(body)) req.Header.Set("Content-Type", "application/json") rec := httptest.NewRecorder() router := chi.NewRouter() router.Post("/integrations/{provider}/tasks", h.CreateWorkItemTask) router.ServeHTTP(rec, req) if rec.Code != http.StatusCreated { t.Fatalf("expected %d, got %d: %s", http.StatusCreated, rec.Code, rec.Body.String()) } if creator.calledWith == nil { t.Fatal("CreateTaskFromWorkItem was not called") } if creator.calledWith.Ref.Provider != "jira" { t.Errorf("ref.provider: got %q", creator.calledWith.Ref.Provider) } if creator.calledWith.Ref.Tenant != "acme" { t.Errorf("ref.tenant: got %q", creator.calledWith.Ref.Tenant) } if creator.calledWith.Ref.ID != "PROJ-1" { t.Errorf("ref.id: got %q", creator.calledWith.Ref.ID) } } func TestCreateWorkItemTaskRejectsUnknownProvider(t *testing.T) { h := newHandlerForTest( withProviders(map[workitem.ProviderID]WorkItemTaskCreator{ "jira": &fakeWorkItemTaskCreator{task: storage.Task{ID: "t1"}}, }), ) body := `{"tenant":"acme","project":"proj-1","id":"TICKET-1"}` req := httptest.NewRequest(http.MethodPost, "/integrations/asana/tasks", strings.NewReader(body)) req.Header.Set("Content-Type", "application/json") rec := httptest.NewRecorder() router := chi.NewRouter() router.Post("/integrations/{provider}/tasks", h.CreateWorkItemTask) router.ServeHTTP(rec, req) if rec.Code != http.StatusBadRequest { t.Fatalf("expected 400, got %d: %s", rec.Code, rec.Body.String()) } var resp map[string]string if err := json.NewDecoder(rec.Body).Decode(&resp); err != nil { t.Fatalf("decode response: %v", err) } if !strings.Contains(resp["error"], "unknown or unconfigured provider") { t.Errorf("unexpected error: %q", resp["error"]) } } func TestCreateWorkItemTaskRejectsProviderMismatch(t *testing.T) { h := newHandlerForTest( withProviders(map[workitem.ProviderID]WorkItemTaskCreator{ "jira": &fakeWorkItemTaskCreator{task: storage.Task{ID: "t1"}}, "asana": &fakeWorkItemTaskCreator{task: storage.Task{ID: "t2"}}, }), ) body := `{"tenant":"acme","project":"proj-1","id":"TICKET-1","provider":"asana"}` req := httptest.NewRequest(http.MethodPost, "/integrations/jira/tasks", strings.NewReader(body)) req.Header.Set("Content-Type", "application/json") rec := httptest.NewRecorder() router := chi.NewRouter() router.Post("/integrations/{provider}/tasks", h.CreateWorkItemTask) router.ServeHTTP(rec, req) if rec.Code != http.StatusBadRequest { t.Fatalf("expected 400, got %d: %s", rec.Code, rec.Body.String()) } var resp map[string]string if err := json.NewDecoder(rec.Body).Decode(&resp); err != nil { t.Fatalf("decode response: %v", err) } if !strings.Contains(resp["error"], "provider mismatch") { t.Errorf("unexpected error: %q", resp["error"]) } } func TestCreateWorkItemTaskRejectsInvalidJSONBody(t *testing.T) { h := newHandlerForTest( withProviders(map[workitem.ProviderID]WorkItemTaskCreator{ "jira": &fakeWorkItemTaskCreator{task: storage.Task{ID: "t1"}}, }), ) body := `{invalid json}` req := httptest.NewRequest(http.MethodPost, "/integrations/jira/tasks", strings.NewReader(body)) req.Header.Set("Content-Type", "application/json") rec := httptest.NewRecorder() router := chi.NewRouter() router.Post("/integrations/{provider}/tasks", h.CreateWorkItemTask) router.ServeHTTP(rec, req) if rec.Code != http.StatusBadRequest { t.Fatalf("expected 400, got %d: %s", rec.Code, rec.Body.String()) } var resp map[string]string if err := json.NewDecoder(rec.Body).Decode(&resp); err != nil { t.Fatalf("decode response: %v", err) } if !strings.Contains(resp["error"], "invalid JSON body") { t.Errorf("unexpected error: %q", resp["error"]) } } func TestCreateWorkItemTaskRejectsEmptyBody(t *testing.T) { h := newHandlerForTest( withProviders(map[workitem.ProviderID]WorkItemTaskCreator{ "jira": &fakeWorkItemTaskCreator{task: storage.Task{ID: "t1"}}, }), ) body := `{"provider":"jira"}` req := httptest.NewRequest(http.MethodPost, "/integrations/jira/tasks", strings.NewReader(body)) req.Header.Set("Content-Type", "application/json") rec := httptest.NewRecorder() router := chi.NewRouter() router.Post("/integrations/{provider}/tasks", h.CreateWorkItemTask) router.ServeHTTP(rec, req) if rec.Code != http.StatusBadRequest { t.Fatalf("expected 400, got %d: %s", rec.Code, rec.Body.String()) } var resp map[string]string if err := json.NewDecoder(rec.Body).Decode(&resp); err != nil { t.Fatalf("decode response: %v", err) } if !strings.Contains(resp["error"], "tenant, project, and id are required") { t.Errorf("unexpected error: %q", resp["error"]) } } func TestCreateWorkItemTaskReturns422WhenProjectSyncMissing(t *testing.T) { h := newHandlerForTest( withProviders(map[workitem.ProviderID]WorkItemTaskCreator{ "jira": &fakeWorkItemTaskCreator{err: storage.ErrProjectSyncNotFound}, }), ) body := `{"tenant":"acme","project":"proj-1","id":"PROJ-1"}` req := httptest.NewRequest(http.MethodPost, "/integrations/jira/tasks", strings.NewReader(body)) req.Header.Set("Content-Type", "application/json") rec := httptest.NewRecorder() router := chi.NewRouter() router.Post("/integrations/{provider}/tasks", h.CreateWorkItemTask) router.ServeHTTP(rec, req) if rec.Code != http.StatusUnprocessableEntity { t.Fatalf("expected 422, got %d: %s", rec.Code, rec.Body.String()) } var resp map[string]string if err := json.NewDecoder(rec.Body).Decode(&resp); err != nil { t.Fatalf("decode response: %v", err) } if !strings.Contains(resp["error"], "no active project sync setting") { t.Errorf("unexpected error: %q", resp["error"]) } } func TestCreateWorkItemTaskReturns503WhenResolverUnconfigured(t *testing.T) { h := newHandlerForTest( withProviders(map[workitem.ProviderID]WorkItemTaskCreator{ "jira": &fakeWorkItemTaskCreator{err: workitempipeline.ErrProjectSyncNotConfigured}, }), ) body := `{"tenant":"acme","project":"proj-1","id":"PROJ-1"}` req := httptest.NewRequest(http.MethodPost, "/integrations/jira/tasks", strings.NewReader(body)) req.Header.Set("Content-Type", "application/json") rec := httptest.NewRecorder() router := chi.NewRouter() router.Post("/integrations/{provider}/tasks", h.CreateWorkItemTask) router.ServeHTTP(rec, req) if rec.Code != http.StatusServiceUnavailable { t.Fatalf("expected 503, got %d: %s", rec.Code, rec.Body.String()) } var resp map[string]string if err := json.NewDecoder(rec.Body).Decode(&resp); err != nil { t.Fatalf("decode response: %v", err) } if !strings.Contains(resp["error"], "project sync resolver is not configured") { t.Errorf("unexpected error: %q", resp["error"]) } } func TestCreateWorkItemTaskReturns409WhenNoSlotAvailable(t *testing.T) { h := newHandlerForTest( withProviders(map[workitem.ProviderID]WorkItemTaskCreator{ "jira": &fakeWorkItemTaskCreator{err: storage.ErrNoAvailableWorkspaceSlot}, }), ) body := `{"tenant":"acme","project":"proj-1","id":"PROJ-1"}` req := httptest.NewRequest(http.MethodPost, "/integrations/jira/tasks", strings.NewReader(body)) req.Header.Set("Content-Type", "application/json") rec := httptest.NewRecorder() router := chi.NewRouter() router.Post("/integrations/{provider}/tasks", h.CreateWorkItemTask) router.ServeHTTP(rec, req) if rec.Code != http.StatusConflict { t.Fatalf("expected 409, got %d: %s", rec.Code, rec.Body.String()) } var resp map[string]string if err := json.NewDecoder(rec.Body).Decode(&resp); err != nil { t.Fatalf("decode response: %v", err) } if !strings.Contains(resp["error"], "no available workspace slot") { t.Errorf("unexpected error: %q", resp["error"]) } } type fakeProjectSyncStore struct { setting db.ProjectSyncSetting settingErr error slot db.WorkspaceSlot slotErr error gotProvider string gotTenant string gotProject string reservedFor int64 upsertedSlot *db.UpsertWorkspaceSlotParams upsertSlot db.WorkspaceSlot upsertErr error } func (f *fakeProjectSyncStore) GetActiveProjectSyncSettingByTarget(_ context.Context, provider, tenant, project string) (db.ProjectSyncSetting, error) { f.gotProvider, f.gotTenant, f.gotProject = provider, tenant, project return f.setting, f.settingErr } func (f *fakeProjectSyncStore) UpsertWorkspaceSlot(_ context.Context, args db.UpsertWorkspaceSlotParams) (db.WorkspaceSlot, error) { f.upsertedSlot = &args return f.upsertSlot, f.upsertErr } func (f *fakeProjectSyncStore) ReserveWorkspaceSlot(_ context.Context, projectSyncSettingID int64) (db.WorkspaceSlot, error) { f.reservedFor = projectSyncSettingID return f.slot, f.slotErr } func TestNewProjectBinderResolvesBindingByTarget(t *testing.T) { store := &fakeProjectSyncStore{setting: db.ProjectSyncSetting{ ID: 7, Provider: "plane", Tenant: "acme", Project: "proj-1", GitRemoteUrl: "git@example.com:acme/nomadcode.git", SourceBranch: "develop", WorkspaceID: "ws-main", WorkspaceBasePath: "/home/user/workspace", RepoDirName: "nomadcode", Active: true, }} binder := NewProjectBinder(store) binding, err := binder.ResolveProjectBinding(context.Background(), workitem.Ref{ Provider: "plane", Tenant: "acme", Project: "proj-1", ID: "work-1", }) if err != nil { t.Fatalf("unexpected error: %v", err) } if store.gotProvider != "plane" || store.gotTenant != "acme" || store.gotProject != "proj-1" { t.Errorf("store received wrong target: %s/%s/%s", store.gotProvider, store.gotTenant, store.gotProject) } if binding.SettingID != 7 { t.Errorf("setting id: got %d, want 7", binding.SettingID) } if binding.Config.Target.Project != "proj-1" || binding.Config.GitRemoteURL != "git@example.com:acme/nomadcode.git" { t.Errorf("config not mapped from setting: %+v", binding.Config) } } func TestNewProjectBinderReservesSlotBySettingID(t *testing.T) { store := &fakeProjectSyncStore{slot: db.WorkspaceSlot{ ID: 11, ProjectSyncSettingID: 7, SlotIndex: 1, State: string(projectsync.SlotStateInUse), Path: "/home/user/workspace/nomadcode/slots/001", }} binder := NewProjectBinder(store) slot, err := binder.ReserveWorkspaceSlot(context.Background(), 7) if err != nil { t.Fatalf("unexpected error: %v", err) } if store.reservedFor != 7 { t.Errorf("reserved for: got %d, want 7", store.reservedFor) } if slot.ID != 11 || slot.Index != projectsync.SlotIndex(1) || slot.State != projectsync.SlotStateInUse { t.Errorf("slot not converted from db record: %+v", slot) } } func TestNewProjectBinderPropagatesErrors(t *testing.T) { resolveStore := &fakeProjectSyncStore{settingErr: storage.ErrProjectSyncNotFound} if _, err := NewProjectBinder(resolveStore).ResolveProjectBinding(context.Background(), workitem.Ref{ Provider: "plane", Tenant: "acme", Project: "proj-x", ID: "work-1", }); !errors.Is(err, storage.ErrProjectSyncNotFound) { t.Errorf("resolve: expected ErrProjectSyncNotFound, got %v", err) } reserveStore := &fakeProjectSyncStore{slotErr: storage.ErrNoAvailableWorkspaceSlot} if _, err := NewProjectBinder(reserveStore).ReserveWorkspaceSlot(context.Background(), 7); !errors.Is(err, storage.ErrNoAvailableWorkspaceSlot) { t.Errorf("reserve: expected ErrNoAvailableWorkspaceSlot, got %v", err) } } func TestNewProjectBinderNilStoreYieldsNil(t *testing.T) { // A nil store must produce a nil binder so the pipeline fails explicitly as // unconfigured rather than panicking at request time. if binder := NewProjectBinder(nil); binder != nil { t.Fatalf("expected nil binder for nil store, got %T", binder) } } type fakeWorkspaceProvisioner struct { err error calledWith *projectsync.ProvisionPlan } func (f *fakeWorkspaceProvisioner) EnsureProvisioned(_ context.Context, plan projectsync.ProvisionPlan) error { f.calledWith = &plan return f.err } func TestNewProjectBinderEnsuresWorkspaceAndRegistersDefaultSlot(t *testing.T) { store := &fakeProjectSyncStore{ setting: db.ProjectSyncSetting{ ID: 5, Provider: "plane", Tenant: "acme", Project: "proj-1", GitRemoteUrl: "git@example.com:acme/nomadcode.git", SourceBranch: "develop", WorkspaceID: "ws-main", WorkspaceBasePath: "/home/user/workspace", RepoDirName: "nomadcode", Active: true, }, upsertSlot: db.WorkspaceSlot{ ID: 11, }, } provisioner := &fakeWorkspaceProvisioner{} binder := NewProjectBinderWithProvisioner(store, provisioner) binding := workitempipeline.ProjectBinding{ SettingID: 5, Config: projectsync.Config{ Target: projectsync.ProviderProjectTarget{ Provider: "plane", Tenant: "acme", Project: "proj-1", }, GitRemoteURL: "git@example.com:acme/nomadcode.git", SourceBranch: "develop", WorkspaceID: "ws-main", WorkspaceBasePath: "/home/user/workspace", RepoDirName: "nomadcode", }, } err := binder.EnsureProjectWorkspace(context.Background(), binding) if err != nil { t.Fatalf("unexpected error: %v", err) } if provisioner.calledWith == nil { t.Fatal("provisioner.EnsureProvisioned was not called") } wantRoot := "/home/user/workspace/nomadcode" if provisioner.calledWith.ProjectWorkspaceRoot != wantRoot { t.Errorf("ProjectWorkspaceRoot: got %q, want %q", provisioner.calledWith.ProjectWorkspaceRoot, wantRoot) } if store.upsertedSlot == nil { t.Fatal("store.UpsertWorkspaceSlot was not called") } if store.upsertedSlot.ProjectSyncSettingID != 5 { t.Errorf("upserted ProjectSyncSettingID: got %d, want 5", store.upsertedSlot.ProjectSyncSettingID) } if store.upsertedSlot.SlotIndex != int32(projectsync.DefaultSlotIndex) { t.Errorf("upserted SlotIndex: got %d, want %d", store.upsertedSlot.SlotIndex, projectsync.DefaultSlotIndex) } wantSlotPath := "/home/user/workspace/nomadcode/slots/000" if store.upsertedSlot.Path != wantSlotPath { t.Errorf("upserted Path: got %q, want %q", store.upsertedSlot.Path, wantSlotPath) } } func TestNewProjectBinderPropagatesProvisionErrorWithoutUpsert(t *testing.T) { store := &fakeProjectSyncStore{} provisionErr := errors.New("provision error") provisioner := &fakeWorkspaceProvisioner{err: provisionErr} binder := NewProjectBinderWithProvisioner(store, provisioner) binding := workitempipeline.ProjectBinding{ SettingID: 5, Config: projectsync.Config{ Target: projectsync.ProviderProjectTarget{ Provider: "plane", Tenant: "acme", Project: "proj-1", }, GitRemoteURL: "git@example.com:acme/nomadcode.git", SourceBranch: "develop", WorkspaceID: "ws-main", WorkspaceBasePath: "/home/user/workspace", RepoDirName: "nomadcode", }, } err := binder.EnsureProjectWorkspace(context.Background(), binding) if !errors.Is(err, provisionErr) { t.Fatalf("expected provision error, got %v", err) } if store.upsertedSlot != nil { t.Error("store.UpsertWorkspaceSlot should not be called on provision failure") } } func TestCreateWorkItemTaskReturns409WhenWorkspaceProvisionNotReady(t *testing.T) { h := newHandlerForTest( withProviders(map[workitem.ProviderID]WorkItemTaskCreator{ "jira": &fakeWorkItemTaskCreator{err: projectsync.ErrWorkspaceProvisionNotReady}, }), ) body := `{"tenant":"acme","project":"proj-1","id":"PROJ-1"}` req := httptest.NewRequest(http.MethodPost, "/integrations/jira/tasks", strings.NewReader(body)) req.Header.Set("Content-Type", "application/json") rec := httptest.NewRecorder() router := chi.NewRouter() router.Post("/integrations/{provider}/tasks", h.CreateWorkItemTask) router.ServeHTTP(rec, req) if rec.Code != http.StatusConflict { t.Fatalf("expected 409, got %d: %s", rec.Code, rec.Body.String()) } var resp map[string]string if err := json.NewDecoder(rec.Body).Decode(&resp); err != nil { t.Fatalf("decode response: %v", err) } if !strings.Contains(resp["error"], "workspace provision is not ready") { t.Errorf("unexpected error: %q", resp["error"]) } } func TestCreateWorkItemTaskPassesCreationTrigger(t *testing.T) { creator := &fakeWorkItemTaskCreator{ task: storage.Task{ID: "task-123", Status: "pending"}, } h := newHandlerForTest( withProviders(map[workitem.ProviderID]WorkItemTaskCreator{ "plane": creator, }), ) body := `{ "tenant": "acme", "project": "proj-1", "id": "work-1", "trigger_state_id": "state-backlog", "agent_assignee_id": "agent-1", "actor": "user-a", "self_actor": "nomadcode-bot" }` req := httptest.NewRequest(http.MethodPost, "/integrations/plane/tasks", strings.NewReader(body)) req.Header.Set("Content-Type", "application/json") rec := httptest.NewRecorder() router := chi.NewRouter() router.Post("/integrations/{provider}/tasks", h.CreateWorkItemTask) router.ServeHTTP(rec, req) if rec.Code != http.StatusCreated { t.Fatalf("expected 201 Created, got %d: %s", rec.Code, rec.Body.String()) } if creator.calledWith == nil { t.Fatal("expected CreateTaskFromWorkItem call") } trig := creator.calledWith.Trigger if trig.RequiredStateID != "state-backlog" || trig.RequiredAssigneeID != "agent-1" || trig.Actor != "user-a" || trig.SelfActor != "nomadcode-bot" { t.Errorf("unexpected trigger passed: %+v", trig) } } func TestCreateWorkItemTaskReturns202WhenTriggerIgnored(t *testing.T) { creator := &fakeWorkItemTaskCreator{ err: workitempipeline.ErrCreationTriggerIgnored, } h := newHandlerForTest( withProviders(map[workitem.ProviderID]WorkItemTaskCreator{ "plane": creator, }), ) body := `{"tenant":"acme","project":"proj-1","id":"work-1"}` req := httptest.NewRequest(http.MethodPost, "/integrations/plane/tasks", strings.NewReader(body)) req.Header.Set("Content-Type", "application/json") rec := httptest.NewRecorder() router := chi.NewRouter() router.Post("/integrations/{provider}/tasks", h.CreateWorkItemTask) router.ServeHTTP(rec, req) if rec.Code != http.StatusAccepted { t.Fatalf("expected 202 Accepted, got %d: %s", rec.Code, rec.Body.String()) } var resp map[string]string if err := json.NewDecoder(rec.Body).Decode(&resp); err != nil { t.Fatalf("decode response: %v", err) } if resp["status"] != "ignored" || resp["reason"] != workitempipeline.ErrCreationTriggerIgnored.Error() { t.Errorf("unexpected ignored response: %v", resp) } }