diff --git a/admin/admin_test.go b/admin/admin_test.go index 4fac365e..c7a1a427 100644 --- a/admin/admin_test.go +++ b/admin/admin_test.go @@ -2,6 +2,190 @@ package admin import ( "testing" + + "github.com/GoCodeAlone/workflow/config" +) + +func TestLoadConfigRaw(t *testing.T) { + t.Parallel() + + data, err := LoadConfigRaw() + if err != nil { + t.Fatalf("LoadConfigRaw() error: %v", err) + } + if len(data) == 0 { + t.Fatal("LoadConfigRaw() returned empty data") + } +} + +func TestLoadConfig(t *testing.T) { + t.Parallel() + + cfg, err := LoadConfig() + if err != nil { + t.Fatalf("LoadConfig() error: %v", err) + } + if cfg == nil { + t.Fatal("LoadConfig() returned nil config") + } + if len(cfg.Modules) == 0 { + t.Error("expected at least one admin module") + } +} + +func TestMergeInto(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + primary *config.WorkflowConfig + admin *config.WorkflowConfig + check func(t *testing.T, result *config.WorkflowConfig) + }{ + { + name: "merge modules appended", + primary: &config.WorkflowConfig{ + Modules: []config.ModuleConfig{ + {Name: "primary-mod", Type: "http.server"}, + }, + Workflows: map[string]any{"http": "primary-wf"}, + Triggers: map[string]any{"http": "primary-trigger"}, + }, + admin: &config.WorkflowConfig{ + Modules: []config.ModuleConfig{ + {Name: "admin-mod", Type: "http.server"}, + }, + Workflows: map[string]any{"http-admin": "admin-wf"}, + Triggers: map[string]any{"admin-trigger": "admin-cfg"}, + }, + check: func(t *testing.T, result *config.WorkflowConfig) { + if len(result.Modules) != 2 { + t.Errorf("expected 2 modules, got %d", len(result.Modules)) + } + if result.Workflows["http-admin"] == nil { + t.Error("admin workflow not merged") + } + if result.Triggers["admin-trigger"] == nil { + t.Error("admin trigger not merged") + } + }, + }, + { + name: "workflows not overwritten", + primary: &config.WorkflowConfig{ + Modules: nil, + Workflows: map[string]any{"http": "primary"}, + Triggers: nil, + }, + admin: &config.WorkflowConfig{ + Modules: nil, + Workflows: map[string]any{"http": "admin-should-not-replace"}, + Triggers: nil, + }, + check: func(t *testing.T, result *config.WorkflowConfig) { + if result.Workflows["http"] != "primary" { + t.Errorf("primary workflow was overwritten: got %v", result.Workflows["http"]) + } + }, + }, + { + name: "nil primary workflows map initialized", + primary: &config.WorkflowConfig{ + Workflows: nil, + Triggers: nil, + }, + admin: &config.WorkflowConfig{ + Workflows: map[string]any{"admin-wf": "cfg"}, + Triggers: nil, + }, + check: func(t *testing.T, result *config.WorkflowConfig) { + if result.Workflows == nil { + t.Fatal("workflows map should have been initialized") + } + if result.Workflows["admin-wf"] == nil { + t.Error("admin workflow not merged into nil map") + } + }, + }, + { + name: "nil primary triggers map initialized", + primary: &config.WorkflowConfig{ + Workflows: map[string]any{}, + Triggers: nil, + }, + admin: &config.WorkflowConfig{ + Workflows: nil, + Triggers: map[string]any{"admin-trig": "cfg"}, + }, + check: func(t *testing.T, result *config.WorkflowConfig) { + if result.Triggers == nil { + t.Fatal("triggers map should have been initialized") + } + if result.Triggers["admin-trig"] == nil { + t.Error("admin trigger not merged into nil map") + } + }, + }, + { + name: "triggers not overwritten", + primary: &config.WorkflowConfig{ + Workflows: map[string]any{}, + Triggers: map[string]any{"http": "primary"}, + }, + admin: &config.WorkflowConfig{ + Triggers: map[string]any{"http": "admin-should-not-replace"}, + }, + check: func(t *testing.T, result *config.WorkflowConfig) { + if result.Triggers["http"] != "primary" { + t.Errorf("primary trigger was overwritten: got %v", result.Triggers["http"]) + } + }, + }, + { + name: "empty admin triggers no-op", + primary: &config.WorkflowConfig{ + Workflows: map[string]any{}, + Triggers: map[string]any{"existing": "val"}, + }, + admin: &config.WorkflowConfig{ + Triggers: nil, + }, + check: func(t *testing.T, result *config.WorkflowConfig) { + if len(result.Triggers) != 1 { + t.Errorf("expected 1 trigger, got %d", len(result.Triggers)) + } + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + MergeInto(tt.primary, tt.admin) + tt.check(t, tt.primary) + }) + } +} + +func TestMergeInto_WithRealAdminConfig(t *testing.T) { + t.Parallel() + + adminCfg, err := LoadConfig() + if err != nil { + t.Fatalf("LoadConfig failed: %v", err) + } + + primary := config.NewEmptyWorkflowConfig() + primary.Modules = []config.ModuleConfig{ + {Name: "user-server", Type: "http.server"}, + } + + initialModuleCount := len(primary.Modules) + MergeInto(primary, adminCfg) + + if len(primary.Modules) <= initialModuleCount { + t.Error("expected admin modules to be appended") + } ) func TestLoadConfig_Parses(t *testing.T) { diff --git a/config/config_extended_test.go b/config/config_extended_test.go new file mode 100644 index 00000000..d04b2fe9 --- /dev/null +++ b/config/config_extended_test.go @@ -0,0 +1,309 @@ +package config + +import ( + "os" + "path/filepath" + "testing" +) + +func TestLoadFromString(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + input string + wantErr bool + check func(t *testing.T, cfg *WorkflowConfig) + }{ + { + name: "valid minimal config", + input: ` +modules: + - name: svc + type: http.server +`, + check: func(t *testing.T, cfg *WorkflowConfig) { + if len(cfg.Modules) != 1 { + t.Fatalf("expected 1 module, got %d", len(cfg.Modules)) + } + if cfg.Modules[0].Name != "svc" { + t.Errorf("expected module name 'svc', got %q", cfg.Modules[0].Name) + } + }, + }, + { + name: "valid with workflows and triggers", + input: ` +modules: + - name: router + type: http.router +workflows: + order-flow: + initial: new +triggers: + http-trigger: + type: http +`, + check: func(t *testing.T, cfg *WorkflowConfig) { + if cfg.Workflows["order-flow"] == nil { + t.Error("expected order-flow workflow") + } + if cfg.Triggers["http-trigger"] == nil { + t.Error("expected http-trigger") + } + }, + }, + { + name: "invalid YAML", + input: "{{invalid", + wantErr: true, + }, + { + name: "empty string", + input: "", + check: func(t *testing.T, cfg *WorkflowConfig) { + if cfg == nil { + t.Fatal("expected non-nil config from empty string") + } + }, + }, + { + name: "config with requires section", + input: ` +modules: [] +requires: + capabilities: + - docker + - kubernetes + plugins: + - name: monitoring + version: ">=1.0.0" +`, + check: func(t *testing.T, cfg *WorkflowConfig) { + if cfg.Requires == nil { + t.Fatal("expected non-nil requires") + } + if len(cfg.Requires.Capabilities) != 2 { + t.Errorf("expected 2 capabilities, got %d", len(cfg.Requires.Capabilities)) + } + if len(cfg.Requires.Plugins) != 1 { + t.Errorf("expected 1 plugin requirement, got %d", len(cfg.Requires.Plugins)) + } + if cfg.Requires.Plugins[0].Name != "monitoring" { + t.Errorf("expected plugin name 'monitoring', got %q", cfg.Requires.Plugins[0].Name) + } + }, + }, + { + name: "config with pipelines", + input: ` +modules: [] +pipelines: + build: + trigger: + type: webhook + steps: + - name: compile + type: exec +`, + check: func(t *testing.T, cfg *WorkflowConfig) { + if cfg.Pipelines == nil || cfg.Pipelines["build"] == nil { + t.Error("expected build pipeline") + } + }, + }, + { + name: "module with dependsOn and branches", + input: ` +modules: + - name: router + type: http.router + dependsOn: + - server + branches: + success: handler + error: fallback +`, + check: func(t *testing.T, cfg *WorkflowConfig) { + mod := cfg.Modules[0] + if len(mod.DependsOn) != 1 || mod.DependsOn[0] != "server" { + t.Errorf("unexpected dependsOn: %v", mod.DependsOn) + } + if mod.Branches["success"] != "handler" { + t.Errorf("unexpected branches: %v", mod.Branches) + } + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + cfg, err := LoadFromString(tt.input) + if tt.wantErr { + if err == nil { + t.Fatal("expected error, got nil") + } + return + } + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if tt.check != nil { + tt.check(t, cfg) + } + }) + } +} + +func TestResolveRelativePath(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + configDir string + path string + want string + }{ + { + name: "relative path resolved", + configDir: "/etc/workflow", + path: "plugins/my-plugin", + want: "/etc/workflow/plugins/my-plugin", + }, + { + name: "absolute path unchanged", + configDir: "/etc/workflow", + path: "/absolute/path", + want: "/absolute/path", + }, + { + name: "empty path unchanged", + configDir: "/etc/workflow", + path: "", + want: "", + }, + { + name: "empty config dir returns path as-is", + configDir: "", + path: "relative/path", + want: "relative/path", + }, + { + name: "both empty", + configDir: "", + path: "", + want: "", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + cfg := &WorkflowConfig{ConfigDir: tt.configDir} + got := cfg.ResolveRelativePath(tt.path) + if got != tt.want { + t.Errorf("ResolveRelativePath(%q) = %q, want %q", tt.path, got, tt.want) + } + }) + } +} + +func TestResolvePathInConfig(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + cfg map[string]any + path string + want string + }{ + { + name: "resolves relative path with _config_dir", + cfg: map[string]any{"_config_dir": "/etc/workflow"}, + path: "data/file.txt", + want: "/etc/workflow/data/file.txt", + }, + { + name: "absolute path unchanged", + cfg: map[string]any{"_config_dir": "/etc/workflow"}, + path: "/absolute/file.txt", + want: "/absolute/file.txt", + }, + { + name: "no _config_dir returns path as-is", + cfg: map[string]any{}, + path: "relative/file.txt", + want: "relative/file.txt", + }, + { + name: "empty _config_dir returns path as-is", + cfg: map[string]any{"_config_dir": ""}, + path: "relative/file.txt", + want: "relative/file.txt", + }, + { + name: "empty path returns empty", + cfg: map[string]any{"_config_dir": "/etc"}, + path: "", + want: "", + }, + { + name: "non-string _config_dir returns path as-is", + cfg: map[string]any{"_config_dir": 42}, + path: "relative/file.txt", + want: "relative/file.txt", + }, + { + name: "nil cfg map", + cfg: nil, + path: "file.txt", + want: "file.txt", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + got := ResolvePathInConfig(tt.cfg, tt.path) + if got != tt.want { + t.Errorf("ResolvePathInConfig(%v, %q) = %q, want %q", tt.cfg, tt.path, got, tt.want) + } + }) + } +} + +func TestLoadFromFile_ConfigDir(t *testing.T) { + t.Parallel() + + dir := t.TempDir() + fp := filepath.Join(dir, "test.yaml") + if err := os.WriteFile(fp, []byte("modules: []"), 0644); err != nil { + t.Fatalf("failed to write: %v", err) + } + + cfg, err := LoadFromFile(fp) + if err != nil { + t.Fatalf("LoadFromFile failed: %v", err) + } + + if cfg.ConfigDir == "" { + t.Error("expected ConfigDir to be set") + } + absDir, _ := filepath.Abs(dir) + if cfg.ConfigDir != absDir { + t.Errorf("expected ConfigDir %q, got %q", absDir, cfg.ConfigDir) + } +} + +func TestNewEmptyWorkflowConfig_Pipelines(t *testing.T) { + t.Parallel() + + cfg := NewEmptyWorkflowConfig() + if cfg.Pipelines == nil { + t.Error("expected non-nil pipelines map") + } + if cfg.Requires != nil { + t.Error("expected nil requires for empty config") + } +} diff --git a/observability/reporter_test.go b/observability/reporter_test.go new file mode 100644 index 00000000..11c06ec0 --- /dev/null +++ b/observability/reporter_test.go @@ -0,0 +1,587 @@ +package observability + +import ( + "context" + "encoding/json" + "fmt" + "io" + "log/slog" + "net/http" + "net/http/httptest" + "strings" + "sync" + "testing" + "time" +) + +func TestDefaultReporterConfig(t *testing.T) { + t.Parallel() + + cfg := DefaultReporterConfig() + if cfg.FlushInterval != 5*time.Second { + t.Errorf("expected FlushInterval 5s, got %v", cfg.FlushInterval) + } + if cfg.BatchSize != 100 { + t.Errorf("expected BatchSize 100, got %d", cfg.BatchSize) + } + if cfg.HeartbeatInterval != 30*time.Second { + t.Errorf("expected HeartbeatInterval 30s, got %v", cfg.HeartbeatInterval) + } + if cfg.InstanceName == "" { + t.Log("InstanceName empty (hostname unavailable), acceptable") + } +} + +func TestNewReporter_Defaults(t *testing.T) { + t.Parallel() + + logger := slog.New(slog.NewTextHandler(io.Discard, nil)) + + tests := []struct { + name string + config ReporterConfig + check func(t *testing.T, r *Reporter) + }{ + { + name: "zero flush interval gets default", + config: ReporterConfig{}, + check: func(t *testing.T, r *Reporter) { + if r.config.FlushInterval != 5*time.Second { + t.Errorf("expected default FlushInterval, got %v", r.config.FlushInterval) + } + }, + }, + { + name: "zero batch size gets default", + config: ReporterConfig{}, + check: func(t *testing.T, r *Reporter) { + if r.config.BatchSize != 100 { + t.Errorf("expected default BatchSize, got %d", r.config.BatchSize) + } + }, + }, + { + name: "zero heartbeat gets default", + config: ReporterConfig{}, + check: func(t *testing.T, r *Reporter) { + if r.config.HeartbeatInterval != 30*time.Second { + t.Errorf("expected default HeartbeatInterval, got %v", r.config.HeartbeatInterval) + } + }, + }, + { + name: "custom values preserved", + config: ReporterConfig{ + FlushInterval: 10 * time.Second, + BatchSize: 50, + HeartbeatInterval: 60 * time.Second, + InstanceName: "test-worker", + }, + check: func(t *testing.T, r *Reporter) { + if r.config.FlushInterval != 10*time.Second { + t.Errorf("expected 10s FlushInterval, got %v", r.config.FlushInterval) + } + if r.config.BatchSize != 50 { + t.Errorf("expected 50 BatchSize, got %d", r.config.BatchSize) + } + if r.config.InstanceName != "test-worker" { + t.Errorf("expected instance name 'test-worker', got %q", r.config.InstanceName) + } + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + r := NewReporter(tt.config, logger) + if r == nil { + t.Fatal("NewReporter returned nil") + } + tt.check(t, r) + }) + } +} + +func TestReporter_BufferAndFlush(t *testing.T) { + t.Parallel() + + var mu sync.Mutex + receivedPaths := make(map[string]int) + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + mu.Lock() + receivedPaths[r.URL.Path]++ + mu.Unlock() + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte(`{"status":"ok"}`)) + })) + defer server.Close() + + logger := slog.New(slog.NewTextHandler(io.Discard, nil)) + reporter := NewReporter(ReporterConfig{ + AdminURL: server.URL, + FlushInterval: 50 * time.Millisecond, + BatchSize: 10, + InstanceName: "test", + HeartbeatInterval: 1 * time.Hour, // don't heartbeat during test + }, logger) + + reporter.ReportExecution(ExecutionReport{ + ID: "exec-1", WorkflowID: "wf-1", Status: "completed", + }) + reporter.ReportLog(LogReport{ + WorkflowID: "wf-1", Level: "info", Message: "test log", + }) + reporter.ReportEvent(EventReport{ + ExecutionID: "exec-1", EventType: "step_completed", + }) + + ctx, cancel := context.WithCancel(context.Background()) + t.Cleanup(cancel) + reporter.Start(ctx) + + deadline := time.Now().Add(2 * time.Second) + for { + mu.Lock() + execFlushed := receivedPaths["/api/v1/admin/ingest/executions"] > 0 + logsFlushed := receivedPaths["/api/v1/admin/ingest/logs"] > 0 + eventsFlushed := receivedPaths["/api/v1/admin/ingest/events"] > 0 + mu.Unlock() + + if execFlushed && logsFlushed && eventsFlushed { + break + } + + if time.Now().After(deadline) { + t.Fatalf("timed out waiting for reports to be flushed, got: %#v", receivedPaths) + } + + time.Sleep(10 * time.Millisecond) + } +} + +func TestReporter_StopFlushesRemaining(t *testing.T) { + t.Parallel() + + var mu sync.Mutex + received := false + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path == "/api/v1/admin/ingest/executions" { + mu.Lock() + received = true + mu.Unlock() + } + w.WriteHeader(http.StatusOK) + })) + defer server.Close() + + logger := slog.New(slog.NewTextHandler(io.Discard, nil)) + reporter := NewReporter(ReporterConfig{ + AdminURL: server.URL, + FlushInterval: 1 * time.Hour, // won't auto-flush + BatchSize: 100, + InstanceName: "test", + HeartbeatInterval: 1 * time.Hour, + }, logger) + + reporter.ReportExecution(ExecutionReport{ID: "exec-final", Status: "completed"}) + + ctx, cancel := context.WithCancel(context.Background()) + reporter.Start(ctx) + cancel() // triggers final flush + + time.Sleep(200 * time.Millisecond) + + mu.Lock() + defer mu.Unlock() + if !received { + t.Error("expected final flush on Stop to send buffered data") + } +} + +func TestReporter_ConcurrentReports(t *testing.T) { + t.Parallel() + + logger := slog.New(slog.NewTextHandler(io.Discard, nil)) + reporter := NewReporter(ReporterConfig{ + AdminURL: "http://localhost:0", // will fail to send, that's ok + BatchSize: 1000, + InstanceName: "test", + }, logger) + + var wg sync.WaitGroup + for i := 0; i < 100; i++ { + wg.Add(3) + go func(n int) { + defer wg.Done() + reporter.ReportExecution(ExecutionReport{ID: fmt.Sprintf("exec-%d", n)}) + }(i) + go func(n int) { + defer wg.Done() + reporter.ReportLog(LogReport{Message: fmt.Sprintf("log-%d", n)}) + }(i) + go func(n int) { + defer wg.Done() + reporter.ReportEvent(EventReport{EventType: fmt.Sprintf("event-%d", n)}) + }(i) + } + wg.Wait() + + reporter.mu.Lock() + defer reporter.mu.Unlock() + if len(reporter.executions) != 100 { + t.Errorf("expected 100 executions, got %d", len(reporter.executions)) + } + if len(reporter.logs) != 100 { + t.Errorf("expected 100 logs, got %d", len(reporter.logs)) + } + if len(reporter.events) != 100 { + t.Errorf("expected 100 events, got %d", len(reporter.events)) + } +} + +func TestReporter_ServerError(t *testing.T) { + t.Parallel() + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusInternalServerError) + })) + defer server.Close() + + logger := slog.New(slog.NewTextHandler(io.Discard, nil)) + reporter := NewReporter(ReporterConfig{ + AdminURL: server.URL, + BatchSize: 10, + InstanceName: "test", + }, logger) + + reporter.ReportExecution(ExecutionReport{ID: "exec-err"}) + + // Flush should not panic on server errors + reporter.flush(context.Background()) +} + +func TestReporter_Stop_NilCancel(t *testing.T) { + t.Parallel() + + logger := slog.New(slog.NewTextHandler(io.Discard, nil)) + reporter := NewReporter(ReporterConfig{InstanceName: "test"}, logger) + + // Stop before Start — should not panic + reporter.Stop() +} + +// --- IngestHandler tests --- + +type mockIngestStore struct { + mu sync.Mutex + executions int + logs int + events int + instances map[string]bool + heartbeats map[string]int + ingestError error +} + +func newMockIngestStore() *mockIngestStore { + return &mockIngestStore{ + instances: make(map[string]bool), + heartbeats: make(map[string]int), + } +} + +func (s *mockIngestStore) IngestExecutions(_ context.Context, _ string, items []ExecutionReport) error { + s.mu.Lock() + defer s.mu.Unlock() + if s.ingestError != nil { + return s.ingestError + } + s.executions += len(items) + return nil +} + +func (s *mockIngestStore) IngestLogs(_ context.Context, _ string, items []LogReport) error { + s.mu.Lock() + defer s.mu.Unlock() + if s.ingestError != nil { + return s.ingestError + } + s.logs += len(items) + return nil +} + +func (s *mockIngestStore) IngestEvents(_ context.Context, _ string, items []EventReport) error { + s.mu.Lock() + defer s.mu.Unlock() + if s.ingestError != nil { + return s.ingestError + } + s.events += len(items) + return nil +} + +func (s *mockIngestStore) RegisterInstance(_ context.Context, name string, _ time.Time) error { + s.mu.Lock() + defer s.mu.Unlock() + s.instances[name] = true + return nil +} + +func (s *mockIngestStore) Heartbeat(_ context.Context, name string, _ time.Time) error { + s.mu.Lock() + defer s.mu.Unlock() + s.heartbeats[name]++ + return nil +} + +func TestIngestHandler_Executions(t *testing.T) { + t.Parallel() + + store := newMockIngestStore() + logger := slog.New(slog.NewTextHandler(io.Discard, nil)) + handler := NewIngestHandler(store, logger) + + mux := http.NewServeMux() + handler.RegisterRoutes(mux) + + body := `{"instance":"worker-1","items":[{"id":"e1","status":"completed"},{"id":"e2","status":"failed"}]}` + req := httptest.NewRequest(http.MethodPost, "/api/v1/admin/ingest/executions", strings.NewReader(body)) + rec := httptest.NewRecorder() + mux.ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Errorf("expected 200, got %d", rec.Code) + } + + var resp map[string]int + if err := json.NewDecoder(rec.Body).Decode(&resp); err != nil { + t.Fatalf("decode response: %v", err) + } + if resp["accepted"] != 2 { + t.Errorf("expected 2 accepted, got %d", resp["accepted"]) + } +} + +func TestIngestHandler_Logs(t *testing.T) { + t.Parallel() + + store := newMockIngestStore() + logger := slog.New(slog.NewTextHandler(io.Discard, nil)) + handler := NewIngestHandler(store, logger) + + mux := http.NewServeMux() + handler.RegisterRoutes(mux) + + body := `{"instance":"worker-1","items":[{"workflow_id":"wf1","level":"info","message":"test"}]}` + req := httptest.NewRequest(http.MethodPost, "/api/v1/admin/ingest/logs", strings.NewReader(body)) + rec := httptest.NewRecorder() + mux.ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Errorf("expected 200, got %d", rec.Code) + } +} + +func TestIngestHandler_Events(t *testing.T) { + t.Parallel() + + store := newMockIngestStore() + logger := slog.New(slog.NewTextHandler(io.Discard, nil)) + handler := NewIngestHandler(store, logger) + + mux := http.NewServeMux() + handler.RegisterRoutes(mux) + + body := `{"instance":"worker-1","items":[{"execution_id":"e1","event_type":"step_done","event_data":{"step":"build"}}]}` + req := httptest.NewRequest(http.MethodPost, "/api/v1/admin/ingest/events", strings.NewReader(body)) + rec := httptest.NewRecorder() + mux.ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Errorf("expected 200, got %d", rec.Code) + } +} + +func TestIngestHandler_InvalidJSON(t *testing.T) { + t.Parallel() + + store := newMockIngestStore() + logger := slog.New(slog.NewTextHandler(io.Discard, nil)) + handler := NewIngestHandler(store, logger) + + mux := http.NewServeMux() + handler.RegisterRoutes(mux) + + endpoints := []string{ + "/api/v1/admin/ingest/executions", + "/api/v1/admin/ingest/logs", + "/api/v1/admin/ingest/events", + } + + for _, ep := range endpoints { + t.Run(ep, func(t *testing.T) { + req := httptest.NewRequest(http.MethodPost, ep, strings.NewReader("{invalid")) + rec := httptest.NewRecorder() + mux.ServeHTTP(rec, req) + + if rec.Code != http.StatusBadRequest { + t.Errorf("expected 400 for invalid JSON at %s, got %d", ep, rec.Code) + } + }) + } +} + +func TestIngestHandler_StoreError(t *testing.T) { + t.Parallel() + + store := newMockIngestStore() + store.ingestError = fmt.Errorf("database unavailable") + logger := slog.New(slog.NewTextHandler(io.Discard, nil)) + handler := NewIngestHandler(store, logger) + + mux := http.NewServeMux() + handler.RegisterRoutes(mux) + + body := `{"instance":"w1","items":[{"id":"e1","status":"done"}]}` + req := httptest.NewRequest(http.MethodPost, "/api/v1/admin/ingest/executions", strings.NewReader(body)) + rec := httptest.NewRecorder() + mux.ServeHTTP(rec, req) + + if rec.Code != http.StatusInternalServerError { + t.Errorf("expected 500, got %d", rec.Code) + } +} + +func TestIngestHandler_Health(t *testing.T) { + t.Parallel() + + store := newMockIngestStore() + logger := slog.New(slog.NewTextHandler(io.Discard, nil)) + handler := NewIngestHandler(store, logger) + + mux := http.NewServeMux() + handler.RegisterRoutes(mux) + + req := httptest.NewRequest(http.MethodGet, "/api/v1/admin/ingest/health", nil) + rec := httptest.NewRecorder() + mux.ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Errorf("expected 200, got %d", rec.Code) + } + + var resp map[string]string + if err := json.NewDecoder(rec.Body).Decode(&resp); err != nil { + t.Fatalf("decode: %v", err) + } + if resp["status"] != "ok" { + t.Errorf("expected status ok, got %q", resp["status"]) + } +} + +func TestIngestHandler_Register(t *testing.T) { + t.Parallel() + + store := newMockIngestStore() + logger := slog.New(slog.NewTextHandler(io.Discard, nil)) + handler := NewIngestHandler(store, logger) + + mux := http.NewServeMux() + handler.RegisterRoutes(mux) + + body := `{"instance_name":"worker-1","registered_at":"2024-01-01T00:00:00Z"}` + req := httptest.NewRequest(http.MethodPost, "/api/v1/admin/instances/register", strings.NewReader(body)) + rec := httptest.NewRecorder() + mux.ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Errorf("expected 200, got %d", rec.Code) + } + if !store.instances["worker-1"] { + t.Error("expected worker-1 to be registered") + } +} + +func TestIngestHandler_Register_InvalidTimestamp(t *testing.T) { + t.Parallel() + + store := newMockIngestStore() + logger := slog.New(slog.NewTextHandler(io.Discard, nil)) + handler := NewIngestHandler(store, logger) + + mux := http.NewServeMux() + handler.RegisterRoutes(mux) + + body := `{"instance_name":"worker-2","registered_at":"not-a-date"}` + req := httptest.NewRequest(http.MethodPost, "/api/v1/admin/instances/register", strings.NewReader(body)) + rec := httptest.NewRecorder() + mux.ServeHTTP(rec, req) + + // Should still succeed — falls back to time.Now() + if rec.Code != http.StatusOK { + t.Errorf("expected 200, got %d", rec.Code) + } +} + +func TestIngestHandler_Heartbeat(t *testing.T) { + t.Parallel() + + store := newMockIngestStore() + logger := slog.New(slog.NewTextHandler(io.Discard, nil)) + handler := NewIngestHandler(store, logger) + + mux := http.NewServeMux() + handler.RegisterRoutes(mux) + + body := `{"instance_name":"worker-1","timestamp":"2024-01-01T00:00:00Z"}` + req := httptest.NewRequest(http.MethodPost, "/api/v1/admin/instances/heartbeat", strings.NewReader(body)) + rec := httptest.NewRecorder() + mux.ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Errorf("expected 200, got %d", rec.Code) + } + if store.heartbeats["worker-1"] != 1 { + t.Errorf("expected 1 heartbeat, got %d", store.heartbeats["worker-1"]) + } +} + +func TestIngestHandler_Heartbeat_InvalidJSON(t *testing.T) { + t.Parallel() + + store := newMockIngestStore() + logger := slog.New(slog.NewTextHandler(io.Discard, nil)) + handler := NewIngestHandler(store, logger) + + mux := http.NewServeMux() + handler.RegisterRoutes(mux) + + req := httptest.NewRequest(http.MethodPost, "/api/v1/admin/instances/heartbeat", strings.NewReader("{bad")) + rec := httptest.NewRecorder() + mux.ServeHTTP(rec, req) + + if rec.Code != http.StatusBadRequest { + t.Errorf("expected 400, got %d", rec.Code) + } +} + +func TestIngestHandler_Register_InvalidJSON(t *testing.T) { + t.Parallel() + + store := newMockIngestStore() + logger := slog.New(slog.NewTextHandler(io.Discard, nil)) + handler := NewIngestHandler(store, logger) + + mux := http.NewServeMux() + handler.RegisterRoutes(mux) + + req := httptest.NewRequest(http.MethodPost, "/api/v1/admin/instances/register", strings.NewReader("{bad")) + rec := httptest.NewRecorder() + mux.ServeHTTP(rec, req) + + if rec.Code != http.StatusBadRequest { + t.Errorf("expected 400, got %d", rec.Code) + } +} diff --git a/plugin/admincore/plugin_test.go b/plugin/admincore/plugin_test.go new file mode 100644 index 00000000..5a284d07 --- /dev/null +++ b/plugin/admincore/plugin_test.go @@ -0,0 +1,138 @@ +package admincore + +import ( + "net/http" + "testing" + + "github.com/GoCodeAlone/workflow/plugin" +) + +func pluginContext() plugin.PluginContext { + return plugin.PluginContext{} +} + +func TestPlugin_Metadata(t *testing.T) { + t.Parallel() + + p := &Plugin{} + + if p.Name() != "admin-core" { + t.Errorf("expected name 'admin-core', got %q", p.Name()) + } + if p.Version() != "1.0.0" { + t.Errorf("expected version '1.0.0', got %q", p.Version()) + } + if p.Description() == "" { + t.Error("expected non-empty description") + } +} + +func TestPlugin_Dependencies(t *testing.T) { + t.Parallel() + + p := &Plugin{} + deps := p.Dependencies() + if deps != nil { + t.Errorf("expected nil dependencies, got %v", deps) + } +} + +func TestPlugin_Lifecycle(t *testing.T) { + t.Parallel() + + p := &Plugin{} + + // OnEnable and OnDisable should be no-ops + if err := p.OnEnable(pluginContext()); err != nil { + t.Errorf("OnEnable error: %v", err) + } + if err := p.OnDisable(pluginContext()); err != nil { + t.Errorf("OnDisable error: %v", err) + } +} + +func TestPlugin_RegisterRoutes(t *testing.T) { + t.Parallel() + + p := &Plugin{} + mux := http.NewServeMux() + + // RegisterRoutes should not panic even though it's a no-op + p.RegisterRoutes(mux) +} + +func TestPlugin_UIPages(t *testing.T) { + t.Parallel() + + p := &Plugin{} + pages := p.UIPages() + + if len(pages) == 0 { + t.Fatal("expected UI pages to be returned") + } + + // Verify expected pages exist + expectedIDs := map[string]bool{ + "dashboard": false, + "editor": false, + "marketplace": false, + "templates": false, + "environments": false, + "settings": false, + "executions": false, + "logs": false, + "events": false, + } + + for _, page := range pages { + if _, ok := expectedIDs[page.ID]; ok { + expectedIDs[page.ID] = true + } else { + t.Errorf("unexpected page ID: %q", page.ID) + } + + if page.Label == "" { + t.Errorf("page %q has empty label", page.ID) + } + if page.Icon == "" { + t.Errorf("page %q has empty icon", page.ID) + } + if page.Category == "" { + t.Errorf("page %q has empty category", page.ID) + } + if page.Category != "global" && page.Category != "workflow" { + t.Errorf("page %q has unexpected category %q", page.ID, page.Category) + } + } + + for id, found := range expectedIDs { + if !found { + t.Errorf("expected page %q not found", id) + } + } +} + +func TestPlugin_UIPages_GlobalVsWorkflow(t *testing.T) { + t.Parallel() + + p := &Plugin{} + pages := p.UIPages() + + globalCount := 0 + workflowCount := 0 + for _, page := range pages { + switch page.Category { + case "global": + globalCount++ + case "workflow": + workflowCount++ + } + } + + if globalCount == 0 { + t.Error("expected global pages") + } + if workflowCount == 0 { + t.Error("expected workflow-scoped pages") + } +} diff --git a/plugin/manager_test.go b/plugin/manager_test.go new file mode 100644 index 00000000..fac68b0f --- /dev/null +++ b/plugin/manager_test.go @@ -0,0 +1,373 @@ +package plugin + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "testing" +) + +// --- PluginManager additional coverage tests --- + +func TestPluginManager_NilDB(t *testing.T) { + t.Parallel() + + pm := NewPluginManager(nil, nil) + p := newSimplePlugin("test", "1.0.0", "Test plugin") + + if err := pm.Register(p); err != nil { + t.Fatalf("Register: %v", err) + } + if err := pm.Enable("test"); err != nil { + t.Fatalf("Enable: %v", err) + } + if !pm.IsEnabled("test") { + t.Error("expected plugin to be enabled") + } + + // Disable should also work without DB + if err := pm.Disable("test"); err != nil { + t.Fatalf("Disable: %v", err) + } +} + +func TestPluginManager_RestoreState(t *testing.T) { + db := openTestDB(t) + + // First manager: register, enable, and persist + pm1 := NewPluginManager(db, nil) + p1 := newSimplePlugin("persistent-plugin", "1.0.0", "Persistent") + if err := pm1.Register(p1); err != nil { + t.Fatalf("Register: %v", err) + } + if err := pm1.Enable("persistent-plugin"); err != nil { + t.Fatalf("Enable: %v", err) + } + + // Second manager: register same plugin, then restore state + pm2 := NewPluginManager(db, nil) + p2 := newSimplePlugin("persistent-plugin", "1.0.0", "Persistent") + if err := pm2.Register(p2); err != nil { + t.Fatalf("Register in pm2: %v", err) + } + + if err := pm2.RestoreState(); err != nil { + t.Fatalf("RestoreState: %v", err) + } + if !pm2.IsEnabled("persistent-plugin") { + t.Error("expected plugin to be restored as enabled") + } +} + +func TestPluginManager_RestoreState_NilDB(t *testing.T) { + t.Parallel() + + pm := NewPluginManager(nil, nil) + if err := pm.RestoreState(); err != nil { + t.Fatalf("RestoreState with nil DB should succeed: %v", err) + } +} + +func TestPluginManager_AllPlugins(t *testing.T) { + db := openTestDB(t) + + pm := NewPluginManager(db, nil) + p1 := newSimplePlugin("alpha", "1.0.0", "Alpha plugin") + p2 := newSimplePlugin("beta", "2.0.0", "Beta plugin") + if err := pm.Register(p1); err != nil { + t.Fatalf("Register p1: %v", err) + } + if err := pm.Register(p2); err != nil { + t.Fatalf("Register p2: %v", err) + } + if err := pm.Enable("alpha"); err != nil { + t.Fatalf("Enable alpha: %v", err) + } + + all := pm.AllPlugins() + if len(all) != 2 { + t.Fatalf("expected 2 plugins, got %d", len(all)) + } + + // Sorted by name + if all[0].Name != "alpha" { + t.Errorf("expected first plugin 'alpha', got %q", all[0].Name) + } + if !all[0].Enabled { + t.Error("expected alpha to be enabled") + } + if all[1].Enabled { + t.Error("expected beta to be disabled") + } +} + +func TestPluginManager_EnabledPlugins(t *testing.T) { + t.Parallel() + + pm := NewPluginManager(nil, nil) + _ = pm.Register(newSimplePlugin("a", "1.0.0", "A")) + _ = pm.Register(newSimplePlugin("b", "1.0.0", "B")) + _ = pm.Register(newSimplePlugin("c", "1.0.0", "C")) + _ = pm.Enable("a") + _ = pm.Enable("c") + + enabled := pm.EnabledPlugins() + if len(enabled) != 2 { + t.Fatalf("expected 2 enabled, got %d", len(enabled)) + } + if enabled[0].Name() != "a" || enabled[1].Name() != "c" { + t.Errorf("unexpected enabled plugins: %s, %s", enabled[0].Name(), enabled[1].Name()) + } +} + +func TestPluginManager_Enable_NotRegistered(t *testing.T) { + t.Parallel() + + pm := NewPluginManager(nil, nil) + if err := pm.Enable("ghost"); err == nil { + t.Fatal("expected error enabling unregistered plugin") + } +} + +func TestPluginManager_Disable_NotRegistered(t *testing.T) { + t.Parallel() + + pm := NewPluginManager(nil, nil) + if err := pm.Disable("ghost"); err == nil { + t.Fatal("expected error disabling unregistered plugin") + } +} + +func TestPluginManager_Disable_AlreadyDisabled(t *testing.T) { + t.Parallel() + + pm := NewPluginManager(nil, nil) + _ = pm.Register(newSimplePlugin("off", "1.0.0", "Off")) + + // Disable when already disabled should be no-op + if err := pm.Disable("off"); err != nil { + t.Fatalf("Disable already-disabled should not error: %v", err) + } +} + +func TestPluginManager_RegisterEmptyName(t *testing.T) { + t.Parallel() + + pm := NewPluginManager(nil, nil) + p := &testPlugin{name: "", version: "1.0.0"} + if err := pm.Register(p); err == nil { + t.Fatal("expected error for empty plugin name") + } +} + +func TestPluginManager_SetContext(t *testing.T) { + t.Parallel() + + pm := NewPluginManager(nil, nil) + ctx := PluginContext{DataDir: "/test"} + pm.SetContext(ctx) + // No panic means success — internal state only +} + +func TestPluginManager_EnableWithVersionConstraint(t *testing.T) { + db := openTestDB(t) + + pm := NewPluginManager(db, nil) + base := newSimplePlugin("base-lib", "2.0.0", "Base library") + dep := newPluginWithDeps("consumer", "1.0.0", + PluginDependency{Name: "base-lib", MinVersion: "1.5.0"}) + + _ = pm.Register(base) + _ = pm.Register(dep) + + if err := pm.Enable("consumer"); err != nil { + t.Fatalf("Enable with valid version constraint: %v", err) + } +} + +func TestPluginManager_EnableWithVersionConstraint_Failure(t *testing.T) { + db := openTestDB(t) + + pm := NewPluginManager(db, nil) + base := newSimplePlugin("base-lib", "1.0.0", "Base library") + dep := newPluginWithDeps("consumer", "1.0.0", + PluginDependency{Name: "base-lib", MinVersion: "2.0.0"}) + + _ = pm.Register(base) + _ = pm.Register(dep) + + if err := pm.Enable("consumer"); err == nil { + t.Fatal("expected error: version constraint not satisfied") + } +} + +func TestPluginManager_OnEnableError(t *testing.T) { + t.Parallel() + + pm := NewPluginManager(nil, nil) + p := newSimplePlugin("failing", "1.0.0", "Failing plugin") + p.onEnableFn = func(_ PluginContext) error { + return http.ErrServerClosed // any error + } + + _ = pm.Register(p) + if err := pm.Enable("failing"); err == nil { + t.Fatal("expected error from OnEnable failure") + } + if pm.IsEnabled("failing") { + t.Error("plugin should not be enabled after OnEnable failure") + } +} + +// --- ServeHTTP tests --- + +func TestPluginManager_ServeHTTP_ListPlugins(t *testing.T) { + pm := NewPluginManager(nil, nil) + _ = pm.Register(newSimplePlugin("my-plugin", "1.0.0", "My Plugin")) + _ = pm.Enable("my-plugin") + + req := httptest.NewRequest(http.MethodGet, "/api/v1/admin/plugins", nil) + rec := httptest.NewRecorder() + pm.ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("expected 200, got %d", rec.Code) + } + + var plugins []PluginInfo + if err := json.NewDecoder(rec.Body).Decode(&plugins); err != nil { + t.Fatalf("decode: %v", err) + } + if len(plugins) != 1 { + t.Errorf("expected 1 plugin, got %d", len(plugins)) + } +} + +func TestPluginManager_ServeHTTP_ListPlugins_MethodNotAllowed(t *testing.T) { + pm := NewPluginManager(nil, nil) + + req := httptest.NewRequest(http.MethodPost, "/api/v1/admin/plugins", nil) + rec := httptest.NewRecorder() + pm.ServeHTTP(rec, req) + + if rec.Code != http.StatusMethodNotAllowed { + t.Errorf("expected 405, got %d", rec.Code) + } +} + +func TestPluginManager_ServeHTTP_Enable(t *testing.T) { + pm := NewPluginManager(nil, nil) + _ = pm.Register(newSimplePlugin("enab", "1.0.0", "Enable test")) + + req := httptest.NewRequest(http.MethodPost, "/api/v1/admin/plugins/enab/enable", nil) + rec := httptest.NewRecorder() + pm.ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Errorf("expected 200, got %d: %s", rec.Code, rec.Body.String()) + } + if !pm.IsEnabled("enab") { + t.Error("expected plugin to be enabled via HTTP") + } +} + +func TestPluginManager_ServeHTTP_Disable(t *testing.T) { + pm := NewPluginManager(nil, nil) + _ = pm.Register(newSimplePlugin("dis", "1.0.0", "Disable test")) + _ = pm.Enable("dis") + + req := httptest.NewRequest(http.MethodPost, "/api/v1/admin/plugins/dis/disable", nil) + rec := httptest.NewRecorder() + pm.ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Errorf("expected 200, got %d: %s", rec.Code, rec.Body.String()) + } + if pm.IsEnabled("dis") { + t.Error("expected plugin to be disabled via HTTP") + } +} + +func TestPluginManager_ServeHTTP_NotFound(t *testing.T) { + pm := NewPluginManager(nil, nil) + + req := httptest.NewRequest(http.MethodGet, "/api/v1/admin/plugins/nonexistent/health", nil) + rec := httptest.NewRecorder() + pm.ServeHTTP(rec, req) + + if rec.Code != http.StatusNotFound { + t.Errorf("expected 404, got %d", rec.Code) + } +} + +func TestPluginManager_ServeHTTP_PluginRoute(t *testing.T) { + pm := NewPluginManager(nil, nil) + p := newSimplePlugin("routed", "1.0.0", "Routed plugin") + _ = pm.Register(p) + _ = pm.Enable("routed") + + req := httptest.NewRequest(http.MethodGet, "/api/v1/admin/plugins/routed/health", nil) + rec := httptest.NewRecorder() + pm.ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Errorf("expected 200, got %d: %s", rec.Code, rec.Body.String()) + } +} + +func TestPluginManager_ServeHTTP_BadPrefix(t *testing.T) { + pm := NewPluginManager(nil, nil) + + req := httptest.NewRequest(http.MethodGet, "/some/random/path", nil) + rec := httptest.NewRecorder() + pm.ServeHTTP(rec, req) + + if rec.Code != http.StatusNotFound { + t.Errorf("expected 404, got %d", rec.Code) + } +} + +// --- NativeHandler tests --- + +func TestNativeHandler_ServeHTTP(t *testing.T) { + pm := NewPluginManager(nil, nil) + _ = pm.Register(newSimplePlugin("nh-test", "1.0.0", "NativeHandler test")) + _ = pm.Enable("nh-test") + + h := NewNativeHandler(pm) + + req := httptest.NewRequest(http.MethodGet, "/api/v1/admin/plugins", nil) + rec := httptest.NewRecorder() + h.ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Errorf("expected 200, got %d", rec.Code) + } +} + +// --- DisableOrder with dependents --- + +func TestPluginManager_DisableWithDependents(t *testing.T) { + pm := NewPluginManager(nil, nil) + a := newSimplePlugin("core", "1.0.0", "Core") + b := newPluginWithDeps("ext", "1.0.0", PluginDependency{Name: "core"}) + + _ = pm.Register(a) + _ = pm.Register(b) + _ = pm.Enable("ext") // auto-enables "core" + + if !pm.IsEnabled("core") || !pm.IsEnabled("ext") { + t.Fatal("both should be enabled") + } + + // Disabling core should also disable ext + if err := pm.Disable("core"); err != nil { + t.Fatalf("Disable core: %v", err) + } + if pm.IsEnabled("ext") { + t.Error("dependent ext should have been disabled") + } + if pm.IsEnabled("core") { + t.Error("core should be disabled") + } +} diff --git a/sandbox/tar_test.go b/sandbox/tar_test.go new file mode 100644 index 00000000..e2b792db --- /dev/null +++ b/sandbox/tar_test.go @@ -0,0 +1,162 @@ +package sandbox + +import ( + "archive/tar" + "context" + "io" + "os" + "path/filepath" + "testing" +) + +func TestCreateTarFromFile(t *testing.T) { + t.Parallel() + + dir := t.TempDir() + content := []byte("hello world from tar test") + fp := filepath.Join(dir, "testfile.txt") + if err := os.WriteFile(fp, content, 0644); err != nil { + t.Fatalf("write file: %v", err) + } + + f, err := os.Open(fp) + if err != nil { + t.Fatalf("open file: %v", err) + } + defer f.Close() + + stat, err := f.Stat() + if err != nil { + t.Fatalf("stat file: %v", err) + } + + reader, err := createTarFromFile(f, stat) + if err != nil { + t.Fatalf("createTarFromFile: %v", err) + } + + // Read back the tar archive + tr := tar.NewReader(reader) + header, err := tr.Next() + if err != nil { + t.Fatalf("read tar header: %v", err) + } + + if header.Name != "testfile.txt" { + t.Errorf("expected name 'testfile.txt', got %q", header.Name) + } + if header.Size != int64(len(content)) { + t.Errorf("expected size %d, got %d", len(content), header.Size) + } + + data, err := io.ReadAll(tr) + if err != nil { + t.Fatalf("read tar content: %v", err) + } + if string(data) != string(content) { + t.Errorf("expected content %q, got %q", content, data) + } + + // Should be no more entries + _, err = tr.Next() + if err != io.EOF { + t.Errorf("expected EOF, got %v", err) + } +} + +func TestCreateTarFromFile_EmptyFile(t *testing.T) { + t.Parallel() + + dir := t.TempDir() + fp := filepath.Join(dir, "empty.txt") + if err := os.WriteFile(fp, []byte{}, 0644); err != nil { + t.Fatalf("write empty file: %v", err) + } + + f, err := os.Open(fp) + if err != nil { + t.Fatalf("open: %v", err) + } + defer f.Close() + + stat, err := f.Stat() + if err != nil { + t.Fatalf("stat: %v", err) + } + + reader, err := createTarFromFile(f, stat) + if err != nil { + t.Fatalf("createTarFromFile: %v", err) + } + + tr := tar.NewReader(reader) + header, err := tr.Next() + if err != nil { + t.Fatalf("read header: %v", err) + } + + if header.Size != 0 { + t.Errorf("expected size 0, got %d", header.Size) + } +} + +func TestCopyIn_ReturnsError(t *testing.T) { + t.Parallel() + + sb := &DockerSandbox{ + config: SandboxConfig{Image: "alpine:latest"}, + } + + err := sb.CopyIn(context.TODO(), "/nonexistent", "/dest") + if err == nil { + t.Fatal("expected error from CopyIn") + } +} + +func TestCopyOut_ReturnsError(t *testing.T) { + t.Parallel() + + sb := &DockerSandbox{ + config: SandboxConfig{Image: "alpine:latest"}, + } + + _, err := sb.CopyOut(context.TODO(), "/src") + if err == nil { + t.Fatal("expected error from CopyOut") + } +} + +func TestExec_EmptyCommand(t *testing.T) { + t.Parallel() + + sb := &DockerSandbox{ + config: SandboxConfig{Image: "alpine:latest"}, + } + + _, err := sb.Exec(context.TODO(), nil) + if err == nil { + t.Fatal("expected error for empty command") + } +} + +func TestExecInContainer_EmptyCommand(t *testing.T) { + t.Parallel() + + sb := &DockerSandbox{ + config: SandboxConfig{Image: "alpine:latest"}, + } + + _, _, err := sb.ExecInContainer(context.TODO(), nil, nil, nil) + if err == nil { + t.Fatal("expected error for empty command") + } +} + +func TestClose_NilClient(t *testing.T) { + t.Parallel() + + sb := &DockerSandbox{} + if err := sb.Close(); err != nil { + t.Fatalf("Close with nil client should return nil: %v", err) + } +}