외부 provider 추가가 handler 시그니처 고정에 묶이지 않도록 provider 목록 기반 조립으로 정리한다. Mattermost adapter 완료 리뷰 로그와 외부 통합 마일스톤 진행 근거도 함께 보존한다.
398 lines
12 KiB
Go
398 lines
12 KiB
Go
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 TestNewHandlerRegistersConfiguredWorkItemProviders(t *testing.T) {
|
|
workflowSvc := workflow.NewService(nil, nil, nil)
|
|
planeReader := &fakeReaderAdapter{}
|
|
jiraReader := &fakeReaderAdapter{}
|
|
|
|
h := NewHandler(
|
|
nil,
|
|
workflowSvc,
|
|
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,
|
|
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,
|
|
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"}`
|
|
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"])
|
|
}
|
|
}
|