From 47dbf827f2cf994074c5e792767c55f60fd6da0e Mon Sep 17 00:00:00 2001 From: Joakim Olsson Date: Thu, 20 Nov 2025 21:09:00 +0100 Subject: [PATCH] fix: add command executor interface for better testing Introduce the CommandExecutor interface to abstract command execution, allowing for easier mocking in tests. Implement DefaultCommandExecutor to use the os/exec package for executing commands. Update the GenerateCosmoRouterConfig function to utilize the new GenerateCosmoRouterConfigWithExecutor function that accepts a command executor parameter. Add a MockCommandExecutor for simulating command execution in unit tests with realistic behavior based on input YAML files. This enhances test coverage and simplifies error handling. --- graph/cosmo.go | 25 +++++- graph/cosmo_test.go | 212 +++++++++++++++++++++++++++++++++++++++++++- 2 files changed, 233 insertions(+), 4 deletions(-) diff --git a/graph/cosmo.go b/graph/cosmo.go index 69deb88..ac62fe2 100644 --- a/graph/cosmo.go +++ b/graph/cosmo.go @@ -11,9 +11,30 @@ import ( "gitlab.com/unboundsoftware/schemas/graph/model" ) +// CommandExecutor is an interface for executing external commands +// This allows for mocking in tests +type CommandExecutor interface { + Execute(name string, args ...string) ([]byte, error) +} + +// DefaultCommandExecutor implements CommandExecutor using os/exec +type DefaultCommandExecutor struct{} + +// Execute runs a command and returns its combined output +func (e *DefaultCommandExecutor) Execute(name string, args ...string) ([]byte, error) { + cmd := exec.Command(name, args...) + return cmd.CombinedOutput() +} + // GenerateCosmoRouterConfig generates a Cosmo Router execution config from subgraphs // using the official wgc CLI tool via npx func GenerateCosmoRouterConfig(subGraphs []*model.SubGraph) (string, error) { + return GenerateCosmoRouterConfigWithExecutor(subGraphs, &DefaultCommandExecutor{}) +} + +// GenerateCosmoRouterConfigWithExecutor generates a Cosmo Router execution config from subgraphs +// using the provided command executor (useful for testing) +func GenerateCosmoRouterConfigWithExecutor(subGraphs []*model.SubGraph, executor CommandExecutor) (string, error) { if len(subGraphs) == 0 { return "", fmt.Errorf("no subgraphs provided") } @@ -85,13 +106,11 @@ func GenerateCosmoRouterConfig(subGraphs []*model.SubGraph) (string, error) { // Execute wgc router compose // wgc is installed globally in the Docker image outputFile := filepath.Join(tmpDir, "config.json") - cmd := exec.Command("wgc", "router", "compose", + output, err := executor.Execute("wgc", "router", "compose", "--input", inputFile, "--out", outputFile, "--suppress-warnings", ) - - output, err := cmd.CombinedOutput() if err != nil { return "", fmt.Errorf("wgc router compose failed: %w\nOutput: %s", err, string(output)) } diff --git a/graph/cosmo_test.go b/graph/cosmo_test.go index 202bdc8..f560fbf 100644 --- a/graph/cosmo_test.go +++ b/graph/cosmo_test.go @@ -2,14 +2,185 @@ package graph import ( "encoding/json" + "fmt" + "os" + "strings" "testing" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + "gopkg.in/yaml.v3" "gitlab.com/unboundsoftware/schemas/graph/model" ) +// MockCommandExecutor implements CommandExecutor for testing +type MockCommandExecutor struct { + // CallCount tracks how many times Execute was called + CallCount int + // LastArgs stores the arguments from the last call + LastArgs []string + // Error can be set to simulate command failures + Error error +} + +// Execute mocks the wgc command by generating a realistic config.json file +func (m *MockCommandExecutor) Execute(name string, args ...string) ([]byte, error) { + m.CallCount++ + m.LastArgs = append([]string{name}, args...) + + if m.Error != nil { + return nil, m.Error + } + + // Parse the input file to understand what subgraphs we're composing + var inputFile, outputFile string + for i, arg := range args { + if arg == "--input" && i+1 < len(args) { + inputFile = args[i+1] + } + if arg == "--out" && i+1 < len(args) { + outputFile = args[i+1] + } + } + + if inputFile == "" || outputFile == "" { + return nil, fmt.Errorf("missing required arguments") + } + + // Read the input YAML to get subgraph information + inputData, err := os.ReadFile(inputFile) + if err != nil { + return nil, fmt.Errorf("failed to read input file: %w", err) + } + + var input struct { + Version int `yaml:"version"` + Subgraphs []struct { + Name string `yaml:"name"` + RoutingURL string `yaml:"routing_url,omitempty"` + Schema map[string]string `yaml:"schema"` + Subscription map[string]interface{} `yaml:"subscription,omitempty"` + } `yaml:"subgraphs"` + } + + if err := yaml.Unmarshal(inputData, &input); err != nil { + return nil, fmt.Errorf("failed to parse input YAML: %w", err) + } + + // Generate a realistic Cosmo Router config based on the input + config := map[string]interface{}{ + "version": "mock-version-uuid", + "subgraphs": func() []map[string]interface{} { + subgraphs := make([]map[string]interface{}, len(input.Subgraphs)) + for i, sg := range input.Subgraphs { + subgraph := map[string]interface{}{ + "id": fmt.Sprintf("mock-id-%d", i), + "name": sg.Name, + } + if sg.RoutingURL != "" { + subgraph["routingUrl"] = sg.RoutingURL + } + subgraphs[i] = subgraph + } + return subgraphs + }(), + "engineConfig": map[string]interface{}{ + "graphqlSchema": generateMockSchema(input.Subgraphs), + "datasourceConfigurations": func() []map[string]interface{} { + dsConfigs := make([]map[string]interface{}, len(input.Subgraphs)) + for i, sg := range input.Subgraphs { + // Read SDL from file + sdl := "" + if schemaFile, ok := sg.Schema["file"]; ok { + if sdlData, err := os.ReadFile(schemaFile); err == nil { + sdl = string(sdlData) + } + } + + dsConfig := map[string]interface{}{ + "id": fmt.Sprintf("datasource-%d", i), + "kind": "GRAPHQL", + "customGraphql": map[string]interface{}{ + "federation": map[string]interface{}{ + "enabled": true, + "serviceSdl": sdl, + }, + "subscription": func() map[string]interface{} { + if len(sg.Subscription) > 0 { + return map[string]interface{}{ + "enabled": true, + "url": map[string]interface{}{ + "staticVariableContent": sg.Subscription["url"], + }, + "protocol": sg.Subscription["protocol"], + "websocketSubprotocol": sg.Subscription["websocket_subprotocol"], + } + } + return map[string]interface{}{ + "enabled": false, + } + }(), + }, + } + dsConfigs[i] = dsConfig + } + return dsConfigs + }(), + }, + } + + // Write the config to the output file + configJSON, err := json.Marshal(config) + if err != nil { + return nil, fmt.Errorf("failed to marshal config: %w", err) + } + + if err := os.WriteFile(outputFile, configJSON, 0o644); err != nil { + return nil, fmt.Errorf("failed to write output file: %w", err) + } + + return []byte("Success"), nil +} + +// generateMockSchema creates a simple merged schema from subgraphs +func generateMockSchema(subgraphs []struct { + Name string `yaml:"name"` + RoutingURL string `yaml:"routing_url,omitempty"` + Schema map[string]string `yaml:"schema"` + Subscription map[string]interface{} `yaml:"subscription,omitempty"` +}, +) string { + schema := strings.Builder{} + schema.WriteString("schema {\n query: Query\n") + + // Check if any subgraph has subscriptions + hasSubscriptions := false + for _, sg := range subgraphs { + if len(sg.Subscription) > 0 { + hasSubscriptions = true + break + } + } + + if hasSubscriptions { + schema.WriteString(" subscription: Subscription\n") + } + schema.WriteString("}\n\n") + + // Add types by reading SDL files + for _, sg := range subgraphs { + if schemaFile, ok := sg.Schema["file"]; ok { + if sdlData, err := os.ReadFile(schemaFile); err == nil { + schema.WriteString(string(sdlData)) + schema.WriteString("\n") + } + } + } + + return schema.String() +} + func TestGenerateCosmoRouterConfig(t *testing.T) { tests := []struct { name string @@ -232,16 +403,28 @@ func TestGenerateCosmoRouterConfig(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - config, err := GenerateCosmoRouterConfig(tt.subGraphs) + // Use mock executor for all tests + mockExecutor := &MockCommandExecutor{} + config, err := GenerateCosmoRouterConfigWithExecutor(tt.subGraphs, mockExecutor) if tt.wantErr { assert.Error(t, err) + // Verify executor was not called for error cases + if len(tt.subGraphs) == 0 { + assert.Equal(t, 0, mockExecutor.CallCount, "Should not call executor for empty subgraphs") + } return } require.NoError(t, err) assert.NotEmpty(t, config, "Config should not be empty") + // Verify executor was called correctly + assert.Equal(t, 1, mockExecutor.CallCount, "Should call executor once") + assert.Equal(t, "wgc", mockExecutor.LastArgs[0], "Should call wgc command") + assert.Contains(t, mockExecutor.LastArgs, "router", "Should include 'router' arg") + assert.Contains(t, mockExecutor.LastArgs, "compose", "Should include 'compose' arg") + if tt.validate != nil { tt.validate(t, config) } @@ -249,6 +432,33 @@ func TestGenerateCosmoRouterConfig(t *testing.T) { } } +// TestGenerateCosmoRouterConfig_MockError tests error handling with mock executor +func TestGenerateCosmoRouterConfig_MockError(t *testing.T) { + subGraphs := []*model.SubGraph{ + { + Service: "test-service", + URL: stringPtr("http://localhost:4001/query"), + Sdl: "type Query { test: String }", + }, + } + + // Create a mock executor that returns an error + mockExecutor := &MockCommandExecutor{ + Error: fmt.Errorf("simulated wgc failure"), + } + + config, err := GenerateCosmoRouterConfigWithExecutor(subGraphs, mockExecutor) + + // Verify error is propagated + assert.Error(t, err) + assert.Contains(t, err.Error(), "wgc router compose failed") + assert.Contains(t, err.Error(), "simulated wgc failure") + assert.Empty(t, config) + + // Verify executor was called + assert.Equal(t, 1, mockExecutor.CallCount, "Should have attempted to call executor") +} + // Helper function for tests func stringPtr(s string) *string { return &s