package http import ( "context" "encoding/json" "net/http" "net/http/httptest" "strings" "testing" "github.com/go-chi/chi/v5" "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 } } func newHandlerForTest(opts ...handlerOpt) *Handler { h := &Handler{ workItemProviders: make(map[workitem.ProviderID]WorkItemTaskCreator), } for _, opt := range opts { opt(h) } return h } func TestNewHandlerDirectlyRegistersPlaneAndJira(t *testing.T) { workflowSvc := workflow.NewService(nil, nil, nil) planeReader := &fakeReaderAdapter{} jiraReader := &fakeReaderAdapter{} h := NewHandler(nil, workflowSvc, planeReader, jiraReader, nil) 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 TestNewHandlerNilPlaneSkipsPlaneRegistration(t *testing.T) { workflowSvc := workflow.NewService(nil, nil, nil) jiraReader := &fakeReaderAdapter{} h := NewHandler(nil, workflowSvc, nil, jiraReader, nil) 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, planeReader, jiraReader, nil) 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"}` 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) } 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"]) } }