package publishers import ( "bytes" "context" "os" "path/filepath" "testing" "github.com/host-uk/core/pkg/io" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) func TestDockerPublisher_Name_Good(t *testing.T) { t.Run("returns docker", func(t *testing.T) { p := NewDockerPublisher() assert.Equal(t, "docker", p.Name()) }) } func TestDockerPublisher_ParseConfig_Good(t *testing.T) { p := NewDockerPublisher() t.Run("uses defaults when no extended config", func(t *testing.T) { pubCfg := PublisherConfig{Type: "docker"} relCfg := &mockReleaseConfig{repository: "owner/repo"} cfg := p.parseConfig(pubCfg, relCfg, "/project") assert.Equal(t, "ghcr.io", cfg.Registry) assert.Equal(t, "owner/repo", cfg.Image) assert.Equal(t, "/project/Dockerfile", cfg.Dockerfile) assert.Equal(t, []string{"linux/amd64", "linux/arm64"}, cfg.Platforms) assert.Equal(t, []string{"latest", "{{.Version}}"}, cfg.Tags) }) t.Run("parses extended config", func(t *testing.T) { pubCfg := PublisherConfig{ Type: "docker", Extended: map[string]any{ "registry": "docker.io", "image": "myorg/myimage", "dockerfile": "docker/Dockerfile.prod", "platforms": []any{"linux/amd64"}, "tags": []any{"latest", "stable", "{{.Version}}"}, "build_args": map[string]any{ "GO_VERSION": "1.21", }, }, } relCfg := &mockReleaseConfig{repository: "owner/repo"} cfg := p.parseConfig(pubCfg, relCfg, "/project") assert.Equal(t, "docker.io", cfg.Registry) assert.Equal(t, "myorg/myimage", cfg.Image) assert.Equal(t, "/project/docker/Dockerfile.prod", cfg.Dockerfile) assert.Equal(t, []string{"linux/amd64"}, cfg.Platforms) assert.Equal(t, []string{"latest", "stable", "{{.Version}}"}, cfg.Tags) assert.Equal(t, "1.21", cfg.BuildArgs["GO_VERSION"]) }) t.Run("handles absolute dockerfile path", func(t *testing.T) { pubCfg := PublisherConfig{ Type: "docker", Extended: map[string]any{ "dockerfile": "/absolute/path/Dockerfile", }, } relCfg := &mockReleaseConfig{repository: "owner/repo"} cfg := p.parseConfig(pubCfg, relCfg, "/project") assert.Equal(t, "/absolute/path/Dockerfile", cfg.Dockerfile) }) } func TestDockerPublisher_ResolveTags_Good(t *testing.T) { p := NewDockerPublisher() t.Run("resolves version template", func(t *testing.T) { tags := p.resolveTags([]string{"latest", "{{.Version}}", "stable"}, "v1.2.3") assert.Equal(t, []string{"latest", "v1.2.3", "stable"}, tags) }) t.Run("handles simple version syntax", func(t *testing.T) { tags := p.resolveTags([]string{"{{Version}}"}, "v1.0.0") assert.Equal(t, []string{"v1.0.0"}, tags) }) t.Run("handles no templates", func(t *testing.T) { tags := p.resolveTags([]string{"latest", "stable"}, "v1.2.3") assert.Equal(t, []string{"latest", "stable"}, tags) }) } func TestDockerPublisher_BuildFullTag_Good(t *testing.T) { p := NewDockerPublisher() tests := []struct { name string registry string image string tag string expected string }{ { name: "with registry", registry: "ghcr.io", image: "owner/repo", tag: "v1.0.0", expected: "ghcr.io/owner/repo:v1.0.0", }, { name: "without registry", registry: "", image: "myimage", tag: "latest", expected: "myimage:latest", }, { name: "docker hub", registry: "docker.io", image: "library/nginx", tag: "alpine", expected: "docker.io/library/nginx:alpine", }, } for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { tag := p.buildFullTag(tc.registry, tc.image, tc.tag) assert.Equal(t, tc.expected, tag) }) } } func TestDockerPublisher_BuildBuildxArgs_Good(t *testing.T) { p := NewDockerPublisher() t.Run("builds basic args", func(t *testing.T) { cfg := DockerConfig{ Registry: "ghcr.io", Image: "owner/repo", Dockerfile: "/project/Dockerfile", Platforms: []string{"linux/amd64", "linux/arm64"}, BuildArgs: make(map[string]string), } tags := []string{"latest", "v1.0.0"} args := p.buildBuildxArgs(cfg, tags, "v1.0.0") assert.Contains(t, args, "buildx") assert.Contains(t, args, "build") assert.Contains(t, args, "--platform") assert.Contains(t, args, "linux/amd64,linux/arm64") assert.Contains(t, args, "-t") assert.Contains(t, args, "ghcr.io/owner/repo:latest") assert.Contains(t, args, "ghcr.io/owner/repo:v1.0.0") assert.Contains(t, args, "-f") assert.Contains(t, args, "/project/Dockerfile") assert.Contains(t, args, "--push") assert.Contains(t, args, ".") }) t.Run("includes build args", func(t *testing.T) { cfg := DockerConfig{ Registry: "ghcr.io", Image: "owner/repo", Dockerfile: "/project/Dockerfile", Platforms: []string{"linux/amd64"}, BuildArgs: map[string]string{ "GO_VERSION": "1.21", "APP_NAME": "myapp", }, } tags := []string{"latest"} args := p.buildBuildxArgs(cfg, tags, "v1.0.0") assert.Contains(t, args, "--build-arg") // Check that build args are present (order may vary) foundGoVersion := false foundAppName := false foundVersion := false for i, arg := range args { if arg == "--build-arg" && i+1 < len(args) { if args[i+1] == "GO_VERSION=1.21" { foundGoVersion = true } if args[i+1] == "APP_NAME=myapp" { foundAppName = true } if args[i+1] == "VERSION=v1.0.0" { foundVersion = true } } } assert.True(t, foundGoVersion, "GO_VERSION build arg not found") assert.True(t, foundAppName, "APP_NAME build arg not found") assert.True(t, foundVersion, "VERSION build arg not found") }) t.Run("expands version in build args", func(t *testing.T) { cfg := DockerConfig{ Registry: "ghcr.io", Image: "owner/repo", Dockerfile: "/project/Dockerfile", Platforms: []string{"linux/amd64"}, BuildArgs: map[string]string{ "APP_VERSION": "{{.Version}}", }, } tags := []string{"latest"} args := p.buildBuildxArgs(cfg, tags, "v2.0.0") foundExpandedVersion := false for i, arg := range args { if arg == "--build-arg" && i+1 < len(args) { if args[i+1] == "APP_VERSION=v2.0.0" { foundExpandedVersion = true } } } assert.True(t, foundExpandedVersion, "APP_VERSION should be expanded to v2.0.0") }) } func TestDockerPublisher_Publish_Bad(t *testing.T) { if testing.Short() { t.Skip("skipping integration test in short mode") } p := NewDockerPublisher() t.Run("fails when dockerfile not found", func(t *testing.T) { release := &Release{ Version: "v1.0.0", ProjectDir: "/nonexistent", FS: io.Local, } pubCfg := PublisherConfig{ Type: "docker", Extended: map[string]any{ "dockerfile": "/nonexistent/Dockerfile", }, } relCfg := &mockReleaseConfig{repository: "owner/repo"} err := p.Publish(context.TODO(), release, pubCfg, relCfg, false) assert.Error(t, err) assert.Contains(t, err.Error(), "Dockerfile not found") }) } func TestDockerConfig_Defaults_Good(t *testing.T) { t.Run("has sensible defaults", func(t *testing.T) { p := NewDockerPublisher() pubCfg := PublisherConfig{Type: "docker"} relCfg := &mockReleaseConfig{repository: "owner/repo"} cfg := p.parseConfig(pubCfg, relCfg, "/project") // Verify defaults assert.Equal(t, "ghcr.io", cfg.Registry) assert.Equal(t, "owner/repo", cfg.Image) assert.Len(t, cfg.Platforms, 2) assert.Contains(t, cfg.Platforms, "linux/amd64") assert.Contains(t, cfg.Platforms, "linux/arm64") assert.Contains(t, cfg.Tags, "latest") }) } func TestDockerPublisher_DryRunPublish_Good(t *testing.T) { p := NewDockerPublisher() t.Run("outputs expected dry run information", func(t *testing.T) { oldStdout := os.Stdout r, w, _ := os.Pipe() os.Stdout = w release := &Release{ Version: "v1.0.0", ProjectDir: "/project", FS: io.Local, } cfg := DockerConfig{ Registry: "ghcr.io", Image: "owner/repo", Dockerfile: "/project/Dockerfile", Platforms: []string{"linux/amd64", "linux/arm64"}, Tags: []string{"latest", "{{.Version}}"}, BuildArgs: make(map[string]string), } err := p.dryRunPublish(release, cfg) _ = w.Close() var buf bytes.Buffer _, _ = buf.ReadFrom(r) os.Stdout = oldStdout require.NoError(t, err) output := buf.String() assert.Contains(t, output, "DRY RUN: Docker Build & Push") assert.Contains(t, output, "Version: v1.0.0") assert.Contains(t, output, "Registry: ghcr.io") assert.Contains(t, output, "Image: owner/repo") assert.Contains(t, output, "Dockerfile: /project/Dockerfile") assert.Contains(t, output, "Platforms: linux/amd64, linux/arm64") assert.Contains(t, output, "Tags to be applied:") assert.Contains(t, output, "ghcr.io/owner/repo:latest") assert.Contains(t, output, "ghcr.io/owner/repo:v1.0.0") assert.Contains(t, output, "Would execute command:") assert.Contains(t, output, "docker buildx build") assert.Contains(t, output, "END DRY RUN") }) t.Run("shows build args when present", func(t *testing.T) { oldStdout := os.Stdout r, w, _ := os.Pipe() os.Stdout = w release := &Release{ Version: "v1.0.0", ProjectDir: "/project", FS: io.Local, } cfg := DockerConfig{ Registry: "docker.io", Image: "myorg/myapp", Dockerfile: "/project/Dockerfile", Platforms: []string{"linux/amd64"}, Tags: []string{"latest"}, BuildArgs: map[string]string{ "GO_VERSION": "1.21", "APP_NAME": "myapp", }, } err := p.dryRunPublish(release, cfg) _ = w.Close() var buf bytes.Buffer _, _ = buf.ReadFrom(r) os.Stdout = oldStdout require.NoError(t, err) output := buf.String() assert.Contains(t, output, "Build arguments:") assert.Contains(t, output, "GO_VERSION=1.21") assert.Contains(t, output, "APP_NAME=myapp") }) t.Run("handles single platform", func(t *testing.T) { oldStdout := os.Stdout r, w, _ := os.Pipe() os.Stdout = w release := &Release{ Version: "v2.0.0", ProjectDir: "/project", FS: io.Local, } cfg := DockerConfig{ Registry: "ghcr.io", Image: "owner/repo", Dockerfile: "/project/Dockerfile.prod", Platforms: []string{"linux/amd64"}, Tags: []string{"stable"}, BuildArgs: make(map[string]string), } err := p.dryRunPublish(release, cfg) _ = w.Close() var buf bytes.Buffer _, _ = buf.ReadFrom(r) os.Stdout = oldStdout require.NoError(t, err) output := buf.String() assert.Contains(t, output, "Platforms: linux/amd64") assert.Contains(t, output, "ghcr.io/owner/repo:stable") }) } func TestDockerPublisher_ParseConfig_EdgeCases_Good(t *testing.T) { p := NewDockerPublisher() t.Run("handles nil release config", func(t *testing.T) { pubCfg := PublisherConfig{ Type: "docker", Extended: map[string]any{ "image": "custom/image", }, } cfg := p.parseConfig(pubCfg, nil, "/project") assert.Equal(t, "custom/image", cfg.Image) assert.Equal(t, "ghcr.io", cfg.Registry) }) t.Run("handles empty repository in release config", func(t *testing.T) { pubCfg := PublisherConfig{ Type: "docker", Extended: map[string]any{ "image": "fallback/image", }, } relCfg := &mockReleaseConfig{repository: ""} cfg := p.parseConfig(pubCfg, relCfg, "/project") assert.Equal(t, "fallback/image", cfg.Image) }) t.Run("extended config overrides repository image", func(t *testing.T) { pubCfg := PublisherConfig{ Type: "docker", Extended: map[string]any{ "image": "override/image", }, } relCfg := &mockReleaseConfig{repository: "original/repo"} cfg := p.parseConfig(pubCfg, relCfg, "/project") assert.Equal(t, "override/image", cfg.Image) }) t.Run("handles mixed build args types", func(t *testing.T) { pubCfg := PublisherConfig{ Type: "docker", Extended: map[string]any{ "build_args": map[string]any{ "STRING_ARG": "value", "INT_ARG": 123, // Non-string value should be skipped }, }, } relCfg := &mockReleaseConfig{repository: "owner/repo"} cfg := p.parseConfig(pubCfg, relCfg, "/project") assert.Equal(t, "value", cfg.BuildArgs["STRING_ARG"]) _, exists := cfg.BuildArgs["INT_ARG"] assert.False(t, exists, "non-string build arg should not be included") }) } func TestDockerPublisher_ResolveTags_EdgeCases_Good(t *testing.T) { p := NewDockerPublisher() t.Run("handles empty tags", func(t *testing.T) { tags := p.resolveTags([]string{}, "v1.0.0") assert.Empty(t, tags) }) t.Run("handles multiple version placeholders", func(t *testing.T) { tags := p.resolveTags([]string{"{{.Version}}", "prefix-{{.Version}}", "{{.Version}}-suffix"}, "v1.2.3") assert.Equal(t, []string{"v1.2.3", "prefix-v1.2.3", "v1.2.3-suffix"}, tags) }) t.Run("handles mixed template formats", func(t *testing.T) { tags := p.resolveTags([]string{"{{.Version}}", "{{Version}}", "latest"}, "v3.0.0") assert.Equal(t, []string{"v3.0.0", "v3.0.0", "latest"}, tags) }) } func TestDockerPublisher_BuildBuildxArgs_EdgeCases_Good(t *testing.T) { p := NewDockerPublisher() t.Run("handles empty platforms", func(t *testing.T) { cfg := DockerConfig{ Registry: "ghcr.io", Image: "owner/repo", Dockerfile: "/project/Dockerfile", Platforms: []string{}, BuildArgs: make(map[string]string), } args := p.buildBuildxArgs(cfg, []string{"latest"}, "v1.0.0") assert.Contains(t, args, "buildx") assert.Contains(t, args, "build") // Should not have --platform if empty foundPlatform := false for i, arg := range args { if arg == "--platform" { foundPlatform = true // Check the next arg exists (it shouldn't be empty) if i+1 < len(args) && args[i+1] == "" { t.Error("platform argument should not be empty string") } } } assert.False(t, foundPlatform, "should not include --platform when platforms is empty") }) t.Run("handles version expansion in build args", func(t *testing.T) { cfg := DockerConfig{ Registry: "ghcr.io", Image: "owner/repo", Dockerfile: "/Dockerfile", Platforms: []string{"linux/amd64"}, BuildArgs: map[string]string{ "VERSION": "{{.Version}}", "SIMPLE_VER": "{{Version}}", "STATIC_VALUE": "static", }, } args := p.buildBuildxArgs(cfg, []string{"latest"}, "v2.5.0") foundVersionArg := false foundSimpleArg := false foundStaticArg := false foundAutoVersion := false for i, arg := range args { if arg == "--build-arg" && i+1 < len(args) { switch args[i+1] { case "VERSION=v2.5.0": foundVersionArg = true case "SIMPLE_VER=v2.5.0": foundSimpleArg = true case "STATIC_VALUE=static": foundStaticArg = true } // Auto-added VERSION build arg if args[i+1] == "VERSION=v2.5.0" { foundAutoVersion = true } } } // Note: VERSION is both in BuildArgs and auto-added, so we just check it exists assert.True(t, foundVersionArg || foundAutoVersion, "VERSION build arg not found") assert.True(t, foundSimpleArg, "SIMPLE_VER build arg not expanded") assert.True(t, foundStaticArg, "STATIC_VALUE build arg not found") }) t.Run("handles empty registry", func(t *testing.T) { cfg := DockerConfig{ Registry: "", Image: "localimage", Dockerfile: "/Dockerfile", Platforms: []string{"linux/amd64"}, BuildArgs: make(map[string]string), } args := p.buildBuildxArgs(cfg, []string{"latest"}, "v1.0.0") assert.Contains(t, args, "-t") assert.Contains(t, args, "localimage:latest") }) } func TestDockerPublisher_Publish_DryRun_Good(t *testing.T) { // Skip if docker CLI is not available - dry run still validates docker is installed if err := validateDockerCli(); err != nil { t.Skip("skipping test: docker CLI not available") } p := NewDockerPublisher() t.Run("dry run succeeds with valid Dockerfile", func(t *testing.T) { // Create temp directory with Dockerfile tmpDir, err := os.MkdirTemp("", "docker-test") require.NoError(t, err) defer func() { _ = os.RemoveAll(tmpDir) }() dockerfilePath := filepath.Join(tmpDir, "Dockerfile") err = os.WriteFile(dockerfilePath, []byte("FROM alpine:latest\n"), 0644) require.NoError(t, err) oldStdout := os.Stdout r, w, _ := os.Pipe() os.Stdout = w release := &Release{ Version: "v1.0.0", ProjectDir: tmpDir, FS: io.Local, } pubCfg := PublisherConfig{Type: "docker"} relCfg := &mockReleaseConfig{repository: "owner/repo"} err = p.Publish(context.TODO(), release, pubCfg, relCfg, true) _ = w.Close() var buf bytes.Buffer _, _ = buf.ReadFrom(r) os.Stdout = oldStdout require.NoError(t, err) output := buf.String() assert.Contains(t, output, "DRY RUN: Docker Build & Push") }) t.Run("dry run uses custom dockerfile path", func(t *testing.T) { // Create temp directory with custom Dockerfile tmpDir, err := os.MkdirTemp("", "docker-test") require.NoError(t, err) defer func() { _ = os.RemoveAll(tmpDir) }() customDir := filepath.Join(tmpDir, "docker") err = os.MkdirAll(customDir, 0755) require.NoError(t, err) dockerfilePath := filepath.Join(customDir, "Dockerfile.prod") err = os.WriteFile(dockerfilePath, []byte("FROM alpine:latest\n"), 0644) require.NoError(t, err) oldStdout := os.Stdout r, w, _ := os.Pipe() os.Stdout = w release := &Release{ Version: "v1.0.0", ProjectDir: tmpDir, FS: io.Local, } pubCfg := PublisherConfig{ Type: "docker", Extended: map[string]any{ "dockerfile": "docker/Dockerfile.prod", }, } relCfg := &mockReleaseConfig{repository: "owner/repo"} err = p.Publish(context.TODO(), release, pubCfg, relCfg, true) _ = w.Close() var buf bytes.Buffer _, _ = buf.ReadFrom(r) os.Stdout = oldStdout require.NoError(t, err) output := buf.String() assert.Contains(t, output, "Dockerfile.prod") }) } func TestDockerPublisher_Publish_Validation_Bad(t *testing.T) { p := NewDockerPublisher() t.Run("fails when Dockerfile not found with docker installed", func(t *testing.T) { if err := validateDockerCli(); err != nil { t.Skip("skipping test: docker CLI not available") } release := &Release{ Version: "v1.0.0", ProjectDir: "/nonexistent/path", FS: io.Local, } pubCfg := PublisherConfig{Type: "docker"} relCfg := &mockReleaseConfig{repository: "owner/repo"} err := p.Publish(context.TODO(), release, pubCfg, relCfg, false) assert.Error(t, err) assert.Contains(t, err.Error(), "Dockerfile not found") }) t.Run("fails when docker CLI not available", func(t *testing.T) { if err := validateDockerCli(); err == nil { t.Skip("skipping test: docker CLI is available") } release := &Release{ Version: "v1.0.0", ProjectDir: "/tmp", FS: io.Local, } pubCfg := PublisherConfig{Type: "docker"} relCfg := &mockReleaseConfig{repository: "owner/repo"} err := p.Publish(context.TODO(), release, pubCfg, relCfg, false) assert.Error(t, err) assert.Contains(t, err.Error(), "docker CLI not found") }) } func TestValidateDockerCli_Good(t *testing.T) { t.Run("returns nil when docker is installed", func(t *testing.T) { err := validateDockerCli() if err != nil { // Docker is not installed, which is fine for this test assert.Contains(t, err.Error(), "docker CLI not found") } // If err is nil, docker is installed - that's OK }) } func TestDockerPublisher_Publish_WithCLI_Good(t *testing.T) { // These tests run only when docker CLI is available if err := validateDockerCli(); err != nil { t.Skip("skipping test: docker CLI not available") } p := NewDockerPublisher() t.Run("dry run succeeds with all config options", func(t *testing.T) { tmpDir, err := os.MkdirTemp("", "docker-test") require.NoError(t, err) defer func() { _ = os.RemoveAll(tmpDir) }() dockerfilePath := filepath.Join(tmpDir, "Dockerfile") err = os.WriteFile(dockerfilePath, []byte("FROM alpine:latest\n"), 0644) require.NoError(t, err) oldStdout := os.Stdout r, w, _ := os.Pipe() os.Stdout = w release := &Release{ Version: "v1.0.0", ProjectDir: tmpDir, FS: io.Local, } pubCfg := PublisherConfig{ Type: "docker", Extended: map[string]any{ "registry": "docker.io", "image": "myorg/myapp", "platforms": []any{"linux/amd64", "linux/arm64"}, "tags": []any{"latest", "{{.Version}}", "stable"}, "build_args": map[string]any{"GO_VERSION": "1.21"}, }, } relCfg := &mockReleaseConfig{repository: "owner/repo"} err = p.Publish(context.TODO(), release, pubCfg, relCfg, true) _ = w.Close() var buf bytes.Buffer _, _ = buf.ReadFrom(r) os.Stdout = oldStdout require.NoError(t, err) output := buf.String() assert.Contains(t, output, "DRY RUN: Docker Build & Push") assert.Contains(t, output, "docker.io") assert.Contains(t, output, "myorg/myapp") }) t.Run("dry run with nil relCfg uses extended image", func(t *testing.T) { tmpDir, err := os.MkdirTemp("", "docker-test") require.NoError(t, err) defer func() { _ = os.RemoveAll(tmpDir) }() dockerfilePath := filepath.Join(tmpDir, "Dockerfile") err = os.WriteFile(dockerfilePath, []byte("FROM alpine:latest\n"), 0644) require.NoError(t, err) oldStdout := os.Stdout r, w, _ := os.Pipe() os.Stdout = w release := &Release{ Version: "v1.0.0", ProjectDir: tmpDir, FS: io.Local, } pubCfg := PublisherConfig{ Type: "docker", Extended: map[string]any{ "image": "standalone/image", }, } err = p.Publish(context.TODO(), release, pubCfg, nil, true) // nil relCfg _ = w.Close() var buf bytes.Buffer _, _ = buf.ReadFrom(r) os.Stdout = oldStdout require.NoError(t, err) output := buf.String() assert.Contains(t, output, "standalone/image") }) t.Run("fails with non-existent Dockerfile in non-dry-run", func(t *testing.T) { tmpDir, err := os.MkdirTemp("", "docker-test") require.NoError(t, err) defer func() { _ = os.RemoveAll(tmpDir) }() // Don't create a Dockerfile release := &Release{ Version: "v1.0.0", ProjectDir: tmpDir, FS: io.Local, } pubCfg := PublisherConfig{Type: "docker"} relCfg := &mockReleaseConfig{repository: "owner/repo"} err = p.Publish(context.TODO(), release, pubCfg, relCfg, false) assert.Error(t, err) assert.Contains(t, err.Error(), "Dockerfile not found") }) }