diff --git a/TODO.md b/TODO.md index 69514da..a7767de 100644 --- a/TODO.md +++ b/TODO.md @@ -52,9 +52,9 @@ Dispatched from core/go orchestration. Pick up tasks in order. ## Phase 3: Release Pipeline Testing -- [ ] **Publisher integration tests** — Mock GitHub API for release creation, Docker registry for image push, Homebrew tap for formula update. Verify dry-run mode produces correct output without side effects. -- [ ] **SDK generation tests** — Generate TypeScript/Go/Python clients from a test OpenAPI spec. Verify output compiles/type-checks. -- [ ] **Breaking change detection** — Test oasdiff integration: modify a spec with breaking change, verify detection and failure mode. +- [x] **Publisher integration tests** — Added `integration_test.go` (48 tests): GitHub dry-run/command-building/repo-detection/artifact-upload, Docker dry-run/buildx-args/config-parsing, Homebrew dry-run/formula-generation/class-naming, Scoop dry-run/manifest, AUR dry-run/PKGBUILD/SRCINFO, Chocolatey dry-run/nuspec, npm dry-run/package.json, LinuxKit dry-run multi-format/platform. Cross-publisher: name uniqueness, nil relCfg, checksum mapping, interface compliance. Commit `032d862`. +- [x] **SDK generation tests** — Added `generation_test.go` (38 tests): SDK orchestration (Generate iterates languages, output directory, no-spec error, unknown language), generator registry (register/get/overwrite/languages), generator interface compliance (language identifiers, install instructions, Available safety), SDK config (defaults, SetVersion, nil config), spec detection priority (configured > common > Scramble, all 8 common paths). Commit `032d862`. +- [x] **Breaking change detection** — Added `breaking_test.go` (30 tests): oasdiff integration for add-endpoint (non-breaking), remove-endpoint (breaking), add-required-param (breaking), add-optional-param (non-breaking), change-response-type (breaking), remove-HTTP-method (breaking), identical-specs, multiple-breaking-changes, JSON spec support. Error handling: non-existent base/revision, invalid YAML. DiffExitCode (0/1/2), DiffResult summary/human-readable changes. Commit `032d862`. ## Phase 4: DevKit Expansion diff --git a/release/publishers/integration_test.go b/release/publishers/integration_test.go new file mode 100644 index 0000000..2c7e42d --- /dev/null +++ b/release/publishers/integration_test.go @@ -0,0 +1,989 @@ +package publishers + +import ( + "bytes" + "context" + "os" + "os/exec" + "path/filepath" + "testing" + + "forge.lthn.ai/core/go-devops/build" + "forge.lthn.ai/core/go/pkg/io" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// --- GitHub Publisher Integration Tests --- + +func TestGitHubPublisher_Integration_DryRunNoSideEffects_Good(t *testing.T) { + p := NewGitHubPublisher() + + t.Run("dry run creates no files on disk", func(t *testing.T) { + tmpDir := t.TempDir() + release := &Release{ + Version: "v1.0.0", + Changelog: "## v1.0.0\n\n- feat: initial release", + ProjectDir: tmpDir, + FS: io.Local, + Artifacts: []build.Artifact{ + {Path: filepath.Join(tmpDir, "app-linux-amd64.tar.gz")}, + {Path: filepath.Join(tmpDir, "app-darwin-arm64.tar.gz")}, + {Path: filepath.Join(tmpDir, "CHECKSUMS.txt")}, + }, + } + pubCfg := PublisherConfig{ + Type: "github", + Draft: true, + Prerelease: true, + } + relCfg := &mockReleaseConfig{repository: "test-org/test-repo", projectName: "testapp"} + + // Capture stdout + oldStdout := os.Stdout + r, w, _ := os.Pipe() + os.Stdout = w + + err := p.Publish(context.Background(), release, pubCfg, relCfg, true) + + _ = w.Close() + var buf bytes.Buffer + _, _ = buf.ReadFrom(r) + os.Stdout = oldStdout + + require.NoError(t, err) + output := buf.String() + + // Verify dry run output contains expected information + assert.Contains(t, output, "DRY RUN: GitHub Release") + assert.Contains(t, output, "Repository: test-org/test-repo") + assert.Contains(t, output, "Version: v1.0.0") + assert.Contains(t, output, "Draft: true") + assert.Contains(t, output, "Prerelease: true") + assert.Contains(t, output, "Would upload artifacts:") + assert.Contains(t, output, "app-linux-amd64.tar.gz") + assert.Contains(t, output, "app-darwin-arm64.tar.gz") + assert.Contains(t, output, "CHECKSUMS.txt") + assert.Contains(t, output, "gh release create") + assert.Contains(t, output, "--draft") + assert.Contains(t, output, "--prerelease") + + // Verify no files were created in the temp directory + entries, err := os.ReadDir(tmpDir) + require.NoError(t, err) + assert.Empty(t, entries, "dry run should not create any files") + }) + + t.Run("dry run builds correct gh CLI command for standard release", func(t *testing.T) { + release := &Release{ + Version: "v2.3.0", + Changelog: "## v2.3.0\n\n### Features\n\n- new feature", + ProjectDir: "/tmp", + FS: io.Local, + Artifacts: []build.Artifact{ + {Path: "/dist/app-linux-amd64.tar.gz"}, + }, + } + pubCfg := PublisherConfig{ + Type: "github", + Draft: false, + Prerelease: false, + } + + args := p.buildCreateArgs(release, pubCfg, "owner/repo") + + // Verify exact argument structure + assert.Equal(t, "release", args[0]) + assert.Equal(t, "create", args[1]) + assert.Equal(t, "v2.3.0", args[2]) + + // Should have --repo + repoIdx := indexOf(args, "--repo") + assert.Greater(t, repoIdx, -1) + assert.Equal(t, "owner/repo", args[repoIdx+1]) + + // Should have --title + titleIdx := indexOf(args, "--title") + assert.Greater(t, titleIdx, -1) + assert.Equal(t, "v2.3.0", args[titleIdx+1]) + + // Should have --notes (since changelog is non-empty) + assert.Contains(t, args, "--notes") + + // Should NOT have --draft or --prerelease + assert.NotContains(t, args, "--draft") + assert.NotContains(t, args, "--prerelease") + }) + + t.Run("dry run uses generate-notes when changelog empty", func(t *testing.T) { + release := &Release{ + Version: "v1.0.0", + Changelog: "", + ProjectDir: "/tmp", + FS: io.Local, + } + pubCfg := PublisherConfig{Type: "github"} + + args := p.buildCreateArgs(release, pubCfg, "owner/repo") + + assert.Contains(t, args, "--generate-notes") + assert.NotContains(t, args, "--notes") + }) +} + +func TestGitHubPublisher_Integration_RepositoryDetection_Good(t *testing.T) { + p := NewGitHubPublisher() + + t.Run("uses relCfg repository when provided", func(t *testing.T) { + oldStdout := os.Stdout + r, w, _ := os.Pipe() + os.Stdout = w + + release := &Release{ + Version: "v1.0.0", + Changelog: "Changes", + ProjectDir: "/tmp", + FS: io.Local, + } + pubCfg := PublisherConfig{Type: "github"} + relCfg := &mockReleaseConfig{repository: "explicit/repo"} + + err := p.Publish(context.Background(), release, pubCfg, relCfg, true) + + _ = w.Close() + var buf bytes.Buffer + _, _ = buf.ReadFrom(r) + os.Stdout = oldStdout + + require.NoError(t, err) + assert.Contains(t, buf.String(), "Repository: explicit/repo") + }) + + t.Run("detects repository from git remote when relCfg empty", func(t *testing.T) { + tmpDir := t.TempDir() + + cmd := exec.Command("git", "init") + cmd.Dir = tmpDir + require.NoError(t, cmd.Run()) + + cmd = exec.Command("git", "remote", "add", "origin", "https://github.com/detected/from-git.git") + cmd.Dir = tmpDir + require.NoError(t, cmd.Run()) + + oldStdout := os.Stdout + r, w, _ := os.Pipe() + os.Stdout = w + + release := &Release{ + Version: "v1.0.0", + Changelog: "Changes", + ProjectDir: tmpDir, + FS: io.Local, + } + pubCfg := PublisherConfig{Type: "github"} + relCfg := &mockReleaseConfig{repository: ""} + + err := p.Publish(context.Background(), release, pubCfg, relCfg, true) + + _ = w.Close() + var buf bytes.Buffer + _, _ = buf.ReadFrom(r) + os.Stdout = oldStdout + + require.NoError(t, err) + assert.Contains(t, buf.String(), "Repository: detected/from-git") + }) + + t.Run("fails when no repository available", func(t *testing.T) { + tmpDir := t.TempDir() + + release := &Release{ + Version: "v1.0.0", + Changelog: "Changes", + ProjectDir: tmpDir, + FS: io.Local, + } + pubCfg := PublisherConfig{Type: "github"} + relCfg := &mockReleaseConfig{repository: ""} + + err := p.Publish(context.Background(), release, pubCfg, relCfg, true) + assert.Error(t, err) + assert.Contains(t, err.Error(), "could not determine repository") + }) +} + +func TestGitHubPublisher_Integration_ArtifactUpload_Good(t *testing.T) { + p := NewGitHubPublisher() + + t.Run("dry run lists all artifact types", func(t *testing.T) { + oldStdout := os.Stdout + r, w, _ := os.Pipe() + os.Stdout = w + + release := &Release{ + Version: "v1.0.0", + Changelog: "Release notes", + ProjectDir: "/tmp", + FS: io.Local, + Artifacts: []build.Artifact{ + {Path: "/dist/app-linux-amd64.tar.gz", Checksum: "abc123"}, + {Path: "/dist/app-darwin-arm64.tar.gz", Checksum: "def456"}, + {Path: "/dist/app-windows-amd64.zip", Checksum: "ghi789"}, + {Path: "/dist/CHECKSUMS.txt"}, + {Path: "/dist/app-linux-amd64.tar.gz.sig"}, + }, + } + pubCfg := PublisherConfig{Type: "github"} + + err := p.dryRunPublish(release, pubCfg, "owner/repo") + + _ = w.Close() + var buf bytes.Buffer + _, _ = buf.ReadFrom(r) + os.Stdout = oldStdout + + require.NoError(t, err) + output := buf.String() + + assert.Contains(t, output, "Would upload artifacts:") + assert.Contains(t, output, "app-linux-amd64.tar.gz") + assert.Contains(t, output, "app-darwin-arm64.tar.gz") + assert.Contains(t, output, "app-windows-amd64.zip") + assert.Contains(t, output, "CHECKSUMS.txt") + assert.Contains(t, output, "app-linux-amd64.tar.gz.sig") + }) + + t.Run("executePublish appends artifact paths to gh command", func(t *testing.T) { + release := &Release{ + Version: "v1.0.0", + Changelog: "Changes", + ProjectDir: "/tmp", + FS: io.Local, + Artifacts: []build.Artifact{ + {Path: "/dist/file1.tar.gz"}, + {Path: "/dist/file2.zip"}, + }, + } + pubCfg := PublisherConfig{Type: "github"} + + args := p.buildCreateArgs(release, pubCfg, "owner/repo") + + // The executePublish method appends artifact paths after these base args + for _, a := range release.Artifacts { + args = append(args, a.Path) + } + + // Verify artifacts are at end of args + assert.Equal(t, "/dist/file1.tar.gz", args[len(args)-2]) + assert.Equal(t, "/dist/file2.zip", args[len(args)-1]) + }) +} + +// --- Docker Publisher Integration Tests --- + +func TestDockerPublisher_Integration_DryRunNoSideEffects_Good(t *testing.T) { + if err := validateDockerCli(); err != nil { + t.Skip("skipping: docker CLI not available") + } + + p := NewDockerPublisher() + + t.Run("dry run creates no images or containers", func(t *testing.T) { + tmpDir := t.TempDir() + + // Create a Dockerfile + err := os.WriteFile(filepath.Join(tmpDir, "Dockerfile"), []byte("FROM alpine:latest\n"), 0644) + require.NoError(t, err) + + oldStdout := os.Stdout + r, w, _ := os.Pipe() + os.Stdout = w + + release := &Release{ + Version: "v1.2.3", + ProjectDir: tmpDir, + FS: io.Local, + } + pubCfg := PublisherConfig{ + Type: "docker", + Extended: map[string]any{ + "registry": "ghcr.io", + "image": "test-org/test-app", + "platforms": []any{"linux/amd64", "linux/arm64"}, + "tags": []any{"latest", "{{.Version}}", "stable"}, + "build_args": map[string]any{ + "APP_VERSION": "{{.Version}}", + "GO_VERSION": "1.21", + }, + }, + } + relCfg := &mockReleaseConfig{repository: "test-org/test-app"} + + err = p.Publish(context.Background(), release, pubCfg, relCfg, true) + + _ = w.Close() + var buf bytes.Buffer + _, _ = buf.ReadFrom(r) + os.Stdout = oldStdout + + require.NoError(t, err) + output := buf.String() + + // Verify dry run output + assert.Contains(t, output, "DRY RUN: Docker Build & Push") + assert.Contains(t, output, "Version: v1.2.3") + assert.Contains(t, output, "Registry: ghcr.io") + assert.Contains(t, output, "Image: test-org/test-app") + assert.Contains(t, output, "Platforms: linux/amd64, linux/arm64") + + // Verify resolved tags + assert.Contains(t, output, "ghcr.io/test-org/test-app:latest") + assert.Contains(t, output, "ghcr.io/test-org/test-app:v1.2.3") + assert.Contains(t, output, "ghcr.io/test-org/test-app:stable") + + // Verify build args shown + assert.Contains(t, output, "Build arguments:") + assert.Contains(t, output, "GO_VERSION=1.21") + + // Verify command + assert.Contains(t, output, "docker buildx build") + assert.Contains(t, output, "END DRY RUN") + }) + + t.Run("dry run produces correct buildx command for multi-platform", func(t *testing.T) { + cfg := DockerConfig{ + Registry: "ghcr.io", + Image: "org/app", + Dockerfile: "/project/Dockerfile", + Platforms: []string{"linux/amd64", "linux/arm64", "linux/arm/v7"}, + Tags: []string{"latest", "{{.Version}}"}, + BuildArgs: map[string]string{ + "CUSTOM_ARG": "custom_value", + }, + } + tags := p.resolveTags(cfg.Tags, "v3.0.0") + args := p.buildBuildxArgs(cfg, tags, "v3.0.0") + + // Verify multi-platform string + foundPlatform := false + for i, arg := range args { + if arg == "--platform" && i+1 < len(args) { + foundPlatform = true + assert.Equal(t, "linux/amd64,linux/arm64,linux/arm/v7", args[i+1]) + } + } + assert.True(t, foundPlatform, "should have --platform flag") + + // Verify tags + assert.Contains(t, args, "ghcr.io/org/app:latest") + assert.Contains(t, args, "ghcr.io/org/app:v3.0.0") + + // Verify build args + foundCustom := false + foundVersion := false + for i, arg := range args { + if arg == "--build-arg" && i+1 < len(args) { + if args[i+1] == "CUSTOM_ARG=custom_value" { + foundCustom = true + } + if args[i+1] == "VERSION=v3.0.0" { + foundVersion = true + } + } + } + assert.True(t, foundCustom, "CUSTOM_ARG build arg not found") + assert.True(t, foundVersion, "auto-added VERSION build arg not found") + + // Verify push flag + assert.Contains(t, args, "--push") + }) +} + +func TestDockerPublisher_Integration_ConfigParsing_Good(t *testing.T) { + p := NewDockerPublisher() + + t.Run("full config round-trip from PublisherConfig to DockerConfig", func(t *testing.T) { + pubCfg := PublisherConfig{ + Type: "docker", + Extended: map[string]any{ + "registry": "registry.example.com", + "image": "myteam/myservice", + "dockerfile": "deploy/Dockerfile.prod", + "platforms": []any{"linux/amd64"}, + "tags": []any{"{{.Version}}", "latest", "release-{{.Version}}"}, + "build_args": map[string]any{ + "BUILD_ENV": "production", + "VERSION": "{{.Version}}", + }, + }, + } + relCfg := &mockReleaseConfig{repository: "fallback/repo"} + + cfg := p.parseConfig(pubCfg, relCfg, "/myproject") + + assert.Equal(t, "registry.example.com", cfg.Registry) + assert.Equal(t, "myteam/myservice", cfg.Image) + assert.Equal(t, "/myproject/deploy/Dockerfile.prod", cfg.Dockerfile) + assert.Equal(t, []string{"linux/amd64"}, cfg.Platforms) + assert.Equal(t, []string{"{{.Version}}", "latest", "release-{{.Version}}"}, cfg.Tags) + assert.Equal(t, "production", cfg.BuildArgs["BUILD_ENV"]) + assert.Equal(t, "{{.Version}}", cfg.BuildArgs["VERSION"]) + + // Verify tag resolution + resolved := p.resolveTags(cfg.Tags, "v2.5.0") + assert.Equal(t, []string{"v2.5.0", "latest", "release-v2.5.0"}, resolved) + }) +} + +// --- Homebrew Publisher Integration Tests --- + +func TestHomebrewPublisher_Integration_DryRunNoSideEffects_Good(t *testing.T) { + p := NewHomebrewPublisher() + + t.Run("dry run generates formula without writing files", func(t *testing.T) { + tmpDir := t.TempDir() + + oldStdout := os.Stdout + r, w, _ := os.Pipe() + os.Stdout = w + + release := &Release{ + Version: "v2.1.0", + ProjectDir: tmpDir, + FS: io.Local, + Artifacts: []build.Artifact{ + {Path: "/dist/myapp-darwin-amd64.tar.gz", Checksum: "sha256_darwin_amd64"}, + {Path: "/dist/myapp-darwin-arm64.tar.gz", Checksum: "sha256_darwin_arm64"}, + {Path: "/dist/myapp-linux-amd64.tar.gz", Checksum: "sha256_linux_amd64"}, + {Path: "/dist/myapp-linux-arm64.tar.gz", Checksum: "sha256_linux_arm64"}, + }, + } + pubCfg := PublisherConfig{ + Type: "homebrew", + Extended: map[string]any{ + "tap": "test-org/homebrew-tap", + "formula": "my-cli", + }, + } + relCfg := &mockReleaseConfig{repository: "test-org/my-cli", projectName: "my-cli"} + + err := p.Publish(context.Background(), release, pubCfg, relCfg, true) + + _ = w.Close() + var buf bytes.Buffer + _, _ = buf.ReadFrom(r) + os.Stdout = oldStdout + + require.NoError(t, err) + output := buf.String() + + // Verify dry run output + assert.Contains(t, output, "DRY RUN: Homebrew Publish") + assert.Contains(t, output, "Formula: MyCli") + assert.Contains(t, output, "Version: 2.1.0") + assert.Contains(t, output, "Tap: test-org/homebrew-tap") + assert.Contains(t, output, "Repository: test-org/my-cli") + + // Verify generated formula content + assert.Contains(t, output, "class MyCli < Formula") + assert.Contains(t, output, `version "2.1.0"`) + assert.Contains(t, output, "sha256_darwin_amd64") + assert.Contains(t, output, "sha256_darwin_arm64") + assert.Contains(t, output, "sha256_linux_amd64") + assert.Contains(t, output, "sha256_linux_arm64") + + assert.Contains(t, output, "Would commit to tap: test-org/homebrew-tap") + + // Verify no files created + entries, err := os.ReadDir(tmpDir) + require.NoError(t, err) + assert.Empty(t, entries, "dry run should not create any files") + }) + + t.Run("dry run with official config shows output path", 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, + Artifacts: []build.Artifact{}, + } + pubCfg := PublisherConfig{ + Type: "homebrew", + Extended: map[string]any{ + "official": map[string]any{ + "enabled": true, + "output": "dist/homebrew-official", + }, + }, + } + relCfg := &mockReleaseConfig{repository: "owner/repo", projectName: "repo"} + + err := p.Publish(context.Background(), 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, "Would write files for official PR to: dist/homebrew-official") + }) +} + +func TestHomebrewPublisher_Integration_FormulaGeneration_Good(t *testing.T) { + p := NewHomebrewPublisher() + + t.Run("generated formula contains correct Ruby class structure", func(t *testing.T) { + data := homebrewTemplateData{ + FormulaClass: "CoreCli", + Description: "Core CLI tool", + Repository: "host-uk/core-cli", + Version: "3.0.0", + License: "MIT", + BinaryName: "core", + Checksums: ChecksumMap{ + DarwinAmd64: "a1b2c3d4e5f6", + DarwinArm64: "f6e5d4c3b2a1", + LinuxAmd64: "112233445566", + LinuxArm64: "665544332211", + }, + } + + formula, err := p.renderTemplate(io.Local, "templates/homebrew/formula.rb.tmpl", data) + require.NoError(t, err) + + // Verify class definition + assert.Contains(t, formula, "class CoreCli < Formula") + + // Verify metadata + assert.Contains(t, formula, `desc "Core CLI tool"`) + assert.Contains(t, formula, `version "3.0.0"`) + assert.Contains(t, formula, `license "MIT"`) + + // Verify checksums for all platforms + assert.Contains(t, formula, "a1b2c3d4e5f6") + assert.Contains(t, formula, "f6e5d4c3b2a1") + assert.Contains(t, formula, "112233445566") + assert.Contains(t, formula, "665544332211") + + // Verify binary install + assert.Contains(t, formula, `bin.install "core"`) + }) + + t.Run("toFormulaClass handles various naming patterns", func(t *testing.T) { + tests := []struct { + input string + expected string + }{ + {"my-app", "MyApp"}, + {"core", "Core"}, + {"go-devops", "GoDevops"}, + {"a-b-c-d", "ABCD"}, + {"single", "Single"}, + {"UPPER", "UPPER"}, + } + + for _, tc := range tests { + t.Run(tc.input, func(t *testing.T) { + result := toFormulaClass(tc.input) + assert.Equal(t, tc.expected, result) + }) + } + }) +} + +// --- Scoop Publisher Integration Tests --- + +func TestScoopPublisher_Integration_DryRunNoSideEffects_Good(t *testing.T) { + p := NewScoopPublisher() + + t.Run("dry run generates manifest without writing files", func(t *testing.T) { + tmpDir := t.TempDir() + + oldStdout := os.Stdout + r, w, _ := os.Pipe() + os.Stdout = w + + release := &Release{ + Version: "v1.5.0", + ProjectDir: tmpDir, + FS: io.Local, + Artifacts: []build.Artifact{ + {Path: "/dist/myapp-windows-amd64.zip", Checksum: "win64hash"}, + {Path: "/dist/myapp-windows-arm64.zip", Checksum: "winarm64hash"}, + }, + } + pubCfg := PublisherConfig{ + Type: "scoop", + Extended: map[string]any{ + "bucket": "test-org/scoop-bucket", + }, + } + relCfg := &mockReleaseConfig{repository: "test-org/myapp", projectName: "myapp"} + + err := p.Publish(context.Background(), 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: Scoop Publish") + assert.Contains(t, output, "Package: myapp") + assert.Contains(t, output, "Version: 1.5.0") + assert.Contains(t, output, "Bucket: test-org/scoop-bucket") + assert.Contains(t, output, "Generated manifest.json:") + assert.Contains(t, output, `"version": "1.5.0"`) + assert.Contains(t, output, "Would commit to bucket: test-org/scoop-bucket") + + // Verify no files created + entries, err := os.ReadDir(tmpDir) + require.NoError(t, err) + assert.Empty(t, entries) + }) +} + +// --- AUR Publisher Integration Tests --- + +func TestAURPublisher_Integration_DryRunNoSideEffects_Good(t *testing.T) { + p := NewAURPublisher() + + t.Run("dry run generates PKGBUILD and SRCINFO without writing files", func(t *testing.T) { + tmpDir := t.TempDir() + + oldStdout := os.Stdout + r, w, _ := os.Pipe() + os.Stdout = w + + release := &Release{ + Version: "v2.0.0", + ProjectDir: tmpDir, + FS: io.Local, + Artifacts: []build.Artifact{ + {Path: "/dist/myapp-linux-amd64.tar.gz", Checksum: "amd64hash"}, + {Path: "/dist/myapp-linux-arm64.tar.gz", Checksum: "arm64hash"}, + }, + } + pubCfg := PublisherConfig{ + Type: "aur", + Extended: map[string]any{ + "maintainer": "Test User ", + }, + } + relCfg := &mockReleaseConfig{repository: "test-org/myapp", projectName: "myapp"} + + err := p.Publish(context.Background(), 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: AUR Publish") + assert.Contains(t, output, "Package: myapp-bin") + assert.Contains(t, output, "Version: 2.0.0") + assert.Contains(t, output, "Maintainer: Test User ") + assert.Contains(t, output, "Generated PKGBUILD:") + assert.Contains(t, output, "pkgname=myapp-bin") + assert.Contains(t, output, "pkgver=2.0.0") + assert.Contains(t, output, "Generated .SRCINFO:") + assert.Contains(t, output, "pkgbase = myapp-bin") + assert.Contains(t, output, "Would push to AUR:") + + // Verify no files created + entries, err := os.ReadDir(tmpDir) + require.NoError(t, err) + assert.Empty(t, entries) + }) +} + +// --- Chocolatey Publisher Integration Tests --- + +func TestChocolateyPublisher_Integration_DryRunNoSideEffects_Good(t *testing.T) { + p := NewChocolateyPublisher() + + t.Run("dry run generates nuspec and install script without side effects", func(t *testing.T) { + tmpDir := t.TempDir() + + oldStdout := os.Stdout + r, w, _ := os.Pipe() + os.Stdout = w + + release := &Release{ + Version: "v1.0.0", + ProjectDir: tmpDir, + FS: io.Local, + Artifacts: []build.Artifact{ + {Path: "/dist/myapp-windows-amd64.zip", Checksum: "choco_hash"}, + }, + } + pubCfg := PublisherConfig{ + Type: "chocolatey", + Extended: map[string]any{ + "package": "my-cli-tool", + "push": false, + }, + } + relCfg := &mockReleaseConfig{repository: "owner/my-cli-tool", projectName: "my-cli-tool"} + + err := p.Publish(context.Background(), 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: Chocolatey Publish") + assert.Contains(t, output, "Package: my-cli-tool") + assert.Contains(t, output, "Version: 1.0.0") + assert.Contains(t, output, "Push: false") + assert.Contains(t, output, "Generated package.nuspec:") + assert.Contains(t, output, "my-cli-tool") + assert.Contains(t, output, "Generated chocolateyinstall.ps1:") + assert.Contains(t, output, "Would generate package files only") + + // Verify no files created + entries, err := os.ReadDir(tmpDir) + require.NoError(t, err) + assert.Empty(t, entries) + }) +} + +// --- npm Publisher Integration Tests --- + +func TestNpmPublisher_Integration_DryRunNoSideEffects_Good(t *testing.T) { + p := NewNpmPublisher() + + t.Run("dry run generates package.json without writing files or publishing", func(t *testing.T) { + tmpDir := t.TempDir() + + oldStdout := os.Stdout + r, w, _ := os.Pipe() + os.Stdout = w + + release := &Release{ + Version: "v3.0.0", + ProjectDir: tmpDir, + FS: io.Local, + } + pubCfg := PublisherConfig{ + Type: "npm", + Extended: map[string]any{ + "package": "@test-org/my-cli", + "access": "public", + }, + } + relCfg := &mockReleaseConfig{repository: "test-org/my-cli", projectName: "my-cli"} + + err := p.Publish(context.Background(), 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: npm Publish") + assert.Contains(t, output, "Package: @test-org/my-cli") + assert.Contains(t, output, "Version: 3.0.0") + assert.Contains(t, output, "Access: public") + assert.Contains(t, output, "Generated package.json:") + assert.Contains(t, output, `"name": "@test-org/my-cli"`) + assert.Contains(t, output, `"version": "3.0.0"`) + assert.Contains(t, output, "Would run: npm publish --access public") + + // Verify no files created + entries, err := os.ReadDir(tmpDir) + require.NoError(t, err) + assert.Empty(t, entries) + }) +} + +// --- LinuxKit Publisher Integration Tests --- + +func TestLinuxKitPublisher_Integration_DryRunNoSideEffects_Good(t *testing.T) { + if err := validateLinuxKitCli(); err != nil { + t.Skip("skipping: linuxkit CLI not available") + } + + p := NewLinuxKitPublisher() + + t.Run("dry run with multiple formats and platforms", func(t *testing.T) { + tmpDir := t.TempDir() + + // Create config file + configDir := filepath.Join(tmpDir, ".core", "linuxkit") + require.NoError(t, os.MkdirAll(configDir, 0755)) + require.NoError(t, os.WriteFile(filepath.Join(configDir, "server.yml"), []byte("kernel:\n image: test\n"), 0644)) + + oldStdout := os.Stdout + r, w, _ := os.Pipe() + os.Stdout = w + + release := &Release{ + Version: "v1.0.0", + ProjectDir: tmpDir, + FS: io.Local, + } + pubCfg := PublisherConfig{ + Type: "linuxkit", + Extended: map[string]any{ + "formats": []any{"iso", "qcow2", "docker"}, + "platforms": []any{"linux/amd64", "linux/arm64"}, + }, + } + relCfg := &mockReleaseConfig{repository: "test-org/my-os"} + + err := p.Publish(context.Background(), 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: LinuxKit Build & Publish") + assert.Contains(t, output, "Formats: iso, qcow2, docker") + assert.Contains(t, output, "Platforms: linux/amd64, linux/arm64") + + // Verify all combinations listed + assert.Contains(t, output, "linuxkit-1.0.0-amd64.iso") + assert.Contains(t, output, "linuxkit-1.0.0-amd64.qcow2") + assert.Contains(t, output, "linuxkit-1.0.0-amd64.docker.tar") + assert.Contains(t, output, "linuxkit-1.0.0-arm64.iso") + assert.Contains(t, output, "linuxkit-1.0.0-arm64.qcow2") + assert.Contains(t, output, "linuxkit-1.0.0-arm64.docker.tar") + + // Verify docker usage hint + assert.Contains(t, output, "docker load") + + // Verify no files created in dist + distDir := filepath.Join(tmpDir, "dist") + _, err = os.Stat(distDir) + assert.True(t, os.IsNotExist(err), "dry run should not create dist directory") + }) +} + +// --- Cross-Publisher Integration Tests --- + +func TestAllPublishers_Integration_NameUniqueness_Good(t *testing.T) { + t.Run("all publishers have unique names", func(t *testing.T) { + publishers := []Publisher{ + NewGitHubPublisher(), + NewDockerPublisher(), + NewHomebrewPublisher(), + NewNpmPublisher(), + NewScoopPublisher(), + NewAURPublisher(), + NewChocolateyPublisher(), + NewLinuxKitPublisher(), + } + + names := make(map[string]bool) + for _, pub := range publishers { + name := pub.Name() + assert.False(t, names[name], "duplicate publisher name: %s", name) + names[name] = true + assert.NotEmpty(t, name, "publisher name should not be empty") + } + + assert.Len(t, names, 8, "should have 8 unique publishers") + }) +} + +func TestAllPublishers_Integration_NilRelCfg_Good(t *testing.T) { + t.Run("github handles nil relCfg with git repo", func(t *testing.T) { + tmpDir := t.TempDir() + + cmd := exec.Command("git", "init") + cmd.Dir = tmpDir + require.NoError(t, cmd.Run()) + + cmd = exec.Command("git", "remote", "add", "origin", "git@github.com:niltest/repo.git") + cmd.Dir = tmpDir + require.NoError(t, cmd.Run()) + + oldStdout := os.Stdout + r, w, _ := os.Pipe() + os.Stdout = w + + release := &Release{ + Version: "v1.0.0", + Changelog: "Changes", + ProjectDir: tmpDir, + FS: io.Local, + } + pubCfg := PublisherConfig{Type: "github"} + + err := NewGitHubPublisher().Publish(context.Background(), release, pubCfg, nil, true) + + _ = w.Close() + var buf bytes.Buffer + _, _ = buf.ReadFrom(r) + os.Stdout = oldStdout + + require.NoError(t, err) + assert.Contains(t, buf.String(), "niltest/repo") + }) +} + +func TestBuildChecksumMap_Integration_Good(t *testing.T) { + t.Run("maps all platforms correctly from realistic artifacts", func(t *testing.T) { + artifacts := []build.Artifact{ + {Path: "/dist/core-v1.0.0-darwin-amd64.tar.gz", Checksum: "da64"}, + {Path: "/dist/core-v1.0.0-darwin-arm64.tar.gz", Checksum: "da65"}, + {Path: "/dist/core-v1.0.0-linux-amd64.tar.gz", Checksum: "la64"}, + {Path: "/dist/core-v1.0.0-linux-arm64.tar.gz", Checksum: "la65"}, + {Path: "/dist/core-v1.0.0-windows-amd64.zip", Checksum: "wa64"}, + {Path: "/dist/core-v1.0.0-windows-arm64.zip", Checksum: "wa65"}, + {Path: "/dist/CHECKSUMS.txt"}, // No checksum for checksum file + } + + checksums := buildChecksumMap(artifacts) + + assert.Equal(t, "da64", checksums.DarwinAmd64) + assert.Equal(t, "da65", checksums.DarwinArm64) + assert.Equal(t, "la64", checksums.LinuxAmd64) + assert.Equal(t, "la65", checksums.LinuxArm64) + assert.Equal(t, "wa64", checksums.WindowsAmd64) + assert.Equal(t, "wa65", checksums.WindowsArm64) + }) +} + +// indexOf returns the index of an element in a string slice, or -1 if not found. +func indexOf(slice []string, item string) int { + for i, s := range slice { + if s == item { + return i + } + } + return -1 +} + +// Compile-time check: all publishers implement Publisher interface +var _ Publisher = (*GitHubPublisher)(nil) +var _ Publisher = (*DockerPublisher)(nil) +var _ Publisher = (*HomebrewPublisher)(nil) +var _ Publisher = (*NpmPublisher)(nil) +var _ Publisher = (*ScoopPublisher)(nil) +var _ Publisher = (*AURPublisher)(nil) +var _ Publisher = (*ChocolateyPublisher)(nil) +var _ Publisher = (*LinuxKitPublisher)(nil) diff --git a/sdk/breaking_test.go b/sdk/breaking_test.go new file mode 100644 index 0000000..c8c579b --- /dev/null +++ b/sdk/breaking_test.go @@ -0,0 +1,630 @@ +package sdk + +import ( + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// --- Breaking Change Detection Tests (oasdiff integration) --- + +func TestDiff_Good_AddEndpoint_NonBreaking(t *testing.T) { + tmpDir := t.TempDir() + + base := `openapi: "3.0.0" +info: + title: Test API + version: "1.0.0" +paths: + /health: + get: + operationId: getHealth + responses: + "200": + description: OK +` + revision := `openapi: "3.0.0" +info: + title: Test API + version: "1.1.0" +paths: + /health: + get: + operationId: getHealth + responses: + "200": + description: OK + /users: + get: + operationId: listUsers + responses: + "200": + description: OK + /status: + get: + operationId: getStatus + responses: + "200": + description: OK +` + basePath := filepath.Join(tmpDir, "base.yaml") + revPath := filepath.Join(tmpDir, "rev.yaml") + require.NoError(t, os.WriteFile(basePath, []byte(base), 0644)) + require.NoError(t, os.WriteFile(revPath, []byte(revision), 0644)) + + result, err := Diff(basePath, revPath) + require.NoError(t, err) + assert.False(t, result.Breaking, "adding endpoints should not be breaking") + assert.Empty(t, result.Changes) + assert.Equal(t, "No breaking changes", result.Summary) +} + +func TestDiff_Good_RemoveEndpoint_Breaking(t *testing.T) { + tmpDir := t.TempDir() + + base := `openapi: "3.0.0" +info: + title: Test API + version: "1.0.0" +paths: + /health: + get: + operationId: getHealth + responses: + "200": + description: OK + /users: + get: + operationId: listUsers + responses: + "200": + description: OK + /orders: + get: + operationId: listOrders + responses: + "200": + description: OK +` + revision := `openapi: "3.0.0" +info: + title: Test API + version: "2.0.0" +paths: + /health: + get: + operationId: getHealth + responses: + "200": + description: OK +` + basePath := filepath.Join(tmpDir, "base.yaml") + revPath := filepath.Join(tmpDir, "rev.yaml") + require.NoError(t, os.WriteFile(basePath, []byte(base), 0644)) + require.NoError(t, os.WriteFile(revPath, []byte(revision), 0644)) + + result, err := Diff(basePath, revPath) + require.NoError(t, err) + assert.True(t, result.Breaking, "removing endpoints should be breaking") + assert.NotEmpty(t, result.Changes) + assert.Contains(t, result.Summary, "breaking change") +} + +func TestDiff_Good_AddRequiredParam_Breaking(t *testing.T) { + tmpDir := t.TempDir() + + base := `openapi: "3.0.0" +info: + title: Test API + version: "1.0.0" +paths: + /users: + get: + operationId: listUsers + responses: + "200": + description: OK +` + revision := `openapi: "3.0.0" +info: + title: Test API + version: "1.1.0" +paths: + /users: + get: + operationId: listUsers + parameters: + - name: tenant_id + in: query + required: true + schema: + type: string + responses: + "200": + description: OK +` + basePath := filepath.Join(tmpDir, "base.yaml") + revPath := filepath.Join(tmpDir, "rev.yaml") + require.NoError(t, os.WriteFile(basePath, []byte(base), 0644)) + require.NoError(t, os.WriteFile(revPath, []byte(revision), 0644)) + + result, err := Diff(basePath, revPath) + require.NoError(t, err) + assert.True(t, result.Breaking, "adding required parameter should be breaking") + assert.NotEmpty(t, result.Changes) +} + +func TestDiff_Good_AddOptionalParam_NonBreaking(t *testing.T) { + tmpDir := t.TempDir() + + base := `openapi: "3.0.0" +info: + title: Test API + version: "1.0.0" +paths: + /users: + get: + operationId: listUsers + responses: + "200": + description: OK +` + revision := `openapi: "3.0.0" +info: + title: Test API + version: "1.1.0" +paths: + /users: + get: + operationId: listUsers + parameters: + - name: page + in: query + required: false + schema: + type: integer + responses: + "200": + description: OK +` + basePath := filepath.Join(tmpDir, "base.yaml") + revPath := filepath.Join(tmpDir, "rev.yaml") + require.NoError(t, os.WriteFile(basePath, []byte(base), 0644)) + require.NoError(t, os.WriteFile(revPath, []byte(revision), 0644)) + + result, err := Diff(basePath, revPath) + require.NoError(t, err) + assert.False(t, result.Breaking, "adding optional parameter should not be breaking") +} + +func TestDiff_Good_ChangeResponseType_Breaking(t *testing.T) { + tmpDir := t.TempDir() + + base := `openapi: "3.0.0" +info: + title: Test API + version: "1.0.0" +paths: + /users: + get: + operationId: listUsers + responses: + "200": + description: OK + content: + application/json: + schema: + type: array + items: + type: object + properties: + id: + type: integer + name: + type: string +` + revision := `openapi: "3.0.0" +info: + title: Test API + version: "2.0.0" +paths: + /users: + get: + operationId: listUsers + responses: + "200": + description: OK + content: + application/json: + schema: + type: object + properties: + data: + type: array + items: + type: object + properties: + id: + type: integer + name: + type: string +` + basePath := filepath.Join(tmpDir, "base.yaml") + revPath := filepath.Join(tmpDir, "rev.yaml") + require.NoError(t, os.WriteFile(basePath, []byte(base), 0644)) + require.NoError(t, os.WriteFile(revPath, []byte(revision), 0644)) + + result, err := Diff(basePath, revPath) + require.NoError(t, err) + assert.True(t, result.Breaking, "changing response schema type should be breaking") +} + +func TestDiff_Good_RemoveHTTPMethod_Breaking(t *testing.T) { + tmpDir := t.TempDir() + + base := `openapi: "3.0.0" +info: + title: Test API + version: "1.0.0" +paths: + /users: + get: + operationId: listUsers + responses: + "200": + description: OK + post: + operationId: createUser + responses: + "201": + description: Created +` + revision := `openapi: "3.0.0" +info: + title: Test API + version: "2.0.0" +paths: + /users: + get: + operationId: listUsers + responses: + "200": + description: OK +` + basePath := filepath.Join(tmpDir, "base.yaml") + revPath := filepath.Join(tmpDir, "rev.yaml") + require.NoError(t, os.WriteFile(basePath, []byte(base), 0644)) + require.NoError(t, os.WriteFile(revPath, []byte(revision), 0644)) + + result, err := Diff(basePath, revPath) + require.NoError(t, err) + assert.True(t, result.Breaking, "removing HTTP method should be breaking") + assert.NotEmpty(t, result.Changes) +} + +func TestDiff_Good_IdenticalSpecs_NonBreaking(t *testing.T) { + tmpDir := t.TempDir() + + spec := `openapi: "3.0.0" +info: + title: Test API + version: "1.0.0" +paths: + /health: + get: + operationId: getHealth + responses: + "200": + description: OK + /users: + get: + operationId: listUsers + responses: + "200": + description: OK + post: + operationId: createUser + responses: + "201": + description: Created +` + basePath := filepath.Join(tmpDir, "base.yaml") + revPath := filepath.Join(tmpDir, "rev.yaml") + require.NoError(t, os.WriteFile(basePath, []byte(spec), 0644)) + require.NoError(t, os.WriteFile(revPath, []byte(spec), 0644)) + + result, err := Diff(basePath, revPath) + require.NoError(t, err) + assert.False(t, result.Breaking, "identical specs should not be breaking") + assert.Empty(t, result.Changes) + assert.Equal(t, "No breaking changes", result.Summary) +} + +// --- Error Handling Tests --- + +func TestDiff_Bad_NonExistentBase(t *testing.T) { + tmpDir := t.TempDir() + + revPath := filepath.Join(tmpDir, "rev.yaml") + require.NoError(t, os.WriteFile(revPath, []byte(`openapi: "3.0.0" +info: + title: Test API + version: "1.0.0" +paths: {} +`), 0644)) + + _, err := Diff(filepath.Join(tmpDir, "nonexistent.yaml"), revPath) + assert.Error(t, err) + assert.Contains(t, err.Error(), "failed to load base spec") +} + +func TestDiff_Bad_NonExistentRevision(t *testing.T) { + tmpDir := t.TempDir() + + basePath := filepath.Join(tmpDir, "base.yaml") + require.NoError(t, os.WriteFile(basePath, []byte(`openapi: "3.0.0" +info: + title: Test API + version: "1.0.0" +paths: {} +`), 0644)) + + _, err := Diff(basePath, filepath.Join(tmpDir, "nonexistent.yaml")) + assert.Error(t, err) + assert.Contains(t, err.Error(), "failed to load revision spec") +} + +func TestDiff_Bad_InvalidYAML(t *testing.T) { + tmpDir := t.TempDir() + + basePath := filepath.Join(tmpDir, "base.yaml") + revPath := filepath.Join(tmpDir, "rev.yaml") + require.NoError(t, os.WriteFile(basePath, []byte("not: valid: openapi: spec: {{{{"), 0644)) + require.NoError(t, os.WriteFile(revPath, []byte(`openapi: "3.0.0" +info: + title: Test API + version: "1.0.0" +paths: {} +`), 0644)) + + _, err := Diff(basePath, revPath) + assert.Error(t, err) +} + +// --- DiffExitCode Tests --- + +func TestDiffExitCode_Good(t *testing.T) { + tests := []struct { + name string + result *DiffResult + err error + expected int + }{ + { + name: "no breaking changes returns 0", + result: &DiffResult{Breaking: false}, + err: nil, + expected: 0, + }, + { + name: "breaking changes returns 1", + result: &DiffResult{Breaking: true, Changes: []string{"removed endpoint"}}, + err: nil, + expected: 1, + }, + { + name: "error returns 2", + result: nil, + err: assert.AnError, + expected: 2, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + code := DiffExitCode(tc.result, tc.err) + assert.Equal(t, tc.expected, code) + }) + } +} + +// --- DiffResult Structure Tests --- + +func TestDiffResult_Good_Summary(t *testing.T) { + t.Run("breaking result has count in summary", func(t *testing.T) { + tmpDir := t.TempDir() + + // Create specs with 2 removed endpoints + base := `openapi: "3.0.0" +info: + title: Test API + version: "1.0.0" +paths: + /health: + get: + operationId: getHealth + responses: + "200": + description: OK + /users: + get: + operationId: listUsers + responses: + "200": + description: OK + /orders: + get: + operationId: listOrders + responses: + "200": + description: OK +` + revision := `openapi: "3.0.0" +info: + title: Test API + version: "2.0.0" +paths: + /health: + get: + operationId: getHealth + responses: + "200": + description: OK +` + basePath := filepath.Join(tmpDir, "base.yaml") + revPath := filepath.Join(tmpDir, "rev.yaml") + require.NoError(t, os.WriteFile(basePath, []byte(base), 0644)) + require.NoError(t, os.WriteFile(revPath, []byte(revision), 0644)) + + result, err := Diff(basePath, revPath) + require.NoError(t, err) + + assert.True(t, result.Breaking) + assert.Contains(t, result.Summary, "breaking change") + // Should have at least 2 changes (removed /users and /orders) + assert.GreaterOrEqual(t, len(result.Changes), 2) + }) +} + +func TestDiffResult_Good_ChangesAreHumanReadable(t *testing.T) { + tmpDir := t.TempDir() + + base := `openapi: "3.0.0" +info: + title: Test API + version: "1.0.0" +paths: + /removed-endpoint: + get: + operationId: removedEndpoint + responses: + "200": + description: OK +` + revision := `openapi: "3.0.0" +info: + title: Test API + version: "2.0.0" +paths: {} +` + basePath := filepath.Join(tmpDir, "base.yaml") + revPath := filepath.Join(tmpDir, "rev.yaml") + require.NoError(t, os.WriteFile(basePath, []byte(base), 0644)) + require.NoError(t, os.WriteFile(revPath, []byte(revision), 0644)) + + result, err := Diff(basePath, revPath) + require.NoError(t, err) + + assert.True(t, result.Breaking) + // Changes should contain human-readable descriptions from oasdiff + for _, change := range result.Changes { + assert.NotEmpty(t, change, "each change should have a description") + } +} + +// --- Multiple Changes Detection Tests --- + +func TestDiff_Good_MultipleBreakingChanges(t *testing.T) { + tmpDir := t.TempDir() + + base := `openapi: "3.0.0" +info: + title: Test API + version: "1.0.0" +paths: + /users: + get: + operationId: listUsers + responses: + "200": + description: OK + post: + operationId: createUser + responses: + "201": + description: Created + delete: + operationId: deleteAllUsers + responses: + "204": + description: No Content +` + revision := `openapi: "3.0.0" +info: + title: Test API + version: "2.0.0" +paths: + /users: + get: + operationId: listUsers + parameters: + - name: required_filter + in: query + required: true + schema: + type: string + responses: + "200": + description: OK +` + basePath := filepath.Join(tmpDir, "base.yaml") + revPath := filepath.Join(tmpDir, "rev.yaml") + require.NoError(t, os.WriteFile(basePath, []byte(base), 0644)) + require.NoError(t, os.WriteFile(revPath, []byte(revision), 0644)) + + result, err := Diff(basePath, revPath) + require.NoError(t, err) + + assert.True(t, result.Breaking) + // Should detect: removed POST, removed DELETE, and possibly added required param + assert.GreaterOrEqual(t, len(result.Changes), 2, + "should detect multiple breaking changes, got: %v", result.Changes) +} + +// --- JSON Spec Support Tests --- + +func TestDiff_Good_JSONSpecs(t *testing.T) { + tmpDir := t.TempDir() + + baseJSON := `{ + "openapi": "3.0.0", + "info": {"title": "Test API", "version": "1.0.0"}, + "paths": { + "/health": { + "get": { + "operationId": "getHealth", + "responses": {"200": {"description": "OK"}} + } + } + } +}` + revJSON := `{ + "openapi": "3.0.0", + "info": {"title": "Test API", "version": "1.1.0"}, + "paths": { + "/health": { + "get": { + "operationId": "getHealth", + "responses": {"200": {"description": "OK"}} + } + }, + "/status": { + "get": { + "operationId": "getStatus", + "responses": {"200": {"description": "OK"}} + } + } + } +}` + basePath := filepath.Join(tmpDir, "base.json") + revPath := filepath.Join(tmpDir, "rev.json") + require.NoError(t, os.WriteFile(basePath, []byte(baseJSON), 0644)) + require.NoError(t, os.WriteFile(revPath, []byte(revJSON), 0644)) + + result, err := Diff(basePath, revPath) + require.NoError(t, err) + assert.False(t, result.Breaking, "adding endpoint in JSON format should not be breaking") +} diff --git a/sdk/generation_test.go b/sdk/generation_test.go new file mode 100644 index 0000000..cb7452b --- /dev/null +++ b/sdk/generation_test.go @@ -0,0 +1,337 @@ +package sdk + +import ( + "context" + "os" + "path/filepath" + "testing" + + "forge.lthn.ai/core/go-devops/sdk/generators" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// --- SDK Generation Orchestration Tests --- + +func TestSDK_Generate_Good_AllLanguages(t *testing.T) { + t.Run("Generate iterates all configured languages", func(t *testing.T) { + tmpDir := t.TempDir() + + // Create a minimal OpenAPI spec + specPath := filepath.Join(tmpDir, "openapi.yaml") + err := os.WriteFile(specPath, []byte(minimalSpec), 0644) + require.NoError(t, err) + + cfg := &Config{ + Spec: "openapi.yaml", + Languages: []string{"nonexistent-lang"}, + Output: "sdk", + Package: PackageConfig{ + Name: "testclient", + Version: "1.0.0", + }, + } + s := New(tmpDir, cfg) + s.SetVersion("v1.0.0") + + // Generate should fail on unknown language + err = s.Generate(context.Background()) + assert.Error(t, err) + assert.Contains(t, err.Error(), "unknown language") + }) +} + +func TestSDK_GenerateLanguage_Good_OutputDir(t *testing.T) { + t.Run("output directory uses language subdirectory", func(t *testing.T) { + tmpDir := t.TempDir() + + specPath := filepath.Join(tmpDir, "openapi.yaml") + err := os.WriteFile(specPath, []byte(minimalSpec), 0644) + require.NoError(t, err) + + cfg := &Config{ + Spec: "openapi.yaml", + Languages: []string{"typescript"}, + Output: "custom-sdk", + Package: PackageConfig{ + Name: "my-client", + Version: "2.0.0", + }, + } + s := New(tmpDir, cfg) + s.SetVersion("v2.0.0") + + // This will fail because generators aren't installed, but we can verify + // the spec detection works correctly + specResult, err := s.DetectSpec() + require.NoError(t, err) + assert.Equal(t, specPath, specResult) + }) +} + +func TestSDK_GenerateLanguage_Bad_NoSpec(t *testing.T) { + t.Run("fails when no OpenAPI spec exists", func(t *testing.T) { + tmpDir := t.TempDir() + + s := New(tmpDir, &Config{ + Languages: []string{"typescript"}, + Output: "sdk", + }) + + err := s.GenerateLanguage(context.Background(), "typescript") + assert.Error(t, err) + assert.Contains(t, err.Error(), "no OpenAPI spec found") + }) +} + +func TestSDK_GenerateLanguage_Bad_UnknownLanguage(t *testing.T) { + t.Run("fails for unregistered language", func(t *testing.T) { + tmpDir := t.TempDir() + specPath := filepath.Join(tmpDir, "openapi.yaml") + err := os.WriteFile(specPath, []byte(minimalSpec), 0644) + require.NoError(t, err) + + s := New(tmpDir, nil) + err = s.GenerateLanguage(context.Background(), "cobol") + assert.Error(t, err) + assert.Contains(t, err.Error(), "unknown language: cobol") + }) +} + +// --- Generator Registry Tests --- + +func TestRegistry_Good_RegisterAndGet(t *testing.T) { + t.Run("register and retrieve all generators", func(t *testing.T) { + registry := generators.NewRegistry() + registry.Register(generators.NewTypeScriptGenerator()) + registry.Register(generators.NewPythonGenerator()) + registry.Register(generators.NewGoGenerator()) + registry.Register(generators.NewPHPGenerator()) + + // Verify all languages are registered + languages := registry.Languages() + assert.Len(t, languages, 4) + assert.Contains(t, languages, "typescript") + assert.Contains(t, languages, "python") + assert.Contains(t, languages, "go") + assert.Contains(t, languages, "php") + + // Verify retrieval + for _, lang := range []string{"typescript", "python", "go", "php"} { + gen, ok := registry.Get(lang) + assert.True(t, ok, "should find generator for %s", lang) + assert.Equal(t, lang, gen.Language()) + } + }) + + t.Run("Get returns false for unregistered language", func(t *testing.T) { + registry := generators.NewRegistry() + gen, ok := registry.Get("rust") + assert.False(t, ok) + assert.Nil(t, gen) + }) +} + +func TestRegistry_Good_OverwritesDuplicateLanguage(t *testing.T) { + registry := generators.NewRegistry() + registry.Register(generators.NewTypeScriptGenerator()) + registry.Register(generators.NewTypeScriptGenerator()) // register again + + languages := registry.Languages() + count := 0 + for _, lang := range languages { + if lang == "typescript" { + count++ + } + } + assert.Equal(t, 1, count, "should have exactly one typescript entry") +} + +// --- Generator Interface Compliance Tests --- + +func TestGenerators_Good_LanguageIdentifiers(t *testing.T) { + tests := []struct { + generator generators.Generator + expected string + }{ + {generators.NewTypeScriptGenerator(), "typescript"}, + {generators.NewPythonGenerator(), "python"}, + {generators.NewGoGenerator(), "go"}, + {generators.NewPHPGenerator(), "php"}, + } + + for _, tc := range tests { + t.Run(tc.expected, func(t *testing.T) { + assert.Equal(t, tc.expected, tc.generator.Language()) + }) + } +} + +func TestGenerators_Good_InstallInstructions(t *testing.T) { + tests := []struct { + language string + gen generators.Generator + contains string + }{ + {"typescript", generators.NewTypeScriptGenerator(), "npm install"}, + {"python", generators.NewPythonGenerator(), "pip install"}, + {"go", generators.NewGoGenerator(), "go install"}, + {"php", generators.NewPHPGenerator(), "Docker"}, + } + + for _, tc := range tests { + t.Run(tc.language, func(t *testing.T) { + instructions := tc.gen.Install() + assert.NotEmpty(t, instructions) + assert.Contains(t, instructions, tc.contains) + }) + } +} + +func TestGenerators_Good_AvailableDoesNotPanic(t *testing.T) { + // Available() should never panic regardless of system state + gens := []generators.Generator{ + generators.NewTypeScriptGenerator(), + generators.NewPythonGenerator(), + generators.NewGoGenerator(), + generators.NewPHPGenerator(), + } + + for _, gen := range gens { + t.Run(gen.Language(), func(t *testing.T) { + // Should not panic — result depends on system + _ = gen.Available() + }) + } +} + +// --- SDK Config Tests --- + +func TestSDKConfig_Good_DefaultConfig(t *testing.T) { + t.Run("default config has all four languages", func(t *testing.T) { + cfg := DefaultConfig() + assert.Contains(t, cfg.Languages, "typescript") + assert.Contains(t, cfg.Languages, "python") + assert.Contains(t, cfg.Languages, "go") + assert.Contains(t, cfg.Languages, "php") + assert.Len(t, cfg.Languages, 4) + }) + + t.Run("default config enables diff", func(t *testing.T) { + cfg := DefaultConfig() + assert.True(t, cfg.Diff.Enabled) + assert.False(t, cfg.Diff.FailOnBreaking) + }) + + t.Run("default config uses sdk/ output", func(t *testing.T) { + cfg := DefaultConfig() + assert.Equal(t, "sdk", cfg.Output) + }) +} + +func TestSDKConfig_Good_SetVersion(t *testing.T) { + t.Run("SetVersion updates both fields", func(t *testing.T) { + s := New("/tmp", &Config{ + Package: PackageConfig{ + Name: "test", + Version: "old", + }, + }) + s.SetVersion("v3.0.0") + + assert.Equal(t, "v3.0.0", s.version) + assert.Equal(t, "v3.0.0", s.config.Package.Version) + }) + + t.Run("SetVersion on nil config is safe", func(t *testing.T) { + s := &SDK{} + // Should not panic + s.SetVersion("v1.0.0") + assert.Equal(t, "v1.0.0", s.version) + }) +} + +func TestSDKConfig_Good_NewWithNilConfig(t *testing.T) { + s := New("/project", nil) + assert.NotNil(t, s.config) + assert.Equal(t, "sdk", s.config.Output) + assert.True(t, s.config.Diff.Enabled) +} + +// --- Spec Detection Integration Tests --- + +func TestSpecDetection_Good_Priority(t *testing.T) { + t.Run("configured spec takes priority over common paths", func(t *testing.T) { + tmpDir := t.TempDir() + + // Create both a common path spec and a configured spec + commonSpec := filepath.Join(tmpDir, "openapi.yaml") + err := os.WriteFile(commonSpec, []byte(minimalSpec), 0644) + require.NoError(t, err) + + configuredSpec := filepath.Join(tmpDir, "custom", "api.yaml") + require.NoError(t, os.MkdirAll(filepath.Dir(configuredSpec), 0755)) + err = os.WriteFile(configuredSpec, []byte(minimalSpec), 0644) + require.NoError(t, err) + + s := New(tmpDir, &Config{Spec: "custom/api.yaml"}) + specPath, err := s.DetectSpec() + require.NoError(t, err) + assert.Equal(t, configuredSpec, specPath) + }) + + t.Run("common paths checked in order", func(t *testing.T) { + tmpDir := t.TempDir() + + // Create the second common path only (api/openapi.yaml is first) + apiDir := filepath.Join(tmpDir, "api") + require.NoError(t, os.MkdirAll(apiDir, 0755)) + apiSpec := filepath.Join(apiDir, "openapi.json") + err := os.WriteFile(apiSpec, []byte(`{"openapi":"3.0.0"}`), 0644) + require.NoError(t, err) + + s := New(tmpDir, nil) + specPath, err := s.DetectSpec() + require.NoError(t, err) + assert.Equal(t, apiSpec, specPath) + }) +} + +func TestSpecDetection_Good_AllCommonPaths(t *testing.T) { + for _, commonPath := range commonSpecPaths { + t.Run(commonPath, func(t *testing.T) { + tmpDir := t.TempDir() + + specPath := filepath.Join(tmpDir, commonPath) + require.NoError(t, os.MkdirAll(filepath.Dir(specPath), 0755)) + err := os.WriteFile(specPath, []byte(minimalSpec), 0644) + require.NoError(t, err) + + s := New(tmpDir, nil) + detected, err := s.DetectSpec() + require.NoError(t, err) + assert.Equal(t, specPath, detected) + }) + } +} + +// --- Compile-time interface checks --- + +var _ generators.Generator = (*generators.TypeScriptGenerator)(nil) +var _ generators.Generator = (*generators.PythonGenerator)(nil) +var _ generators.Generator = (*generators.GoGenerator)(nil) +var _ generators.Generator = (*generators.PHPGenerator)(nil) + +// minimalSpec is a valid OpenAPI 3.0 spec used across tests. +const minimalSpec = `openapi: "3.0.0" +info: + title: Test API + version: "1.0.0" +paths: + /health: + get: + operationId: getHealth + responses: + "200": + description: OK +`