mirror of
https://github.com/ivuorinen/gh-action-readme.git
synced 2026-01-26 11:14:04 +00:00
This commit represents a comprehensive refactoring of the codebase focused on improving code quality, testability, and maintainability. Key improvements: - Implement dependency injection and interface-based architecture - Add comprehensive test framework with fixtures and test suites - Fix all linting issues (errcheck, gosec, staticcheck, goconst, etc.) - Achieve full EditorConfig compliance across all files - Replace hardcoded test data with proper fixture files - Add configuration loader with hierarchical config support - Improve error handling with contextual information - Add progress indicators for better user feedback - Enhance Makefile with help system and improved editorconfig commands - Consolidate constants and remove deprecated code - Strengthen validation logic for GitHub Actions - Add focused consumer interfaces for better separation of concerns Testing improvements: - Add comprehensive integration tests - Implement test executor pattern for better test organization - Create extensive YAML fixture library for testing - Fix all failing tests and improve test coverage - Add validation test fixtures to avoid embedded YAML in Go files Build and tooling: - Update Makefile to show help by default - Fix editorconfig commands to use eclint properly - Add comprehensive help documentation to all make targets - Improve file selection patterns to avoid glob errors This refactoring maintains backward compatibility while significantly improving the internal architecture and developer experience.
572 lines
13 KiB
Go
572 lines
13 KiB
Go
package cache
|
|
|
|
import (
|
|
"fmt"
|
|
"os"
|
|
"strings"
|
|
"sync"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/ivuorinen/gh-action-readme/testutil"
|
|
)
|
|
|
|
func TestNewCache(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
config *Config
|
|
expectError bool
|
|
}{
|
|
{
|
|
name: "default config",
|
|
config: nil,
|
|
expectError: false,
|
|
},
|
|
{
|
|
name: "custom config",
|
|
config: &Config{
|
|
DefaultTTL: 30 * time.Minute,
|
|
CleanupInterval: 10 * time.Minute,
|
|
MaxSize: 50 * 1024 * 1024,
|
|
},
|
|
expectError: false,
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
// Set XDG_CACHE_HOME to temp directory
|
|
tmpDir, cleanup := testutil.TempDir(t)
|
|
defer cleanup()
|
|
|
|
originalXDGCache := os.Getenv("XDG_CACHE_HOME")
|
|
_ = os.Setenv("XDG_CACHE_HOME", tmpDir)
|
|
defer func() {
|
|
if originalXDGCache != "" {
|
|
_ = os.Setenv("XDG_CACHE_HOME", originalXDGCache)
|
|
} else {
|
|
_ = os.Unsetenv("XDG_CACHE_HOME")
|
|
}
|
|
}()
|
|
|
|
cache, err := NewCache(tt.config)
|
|
|
|
if tt.expectError {
|
|
testutil.AssertError(t, err)
|
|
return
|
|
}
|
|
|
|
testutil.AssertNoError(t, err)
|
|
|
|
// Verify cache was created
|
|
if cache == nil {
|
|
t.Fatal("expected cache to be created")
|
|
}
|
|
|
|
// Verify default TTL
|
|
expectedTTL := 15 * time.Minute
|
|
if tt.config != nil && tt.config.DefaultTTL != 0 {
|
|
expectedTTL = tt.config.DefaultTTL
|
|
}
|
|
testutil.AssertEqual(t, expectedTTL, cache.defaultTTL)
|
|
|
|
// Clean up
|
|
_ = cache.Close()
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestCache_SetAndGet(t *testing.T) {
|
|
tmpDir, cleanup := testutil.TempDir(t)
|
|
defer cleanup()
|
|
|
|
cache := createTestCache(t, tmpDir)
|
|
defer func() { _ = cache.Close() }()
|
|
|
|
tests := []struct {
|
|
name string
|
|
key string
|
|
value any
|
|
expected any
|
|
}{
|
|
{
|
|
name: "string value",
|
|
key: "test-key",
|
|
value: "test-value",
|
|
expected: "test-value",
|
|
},
|
|
{
|
|
name: "struct value",
|
|
key: "struct-key",
|
|
value: map[string]string{"foo": "bar"},
|
|
expected: map[string]string{"foo": "bar"},
|
|
},
|
|
{
|
|
name: "nil value",
|
|
key: "nil-key",
|
|
value: nil,
|
|
expected: nil,
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
// Set value
|
|
err := cache.Set(tt.key, tt.value)
|
|
testutil.AssertNoError(t, err)
|
|
|
|
// Get value
|
|
value, exists := cache.Get(tt.key)
|
|
if !exists {
|
|
t.Fatal("expected value to exist in cache")
|
|
}
|
|
|
|
testutil.AssertEqual(t, tt.expected, value)
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestCache_TTL(t *testing.T) {
|
|
tmpDir, cleanup := testutil.TempDir(t)
|
|
defer cleanup()
|
|
|
|
cache := createTestCache(t, tmpDir)
|
|
defer func() { _ = cache.Close() }()
|
|
|
|
// Set value with short TTL
|
|
shortTTL := 100 * time.Millisecond
|
|
err := cache.SetWithTTL("short-lived", "value", shortTTL)
|
|
testutil.AssertNoError(t, err)
|
|
|
|
// Should exist immediately
|
|
value, exists := cache.Get("short-lived")
|
|
if !exists {
|
|
t.Fatal("expected value to exist immediately")
|
|
}
|
|
testutil.AssertEqual(t, "value", value)
|
|
|
|
// Wait for expiration
|
|
time.Sleep(shortTTL + 50*time.Millisecond)
|
|
|
|
// Should not exist after TTL
|
|
_, exists = cache.Get("short-lived")
|
|
if exists {
|
|
t.Error("expected value to be expired")
|
|
}
|
|
}
|
|
|
|
func TestCache_GetOrSet(t *testing.T) {
|
|
tmpDir, cleanup := testutil.TempDir(t)
|
|
defer cleanup()
|
|
|
|
cache := createTestCache(t, tmpDir)
|
|
defer func() { _ = cache.Close() }()
|
|
|
|
// Use unique key to avoid interference from other tests
|
|
testKey := fmt.Sprintf("test-key-%d", time.Now().UnixNano())
|
|
|
|
callCount := 0
|
|
getter := func() (any, error) {
|
|
callCount++
|
|
return fmt.Sprintf("generated-value-%d", callCount), nil
|
|
}
|
|
|
|
// First call should invoke getter
|
|
value1, err := cache.GetOrSet(testKey, getter)
|
|
testutil.AssertNoError(t, err)
|
|
testutil.AssertEqual(t, "generated-value-1", value1)
|
|
testutil.AssertEqual(t, 1, callCount)
|
|
|
|
// Second call should use cached value
|
|
value2, err := cache.GetOrSet(testKey, getter)
|
|
testutil.AssertNoError(t, err)
|
|
testutil.AssertEqual(t, "generated-value-1", value2) // Same value
|
|
testutil.AssertEqual(t, 1, callCount) // Getter not called again
|
|
}
|
|
|
|
func TestCache_GetOrSetError(t *testing.T) {
|
|
tmpDir, cleanup := testutil.TempDir(t)
|
|
defer cleanup()
|
|
|
|
cache := createTestCache(t, tmpDir)
|
|
defer func() { _ = cache.Close() }()
|
|
|
|
// Getter that returns error
|
|
getter := func() (any, error) {
|
|
return nil, fmt.Errorf("getter error")
|
|
}
|
|
|
|
value, err := cache.GetOrSet("error-key", getter)
|
|
testutil.AssertError(t, err)
|
|
testutil.AssertStringContains(t, err.Error(), "getter error")
|
|
|
|
if value != nil {
|
|
t.Errorf("expected nil value on error, got: %v", value)
|
|
}
|
|
|
|
// Verify nothing was cached
|
|
_, exists := cache.Get("error-key")
|
|
if exists {
|
|
t.Error("expected no value to be cached on error")
|
|
}
|
|
}
|
|
|
|
func TestCache_ConcurrentAccess(t *testing.T) {
|
|
tmpDir, cleanup := testutil.TempDir(t)
|
|
defer cleanup()
|
|
|
|
cache := createTestCache(t, tmpDir)
|
|
defer func() { _ = cache.Close() }()
|
|
|
|
const numGoroutines = 10
|
|
const numOperations = 100
|
|
|
|
var wg sync.WaitGroup
|
|
wg.Add(numGoroutines)
|
|
|
|
// Launch multiple goroutines doing concurrent operations
|
|
for i := 0; i < numGoroutines; i++ {
|
|
go func(goroutineID int) {
|
|
defer wg.Done()
|
|
|
|
for j := 0; j < numOperations; j++ {
|
|
key := fmt.Sprintf("key-%d-%d", goroutineID, j)
|
|
value := fmt.Sprintf("value-%d-%d", goroutineID, j)
|
|
|
|
// Set value
|
|
err := cache.Set(key, value)
|
|
if err != nil {
|
|
t.Errorf("error setting value: %v", err)
|
|
return
|
|
}
|
|
|
|
// Get value
|
|
retrieved, exists := cache.Get(key)
|
|
if !exists {
|
|
t.Errorf("expected key %s to exist", key)
|
|
return
|
|
}
|
|
|
|
if retrieved != value {
|
|
t.Errorf("expected %s, got %s", value, retrieved)
|
|
return
|
|
}
|
|
}
|
|
}(i)
|
|
}
|
|
|
|
wg.Wait()
|
|
}
|
|
|
|
func TestCache_Persistence(t *testing.T) {
|
|
tmpDir, cleanup := testutil.TempDir(t)
|
|
defer cleanup()
|
|
|
|
// Create cache and add some data
|
|
cache1 := createTestCache(t, tmpDir)
|
|
err := cache1.Set("persistent-key", "persistent-value")
|
|
testutil.AssertNoError(t, err)
|
|
|
|
// Close cache to trigger save
|
|
err = cache1.Close()
|
|
testutil.AssertNoError(t, err)
|
|
|
|
// Create new cache instance (should load from disk)
|
|
cache2 := createTestCache(t, tmpDir)
|
|
defer func() { _ = cache2.Close() }()
|
|
|
|
// Value should still exist
|
|
value, exists := cache2.Get("persistent-key")
|
|
if !exists {
|
|
t.Fatal("expected persistent value to exist after restart")
|
|
}
|
|
testutil.AssertEqual(t, "persistent-value", value)
|
|
}
|
|
|
|
func TestCache_Clear(t *testing.T) {
|
|
tmpDir, cleanup := testutil.TempDir(t)
|
|
defer cleanup()
|
|
|
|
cache := createTestCache(t, tmpDir)
|
|
defer func() { _ = cache.Close() }()
|
|
|
|
// Add some data
|
|
_ = cache.Set("key1", "value1")
|
|
_ = cache.Set("key2", "value2")
|
|
|
|
// Verify data exists
|
|
_, exists1 := cache.Get("key1")
|
|
_, exists2 := cache.Get("key2")
|
|
if !exists1 || !exists2 {
|
|
t.Fatal("expected test data to exist before clear")
|
|
}
|
|
|
|
// Clear cache
|
|
err := cache.Clear()
|
|
testutil.AssertNoError(t, err)
|
|
|
|
// Verify data is gone
|
|
_, exists1 = cache.Get("key1")
|
|
_, exists2 = cache.Get("key2")
|
|
if exists1 || exists2 {
|
|
t.Error("expected data to be cleared")
|
|
}
|
|
}
|
|
|
|
func TestCache_Delete(t *testing.T) {
|
|
tmpDir, cleanup := testutil.TempDir(t)
|
|
defer cleanup()
|
|
|
|
cache := createTestCache(t, tmpDir)
|
|
defer func() { _ = cache.Close() }()
|
|
|
|
// Add some data
|
|
_ = cache.Set("key1", "value1")
|
|
_ = cache.Set("key2", "value2")
|
|
_ = cache.Set("key3", "value3")
|
|
|
|
// Verify data exists
|
|
_, exists := cache.Get("key1")
|
|
if !exists {
|
|
t.Fatal("expected key1 to exist before delete")
|
|
}
|
|
|
|
// Delete specific key
|
|
cache.Delete("key1")
|
|
|
|
// Verify deleted key is gone but others remain
|
|
_, exists1 := cache.Get("key1")
|
|
_, exists2 := cache.Get("key2")
|
|
_, exists3 := cache.Get("key3")
|
|
|
|
if exists1 {
|
|
t.Error("expected key1 to be deleted")
|
|
}
|
|
if !exists2 || !exists3 {
|
|
t.Error("expected key2 and key3 to still exist")
|
|
}
|
|
|
|
// Test deleting non-existent key (should not panic)
|
|
cache.Delete("nonexistent")
|
|
}
|
|
|
|
func TestCache_Stats(t *testing.T) {
|
|
tmpDir, cleanup := testutil.TempDir(t)
|
|
defer cleanup()
|
|
|
|
cache := createTestCache(t, tmpDir)
|
|
defer func() { _ = cache.Close() }()
|
|
|
|
// Ensure cache starts clean
|
|
_ = cache.Clear()
|
|
|
|
// Add some data
|
|
_ = cache.Set("key1", "value1")
|
|
_ = cache.Set("key2", "larger-value-with-more-content")
|
|
|
|
stats := cache.Stats()
|
|
|
|
// Check stats structure
|
|
if _, ok := stats["cache_dir"]; !ok {
|
|
t.Error("expected cache_dir in stats")
|
|
}
|
|
|
|
if _, ok := stats["total_entries"]; !ok {
|
|
t.Error("expected total_entries in stats")
|
|
}
|
|
|
|
if _, ok := stats["total_size"]; !ok {
|
|
t.Error("expected total_size in stats")
|
|
}
|
|
|
|
// Verify entry count
|
|
totalEntries, ok := stats["total_entries"].(int)
|
|
if !ok {
|
|
t.Error("expected total_entries to be int")
|
|
}
|
|
if totalEntries != 2 {
|
|
t.Errorf("expected 2 entries, got %d", totalEntries)
|
|
}
|
|
|
|
// Verify size is reasonable
|
|
totalSize, ok := stats["total_size"].(int64)
|
|
if !ok {
|
|
t.Error("expected total_size to be int64")
|
|
}
|
|
if totalSize <= 0 {
|
|
t.Errorf("expected positive total size, got %d", totalSize)
|
|
}
|
|
}
|
|
|
|
func TestCache_CleanupExpiredEntries(t *testing.T) {
|
|
tmpDir, cleanup := testutil.TempDir(t)
|
|
defer cleanup()
|
|
|
|
// Create cache with short cleanup interval
|
|
config := &Config{
|
|
DefaultTTL: 50 * time.Millisecond,
|
|
CleanupInterval: 30 * time.Millisecond,
|
|
MaxSize: 1024 * 1024,
|
|
}
|
|
|
|
originalXDGCache := os.Getenv("XDG_CACHE_HOME")
|
|
_ = os.Setenv("XDG_CACHE_HOME", tmpDir)
|
|
defer func() {
|
|
if originalXDGCache != "" {
|
|
_ = os.Setenv("XDG_CACHE_HOME", originalXDGCache)
|
|
} else {
|
|
_ = os.Unsetenv("XDG_CACHE_HOME")
|
|
}
|
|
}()
|
|
|
|
cache, err := NewCache(config)
|
|
testutil.AssertNoError(t, err)
|
|
defer func() { _ = cache.Close() }()
|
|
|
|
// Add entry that will expire
|
|
err = cache.Set("expiring-key", "expiring-value")
|
|
testutil.AssertNoError(t, err)
|
|
|
|
// Verify it exists
|
|
_, exists := cache.Get("expiring-key")
|
|
if !exists {
|
|
t.Fatal("expected entry to exist initially")
|
|
}
|
|
|
|
// Wait for cleanup to run
|
|
time.Sleep(config.DefaultTTL + config.CleanupInterval + 20*time.Millisecond)
|
|
|
|
// Entry should be cleaned up
|
|
_, exists = cache.Get("expiring-key")
|
|
if exists {
|
|
t.Error("expected expired entry to be cleaned up")
|
|
}
|
|
}
|
|
|
|
func TestCache_ErrorHandling(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
setupFunc func(t *testing.T) *Cache
|
|
testFunc func(t *testing.T, cache *Cache)
|
|
expectError bool
|
|
}{
|
|
{
|
|
name: "invalid cache directory permissions",
|
|
setupFunc: func(t *testing.T) *Cache {
|
|
// This test would require special setup for permission testing
|
|
// For now, we'll create a valid cache and test other error scenarios
|
|
tmpDir, _ := testutil.TempDir(t)
|
|
return createTestCache(t, tmpDir)
|
|
},
|
|
testFunc: func(t *testing.T, cache *Cache) {
|
|
// Test setting a value that might cause issues during marshaling
|
|
// Circular reference would cause JSON marshal to fail, but
|
|
// Go's JSON package handles most cases gracefully
|
|
err := cache.Set("test", "normal-value")
|
|
testutil.AssertNoError(t, err)
|
|
},
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
cache := tt.setupFunc(t)
|
|
defer func() { _ = cache.Close() }()
|
|
|
|
tt.testFunc(t, cache)
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestCache_AsyncSaveErrorHandling(t *testing.T) {
|
|
tmpDir, cleanup := testutil.TempDir(t)
|
|
defer cleanup()
|
|
|
|
cache := createTestCache(t, tmpDir)
|
|
defer func() { _ = cache.Close() }()
|
|
|
|
// This tests our new saveToDiskAsync error handling
|
|
// Set a value to trigger async save
|
|
err := cache.Set("test-key", "test-value")
|
|
testutil.AssertNoError(t, err)
|
|
|
|
// Give some time for async save to complete
|
|
time.Sleep(100 * time.Millisecond)
|
|
|
|
// The async save should have completed without panicking
|
|
// We can't easily test the error logging without capturing logs,
|
|
// but we can verify the cache still works
|
|
value, exists := cache.Get("test-key")
|
|
if !exists {
|
|
t.Error("expected value to exist after async save")
|
|
}
|
|
testutil.AssertEqual(t, "test-value", value)
|
|
}
|
|
|
|
func TestCache_EstimateSize(t *testing.T) {
|
|
tmpDir, cleanup := testutil.TempDir(t)
|
|
defer cleanup()
|
|
|
|
cache := createTestCache(t, tmpDir)
|
|
defer func() { _ = cache.Close() }()
|
|
|
|
tests := []struct {
|
|
name string
|
|
value any
|
|
minSize int64
|
|
maxSize int64
|
|
}{
|
|
{
|
|
name: "small string",
|
|
value: "test",
|
|
minSize: 4,
|
|
maxSize: 50,
|
|
},
|
|
{
|
|
name: "large string",
|
|
value: strings.Repeat("a", 1000),
|
|
minSize: 1000,
|
|
maxSize: 1100,
|
|
},
|
|
{
|
|
name: "struct",
|
|
value: map[string]any{
|
|
"key1": "value1",
|
|
"key2": 42,
|
|
"key3": []string{"a", "b", "c"},
|
|
},
|
|
minSize: 30,
|
|
maxSize: 200,
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
size := cache.estimateSize(tt.value)
|
|
if size < tt.minSize || size > tt.maxSize {
|
|
t.Errorf("expected size between %d and %d, got %d", tt.minSize, tt.maxSize, size)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// createTestCache creates a cache instance for testing.
|
|
func createTestCache(t *testing.T, tmpDir string) *Cache {
|
|
t.Helper()
|
|
|
|
originalXDGCache := os.Getenv("XDG_CACHE_HOME")
|
|
_ = os.Setenv("XDG_CACHE_HOME", tmpDir)
|
|
t.Cleanup(func() {
|
|
if originalXDGCache != "" {
|
|
_ = os.Setenv("XDG_CACHE_HOME", originalXDGCache)
|
|
} else {
|
|
_ = os.Unsetenv("XDG_CACHE_HOME")
|
|
}
|
|
})
|
|
|
|
cache, err := NewCache(DefaultConfig())
|
|
testutil.AssertNoError(t, err)
|
|
|
|
return cache
|
|
}
|