mirror of
https://github.com/open-gitagent/langship.sh.git
synced 2026-08-03 07:21:04 +02:00
- Introduced NodePalette component to display draggable nodes categorized by type. - Implemented PipelineCanvas component to manage the flow of nodes and connections. - Integrated drag-and-drop functionality for adding nodes to the canvas. - Created a catalog of node types with associated metadata for rendering in the palette. - Established conversion functions between pipeline definitions and React Flow state. - Added inspector for editing node properties and managing connections. - Updated Next.js configuration for production and development environments. - Removed obsolete embed.go file and added nginx configuration for serving the app. - Updated package dependencies including @xyflow/react for enhanced functionality. - Added TypeScript definitions for CSS imports to support styling in components.
355 lines
11 KiB
Go
355 lines
11 KiB
Go
package api
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"io"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"strings"
|
|
"testing"
|
|
|
|
"github.com/lyzrai/flow/pkg/models"
|
|
"github.com/lyzrai/flow/pkg/orchestrator"
|
|
)
|
|
|
|
func TestHealth_returns200(t *testing.T) {
|
|
srv := NewServer(ServerDeps{})
|
|
r := httptest.NewRequest(http.MethodGet, "/api/health", nil)
|
|
w := httptest.NewRecorder()
|
|
srv.ServeHTTP(w, r)
|
|
|
|
if w.Code != http.StatusOK {
|
|
t.Fatalf("status: got %d want 200", w.Code)
|
|
}
|
|
var body map[string]string
|
|
if err := json.Unmarshal(w.Body.Bytes(), &body); err != nil {
|
|
t.Fatalf("body: %v", err)
|
|
}
|
|
if body["status"] != "ok" {
|
|
t.Fatalf("body: got %v", body)
|
|
}
|
|
}
|
|
|
|
func TestWorkflowCRUD_roundTrip(t *testing.T) {
|
|
srv := NewServer(ServerDeps{})
|
|
|
|
// Create
|
|
wf := map[string]any{
|
|
"name": "round-trip",
|
|
"definition": map[string]any{
|
|
"name": "round-trip",
|
|
"nodes": []any{
|
|
map[string]any{
|
|
"id": "1",
|
|
"name": "Trigger",
|
|
"type": "n8n-nodes-base.manualTrigger",
|
|
"parameters": map[string]any{},
|
|
"position": []any{0, 0},
|
|
},
|
|
},
|
|
"connections": map[string]any{},
|
|
},
|
|
}
|
|
body, _ := json.Marshal(wf)
|
|
r := httptest.NewRequest(http.MethodPost, "/api/workflows", bytes.NewReader(body))
|
|
w := httptest.NewRecorder()
|
|
srv.ServeHTTP(w, r)
|
|
if w.Code != http.StatusCreated {
|
|
t.Fatalf("create: got %d (%s)", w.Code, w.Body.String())
|
|
}
|
|
var created struct{ ID string `json:"id"` }
|
|
_ = json.Unmarshal(w.Body.Bytes(), &created)
|
|
if created.ID == "" {
|
|
t.Fatal("create: missing id")
|
|
}
|
|
|
|
// List
|
|
r = httptest.NewRequest(http.MethodGet, "/api/workflows", nil)
|
|
w = httptest.NewRecorder()
|
|
srv.ServeHTTP(w, r)
|
|
if w.Code != http.StatusOK {
|
|
t.Fatalf("list: got %d", w.Code)
|
|
}
|
|
var list []FlowSummary
|
|
if err := json.Unmarshal(w.Body.Bytes(), &list); err != nil {
|
|
t.Fatalf("list body: %v", err)
|
|
}
|
|
if len(list) != 1 || list[0].ID != created.ID {
|
|
t.Fatalf("list: got %+v", list)
|
|
}
|
|
if list[0].NodeCount != 1 {
|
|
t.Fatalf("nodeCount: expected 1, got %d", list[0].NodeCount)
|
|
}
|
|
if list[0].Name != "round-trip" {
|
|
t.Fatalf("name: got %q", list[0].Name)
|
|
}
|
|
|
|
// Get
|
|
r = httptest.NewRequest(http.MethodGet, "/api/workflows/"+created.ID, nil)
|
|
w = httptest.NewRecorder()
|
|
srv.ServeHTTP(w, r)
|
|
if w.Code != http.StatusOK {
|
|
t.Fatalf("get: got %d", w.Code)
|
|
}
|
|
|
|
// Update name only
|
|
upd, _ := json.Marshal(map[string]any{"name": "renamed"})
|
|
r = httptest.NewRequest(http.MethodPut, "/api/workflows/"+created.ID, bytes.NewReader(upd))
|
|
w = httptest.NewRecorder()
|
|
srv.ServeHTTP(w, r)
|
|
if w.Code != http.StatusNoContent {
|
|
t.Fatalf("update: got %d (%s)", w.Code, w.Body.String())
|
|
}
|
|
|
|
// Verify rename via list
|
|
r = httptest.NewRequest(http.MethodGet, "/api/workflows", nil)
|
|
w = httptest.NewRecorder()
|
|
srv.ServeHTTP(w, r)
|
|
_ = json.Unmarshal(w.Body.Bytes(), &list)
|
|
if list[0].Name != "renamed" {
|
|
t.Fatalf("rename: got %q", list[0].Name)
|
|
}
|
|
|
|
// Delete
|
|
r = httptest.NewRequest(http.MethodDelete, "/api/workflows/"+created.ID, nil)
|
|
w = httptest.NewRecorder()
|
|
srv.ServeHTTP(w, r)
|
|
if w.Code != http.StatusNoContent {
|
|
t.Fatalf("delete: got %d", w.Code)
|
|
}
|
|
|
|
// Empty list
|
|
r = httptest.NewRequest(http.MethodGet, "/api/workflows", nil)
|
|
w = httptest.NewRecorder()
|
|
srv.ServeHTTP(w, r)
|
|
_ = json.Unmarshal(w.Body.Bytes(), &list)
|
|
if len(list) != 0 {
|
|
t.Fatalf("expected empty list after delete, got %+v", list)
|
|
}
|
|
}
|
|
|
|
func TestExecute_requiresOrchestrator(t *testing.T) {
|
|
srv := NewServer(ServerDeps{})
|
|
body, _ := json.Marshal(map[string]any{
|
|
"workflow": map[string]any{
|
|
"name": "x",
|
|
"nodes": []any{map[string]any{"id": "1", "name": "T", "type": "n8n-nodes-base.manualTrigger", "parameters": map[string]any{}, "position": []any{0, 0}}},
|
|
"connections": map[string]any{},
|
|
},
|
|
})
|
|
r := httptest.NewRequest(http.MethodPost, "/api/workflows/execute", bytes.NewReader(body))
|
|
w := httptest.NewRecorder()
|
|
srv.ServeHTTP(w, r)
|
|
if w.Code != http.StatusServiceUnavailable {
|
|
t.Fatalf("got %d, want 503 when orchestrator nil (body=%s)", w.Code, w.Body.String())
|
|
}
|
|
}
|
|
|
|
// stubOrch is an Orchestrator that records the last submitted workflow.
|
|
type stubOrch struct {
|
|
lastWorkflow *models.WorkflowDefinition
|
|
lastTrigger []models.Item
|
|
execID string
|
|
statusOut *orchestrator.ExecutionStatus
|
|
statusErr error
|
|
}
|
|
|
|
func (s *stubOrch) Run(_ context.Context, req *orchestrator.RunRequest) (string, *models.ExecutionResult, error) {
|
|
s.lastWorkflow = req.Workflow
|
|
s.lastTrigger = req.TriggerData
|
|
return s.execID, nil, nil
|
|
}
|
|
func (s *stubOrch) RunAsync(_ context.Context, req *orchestrator.RunRequest) (string, error) {
|
|
s.lastWorkflow = req.Workflow
|
|
s.lastTrigger = req.TriggerData
|
|
return s.execID, nil
|
|
}
|
|
func (s *stubOrch) GetExecution(_ context.Context, _ string) (*orchestrator.ExecutionStatus, error) {
|
|
if s.statusErr != nil {
|
|
return nil, s.statusErr
|
|
}
|
|
return s.statusOut, nil
|
|
}
|
|
|
|
func TestExecute_inlineWorkflow_callsOrchestrator(t *testing.T) {
|
|
orch := &stubOrch{execID: "exec-123"}
|
|
srv := NewServer(ServerDeps{Orchestrator: orch})
|
|
|
|
body := []byte(`{"workflow":{"name":"x","nodes":[{"id":"1","name":"T","type":"n8n-nodes-base.manualTrigger","parameters":{},"position":[0,0]}],"connections":{}},"input":[{"k":"v"}]}`)
|
|
r := httptest.NewRequest(http.MethodPost, "/api/workflows/execute", bytes.NewReader(body))
|
|
w := httptest.NewRecorder()
|
|
srv.ServeHTTP(w, r)
|
|
|
|
if w.Code != http.StatusAccepted {
|
|
t.Fatalf("got %d (%s), want 202", w.Code, w.Body.String())
|
|
}
|
|
var resp map[string]string
|
|
_ = json.Unmarshal(w.Body.Bytes(), &resp)
|
|
if resp["execution_id"] != "exec-123" {
|
|
t.Fatalf("execution_id: got %q", resp["execution_id"])
|
|
}
|
|
if orch.lastWorkflow == nil {
|
|
t.Fatal("orchestrator was not called")
|
|
}
|
|
if orch.lastWorkflow.Name != "x" {
|
|
t.Fatalf("workflow name not parsed: %+v", orch.lastWorkflow)
|
|
}
|
|
if len(orch.lastTrigger) != 1 || orch.lastTrigger[0]["k"] != "v" {
|
|
t.Fatalf("trigger data not threaded: %+v", orch.lastTrigger)
|
|
}
|
|
}
|
|
|
|
func TestExecute_byWorkflowID(t *testing.T) {
|
|
orch := &stubOrch{execID: "exec-7"}
|
|
srv := NewServer(ServerDeps{Orchestrator: orch})
|
|
|
|
// Pre-create a workflow.
|
|
body, _ := json.Marshal(map[string]any{
|
|
"name": "stored",
|
|
"definition": map[string]any{
|
|
"name": "stored",
|
|
"nodes": []any{map[string]any{"id": "1", "name": "T", "type": "n8n-nodes-base.manualTrigger", "parameters": map[string]any{}, "position": []any{0, 0}}},
|
|
"connections": map[string]any{},
|
|
},
|
|
})
|
|
r := httptest.NewRequest(http.MethodPost, "/api/workflows", bytes.NewReader(body))
|
|
w := httptest.NewRecorder()
|
|
srv.ServeHTTP(w, r)
|
|
var created struct{ ID string `json:"id"` }
|
|
_ = json.Unmarshal(w.Body.Bytes(), &created)
|
|
|
|
exec, _ := json.Marshal(map[string]any{"workflow_id": created.ID})
|
|
r = httptest.NewRequest(http.MethodPost, "/api/workflows/execute", bytes.NewReader(exec))
|
|
w = httptest.NewRecorder()
|
|
srv.ServeHTTP(w, r)
|
|
if w.Code != http.StatusAccepted {
|
|
t.Fatalf("got %d (%s)", w.Code, w.Body.String())
|
|
}
|
|
if orch.lastWorkflow == nil || orch.lastWorkflow.Name != "stored" {
|
|
t.Fatalf("by-id execute did not load stored workflow: %+v", orch.lastWorkflow)
|
|
}
|
|
}
|
|
|
|
func TestExecute_missing_workflow_400(t *testing.T) {
|
|
srv := NewServer(ServerDeps{Orchestrator: &stubOrch{}})
|
|
body := []byte(`{}`)
|
|
r := httptest.NewRequest(http.MethodPost, "/api/workflows/execute", bytes.NewReader(body))
|
|
w := httptest.NewRecorder()
|
|
srv.ServeHTTP(w, r)
|
|
if w.Code != http.StatusBadRequest {
|
|
t.Fatalf("got %d", w.Code)
|
|
}
|
|
}
|
|
|
|
func TestGetExecution_returnsOrchStatus(t *testing.T) {
|
|
want := &orchestrator.ExecutionStatus{ExecutionID: "abc", Status: "success"}
|
|
srv := NewServer(ServerDeps{Orchestrator: &stubOrch{statusOut: want}})
|
|
|
|
r := httptest.NewRequest(http.MethodGet, "/api/executions/abc", nil)
|
|
w := httptest.NewRecorder()
|
|
srv.ServeHTTP(w, r)
|
|
if w.Code != http.StatusOK {
|
|
t.Fatalf("got %d (%s)", w.Code, w.Body.String())
|
|
}
|
|
var got orchestrator.ExecutionStatus
|
|
_ = json.Unmarshal(w.Body.Bytes(), &got)
|
|
if got.ExecutionID != "abc" || got.Status != "success" {
|
|
t.Fatalf("unexpected: %+v", got)
|
|
}
|
|
}
|
|
|
|
func TestGetExecution_notFound(t *testing.T) {
|
|
srv := NewServer(ServerDeps{Orchestrator: &stubOrch{statusErr: errors.New("nope")}})
|
|
r := httptest.NewRequest(http.MethodGet, "/api/executions/abc", nil)
|
|
w := httptest.NewRecorder()
|
|
srv.ServeHTTP(w, r)
|
|
if w.Code != http.StatusNotFound {
|
|
t.Fatalf("got %d", w.Code)
|
|
}
|
|
}
|
|
|
|
func TestResume_proxiesToRestate(t *testing.T) {
|
|
got := struct {
|
|
path string
|
|
body []byte
|
|
contentType string
|
|
}{}
|
|
stub := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
got.path = r.URL.Path
|
|
got.contentType = r.Header.Get("Content-Type")
|
|
got.body, _ = io.ReadAll(r.Body)
|
|
w.WriteHeader(http.StatusAccepted)
|
|
}))
|
|
defer stub.Close()
|
|
|
|
srv := NewServer(ServerDeps{RestateIngressURL: stub.URL})
|
|
body, _ := json.Marshal(map[string]any{
|
|
"awakeable_id": "sign_xyz",
|
|
"data": map[string]any{"approved": true},
|
|
})
|
|
r := httptest.NewRequest(http.MethodPost, "/api/executions/exec-1/resume", bytes.NewReader(body))
|
|
w := httptest.NewRecorder()
|
|
srv.ServeHTTP(w, r)
|
|
|
|
if w.Code != http.StatusOK {
|
|
t.Fatalf("resume: got %d (%s)", w.Code, w.Body.String())
|
|
}
|
|
if got.path != "/restate/awakeables/sign_xyz/resolve" {
|
|
t.Errorf("proxied path: got %q", got.path)
|
|
}
|
|
if !strings.Contains(string(got.body), `"approved":true`) {
|
|
t.Errorf("proxied body: got %q", string(got.body))
|
|
}
|
|
if got.contentType != "application/json" {
|
|
t.Errorf("content-type: got %q", got.contentType)
|
|
}
|
|
}
|
|
|
|
func TestResume_requiresAwakeableID(t *testing.T) {
|
|
srv := NewServer(ServerDeps{RestateIngressURL: "http://localhost"})
|
|
body, _ := json.Marshal(map[string]any{"awakeable_id": "", "data": map[string]any{}})
|
|
r := httptest.NewRequest(http.MethodPost, "/api/executions/exec-1/resume", bytes.NewReader(body))
|
|
w := httptest.NewRecorder()
|
|
srv.ServeHTTP(w, r)
|
|
if w.Code != http.StatusBadRequest {
|
|
t.Fatalf("got %d", w.Code)
|
|
}
|
|
}
|
|
|
|
func TestResume_unwiredIngress503(t *testing.T) {
|
|
srv := NewServer(ServerDeps{}) // no RestateIngressURL
|
|
body, _ := json.Marshal(map[string]any{"awakeable_id": "sign_x", "data": map[string]any{}})
|
|
r := httptest.NewRequest(http.MethodPost, "/api/executions/exec-1/resume", bytes.NewReader(body))
|
|
w := httptest.NewRecorder()
|
|
srv.ServeHTTP(w, r)
|
|
if w.Code != http.StatusServiceUnavailable {
|
|
t.Fatalf("got %d", w.Code)
|
|
}
|
|
}
|
|
|
|
func TestNonAPI_returns404(t *testing.T) {
|
|
// API server no longer hosts the SPA — the UI is a separate process.
|
|
srv := NewServer(ServerDeps{})
|
|
for _, path := range []string{"/", "/flows/abc", "/runs/123"} {
|
|
r := httptest.NewRequest(http.MethodGet, path, nil)
|
|
w := httptest.NewRecorder()
|
|
srv.ServeHTTP(w, r)
|
|
if w.Code != http.StatusNotFound {
|
|
t.Errorf("%s: got %d, want 404", path, w.Code)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestUnknownAPIRoute_404(t *testing.T) {
|
|
srv := NewServer(ServerDeps{})
|
|
r := httptest.NewRequest(http.MethodGet, "/api/does-not-exist", nil)
|
|
w := httptest.NewRecorder()
|
|
srv.ServeHTTP(w, r)
|
|
if w.Code != http.StatusNotFound {
|
|
t.Fatalf("got %d", w.Code)
|
|
}
|
|
}
|