test(release): Phase 3 — publisher integration, SDK generation, breaking change detection

Publisher integration tests (48 tests): dry-run verification for all 8 publishers
(GitHub, Docker, Homebrew, Scoop, AUR, Chocolatey, npm, LinuxKit), command building,
config parsing, repository detection, artifact handling, cross-publisher name
uniqueness, nil relCfg handling, checksum mapping, interface compliance.

SDK generation tests (38 tests): orchestration, generator registry, interface
compliance for all 4 languages (TypeScript, Python, Go, PHP), config defaults,
SetVersion, spec detection priority across all 8 common paths.

Breaking change detection tests (30 tests): oasdiff integration covering
add/remove endpoints, required/optional params, response type changes,
HTTP method removal, identical specs, multiple breaking changes, JSON format
support, error handling, DiffExitCode, DiffResult structure.

Co-Authored-By: Virgil <virgil@lethean.io>
This commit is contained in:
Snider 2026-02-20 05:27:34 +00:00
parent 50ad540241
commit 7aaa2154b6
4 changed files with 1959 additions and 3 deletions

View file

@ -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

View file

@ -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 <test@example.com>",
},
}
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 <test@example.com>")
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, "<id>my-cli-tool</id>")
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)

630
sdk/breaking_test.go Normal file
View file

@ -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")
}

337
sdk/generation_test.go Normal file
View file

@ -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
`