nomadcode/services/core/internal/http/handlers_test.go
toki 8bdbc75b4b feat(core): provider registry 조립을 정리한다
외부 provider 추가가 handler 시그니처 고정에 묶이지 않도록 provider 목록 기반 조립으로 정리한다.

Mattermost adapter 완료 리뷰 로그와 외부 통합 마일스톤 진행 근거도 함께 보존한다.
2026-06-05 11:52:28 +09:00

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"])
}
}