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_Stats(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", "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 }