diff --git a/pkg/bundler/bundler.go b/pkg/bundler/bundler.go new file mode 100644 index 000000000..946445e74 --- /dev/null +++ b/pkg/bundler/bundler.go @@ -0,0 +1,70 @@ +package bundler + +import ( + "fmt" + + "github.com/windsorcli/cli/pkg/di" + "github.com/windsorcli/cli/pkg/shell" +) + +// The Bundler provides an interface for adding content to artifacts during the bundling process. +// It provides a unified approach for different content types (templates, kustomize, terraform) +// to contribute their files to the artifact build directory. The Bundler serves as a composable +// component that can validate and bundle specific types of content into distributable artifacts. + +// ============================================================================= +// Interfaces +// ============================================================================= + +// Bundler defines the interface for content bundling operations +type Bundler interface { + Initialize(injector di.Injector) error + Bundle(artifact Artifact) error +} + +// ============================================================================= +// Types +// ============================================================================= + +// BaseBundler provides common functionality for bundler implementations +type BaseBundler struct { + injector di.Injector + shims *Shims + shell shell.Shell +} + +// ============================================================================= +// Constructor +// ============================================================================= + +// NewBaseBundler creates a new BaseBundler instance +func NewBaseBundler() *BaseBundler { + return &BaseBundler{ + shims: NewShims(), + } +} + +// ============================================================================= +// Public Methods +// ============================================================================= + +// Initialize initializes the BaseBundler with dependency injection +func (b *BaseBundler) Initialize(injector di.Injector) error { + b.injector = injector + + shell, ok := injector.Resolve("shell").(shell.Shell) + if !ok { + return fmt.Errorf("failed to resolve shell from injector") + } + b.shell = shell + + return nil +} + +// Bundle provides a default implementation that can be overridden by concrete bundlers +func (b *BaseBundler) Bundle(artifact Artifact) error { + return nil +} + +// Ensure BaseBundler implements Bundler interface +var _ Bundler = (*BaseBundler)(nil) diff --git a/pkg/bundler/bundler_test.go b/pkg/bundler/bundler_test.go new file mode 100644 index 000000000..9c14daa20 --- /dev/null +++ b/pkg/bundler/bundler_test.go @@ -0,0 +1,147 @@ +package bundler + +import ( + "os" + "path/filepath" + "testing" + + "github.com/windsorcli/cli/pkg/di" + "github.com/windsorcli/cli/pkg/shell" +) + +// ============================================================================= +// Test Setup +// ============================================================================= + +type BundlerMocks struct { + Injector di.Injector + Shell *shell.MockShell + Shims *Shims + Artifact *MockArtifact +} + +func setupBundlerMocks(t *testing.T) *BundlerMocks { + t.Helper() + + // Create temp directory + tmpDir := t.TempDir() + + // Create injector + injector := di.NewInjector() + + // Set up shell + mockShell := shell.NewMockShell(injector) + mockShell.GetProjectRootFunc = func() (string, error) { + return tmpDir, nil + } + injector.Register("shell", mockShell) + + // Create test-friendly shims + shims := NewShims() + shims.Stat = func(name string) (os.FileInfo, error) { + return &mockFileInfo{name: name, isDir: true}, nil + } + shims.Walk = func(root string, fn filepath.WalkFunc) error { + return nil + } + shims.FilepathRel = func(basepath, targpath string) (string, error) { + return "test.txt", nil + } + shims.ReadFile = func(filename string) ([]byte, error) { + return []byte("test content"), nil + } + + // Create mock artifact + artifact := NewMockArtifact() + + return &BundlerMocks{ + Injector: injector, + Shell: mockShell, + Shims: shims, + Artifact: artifact, + } +} + +// ============================================================================= +// Test BaseBundler +// ============================================================================= + +func TestBaseBundler_NewBaseBundler(t *testing.T) { + t.Run("Success", func(t *testing.T) { + // Given no preconditions + // When creating a new base bundler + bundler := NewBaseBundler() + + // Then it should not be nil + if bundler == nil { + t.Fatal("Expected non-nil bundler") + } + // And shims should be initialized + if bundler.shims == nil { + t.Error("Expected shims to be initialized") + } + // And other fields should be nil until Initialize + if bundler.shell != nil { + t.Error("Expected shell to be nil before Initialize") + } + }) +} + +func TestBaseBundler_Initialize(t *testing.T) { + t.Run("Success", func(t *testing.T) { + // Given a base bundler and mocks + mocks := setupBundlerMocks(t) + bundler := NewBaseBundler() + + // When calling Initialize + err := bundler.Initialize(mocks.Injector) + + // Then no error should be returned + if err != nil { + t.Errorf("Expected nil error, got %v", err) + } + // And shell should be injected + if bundler.shell == nil { + t.Error("Expected shell to be set after Initialize") + } + // And injector should be stored + if bundler.injector == nil { + t.Error("Expected injector to be stored") + } + }) + + t.Run("ErrorWhenShellNotFound", func(t *testing.T) { + // Given a bundler and injector without shell + bundler := NewBaseBundler() + injector := di.NewInjector() + injector.Register("shell", "not-a-shell") + + // When calling Initialize + err := bundler.Initialize(injector) + + // Then an error should be returned + if err == nil { + t.Error("Expected error when shell not found") + } + if err.Error() != "failed to resolve shell from injector" { + t.Errorf("Expected shell resolution error, got: %v", err) + } + }) +} + +func TestBaseBundler_Bundle(t *testing.T) { + t.Run("Success", func(t *testing.T) { + // Given a base bundler + mocks := setupBundlerMocks(t) + bundler := NewBaseBundler() + bundler.Initialize(mocks.Injector) + + // When calling Bundle + err := bundler.Bundle(mocks.Artifact) + + // Then no error should be returned (default implementation) + if err != nil { + t.Errorf("Expected nil error, got %v", err) + } + }) +} diff --git a/pkg/bundler/mock_bundler.go b/pkg/bundler/mock_bundler.go new file mode 100644 index 000000000..98acf2ce2 --- /dev/null +++ b/pkg/bundler/mock_bundler.go @@ -0,0 +1,52 @@ +package bundler + +import ( + "github.com/windsorcli/cli/pkg/di" +) + +// The MockBundler is a mock implementation of the Bundler interface for testing. +// It provides function fields that can be overridden to control behavior during tests. +// It serves as a test double for the Bundler interface in unit tests. +// It enables isolation and verification of component interactions with the bundler system. + +// ============================================================================= +// Types +// ============================================================================= + +// MockBundler is a mock implementation of the Bundler interface +type MockBundler struct { + InitializeFunc func(injector di.Injector) error + BundleFunc func(artifact Artifact) error +} + +// ============================================================================= +// Constructor +// ============================================================================= + +// NewMockBundler creates a new MockBundler instance +func NewMockBundler() *MockBundler { + return &MockBundler{} +} + +// ============================================================================= +// Public Methods +// ============================================================================= + +// Initialize calls the mock InitializeFunc if set, otherwise returns nil +func (m *MockBundler) Initialize(injector di.Injector) error { + if m.InitializeFunc != nil { + return m.InitializeFunc(injector) + } + return nil +} + +// Bundle calls the mock BundleFunc if set, otherwise returns nil +func (m *MockBundler) Bundle(artifact Artifact) error { + if m.BundleFunc != nil { + return m.BundleFunc(artifact) + } + return nil +} + +// Ensure MockBundler implements Bundler interface +var _ Bundler = (*MockBundler)(nil) diff --git a/pkg/bundler/mock_bundler_test.go b/pkg/bundler/mock_bundler_test.go new file mode 100644 index 000000000..e2555de4c --- /dev/null +++ b/pkg/bundler/mock_bundler_test.go @@ -0,0 +1,98 @@ +package bundler + +import ( + "testing" + + "github.com/windsorcli/cli/pkg/di" +) + +// ============================================================================= +// Test Public Methods +// ============================================================================= + +func TestMockBundler_NewMockBundler(t *testing.T) { + t.Run("Success", func(t *testing.T) { + // Given no preconditions + // When creating a new mock bundler + mock := NewMockBundler() + + // Then it should not be nil + if mock == nil { + t.Fatal("Expected non-nil mock bundler") + } + }) +} + +func TestMockBundler_Initialize(t *testing.T) { + t.Run("Success", func(t *testing.T) { + // Given a mock with a custom initialize function + mock := NewMockBundler() + called := false + mock.InitializeFunc = func(injector di.Injector) error { + called = true + return nil + } + + // When calling Initialize + err := mock.Initialize(di.NewInjector()) + + // Then the mock function should be called + if !called { + t.Error("Expected InitializeFunc to be called") + } + if err != nil { + t.Errorf("Expected nil error, got %v", err) + } + }) + + t.Run("NotImplemented", func(t *testing.T) { + // Given a mock with no custom initialize function + mock := NewMockBundler() + + // When calling Initialize + err := mock.Initialize(di.NewInjector()) + + // Then no error should be returned + if err != nil { + t.Errorf("Expected nil error, got %v", err) + } + }) +} + +func TestMockBundler_Bundle(t *testing.T) { + t.Run("Success", func(t *testing.T) { + // Given a mock with a custom bundle function + mock := NewMockBundler() + called := false + mock.BundleFunc = func(artifact Artifact) error { + called = true + return nil + } + + // When calling Bundle + artifact := NewMockArtifact() + err := mock.Bundle(artifact) + + // Then the mock function should be called + if !called { + t.Error("Expected BundleFunc to be called") + } + if err != nil { + t.Errorf("Expected nil error, got %v", err) + } + }) + + t.Run("NotImplemented", func(t *testing.T) { + // Given a mock with no custom bundle function + mock := NewMockBundler() + + // When calling Bundle + artifact := NewMockArtifact() + err := mock.Bundle(artifact) + + // Then no error should be returned + if err != nil { + t.Errorf("Expected nil error, got %v", err) + } + }) +} diff --git a/pkg/bundler/shims.go b/pkg/bundler/shims.go index f78568d8f..3354d5e09 100644 --- a/pkg/bundler/shims.go +++ b/pkg/bundler/shims.go @@ -5,6 +5,7 @@ import ( "compress/gzip" "io" "os" + "path/filepath" "github.com/goccy/go-yaml" ) @@ -29,9 +30,12 @@ type TarWriter interface { type Shims struct { Stat func(name string) (os.FileInfo, error) Create func(name string) (io.WriteCloser, error) + ReadFile func(name string) ([]byte, error) + Walk func(root string, walkFn filepath.WalkFunc) error NewGzipWriter func(w io.Writer) *gzip.Writer NewTarWriter func(w io.Writer) TarWriter YamlUnmarshal func(data []byte, v any) error + FilepathRel func(basepath, targpath string) (string, error) YamlMarshal func(data any) ([]byte, error) } @@ -45,9 +49,12 @@ func NewShims() *Shims { Stat: os.Stat, // #nosec G304 - User-controlled output path is intentional for build artifact creation Create: func(name string) (io.WriteCloser, error) { return os.Create(name) }, + ReadFile: os.ReadFile, + Walk: filepath.Walk, NewGzipWriter: gzip.NewWriter, NewTarWriter: func(w io.Writer) TarWriter { return tar.NewWriter(w) }, YamlUnmarshal: yaml.Unmarshal, + FilepathRel: filepath.Rel, YamlMarshal: yaml.Marshal, } }