Files
gh-action-readme/internal/configuration_loader_test.go
Ismo Vuorinen 7f80105ff5 feat: go 1.25.5, dependency updates, renamed internal/errors (#129)
* feat: rename internal/errors to internal/apperrors

* fix(tests): clear env values before using in tests

* feat: rename internal/errors to internal/apperrors

* chore(deps): update go and all dependencies

* chore: remove renovate from pre-commit, formatting

* chore: sonarcloud fixes

* feat: consolidate constants to appconstants/constants.go

* chore: sonarcloud fixes

* feat: simplification, deduplication, test utils

* chore: sonarcloud fixes

* chore: sonarcloud fixes

* chore: sonarcloud fixes

* chore: sonarcloud fixes

* chore: clean up

* fix: config discovery, const deduplication

* chore: fixes
2026-01-01 23:17:29 +02:00

778 lines
21 KiB
Go

package internal
import (
"os"
"path/filepath"
"testing"
"github.com/ivuorinen/gh-action-readme/appconstants"
"github.com/ivuorinen/gh-action-readme/testutil"
)
func TestNewConfigurationLoader(t *testing.T) {
t.Parallel()
loader := NewConfigurationLoader()
if loader == nil {
t.Fatal("expected non-nil loader")
}
if loader.viper == nil {
t.Fatal("expected viper instance to be initialized")
}
// Check default sources are enabled
expectedSources := []appconstants.ConfigurationSource{
appconstants.SourceDefaults, appconstants.SourceGlobal, appconstants.SourceRepoOverride,
appconstants.SourceRepoConfig, appconstants.SourceActionConfig, appconstants.SourceEnvironment,
}
for _, source := range expectedSources {
if !loader.sources[source] {
t.Errorf("expected source %s to be enabled by default", source.String())
}
}
// CLI flags should be disabled by default
if loader.sources[appconstants.SourceCLIFlags] {
t.Error("expected CLI flags source to be disabled by default")
}
}
func TestNewConfigurationLoaderWithOptions(t *testing.T) {
t.Parallel()
tests := []struct {
name string
opts ConfigurationOptions
expected []appconstants.ConfigurationSource
}{
{
name: "default options",
opts: ConfigurationOptions{},
expected: []appconstants.ConfigurationSource{
appconstants.SourceDefaults, appconstants.SourceGlobal, appconstants.SourceRepoOverride,
appconstants.SourceRepoConfig, appconstants.SourceActionConfig, appconstants.SourceEnvironment,
},
},
{
name: "custom enabled sources",
opts: ConfigurationOptions{
EnabledSources: []appconstants.ConfigurationSource{
appconstants.SourceDefaults,
appconstants.SourceGlobal,
},
},
expected: []appconstants.ConfigurationSource{appconstants.SourceDefaults, appconstants.SourceGlobal},
},
{
name: "all sources enabled",
opts: ConfigurationOptions{
EnabledSources: []appconstants.ConfigurationSource{
appconstants.SourceDefaults, appconstants.SourceGlobal,
appconstants.SourceRepoOverride, appconstants.SourceRepoConfig,
appconstants.SourceActionConfig, appconstants.SourceEnvironment,
appconstants.SourceCLIFlags,
},
},
expected: []appconstants.ConfigurationSource{
appconstants.SourceDefaults, appconstants.SourceGlobal,
appconstants.SourceRepoOverride, appconstants.SourceRepoConfig,
appconstants.SourceActionConfig, appconstants.SourceEnvironment,
appconstants.SourceCLIFlags,
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
loader := NewConfigurationLoaderWithOptions(tt.opts)
for _, expectedSource := range tt.expected {
if !loader.sources[expectedSource] {
t.Errorf("expected source %s to be enabled", expectedSource.String())
}
}
// Check that non-expected sources are disabled
allSources := []appconstants.ConfigurationSource{
appconstants.SourceDefaults, appconstants.SourceGlobal,
appconstants.SourceRepoOverride, appconstants.SourceRepoConfig,
appconstants.SourceActionConfig, appconstants.SourceEnvironment,
appconstants.SourceCLIFlags,
}
for _, source := range allSources {
expected := false
for _, expectedSource := range tt.expected {
if source == expectedSource {
expected = true
break
}
}
if loader.sources[source] != expected {
t.Errorf("source %s enabled=%v, expected=%v", source.String(), loader.sources[source], expected)
}
}
})
}
}
func TestConfigurationLoader_LoadConfiguration(t *testing.T) {
tests := []struct {
name string
setupFunc func(t *testing.T, tempDir string) (configFile, repoRoot, actionDir string)
expectError bool
checkFunc func(t *testing.T, config *AppConfig)
}{
{
name: "defaults only",
setupFunc: func(_ *testing.T, _ string) (string, string, string) {
return "", "", ""
},
checkFunc: func(_ *testing.T, config *AppConfig) {
testutil.AssertEqual(t, "default", config.Theme)
testutil.AssertEqual(t, "md", config.OutputFormat)
testutil.AssertEqual(t, ".", config.OutputDir)
},
},
{
name: "multi-level configuration hierarchy",
setupFunc: func(_ *testing.T, tempDir string) (string, string, string) {
// 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: ghp_test1234567890abcdefghijklmnopqrstuvwxyz
verbose: false
`)
// 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
verbose: true
`)
// Create action directory with action-specific config
actionDir := filepath.Join(repoRoot, "action")
_ = os.MkdirAll(actionDir, 0750) // #nosec G301 -- test directory permissions
testutil.WriteTestFile(t, filepath.Join(actionDir, "config.yaml"), `
theme: professional
output_dir: output
quiet: false
`)
return globalConfigPath, repoRoot, actionDir
},
checkFunc: func(_ *testing.T, config *AppConfig) {
// 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)
testutil.AssertEqual(t, true, config.Verbose)
// Should inherit GitHub token from global config
testutil.AssertEqual(t, "ghp_test1234567890abcdefghijklmnopqrstuvwxyz", 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")
// Create config file with different token
configPath := filepath.Join(tempDir, "config.yml")
testutil.WriteTestFile(t, configPath, `
theme: minimal
github_token: config-token
`)
return configPath, tempDir, ""
},
checkFunc: func(_ *testing.T, config *AppConfig) {
// Environment variable should override config file
testutil.AssertEqual(t, "env-token", config.GitHubToken)
testutil.AssertEqual(t, "minimal", config.Theme)
},
},
{
name: "hidden config file priority",
setupFunc: func(_ *testing.T, tempDir string) (string, string, string) {
repoRoot := filepath.Join(tempDir, "repo")
_ = os.MkdirAll(repoRoot, 0750) // #nosec G301 -- test directory permissions
// Create multiple hidden config files - first one should win
testutil.WriteTestFile(t, filepath.Join(repoRoot, ".ghreadme.yaml"), `
theme: minimal
output_format: json
`)
configDir := filepath.Join(repoRoot, ".config")
_ = os.MkdirAll(configDir, 0750) // #nosec G301 -- test directory permissions
testutil.WriteTestFile(t, filepath.Join(configDir, "ghreadme.yaml"), `
theme: professional
quiet: true
`)
githubDir := filepath.Join(repoRoot, ".github")
_ = os.MkdirAll(githubDir, 0750) // #nosec G301 -- test directory permissions
testutil.WriteTestFile(t, filepath.Join(githubDir, "ghreadme.yaml"), `
theme: github
verbose: true
`)
return "", repoRoot, ""
},
checkFunc: func(_ *testing.T, config *AppConfig) {
// Should use the first found config (.ghreadme.yaml has priority)
testutil.AssertEqual(t, "minimal", config.Theme)
testutil.AssertEqual(t, "json", config.OutputFormat)
},
},
{
name: "selective source loading",
setupFunc: func(_ *testing.T, _ string) (string, string, string) {
// This test uses a loader with specific sources enabled
return "", "", ""
},
checkFunc: func(_ *testing.T, _ *AppConfig) {
// This will be tested with a custom loader
},
},
}
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, actionDir := tt.setupFunc(t, tmpDir)
// Special handling for selective source loading test
var loader *ConfigurationLoader
if tt.name == "selective source loading" {
// Create loader with only defaults and global sources
loader = NewConfigurationLoaderWithOptions(ConfigurationOptions{
EnabledSources: []appconstants.ConfigurationSource{
appconstants.SourceDefaults,
appconstants.SourceGlobal,
},
})
} else {
loader = NewConfigurationLoader()
}
config, err := loader.LoadConfiguration(configFile, repoRoot, actionDir)
if tt.expectError {
testutil.AssertError(t, err)
return
}
testutil.AssertNoError(t, err)
if tt.checkFunc != nil {
tt.checkFunc(t, config)
}
})
}
}
func TestConfigurationLoader_LoadGlobalConfig(t *testing.T) {
tests := []struct {
name string
setupFunc func(t *testing.T, tempDir string) string
expectError bool
checkFunc func(t *testing.T, config *AppConfig)
}{
{
name: "valid global config",
setupFunc: func(t *testing.T, tempDir string) string {
t.Helper()
configPath := filepath.Join(tempDir, "config.yaml")
testutil.WriteTestFile(t, configPath, `
theme: professional
output_format: html
github_token: test-token
verbose: true
`)
return configPath
},
checkFunc: func(_ *testing.T, config *AppConfig) {
testutil.AssertEqual(t, "professional", config.Theme)
testutil.AssertEqual(t, "html", config.OutputFormat)
testutil.AssertEqual(t, "test-token", config.GitHubToken)
testutil.AssertEqual(t, true, config.Verbose)
},
},
{
name: "nonexistent config file",
setupFunc: func(_ *testing.T, tempDir string) string {
return filepath.Join(tempDir, "nonexistent.yaml")
},
expectError: true,
},
{
name: "invalid YAML",
setupFunc: func(t *testing.T, tempDir string) string {
t.Helper()
configPath := filepath.Join(tempDir, "invalid.yaml")
testutil.WriteTestFile(t, configPath, "invalid: yaml: content: [")
return configPath
},
expectError: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
tmpDir, cleanup := testutil.TempDir(t)
defer cleanup()
// Set HOME to temp directory
t.Setenv("HOME", tmpDir)
configFile := tt.setupFunc(t, tmpDir)
loader := NewConfigurationLoader()
config, err := loader.LoadGlobalConfig(configFile)
if tt.expectError {
testutil.AssertError(t, err)
return
}
testutil.AssertNoError(t, err)
if tt.checkFunc != nil {
tt.checkFunc(t, config)
}
})
}
}
func TestConfigurationLoader_ValidateConfiguration(t *testing.T) {
t.Parallel()
tests := []struct {
name string
config *AppConfig
expectError bool
errorMsg string
}{
{
name: "nil config",
config: nil,
expectError: true,
errorMsg: "configuration cannot be nil",
},
{
name: "valid config",
config: &AppConfig{
Theme: "default",
OutputFormat: "md",
OutputDir: ".",
Verbose: false,
Quiet: false,
},
expectError: false,
},
{
name: "invalid output format",
config: &AppConfig{
Theme: "default",
OutputFormat: "invalid",
OutputDir: ".",
},
expectError: true,
errorMsg: "invalid output format",
},
{
name: "empty output directory",
config: &AppConfig{
Theme: "default",
OutputFormat: "md",
OutputDir: "",
},
expectError: true,
errorMsg: "output directory cannot be empty",
},
{
name: "verbose and quiet both true",
config: &AppConfig{
Theme: "default",
OutputFormat: "md",
OutputDir: ".",
Verbose: true,
Quiet: true,
},
expectError: true,
errorMsg: "verbose and quiet flags are mutually exclusive",
},
{
name: "invalid theme",
config: &AppConfig{
Theme: "nonexistent",
OutputFormat: "md",
OutputDir: ".",
},
expectError: true,
errorMsg: "invalid theme",
},
{
name: "valid built-in themes",
config: &AppConfig{
Theme: "github",
OutputFormat: "html",
OutputDir: "docs",
},
expectError: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
loader := NewConfigurationLoader()
err := loader.ValidateConfiguration(tt.config)
if tt.expectError {
testutil.AssertError(t, err)
if tt.errorMsg != "" {
testutil.AssertStringContains(t, err.Error(), tt.errorMsg)
}
} else {
testutil.AssertNoError(t, err)
}
})
}
}
func TestConfigurationLoader_SourceManagement(t *testing.T) {
t.Parallel()
loader := NewConfigurationLoader()
// Test initial state
sources := loader.GetConfigurationSources()
if len(sources) != 6 { // All except CLI flags
t.Errorf("expected 6 enabled sources, got %d", len(sources))
}
// Test disabling a source
loader.DisableSource(appconstants.SourceGlobal)
if loader.sources[appconstants.SourceGlobal] {
t.Error("expected appconstants.SourceGlobal to be disabled")
}
// Test enabling a source
loader.EnableSource(appconstants.SourceCLIFlags)
if !loader.sources[appconstants.SourceCLIFlags] {
t.Error("expected appconstants.SourceCLIFlags to be enabled")
}
// Test updated sources list
sources = loader.GetConfigurationSources()
expectedCount := 6 // 5 original + CLI flags - Global
if len(sources) != expectedCount {
t.Errorf("expected %d enabled sources, got %d", expectedCount, len(sources))
}
}
func TestConfigurationSource_String(t *testing.T) {
t.Parallel()
tests := []struct {
source appconstants.ConfigurationSource
expected string
}{
{appconstants.SourceDefaults, "defaults"},
{appconstants.SourceGlobal, "global"},
{appconstants.SourceRepoOverride, "repo-override"},
{appconstants.SourceRepoConfig, "repo-config"},
{appconstants.SourceActionConfig, "action-config"},
{appconstants.SourceEnvironment, "environment"},
{appconstants.SourceCLIFlags, "cli-flags"},
{appconstants.ConfigurationSource(999), "unknown"},
}
for _, tt := range tests {
result := tt.source.String()
if result != tt.expected {
t.Errorf("source %d String() = %s, expected %s", int(tt.source), result, tt.expected)
}
}
}
func TestConfigurationLoader_EnvironmentOverrides(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()
loader := NewConfigurationLoader()
config, err := loader.LoadConfiguration("", tmpDir, "")
testutil.AssertNoError(t, err)
testutil.AssertEqual(t, tt.ExpectedToken, config.GitHubToken)
})
}
}
func TestConfigurationLoader_RepoOverrides(t *testing.T) {
tmpDir, cleanup := testutil.TempDir(t)
defer cleanup()
// Create a mock git repository structure for testing
repoRoot := filepath.Join(tmpDir, "test-repo")
_ = os.MkdirAll(repoRoot, 0750) // #nosec G301 -- test directory permissions
// Create global config with repo overrides
globalConfigDir := filepath.Join(tmpDir, ".config", "gh-action-readme")
_ = os.MkdirAll(globalConfigDir, 0750) // #nosec G301 -- test directory permissions
globalConfigPath := filepath.Join(globalConfigDir, "config.yaml")
globalConfigContent := "theme: default\n"
globalConfigContent += "output_format: md\n"
globalConfigContent += "repo_overrides:\n"
globalConfigContent += " test-repo:\n"
globalConfigContent += " theme: github\n"
globalConfigContent += " output_format: html\n"
globalConfigContent += " verbose: true\n"
testutil.WriteTestFile(t, globalConfigPath, globalConfigContent)
// Set environment for XDG compliance
t.Setenv("HOME", tmpDir)
loader := NewConfigurationLoader()
config, err := loader.LoadConfiguration(globalConfigPath, repoRoot, "")
testutil.AssertNoError(t, err)
// Note: Since we don't have actual git repository detection in this test,
// repo overrides won't be applied. This test validates the structure works.
testutil.AssertEqual(t, "default", config.Theme)
testutil.AssertEqual(t, "md", config.OutputFormat)
}
// TestConfigurationLoader_ApplyRepoOverrides tests repo-specific overrides.
func TestConfigurationLoader_ApplyRepoOverrides(t *testing.T) {
t.Parallel()
tests := []struct {
name string
config *AppConfig
expectedTheme string
expectedFormat string
}{
{
name: "no repo overrides configured",
config: &AppConfig{
Theme: "default",
OutputFormat: "md",
RepoOverrides: nil,
},
expectedTheme: "default",
expectedFormat: "md",
},
{
name: "empty repo overrides map",
config: &AppConfig{
Theme: "default",
OutputFormat: "md",
RepoOverrides: map[string]AppConfig{},
},
expectedTheme: "default",
expectedFormat: "md",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
tmpDir, cleanup := testutil.TempDir(t)
defer cleanup()
loader := NewConfigurationLoader()
loader.applyRepoOverrides(tt.config, tmpDir)
testutil.AssertEqual(t, tt.expectedTheme, tt.config.Theme)
testutil.AssertEqual(t, tt.expectedFormat, tt.config.OutputFormat)
})
}
}
// TestConfigurationLoader_LoadActionConfig tests action-specific configuration loading.
func TestConfigurationLoader_LoadActionConfig(t *testing.T) {
t.Parallel()
tests := []struct {
name string
setupFunc func(t *testing.T, tmpDir string) string
expectError bool
expectedVals map[string]string
}{
{
name: "no action directory provided",
setupFunc: func(_ *testing.T, _ string) string {
return ""
},
expectError: false,
expectedVals: map[string]string{},
},
{
name: "action directory with config file",
setupFunc: func(t *testing.T, tmpDir string) string {
t.Helper()
actionDir := filepath.Join(tmpDir, "action")
_ = os.MkdirAll(actionDir, 0750) // #nosec G301 -- test directory permissions
configPath := filepath.Join(actionDir, "config.yaml")
testutil.WriteTestFile(t, configPath, `
theme: minimal
output_format: json
verbose: true
`)
return actionDir
},
expectError: false,
expectedVals: map[string]string{
"theme": "minimal",
"output_format": "json",
},
},
{
name: "action directory with malformed config file",
setupFunc: func(t *testing.T, tmpDir string) string {
t.Helper()
actionDir := filepath.Join(tmpDir, "action")
_ = os.MkdirAll(actionDir, 0750) // #nosec G301 -- test directory permissions
configPath := filepath.Join(actionDir, "config.yaml")
testutil.WriteTestFile(t, configPath, "invalid yaml content:\n - broken [")
return actionDir
},
expectError: false, // Function may handle YAML errors gracefully
expectedVals: map[string]string{},
},
{
name: "action directory without config file",
setupFunc: func(_ *testing.T, tmpDir string) string {
actionDir := filepath.Join(tmpDir, "action")
_ = os.MkdirAll(actionDir, 0750) // #nosec G301 -- test directory permissions
return actionDir
},
expectError: false,
expectedVals: map[string]string{},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
tmpDir, cleanup := testutil.TempDir(t)
defer cleanup()
actionDir := tt.setupFunc(t, tmpDir)
loader := NewConfigurationLoader()
config, err := loader.loadActionConfig(actionDir)
if tt.expectError {
testutil.AssertError(t, err)
} else {
testutil.AssertNoError(t, err)
// Check expected values if no error
if config != nil {
for key, expected := range tt.expectedVals {
switch key {
case "theme":
testutil.AssertEqual(t, expected, config.Theme)
case "output_format":
testutil.AssertEqual(t, expected, config.OutputFormat)
}
}
}
}
})
}
}
// TestConfigurationLoader_ValidateTheme tests theme validation edge cases.
func TestConfigurationLoader_ValidateTheme(t *testing.T) {
t.Parallel()
tests := []struct {
name string
theme string
expectError bool
}{
{
name: "valid built-in theme",
theme: "github",
expectError: false,
},
{
name: "valid default theme",
theme: "default",
expectError: false,
},
{
name: "empty theme returns error",
theme: "",
expectError: true,
},
{
name: "invalid theme",
theme: "nonexistent-theme",
expectError: true,
},
{
name: "case sensitive theme",
theme: "GitHub",
expectError: true,
},
{
name: "custom theme path",
theme: "/custom/theme/path.tmpl",
expectError: false,
},
{
name: "relative theme path",
theme: "custom/theme.tmpl",
expectError: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
loader := NewConfigurationLoader()
err := loader.validateTheme(tt.theme)
if tt.expectError {
testutil.AssertError(t, err)
} else {
testutil.AssertNoError(t, err)
}
})
}
}