Files
gh-action-readme/internal/config_test.go
Ismo Vuorinen 4f12c4d3dd feat(lint): add many linters, make all the tests run fast! (#23)
* chore(lint): added nlreturn, run linting

* chore(lint): replace some fmt.Sprintf calls

* chore(lint): replace fmt.Sprintf with strconv

* chore(lint): add goconst, use http lib for status codes, and methods

* chore(lint): use errors lib, errCodes from internal/errors

* chore(lint): dupl, thelper and usetesting

* chore(lint): fmt.Errorf %v to %w, more linters

* chore(lint): paralleltest, where possible

* perf(test): optimize test performance by 78%

- Implement shared binary building with package-level cache to eliminate redundant builds
- Add strategic parallelization to 15+ tests while preserving environment variable isolation
- Implement thread-safe fixture caching with RWMutex to reduce I/O operations
- Remove unnecessary working directory changes by leveraging embedded templates
- Add embedded template system with go:embed directive for reliable template resolution
- Fix linting issues: rename sharedBinaryError to errSharedBinary, add nolint directive

Performance improvements:
- Total test execution time: 12+ seconds → 2.7 seconds (78% faster)
- Binary build overhead: 14+ separate builds → 1 shared build (93% reduction)
- Parallel execution: Limited → 15+ concurrent tests (60-70% better CPU usage)
- I/O operations: 66+ fixture reads → cached with sync.RWMutex (50% reduction)

All tests maintain 100% success rate and coverage while running nearly 4x faster.
2025-08-06 15:28:09 +03:00

733 lines
19 KiB
Go

package internal
import (
"os"
"path/filepath"
"testing"
"github.com/ivuorinen/gh-action-readme/testutil"
)
func TestInitConfig(t *testing.T) {
tests := []struct {
name string
configFile string
setupFunc func(t *testing.T, tempDir string)
expectError bool
expected *AppConfig
}{
{
name: "default config when no file exists",
configFile: "",
setupFunc: nil,
expected: &AppConfig{
Theme: "default",
OutputFormat: "md",
OutputDir: ".",
Template: "templates/readme.tmpl",
Schema: "schemas/schema.json",
Verbose: false,
Quiet: false,
GitHubToken: "",
},
},
{
name: "custom config file",
configFile: "custom-config.yml",
setupFunc: func(t *testing.T, tempDir string) {
t.Helper()
configPath := filepath.Join(tempDir, "custom-config.yml")
testutil.WriteTestFile(t, configPath, testutil.MustReadFixture("professional-config.yml"))
},
expected: &AppConfig{
Theme: "professional",
OutputFormat: "html",
OutputDir: "docs",
Template: "custom-template.tmpl",
Schema: "custom-schema.json",
Verbose: true,
Quiet: false,
GitHubToken: "test-token-from-config",
},
},
{
name: "invalid config file",
configFile: "config.yml",
setupFunc: func(t *testing.T, tempDir string) {
t.Helper()
configPath := filepath.Join(tempDir, "config.yml")
testutil.WriteTestFile(t, configPath, "invalid: yaml: content: [")
},
expectError: true,
},
{
name: "nonexistent config file",
configFile: "nonexistent.yml",
expectError: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
tmpDir, cleanup := testutil.TempDir(t)
defer cleanup()
// Set XDG_CONFIG_HOME to our temp directory
t.Setenv("XDG_CONFIG_HOME", tmpDir)
t.Setenv("HOME", tmpDir)
if tt.setupFunc != nil {
tt.setupFunc(t, tmpDir)
}
// Set config file path if specified
configPath := ""
if tt.configFile != "" {
configPath = filepath.Join(tmpDir, tt.configFile)
}
config, err := InitConfig(configPath)
if tt.expectError {
testutil.AssertError(t, err)
return
}
testutil.AssertNoError(t, err)
// Verify config values
if tt.expected != nil {
testutil.AssertEqual(t, tt.expected.Theme, config.Theme)
testutil.AssertEqual(t, tt.expected.OutputFormat, config.OutputFormat)
testutil.AssertEqual(t, tt.expected.OutputDir, config.OutputDir)
testutil.AssertEqual(t, tt.expected.Template, config.Template)
testutil.AssertEqual(t, tt.expected.Schema, config.Schema)
testutil.AssertEqual(t, tt.expected.Verbose, config.Verbose)
testutil.AssertEqual(t, tt.expected.Quiet, config.Quiet)
testutil.AssertEqual(t, tt.expected.GitHubToken, config.GitHubToken)
}
})
}
}
func TestLoadConfiguration(t *testing.T) {
tests := []struct {
name string
setupFunc func(t *testing.T, tempDir string) (configFile, repoRoot, currentDir string)
expectError bool
checkFunc func(t *testing.T, config *AppConfig)
}{
{
name: "multi-level config hierarchy",
setupFunc: func(t *testing.T, tempDir string) (string, string, string) {
t.Helper()
// Create global config
globalConfigDir := filepath.Join(tempDir, ".config", "gh-action-readme")
_ = os.MkdirAll(globalConfigDir, 0750) // #nosec G301 -- test directory permissions
globalConfigPath := filepath.Join(globalConfigDir, "config.yaml")
testutil.WriteTestFile(t, globalConfigPath, `
theme: default
output_format: md
github_token: global-token
`)
// Create repo root with repo-specific config
repoRoot := filepath.Join(tempDir, "repo")
_ = os.MkdirAll(repoRoot, 0750) // #nosec G301 -- test directory permissions
testutil.WriteTestFile(t, filepath.Join(repoRoot, ".ghreadme.yaml"), `
theme: github
output_format: html
`)
// Create current directory with action-specific config
currentDir := filepath.Join(repoRoot, "action")
_ = os.MkdirAll(currentDir, 0750) // #nosec G301 -- test directory permissions
testutil.WriteTestFile(t, filepath.Join(currentDir, "config.yaml"), `
theme: professional
output_dir: output
`)
return globalConfigPath, repoRoot, currentDir
},
checkFunc: func(t *testing.T, config *AppConfig) {
t.Helper()
// Should have action-level overrides
testutil.AssertEqual(t, "professional", config.Theme)
testutil.AssertEqual(t, "output", config.OutputDir)
// Should inherit from repo level
testutil.AssertEqual(t, "html", config.OutputFormat)
// Should inherit GitHub token from global config
testutil.AssertEqual(t, "global-token", config.GitHubToken)
},
},
{
name: "environment variable overrides",
setupFunc: func(t *testing.T, tempDir string) (string, string, string) {
t.Helper()
// Set environment variables
t.Setenv("GH_README_GITHUB_TOKEN", "env-token")
t.Setenv("GITHUB_TOKEN", "fallback-token")
// Create config file
configPath := filepath.Join(tempDir, "config.yml")
testutil.WriteTestFile(t, configPath, `
theme: minimal
github_token: config-token
`)
return configPath, tempDir, tempDir
},
checkFunc: func(t *testing.T, config *AppConfig) {
t.Helper()
// Environment variable should override config file
testutil.AssertEqual(t, "env-token", config.GitHubToken)
testutil.AssertEqual(t, "minimal", config.Theme)
},
},
{
name: "XDG compliance",
setupFunc: func(t *testing.T, tempDir string) (string, string, string) {
t.Helper()
// Set XDG environment variables
xdgConfigHome := filepath.Join(tempDir, "xdg-config")
t.Setenv("XDG_CONFIG_HOME", xdgConfigHome)
// Create XDG-compliant config
configDir := filepath.Join(xdgConfigHome, "gh-action-readme")
_ = os.MkdirAll(configDir, 0750) // #nosec G301 -- test directory permissions
configPath := filepath.Join(configDir, "config.yaml")
testutil.WriteTestFile(t, configPath, `
theme: github
verbose: true
`)
return configPath, tempDir, tempDir
},
checkFunc: func(t *testing.T, config *AppConfig) {
t.Helper()
testutil.AssertEqual(t, "github", config.Theme)
testutil.AssertEqual(t, true, config.Verbose)
},
},
{
name: "hidden config file discovery",
setupFunc: func(t *testing.T, tempDir string) (string, string, string) {
t.Helper()
repoRoot := filepath.Join(tempDir, "repo")
_ = os.MkdirAll(repoRoot, 0750) // #nosec G301 -- test directory permissions
// Create multiple hidden config files
testutil.WriteTestFile(t, filepath.Join(repoRoot, ".ghreadme.yaml"), `
theme: minimal
output_format: json
`)
testutil.WriteTestFile(t, filepath.Join(repoRoot, ".config", "ghreadme.yaml"), `
theme: professional
quiet: true
`)
testutil.WriteTestFile(t, filepath.Join(repoRoot, ".github", "ghreadme.yaml"), `
theme: github
verbose: true
`)
return "", repoRoot, repoRoot
},
checkFunc: func(t *testing.T, config *AppConfig) {
t.Helper()
// Should use the first found config (.ghreadme.yaml has priority)
testutil.AssertEqual(t, "minimal", config.Theme)
testutil.AssertEqual(t, "json", config.OutputFormat)
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
tmpDir, cleanup := testutil.TempDir(t)
defer cleanup()
// Set HOME to temp directory for fallback
t.Setenv("HOME", tmpDir)
configFile, repoRoot, currentDir := tt.setupFunc(t, tmpDir)
config, err := LoadConfiguration(configFile, repoRoot, currentDir)
if tt.expectError {
testutil.AssertError(t, err)
return
}
testutil.AssertNoError(t, err)
if tt.checkFunc != nil {
tt.checkFunc(t, config)
}
})
}
}
func TestGetConfigPath(t *testing.T) {
tests := []struct {
name string
setupFunc func(t *testing.T, tempDir string)
contains string
}{
{
name: "XDG_CONFIG_HOME set",
setupFunc: func(t *testing.T, tempDir string) {
t.Helper()
t.Setenv("XDG_CONFIG_HOME", tempDir)
t.Setenv("HOME", "")
},
contains: "gh-action-readme",
},
{
name: "HOME fallback",
setupFunc: func(t *testing.T, tempDir string) {
t.Helper()
t.Setenv("XDG_CONFIG_HOME", "")
t.Setenv("HOME", tempDir)
},
contains: ".config",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
tmpDir, cleanup := testutil.TempDir(t)
defer cleanup()
tt.setupFunc(t, tmpDir)
path, err := GetConfigPath()
testutil.AssertNoError(t, err)
if !filepath.IsAbs(path) {
t.Errorf("expected absolute path, got: %s", path)
}
testutil.AssertStringContains(t, path, tt.contains)
})
}
}
func TestWriteDefaultConfig(t *testing.T) {
tmpDir, cleanup := testutil.TempDir(t)
defer cleanup()
// Set XDG_CONFIG_HOME to our temp directory
t.Setenv("XDG_CONFIG_HOME", tmpDir)
err := WriteDefaultConfig()
testutil.AssertNoError(t, err)
// Check that config file was created
configPath, _ := GetConfigPath()
t.Logf("Expected config path: %s", configPath)
if _, err := os.Stat(configPath); os.IsNotExist(err) {
t.Errorf("config file was not created at: %s", configPath)
// List what files were actually created
if files, err := os.ReadDir(tmpDir); err == nil {
t.Logf("Files in tmpDir: %v", files)
}
}
// Verify config file content
config, err := InitConfig(configPath)
testutil.AssertNoError(t, err)
// Should have default values
testutil.AssertEqual(t, "default", config.Theme)
testutil.AssertEqual(t, "md", config.OutputFormat)
testutil.AssertEqual(t, ".", config.OutputDir)
}
func TestResolveThemeTemplate(t *testing.T) {
t.Parallel()
tests := []struct {
name string
theme string
expectError bool
shouldExist bool
expectedPath string
}{
{
name: "default theme",
theme: "default",
expectError: false,
shouldExist: true,
expectedPath: "templates/readme.tmpl",
},
{
name: "github theme",
theme: "github",
expectError: false,
shouldExist: true,
expectedPath: "templates/themes/github/readme.tmpl",
},
{
name: "gitlab theme",
theme: "gitlab",
expectError: false,
shouldExist: true,
expectedPath: "templates/themes/gitlab/readme.tmpl",
},
{
name: "minimal theme",
theme: "minimal",
expectError: false,
shouldExist: true,
expectedPath: "templates/themes/minimal/readme.tmpl",
},
{
name: "professional theme",
theme: "professional",
expectError: false,
shouldExist: true,
expectedPath: "templates/themes/professional/readme.tmpl",
},
{
name: "unknown theme",
theme: "nonexistent",
expectError: true,
},
{
name: "empty theme",
theme: "",
expectError: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
path := resolveThemeTemplate(tt.theme)
if tt.expectError {
if path != "" {
t.Errorf("expected empty path on error, got: %s", path)
}
return
}
if path == "" {
t.Error("expected non-empty path")
}
if tt.expectedPath != "" {
testutil.AssertStringContains(t, path, tt.expectedPath)
}
// Note: We can't check file existence here because template files
// might not be present in the test environment
})
}
}
func TestConfigTokenHierarchy(t *testing.T) {
tests := testutil.GetGitHubTokenHierarchyTests()
for _, tt := range tests {
t.Run(tt.Name, func(t *testing.T) {
cleanup := tt.SetupFunc(t)
defer cleanup()
tmpDir, tmpCleanup := testutil.TempDir(t)
defer tmpCleanup()
// Use default config
config, err := LoadConfiguration("", tmpDir, tmpDir)
testutil.AssertNoError(t, err)
testutil.AssertEqual(t, tt.ExpectedToken, config.GitHubToken)
})
}
}
func TestConfigMerging(t *testing.T) {
tmpDir, cleanup := testutil.TempDir(t)
defer cleanup()
// Test config merging by creating config files and seeing the result
globalConfigDir := filepath.Join(tmpDir, ".config", "gh-action-readme")
_ = os.MkdirAll(globalConfigDir, 0750) // #nosec G301 -- test directory permissions
testutil.WriteTestFile(t, filepath.Join(globalConfigDir, "config.yaml"), `
theme: default
output_format: md
github_token: base-token
verbose: false
`)
repoRoot := filepath.Join(tmpDir, "repo")
_ = os.MkdirAll(repoRoot, 0750) // #nosec G301 -- test directory permissions
testutil.WriteTestFile(t, filepath.Join(repoRoot, ".ghreadme.yaml"), `
theme: github
output_format: html
verbose: true
`)
// Set HOME and XDG_CONFIG_HOME to temp directory
t.Setenv("HOME", tmpDir)
t.Setenv("XDG_CONFIG_HOME", filepath.Join(tmpDir, ".config"))
// Use the specific config file path instead of relying on XDG discovery
configPath := filepath.Join(tmpDir, ".config", "gh-action-readme", "config.yaml")
config, err := LoadConfiguration(configPath, repoRoot, repoRoot)
testutil.AssertNoError(t, err)
// Should have merged values
testutil.AssertEqual(t, "github", config.Theme) // from repo config
testutil.AssertEqual(t, "html", config.OutputFormat) // from repo config
testutil.AssertEqual(t, true, config.Verbose) // from repo config
testutil.AssertEqual(t, "base-token", config.GitHubToken) // from global config
testutil.AssertEqual(t, "schemas/schema.json", config.Schema) // default value
}
// TestGetGitHubToken tests GitHub token resolution with different priority levels.
func TestGetGitHubToken(t *testing.T) {
tests := []struct {
name string
toolEnvToken string
stdEnvToken string
configToken string
expectedToken string
}{
{
name: "tool-specific env var has highest priority",
toolEnvToken: "tool-token",
stdEnvToken: "std-token",
configToken: "config-token",
expectedToken: "tool-token",
},
{
name: "standard env var when tool env not set",
toolEnvToken: "",
stdEnvToken: "std-token",
configToken: "config-token",
expectedToken: "std-token",
},
{
name: "config token when env vars not set",
toolEnvToken: "",
stdEnvToken: "",
configToken: "config-token",
expectedToken: "config-token",
},
{
name: "empty string when nothing set",
toolEnvToken: "",
stdEnvToken: "",
configToken: "",
expectedToken: "",
},
{
name: "empty env var does not override config",
toolEnvToken: "",
stdEnvToken: "",
configToken: "config-token",
expectedToken: "config-token",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
// Set up environment
if tt.toolEnvToken != "" {
t.Setenv(EnvGitHubToken, tt.toolEnvToken)
} else {
t.Setenv(EnvGitHubToken, "")
}
if tt.stdEnvToken != "" {
t.Setenv(EnvGitHubTokenStandard, tt.stdEnvToken)
} else {
t.Setenv(EnvGitHubTokenStandard, "")
}
config := &AppConfig{GitHubToken: tt.configToken}
result := GetGitHubToken(config)
testutil.AssertEqual(t, tt.expectedToken, result)
})
}
}
// TestMergeMapFields tests the merging of map fields in configuration.
func TestMergeMapFields(t *testing.T) {
t.Parallel()
tests := []struct {
name string
dst *AppConfig
src *AppConfig
expected *AppConfig
}{
{
name: "merge permissions into empty dst",
dst: &AppConfig{},
src: &AppConfig{
Permissions: map[string]string{"read": "read", "write": "write"},
},
expected: &AppConfig{
Permissions: map[string]string{"read": "read", "write": "write"},
},
},
{
name: "merge permissions into existing dst",
dst: &AppConfig{
Permissions: map[string]string{"read": "existing"},
},
src: &AppConfig{
Permissions: map[string]string{"read": "new", "write": "write"},
},
expected: &AppConfig{
Permissions: map[string]string{"read": "new", "write": "write"},
},
},
{
name: "merge variables into empty dst",
dst: &AppConfig{},
src: &AppConfig{
Variables: map[string]string{"VAR1": "value1", "VAR2": "value2"},
},
expected: &AppConfig{
Variables: map[string]string{"VAR1": "value1", "VAR2": "value2"},
},
},
{
name: "merge variables into existing dst",
dst: &AppConfig{
Variables: map[string]string{"VAR1": "existing"},
},
src: &AppConfig{
Variables: map[string]string{"VAR1": "new", "VAR2": "value2"},
},
expected: &AppConfig{
Variables: map[string]string{"VAR1": "new", "VAR2": "value2"},
},
},
{
name: "merge both permissions and variables",
dst: &AppConfig{
Permissions: map[string]string{"read": "existing"},
},
src: &AppConfig{
Permissions: map[string]string{"write": "write"},
Variables: map[string]string{"VAR1": "value1"},
},
expected: &AppConfig{
Permissions: map[string]string{"read": "existing", "write": "write"},
Variables: map[string]string{"VAR1": "value1"},
},
},
{
name: "empty src does not affect dst",
dst: &AppConfig{
Permissions: map[string]string{"read": "read"},
Variables: map[string]string{"VAR1": "value1"},
},
src: &AppConfig{},
expected: &AppConfig{
Permissions: map[string]string{"read": "read"},
Variables: map[string]string{"VAR1": "value1"},
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
// Deep copy dst to avoid modifying test data
dst := &AppConfig{}
if tt.dst.Permissions != nil {
dst.Permissions = make(map[string]string)
for k, v := range tt.dst.Permissions {
dst.Permissions[k] = v
}
}
if tt.dst.Variables != nil {
dst.Variables = make(map[string]string)
for k, v := range tt.dst.Variables {
dst.Variables[k] = v
}
}
mergeMapFields(dst, tt.src)
testutil.AssertEqual(t, tt.expected.Permissions, dst.Permissions)
testutil.AssertEqual(t, tt.expected.Variables, dst.Variables)
})
}
}
// TestMergeSliceFields tests the merging of slice fields in configuration.
func TestMergeSliceFields(t *testing.T) {
t.Parallel()
tests := []struct {
name string
dst *AppConfig
src *AppConfig
expected []string
}{
{
name: "merge runsOn into empty dst",
dst: &AppConfig{},
src: &AppConfig{RunsOn: []string{"ubuntu-latest", "windows-latest"}},
expected: []string{"ubuntu-latest", "windows-latest"},
},
{
name: "merge runsOn replaces existing dst",
dst: &AppConfig{RunsOn: []string{"macos-latest"}},
src: &AppConfig{RunsOn: []string{"ubuntu-latest", "windows-latest"}},
expected: []string{"ubuntu-latest", "windows-latest"},
},
{
name: "empty src does not affect dst",
dst: &AppConfig{RunsOn: []string{"ubuntu-latest"}},
src: &AppConfig{},
expected: []string{"ubuntu-latest"},
},
{
name: "empty src slice does not affect dst",
dst: &AppConfig{RunsOn: []string{"ubuntu-latest"}},
src: &AppConfig{RunsOn: []string{}},
expected: []string{"ubuntu-latest"},
},
{
name: "single item slice",
dst: &AppConfig{},
src: &AppConfig{RunsOn: []string{"self-hosted"}},
expected: []string{"self-hosted"},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
mergeSliceFields(tt.dst, tt.src)
// Compare slices manually since they can't be compared directly
if len(tt.expected) != len(tt.dst.RunsOn) {
t.Errorf("expected slice length %d, got %d", len(tt.expected), len(tt.dst.RunsOn))
return
}
for i, expected := range tt.expected {
if i >= len(tt.dst.RunsOn) || tt.dst.RunsOn[i] != expected {
t.Errorf("expected %v, got %v", tt.expected, tt.dst.RunsOn)
return
}
}
})
}
}